feat(tui): add agent interaction workflows
This commit is contained in:
@@ -163,6 +163,7 @@ class TurnContext:
|
||||
turn_wall_started_at: float = field(default_factory=time.time)
|
||||
visible_run_started_at: float | None = None
|
||||
turn_latency_ms: int | None = None
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def require_runtime(self) -> LLMRuntime:
|
||||
"""Return the runtime established by the BUILD stage."""
|
||||
@@ -1897,6 +1898,8 @@ class AgentLoop:
|
||||
ctx.all_messages = all_msgs
|
||||
ctx.stop_reason = stop_reason
|
||||
ctx.had_injections = had_injections
|
||||
ctx.usage = dict(self._last_usage)
|
||||
ctx.delivery.record_usage(ctx.usage)
|
||||
if ctx.kind is TurnKind.USER:
|
||||
await turn_continuation.maybe_continue_turn(ctx)
|
||||
|
||||
@@ -1922,6 +1925,8 @@ class AgentLoop:
|
||||
else ctx.turn_wall_started_at
|
||||
)
|
||||
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
|
||||
if ctx.usage and not ctx.ephemeral:
|
||||
session.metadata["_last_usage"] = dict(ctx.usage)
|
||||
self._save_turn(
|
||||
session, ctx.all_messages, ctx.save_skip,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -203,6 +203,9 @@ class TurnDelivery:
|
||||
def record_latency(self, latency_ms: int | None) -> None:
|
||||
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
|
||||
|
||||
def record_usage(self, usage: Mapping[str, int]) -> None:
|
||||
self.runtime_event_publisher.record_turn_usage(self.session_key, usage)
|
||||
|
||||
def background_response(
|
||||
self,
|
||||
content: str | None,
|
||||
|
||||
@@ -58,6 +58,8 @@ class StreamedResponseEvent(OutboundEvent):
|
||||
class TurnEndEvent(OutboundEvent):
|
||||
latency_ms: int | None = None
|
||||
goal_state: dict[str, Any] | None = None
|
||||
usage: dict[str, int] | None = None
|
||||
context_window_tokens: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -88,6 +90,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
|
||||
|
||||
model: str
|
||||
model_preset: str | None = None
|
||||
context_window_tokens: int | None = None
|
||||
|
||||
|
||||
def outbound_message_for_event(
|
||||
@@ -172,6 +175,12 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
||||
return TurnEndEvent(
|
||||
latency_ms=_metadata_int(meta, "latency_ms"),
|
||||
goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
|
||||
usage=(
|
||||
cast(dict[str, int], meta.get("usage"))
|
||||
if isinstance(meta.get("usage"), dict)
|
||||
else None
|
||||
),
|
||||
context_window_tokens=_metadata_int(meta, "context_window_tokens"),
|
||||
)
|
||||
if meta.get("_session_updated"):
|
||||
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -64,6 +64,7 @@ class TurnCompleted:
|
||||
context: RuntimeEventContext
|
||||
latency_ms: int | None = None
|
||||
runtime: LLMRuntime | None = None
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -169,6 +170,7 @@ class RuntimeEventPublisher:
|
||||
self.bus = bus or RuntimeEventBus()
|
||||
self._turn_latency_ms: dict[str, int] = {}
|
||||
self._turn_runtime: dict[str, LLMRuntime] = {}
|
||||
self._turn_usage: dict[str, dict[str, int]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _context(
|
||||
@@ -194,9 +196,17 @@ class RuntimeEventPublisher:
|
||||
if latency_ms is not None:
|
||||
self._turn_latency_ms[session_key] = int(latency_ms)
|
||||
|
||||
def record_turn_usage(self, session_key: str, usage: Mapping[str, int]) -> None:
|
||||
self._turn_usage[session_key] = {
|
||||
key: int(value)
|
||||
for key, value in usage.items()
|
||||
if type(value) is int and value >= 0
|
||||
}
|
||||
|
||||
def clear_turn(self, session_key: str) -> None:
|
||||
self._turn_latency_ms.pop(session_key, None)
|
||||
self._turn_runtime.pop(session_key, None)
|
||||
self._turn_usage.pop(session_key, None)
|
||||
|
||||
async def session_turn_started(
|
||||
self,
|
||||
@@ -295,6 +305,7 @@ class RuntimeEventPublisher:
|
||||
),
|
||||
latency_ms=self._turn_latency_ms.pop(session_key, None),
|
||||
runtime=self._turn_runtime.pop(session_key, None),
|
||||
usage=self._turn_usage.pop(session_key, {}),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -434,18 +434,34 @@ class WebSocketChannel(BaseChannel):
|
||||
self._subs.setdefault(chat_id, set()).add(connection)
|
||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||
|
||||
def _attached_model_fields(self, chat_id: str) -> dict[str, str | None]:
|
||||
"""Expose the session's canonical preset on the attach handshake."""
|
||||
def _attached_model_fields(self, chat_id: str) -> dict[str, Any]:
|
||||
"""Expose small session runtime facts on the attach handshake."""
|
||||
sessions = self.gateway.session_manager
|
||||
if sessions is None:
|
||||
return {}
|
||||
snapshot = sessions.read_session_metadata(f"websocket:{chat_id}")
|
||||
metadata = snapshot.get("metadata") if isinstance(snapshot, dict) else None
|
||||
raw_metadata = snapshot.get("metadata") if snapshot is not None else None
|
||||
metadata = cast(dict[str, object], raw_metadata) if isinstance(raw_metadata, dict) else None
|
||||
fields: dict[str, Any] = {}
|
||||
try:
|
||||
return {"model_preset": model_preset_from_metadata(metadata)}
|
||||
fields["model_preset"] = model_preset_from_metadata(metadata)
|
||||
except ValueError:
|
||||
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
||||
return {"model_preset": None}
|
||||
fields["model_preset"] = None
|
||||
if isinstance(metadata, dict):
|
||||
usage = metadata.get("_last_usage")
|
||||
if isinstance(usage, dict):
|
||||
sanitized_usage: dict[str, int | float] = {}
|
||||
for key, value in cast(dict[object, object], usage).items():
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value >= 0
|
||||
):
|
||||
sanitized_usage[key] = value
|
||||
fields["usage"] = sanitized_usage
|
||||
return fields
|
||||
|
||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||
chats = self._conn_chats.get(connection)
|
||||
@@ -1564,6 +1580,7 @@ class WebSocketChannel(BaseChannel):
|
||||
msg.chat_id,
|
||||
model_name=event.model,
|
||||
model_preset=event.model_preset,
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
)
|
||||
return
|
||||
if isinstance(event, GoalStateSyncEvent):
|
||||
@@ -1608,6 +1625,8 @@ class WebSocketChannel(BaseChannel):
|
||||
msg.chat_id,
|
||||
latency_ms=event.latency_ms,
|
||||
goal_state=event.goal_state,
|
||||
usage=event.usage,
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
metadata=msg.metadata,
|
||||
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||
)
|
||||
@@ -1822,16 +1841,25 @@ class WebSocketChannel(BaseChannel):
|
||||
latency_ms: int | None = None,
|
||||
*,
|
||||
goal_state: dict[str, Any] | None = None,
|
||||
usage: dict[str, int] | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
turn_owner: str | None = None,
|
||||
) -> None:
|
||||
"""Signal that the agent has fully finished processing the current turn."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
|
||||
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||
if isinstance(turn_id, str) and turn_id:
|
||||
body["turn_id"] = turn_id
|
||||
if latency_ms is not None:
|
||||
body["latency_ms"] = int(latency_ms)
|
||||
if goal_state is not None:
|
||||
body["goal_state"] = goal_state
|
||||
if usage:
|
||||
body["usage"] = usage
|
||||
if context_window_tokens is not None:
|
||||
body["context_window_tokens"] = int(context_window_tokens)
|
||||
canonical_webui_turn = (metadata or {}).get("webui") is True
|
||||
prior_persistence_failure = (
|
||||
canonical_webui_turn
|
||||
@@ -1932,6 +1960,7 @@ class WebSocketChannel(BaseChannel):
|
||||
*,
|
||||
model_name: Any,
|
||||
model_preset: Any = None,
|
||||
context_window_tokens: Any = None,
|
||||
) -> None:
|
||||
"""Notify one chat's subscribers which model is handling its current request."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@@ -1948,6 +1977,8 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if isinstance(model_preset, str) and model_preset.strip():
|
||||
body["model_preset"] = model_preset.strip()
|
||||
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
||||
body["context_window_tokens"] = context_window_tokens
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||
|
||||
@@ -1926,6 +1926,7 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
event=TurnModelUpdatedEvent(
|
||||
model="deepseek/deepseek-chat",
|
||||
model_preset="Deep Research",
|
||||
context_window_tokens=128_000,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1936,10 +1937,37 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
"chat_id": "chat-1",
|
||||
"model_name": "deepseek/deepseek-chat",
|
||||
"model_preset": "Deep Research",
|
||||
"context_window_tokens": 128_000,
|
||||
}
|
||||
chat_two.send.assert_not_awaited()
|
||||
|
||||
|
||||
def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
|
||||
manager = MagicMock()
|
||||
manager.read_session_metadata.return_value = {
|
||||
"metadata": {
|
||||
SESSION_MODEL_PRESET_METADATA_KEY: "Deep Research",
|
||||
"_last_usage": {
|
||||
"prompt_tokens": 120,
|
||||
"completion_tokens": 8,
|
||||
"negative": -1,
|
||||
"boolean": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=manager),
|
||||
)
|
||||
|
||||
assert channel._attached_model_fields("chat-1") == {
|
||||
"model_preset": "Deep Research",
|
||||
"usage": {"prompt_tokens": 120, "completion_tokens": 8},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
@@ -2938,11 +2966,21 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
event=TurnEndEvent(latency_ms=1500),
|
||||
event=TurnEndEvent(
|
||||
latency_ms=1500,
|
||||
usage={"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
|
||||
context_window_tokens=128_000,
|
||||
),
|
||||
))
|
||||
|
||||
assert _sent_ws_payloads(mock_ws) == [
|
||||
{"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "chat-1",
|
||||
"latency_ms": 1500,
|
||||
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
|
||||
"context_window_tokens": 128_000,
|
||||
},
|
||||
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
|
||||
]
|
||||
|
||||
@@ -4787,6 +4825,7 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
|
||||
session = Session(
|
||||
key="websocket:context-route",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
metadata={"_last_usage": {"prompt_tokens": 12, "completion_tokens": 3}},
|
||||
)
|
||||
manager = MagicMock()
|
||||
manager.read_session_snapshot.return_value = session
|
||||
@@ -4801,7 +4840,9 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
|
||||
response = await gateway.http._handle_session_context_get(request, encoded)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body.decode())["replay_messages"] == 1
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["replay_messages"] == 1
|
||||
assert body["last_usage"] == {"prompt_tokens": 12, "completion_tokens": 3}
|
||||
manager.read_session_snapshot.assert_called_once_with(session.key)
|
||||
|
||||
|
||||
|
||||
@@ -559,6 +559,7 @@ class WebuiTurnCoordinator:
|
||||
event=TurnModelUpdatedEvent(
|
||||
model=event.runtime.model,
|
||||
model_preset=event.runtime.model_preset,
|
||||
context_window_tokens=event.runtime.context_window_tokens,
|
||||
),
|
||||
metadata=event.context.metadata,
|
||||
)
|
||||
@@ -572,6 +573,10 @@ class WebuiTurnCoordinator:
|
||||
msg,
|
||||
session_key=event.context.session_key,
|
||||
latency_ms=event.latency_ms,
|
||||
usage=event.usage,
|
||||
context_window_tokens=(
|
||||
event.runtime.context_window_tokens if event.runtime is not None else None
|
||||
),
|
||||
)
|
||||
self._schedule_title_update_from_event(event)
|
||||
|
||||
@@ -619,6 +624,8 @@ class WebuiTurnCoordinator:
|
||||
*,
|
||||
session_key: str,
|
||||
latency_ms: int | None,
|
||||
usage: dict[str, int] | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
) -> None:
|
||||
if msg.channel != "websocket":
|
||||
return
|
||||
@@ -631,6 +638,8 @@ class WebuiTurnCoordinator:
|
||||
event=TurnEndEvent(
|
||||
latency_ms=latency_ms,
|
||||
goal_state=goal_state_ws_blob(session.metadata),
|
||||
usage=usage or None,
|
||||
context_window_tokens=context_window_tokens,
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,18 @@ def session_context_payload(session: Session) -> dict[str, Any]:
|
||||
summary_tokens = (
|
||||
estimate_message_tokens({"role": "system", "content": summary}) if summary else 0
|
||||
)
|
||||
raw_usage = session.metadata.get("_last_usage")
|
||||
last_usage = (
|
||||
{
|
||||
key: value
|
||||
for key, value in cast(dict[object, object], raw_usage).items()
|
||||
if isinstance(key, str)
|
||||
and type(value) is int
|
||||
and value >= 0
|
||||
}
|
||||
if isinstance(raw_usage, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
@@ -48,4 +60,5 @@ def session_context_payload(session: Session) -> dict[str, Any]:
|
||||
"estimated_session_tokens": replay_tokens + summary_tokens,
|
||||
"archived_summary": summary_preview or None,
|
||||
"archived_summary_at": summary_at,
|
||||
"last_usage": last_usage,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user