feat(webui): persist agent activity events
This commit is contained in:
+25
-64
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user