feat(webui): unify turn observability

This commit is contained in:
Xubin Ren
2026-08-22 20:51:24 +08:00
parent dbc1801d3c
commit 48eea29313
44 changed files with 1871 additions and 930 deletions
+13 -6
View File
@@ -425,7 +425,7 @@ class AgentRunner:
) -> AgentRunResult:
final_content: str | None = None
tools_used: list[str] = []
usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
usage = {"prompt_tokens": 0, "completion_tokens": 0}
error: str | None = None
stop_reason = "completed"
tool_events: list[dict[str, str]] = []
@@ -1384,11 +1384,6 @@ class AgentRunner:
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
))
@staticmethod
def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
for key, value in addition.items():
target[key] = target.get(key, 0) + value
@staticmethod
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
merged = dict(left)
@@ -1396,6 +1391,18 @@ class AgentRunner:
merged[key] = merged.get(key, 0) + value
return merged
@staticmethod
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
"""Fold one model request into the current turn's usage."""
total["request_count"] = total.get("request_count", 0) + 1
prompt_tokens = request.get("prompt_tokens")
if prompt_tokens is not None and prompt_tokens >= 0:
total["context_tokens"] = prompt_tokens
for key, value in request.items():
if key in {"context_tokens", "request_count"} or value < 0:
continue
total[key] = total.get(key, 0) + value
async def _execute_tools(
self,
spec: AgentRunSpec,
+1
View File
@@ -100,6 +100,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
model: str
model_preset: str | None = None
context_window_tokens: int | None = None
fallback: bool = False
def outbound_message_for_event(
+65 -8
View File
@@ -426,6 +426,7 @@ class WebSocketChannel(BaseChannel):
)
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
# -- Subscription bookkeeping -------------------------------------------
@@ -482,6 +483,9 @@ class WebSocketChannel(BaseChannel):
for key in tuple(self._stream_text_buffers):
if key[0] == chat_id:
self._stream_text_buffers.pop(key, None)
for key in tuple(self._reasoning_text_buffers):
if key[0] == chat_id:
self._reasoning_text_buffers.pop(key, None)
async def _discard_connection_owned_chat(
self,
@@ -1641,11 +1645,22 @@ class WebSocketChannel(BaseChannel):
include_source=include_source,
transcript_overrides=transcript_overrides,
)
if (
not persisted
and phase in {"answer", "complete"}
and (metadata or {}).get("webui") is True
):
return self._retain_turn_on_transcript_failure(
chat_id,
persisted=persisted,
metadata=metadata,
phase=phase,
)
@staticmethod
def _retain_turn_on_transcript_failure(
chat_id: str,
*,
persisted: bool,
metadata: dict[str, Any] | None,
phase: str,
) -> bool:
if not persisted and phase in {"answer", "complete"} and (metadata or {}).get("webui") is True:
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
mark_websocket_turn_transcript_persistence_failed(
chat_id,
@@ -1653,6 +1668,34 @@ class WebSocketChannel(BaseChannel):
)
return persisted
def _persist_turn_stream_event(
self,
chat_id: str,
event: dict[str, Any],
*,
completed_text: str | None,
metadata: dict[str, Any] | None,
phase: str,
include_source: bool = False,
) -> bool:
"""Persist the canonical end of a live stream, never its wire chunks."""
if not self._temporary_chats.should_persist_transcript(chat_id):
return True
persisted = self._transcripts.prepare_and_append_stream_event(
chat_id,
event,
completed_text=completed_text,
metadata=metadata,
phase=phase,
include_source=include_source,
)
return self._retain_turn_on_transcript_failure(
chat_id,
persisted=persisted,
metadata=metadata,
phase=phase,
)
async def send(self, msg: OutboundMessage) -> None:
event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
@@ -1685,6 +1728,7 @@ class WebSocketChannel(BaseChannel):
model_name=event.model,
model_preset=event.model_preset,
context_window_tokens=event.context_window_tokens,
fallback=event.fallback,
)
return
if isinstance(event, UserInputEvent):
@@ -1834,9 +1878,12 @@ class WebSocketChannel(BaseChannel):
}
if stream_id is not None:
body["stream_id"] = stream_id
self._persist_turn_transcript_event(
stream_key = (chat_id, str(stream_id or ""))
self._reasoning_text_buffers.setdefault(stream_key, []).append(delta)
self._persist_turn_stream_event(
chat_id,
body,
completed_text=None,
metadata=meta,
phase="reasoning",
)
@@ -1862,9 +1909,12 @@ class WebSocketChannel(BaseChannel):
}
if stream_id is not None:
body["stream_id"] = stream_id
self._persist_turn_transcript_event(
stream_key = (chat_id, str(stream_id or ""))
reasoning_text = "".join(self._reasoning_text_buffers.pop(stream_key, []))
self._persist_turn_stream_event(
chat_id,
body,
completed_text=reasoning_text or None,
metadata=meta,
phase="reasoning",
)
@@ -1912,6 +1962,7 @@ class WebSocketChannel(BaseChannel):
conns = list(self._subs.get(chat_id, ()))
meta = metadata or {}
stream_key = (chat_id, str(stream_id or ""))
completed_text: str | None = None
if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = (
@@ -1923,6 +1974,7 @@ class WebSocketChannel(BaseChannel):
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._media.rewrite_local_markdown_images(full_text)
completed_text = rewritten
if delta or rewritten != full_text:
body["text"] = rewritten
else:
@@ -1938,9 +1990,10 @@ class WebSocketChannel(BaseChannel):
body["resuming"] = True
if stream_end and merge_next:
body["merge_next"] = True
self._persist_turn_transcript_event(
self._persist_turn_stream_event(
chat_id,
body,
completed_text=completed_text,
metadata=meta,
phase="answer",
include_source=True,
@@ -1997,6 +2050,7 @@ class WebSocketChannel(BaseChannel):
# carries a durable incomplete marker. The HTTP replay path can
# recover the latter from session history after a gateway restart.
clear_websocket_turn_if_current(chat_id, turn_owner)
self._clear_stream_buffers(chat_id)
raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
@@ -2102,6 +2156,7 @@ class WebSocketChannel(BaseChannel):
model_name: Any,
model_preset: Any = None,
context_window_tokens: Any = None,
fallback: bool = False,
) -> None:
"""Notify one chat's subscribers which model is handling its current request."""
conns = list(self._subs.get(chat_id, ()))
@@ -2120,6 +2175,8 @@ class WebSocketChannel(BaseChannel):
body["model_preset"] = model_preset.strip()
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
body["context_window_tokens"] = context_window_tokens
if fallback:
body["fallback"] = True
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
@@ -2073,6 +2073,21 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
"model_preset": "Deep Research",
"context_window_tokens": 128_000,
}
await channel.send(
OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
event=TurnModelUpdatedEvent(
model="deepseek/deepseek-chat",
model_preset="Deep Research",
fallback=True,
),
)
)
fallback_payload = json.loads(chat_one.send.call_args.args[0])
assert fallback_payload["fallback"] is True
chat_two.send.assert_not_awaited()
@@ -2348,8 +2363,9 @@ async def test_send_delta_preserves_webui_source_metadata() -> None:
assert second["event"] == "stream_end"
assert second["source"] == source
lines = read_transcript_lines("websocket:chat-source-stream")
assert lines[-2]["source"] == source
assert lines[-1]["source"] == source
assert lines[-1]["event"] == "stream_end"
assert lines[-1]["text"] == "done"
@pytest.mark.asyncio
@@ -2374,6 +2390,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
@pytest.mark.asyncio
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
@@ -2403,6 +2421,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
"second",
]
assert ("chat-1", "sid") not in channel._stream_text_buffers
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["stream_end", "stream_end"]
assert [line["text"] for line in lines] == ["first ", "first second"]
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["content"] == "first second"
@pytest.mark.asyncio
@@ -2596,7 +2620,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
assert [line["event"] for line in lines] == ["stream_end", "turn_end"]
assert lines[0]["text"] == "hello world"
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["role"] == "assistant"
@@ -2604,6 +2629,77 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
assert body["messages"][-1]["latencyMs"] == 42
@pytest.mark.asyncio
async def test_stream_transcript_writes_once_per_completed_segment(monkeypatch) -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
append = MagicMock()
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
await channel.send_delta("chat-write-rate", "one", stream_id="s1")
await channel.send_delta("chat-write-rate", " two", stream_id="s1")
await channel.send_delta("chat-write-rate", " three", stream_id="s1")
append.assert_not_called()
await channel.send_delta("chat-write-rate", "", stream_id="s1", stream_end=True)
append.assert_called_once()
persisted = append.call_args.args[1]
assert persisted["event"] == "stream_end"
assert persisted["text"] == "one two three"
@pytest.mark.asyncio
async def test_reasoning_transcript_persists_one_canonical_record(monkeypatch) -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus),
)
append = MagicMock()
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
await channel.send_reasoning_delta("chat-reasoning-write-rate", "plan ", stream_id="r1")
await channel.send_reasoning_delta("chat-reasoning-write-rate", "then act", stream_id="r1")
append.assert_not_called()
await channel.send_reasoning_end("chat-reasoning-write-rate", stream_id="r1")
append.assert_called_once()
persisted = append.call_args.args[1]
assert persisted["event"] == "reasoning_end"
assert persisted["text"] == "plan then act"
@pytest.mark.asyncio
async def test_turn_end_discards_unclosed_stream_buffers() -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-unclosed", "partial", stream_id="s1")
await channel.send_reasoning_delta("chat-unclosed", "thinking", stream_id="r1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-unclosed",
content="",
event=TurnEndEvent(),
))
assert channel._stream_text_buffers == {}
assert channel._reasoning_text_buffers == {}
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
+5 -2
View File
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
)
continue
await self._notify_fallback_model(fallback_model)
fallback_kwargs = {
**kwargs,
"model": fallback_model,
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
fallback_response = await call(fallback_provider, fallback_kwargs)
if fallback_response.finish_reason != "error":
# Do not publish a model switch merely because a fallback was
# attempted. A fallback can fail just like the primary, and
# the WebUI would otherwise show a misleading success signal.
# Publish only after this response is known to be usable.
await self._notify_fallback_model(fallback_model)
logger.info(
"Fallback '{}' succeeded after primary '{}' failed",
fallback_model, primary_model,
+37 -1
View File
@@ -251,6 +251,26 @@ def _refusal_event_key(
)
def _reasoning_summary_event_key(
item_id: object,
summary_index: object,
) -> tuple[str | None, int] | None:
"""Identify one reasoning summary part across its text deltas."""
if not isinstance(summary_index, int) or isinstance(summary_index, bool):
return None
return (
item_id if isinstance(item_id, str) else None,
summary_index,
)
def _separate_reasoning_part(content: str | None, part: str) -> str:
"""Separate summary parts only when the provider supplied no whitespace."""
if content and not content[-1].isspace() and not part[0].isspace():
return "\n" + part
return part
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
"""Return only text not already surfaced by refusal deltas."""
if not streamed_text:
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
usage: dict[str, int] = {}
reasoning_content: str | None = None
streamed_reasoning = False
reasoning_summary_key: tuple[str | None, int] | None = None
refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = ""
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or ""
if delta_text:
summary_key = _reasoning_summary_event_key(
event.get("item_id"),
event.get("summary_index"),
)
if (
summary_key is not None
and reasoning_summary_key is not None
and summary_key != reasoning_summary_key
):
delta_text = _separate_reasoning_part(reasoning_content, delta_text)
if summary_key is not None:
reasoning_summary_key = summary_key
reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True
if on_reasoning_delta:
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
text = summary.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts) or None
content = ""
for part in parts:
content += _separate_reasoning_part(content, part)
return content or None
def parse_response_output(
+1
View File
@@ -495,6 +495,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
if context.runtime is not None
else None
),
fallback=True,
),
metadata=context.metadata,
)
+89 -23
View File
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
record.update(transcript_overrides)
return self.append(chat_id, record)
def prepare_and_append_stream_event(
self,
chat_id: str,
event: dict[str, Any],
*,
completed_text: str | None,
metadata: dict[str, Any] | None = None,
phase: str | None = None,
include_source: bool = False,
) -> bool:
"""Annotate every live stream event, but persist only completed segments.
Delta frames are a transport concern: retaining each token-sized chunk
would turn rendering cadence into disk-write cadence. The matching end
event carries the canonical segment text used by history replay.
"""
self.prepare_event(
chat_id,
event,
metadata=metadata,
phase=phase,
include_source=include_source,
)
if event.get("event") in {"delta", "reasoning_delta"}:
return True
record = dict(event)
if completed_text is not None:
record["text"] = completed_text
return self.append(chat_id, record)
def append_user_message(
self,
chat_id: str,
@@ -1903,13 +1933,24 @@ def replay_transcript_to_ui_messages(
kept.append(m)
messages = kept
def stamp_latency(latency_ms: int) -> None:
def stamp_completion(
*,
latency_ms: int | None = None,
usage: dict[str, int] | None = None,
context_window_tokens: int | None = None,
) -> None:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
completion: dict[str, Any] = {"isStreaming": False}
if latency_ms is not None:
completion["latencyMs"] = latency_ms
if usage:
completion["usage"] = usage
if context_window_tokens is not None:
completion["contextWindowTokens"] = context_window_tokens
messages[i] = {
**messages[i],
"latencyMs": latency_ms,
"isStreaming": False,
**completion,
}
return
@@ -2215,30 +2256,27 @@ def replay_transcript_to_ui_messages(
turn_fields = _turn_fields(rec, "answer")
source_fields = _source_fields(rec)
if isinstance(final_text, str):
if buffer_message_id is None:
buffer_message_id = find_active_placeholder(messages, turn_fields)
if buffer_message_id is None:
buffer_message_id = _new_id("buf", idx)
messages.append(
{
"id": buffer_message_id,
"role": "assistant",
messages.append({
"id": buffer_message_id,
"role": "assistant",
"content": "",
"isStreaming": True,
"createdAt": _created_at_ms(rec, idx),
})
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {
**m,
"content": final_text,
"isStreaming": True,
**turn_fields,
**source_fields,
"createdAt": _created_at_ms(rec, idx),
},
)
else:
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {
**m,
"content": final_text,
"isStreaming": True,
**turn_fields,
**source_fields,
}
break
}
break
if merge_next:
buffer_parts = [final_text]
elif source_fields and buffer_message_id is not None:
@@ -2274,6 +2312,16 @@ def replay_transcript_to_ui_messages(
if ev == "reasoning_end":
if suppress_until_turn_end:
continue
text = rec.get("text")
if isinstance(text, str) and text:
close_file_edit_phase_before_activity()
attach_reasoning_chunk(
messages,
text,
idx,
_turn_fields(rec, "reasoning"),
_created_at_ms(rec, idx),
)
close_reasoning(messages)
continue
@@ -2401,8 +2449,26 @@ def replay_transcript_to_ui_messages(
messages[i] = {**m, "isStreaming": False}
prune_reasoning_only()
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
stamp_latency(int(lat))
usage = rec.get("usage")
sanitized_usage = (
{
key: value
for key, value in cast(dict[object, object], usage).items()
if isinstance(key, str) and type(value) is int and value >= 0
}
if isinstance(usage, dict)
else None
)
context_window = rec.get("context_window_tokens")
stamp_completion(
latency_ms=int(lat) if isinstance(lat, (int, float)) and lat >= 0 else None,
usage=sanitized_usage,
context_window_tokens=(
int(context_window)
if isinstance(context_window, (int, float)) and context_window >= 0
else None
),
)
buffer_message_id = None
buffer_parts = []
continue