feat(webui): persist agent activity events

This commit is contained in:
Xubin Ren
2026-05-17 23:51:52 +08:00
parent 4b5de66c58
commit c8bb04a8fe
13 changed files with 1238 additions and 208 deletions
+25 -64
View File
@@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.goal_state import (
goal_state_ws_blob,
runner_wall_llm_timeout_s,
)
from nanobot.session.manager import Session, SessionManager
@@ -44,8 +43,11 @@ from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
from nanobot.utils.webui_turn_helpers import publish_turn_run_status
from nanobot.utils.webui_turn_helpers import (
WebuiTurnCoordinator,
build_bus_progress_callback,
mark_webui_session,
)
if TYPE_CHECKING:
from nanobot.config.schema import (
@@ -237,6 +239,11 @@ class AgentLoop:
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
self._webui_turns = WebuiTurnCoordinator(
bus=self.bus,
sessions=self.sessions,
schedule_background=lambda coro: self._schedule_background(coro),
)
self.tools = ToolRegistry()
# One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars.
@@ -524,34 +531,7 @@ class AgentLoop:
self, msg: InboundMessage
) -> Callable[..., Awaitable[None]]:
"""Build a progress callback that publishes to the message bus."""
async def _bus_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
await self.bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
return _bus_progress
return build_bus_progress_callback(self.bus, msg)
async def _build_retry_wait_callback(
self, msg: InboundMessage
@@ -938,38 +918,12 @@ class AgentLoop:
content="", metadata=msg.metadata or {},
))
if msg.channel == "websocket":
# Signal that the turn is fully complete (all tools executed,
# final text streamed). This lets WS clients know when to
# definitively stop the loading indicator.
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if turn_lat is not None:
turn_metadata["latency_ms"] = int(turn_lat)
sess_turn = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=turn_metadata,
))
if msg.metadata.get("webui") is True:
async def _generate_title_and_notify() -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=self.provider,
model=self.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={**msg.metadata, "_session_updated": True},
))
self._schedule_background(_generate_title_and_notify())
await self._webui_turns.handle_turn_end(
msg,
session_key=session_key,
latency_ms=turn_lat,
)
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
@@ -1021,8 +975,9 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
await publish_turn_run_status(self.bus, msg, "idle")
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
@@ -1338,6 +1293,12 @@ class AgentLoop:
"include_timestamps": True,
}
ctx.history = ctx.session.get_history(**_hist_kwargs)
self._webui_turns.capture_title_context(
ctx.session_key,
ctx.msg,
self.provider,
self.model,
)
ctx.initial_messages = self._build_initial_messages(
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
@@ -1354,7 +1315,7 @@ class AgentLoop:
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
await publish_turn_run_status(self.bus, ctx.msg, "running")
await self._webui_turns.publish_run_status(ctx.msg, "running")
result = await self._run_agent_loop(
ctx.initial_messages,
on_progress=ctx.on_progress,
+38
View File
@@ -15,6 +15,12 @@ from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_tracker,
)
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message,
@@ -26,6 +32,7 @@ from nanobot.utils.helpers import (
strip_think,
truncate_text,
)
from nanobot.utils.progress_events import invoke_file_edit_progress
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
@@ -813,6 +820,21 @@ class AgentRunner:
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
file_edit_tracker = prepare_file_edit_tracker(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=spec.workspace,
params=params if isinstance(params, dict) else None,
)
if file_edit_tracker is not None and spec.progress_callback is not None:
await invoke_file_edit_progress(
spec.progress_callback,
[build_file_edit_start_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
)],
)
try:
if tool is not None:
result = await tool.execute(**params)
@@ -821,6 +843,11 @@ class AgentRunner:
except asyncio.CancelledError:
raise
except BaseException as exc:
if file_edit_tracker is not None and spec.progress_callback is not None:
await invoke_file_edit_progress(
spec.progress_callback,
[build_file_edit_error_event(file_edit_tracker, str(exc))],
)
event = {
"name": tool_call.name,
"status": "error",
@@ -842,6 +869,11 @@ class AgentRunner:
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
if file_edit_tracker is not None and spec.progress_callback is not None:
await invoke_file_edit_progress(
spec.progress_callback,
[build_file_edit_error_event(file_edit_tracker, result)],
)
event = {
"name": tool_call.name,
"status": "error",
@@ -860,6 +892,12 @@ class AgentRunner:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
if file_edit_tracker is not None and spec.progress_callback is not None:
await invoke_file_edit_progress(
spec.progress_callback,
[build_file_edit_end_event(file_edit_tracker)],
)
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail: