refactor: decouple webui runtime state via events

This commit is contained in:
chengyongru
2026-06-01 23:00:53 +08:00
committed by Xubin Ren
parent 0c6ce80aeb
commit 628b250e9a
11 changed files with 559 additions and 153 deletions
+1
View File
@@ -57,3 +57,4 @@ class ToolContext:
image_generation_provider_configs: dict[str, Any] | None = None
timezone: str = "UTC"
workspace_sandbox: Any | None = None
runtime_events: Any | None = None
+43 -26
View File
@@ -23,12 +23,11 @@ from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.events import OutboundMessage
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
discard_legacy_goal_state_key,
goal_state_raw,
goal_state_ws_blob,
parse_goal_state,
)
@@ -43,9 +42,13 @@ def _iso_now() -> str:
class _GoalToolsMixin(ContextAware):
"""Shared routing context + Session lookup."""
def __init__(self, sessions: SessionManager, bus: Any | None = None) -> None:
def __init__(
self,
sessions: SessionManager,
runtime_events: RuntimeEventBus | None = None,
) -> None:
self._sessions = sessions
self._bus = bus
self._runtime_events = runtime_events
# Each subclass gets its own ContextVar so concurrent tasks across
# different tool types (LongTaskTool vs CompleteGoalTool) do not
# interfere with each other.
@@ -66,25 +69,25 @@ class _GoalToolsMixin(ContextAware):
return None
return self._sessions.get_or_create(key)
async def _publish_goal_state_ws(self, metadata: dict[str, Any]) -> None:
"""Fan-out authoritative goal snapshot for this WebSocket chat only."""
bus = self._bus
async def _publish_goal_state_changed(self, metadata: dict[str, Any]) -> None:
"""Publish authoritative goal metadata as a runtime event."""
runtime_events = self._runtime_events
rc = self._request_ctx.get()
if bus is None or rc is None or rc.channel != "websocket":
if runtime_events is None or rc is None:
return
cid = (rc.chat_id or "").strip()
if not cid:
return
await bus.publish_outbound(
OutboundMessage(
channel="websocket",
chat_id=cid,
content="",
metadata={
"_goal_state_sync": True,
"goal_state": goal_state_ws_blob(metadata),
},
),
await runtime_events.publish(
GoalStateChanged(
context=RuntimeEventContext(
channel=rc.channel,
chat_id=cid,
session_key=rc.session_key or f"{rc.channel}:{cid}",
metadata=dict(rc.metadata or {}),
),
session_metadata=dict(metadata),
)
)
@@ -108,14 +111,21 @@ class _GoalToolsMixin(ContextAware):
class LongTaskTool(Tool, _GoalToolsMixin):
"""Begin or replace focus on a long-running objective stored on the session."""
def __init__(self, sessions: Any, bus: Any | None = None) -> None:
_GoalToolsMixin.__init__(self, sessions, bus)
def __init__(
self,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None # guarded by enabled()
return cls(sessions=sess, bus=getattr(ctx, "bus", None))
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
@@ -160,7 +170,7 @@ class LongTaskTool(Tool, _GoalToolsMixin):
sess.metadata[GOAL_STATE_KEY] = blob
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
await self._publish_goal_state_ws(sess.metadata)
await self._publish_goal_state_changed(sess.metadata)
extra = f"\nSummary line: {summary}" if summary else ""
return (
"Goal recorded. Keep working toward the objective using ordinary tools. "
@@ -183,14 +193,21 @@ class LongTaskTool(Tool, _GoalToolsMixin):
class CompleteGoalTool(Tool, _GoalToolsMixin):
"""Mark the active sustained goal finished after all required work is verified."""
def __init__(self, sessions: Any, bus: Any | None = None) -> None:
_GoalToolsMixin.__init__(self, sessions, bus)
def __init__(
self,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
return cls(sessions=sess, bus=getattr(ctx, "bus", None))
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
@@ -227,7 +244,7 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
}
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
await self._publish_goal_state_ws(sess.metadata)
await self._publish_goal_state_changed(sess.metadata)
tail = (recap or "").strip()
if tail:
return f"Goal marked complete ({ended}). Recap:\n{tail}"