mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 00:22:31 +00:00
Reasoning now flows as its own stream — symmetric to the answer's ``delta`` / ``stream_end`` pair — instead of being shipped as one oversized progress message. This lets WebUI render a live "Thinking…" bubble that updates in place, then auto-collapses when the stream closes. Other channels remain plugin no-ops by default. ## Protocol New metadata: ``_reasoning_delta`` (chunk) and ``_reasoning_end`` (close marker). ChannelManager routes both to the dedicated plugin hooks below; the legacy one-shot ``_reasoning`` is kept for back-compat and BaseChannel expands it into a single delta + end pair so plugins only ever implement the streaming primitives. WebSocket emits two new events: - ``reasoning_delta`` (event, chat_id, text, optional stream_id) - ``reasoning_end`` (event, chat_id, optional stream_id) ## BaseChannel surface - ``send_reasoning_delta(chat_id, delta, metadata)`` — no-op default - ``send_reasoning_end(chat_id, metadata)`` — no-op default - ``send_reasoning(msg)`` — back-compat wrapper, base impl forwards to the streaming primitives A channel adds reasoning support by overriding the two streaming primitives. Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-ops until their bubble UIs are adapted; reasoning silently drops at dispatch, never as a stray text message. ## AgentHook Adds ``emit_reasoning_end`` to the hook lifecycle. ``_LoopHook`` tracks whether a reasoning segment is open and closes it on: - the first answer delta arriving (so the UI locks the bubble before the answer renders below), - ``on_stream_end``, - one-shot ``reasoning_content`` / ``thinking_blocks`` after a single non-streaming response. ## WebUI - ``UIMessage.reasoning`` is now a single accumulated string with a companion ``reasoningStreaming`` flag. - ``useNanobotStream`` consumes ``reasoning_delta`` / ``reasoning_end``; legacy ``kind: "reasoning"`` is auto-translated to a delta + end. - New ``ReasoningBubble``: shimmer header + auto-expanded while streaming, collapses to a clickable "Thinking" pill once closed, respects ``prefers-reduced-motion``. - Answer deltas adopt the reasoning placeholder so the bubble and the answer share one assistant row. ## Tests - ``tests/channels/test_channel_manager_reasoning.py`` — manager routes delta + end, drops on channel opt-out, expands one-shot back-compat. - ``tests/channels/test_websocket_channel.py`` — new ``reasoning_delta`` / ``reasoning_end`` frames, empty-chunk safety, no-subscriber safety, back-compat expansion. - ``tests/agent/test_runner_reasoning.py`` — runner closes the segment on streaming answer start and after one-shot reasoning. - WebUI ``useNanobotStream`` + ``message-bubble`` cover the new protocol and the shimmer styling. ## Docs ``docs/configuration.md`` and ``docs/websocket.md`` document the new events and the plugin contract. Co-authored-by: Cursor <cursoragent@cursor.com>
142 lines
4.9 KiB
Python
142 lines
4.9 KiB
Python
"""Shared lifecycle hook primitives for agent runs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AgentHookContext:
|
|
"""Mutable per-iteration state exposed to runner hooks."""
|
|
|
|
iteration: int
|
|
messages: list[dict[str, Any]]
|
|
response: LLMResponse | None = None
|
|
usage: dict[str, int] = field(default_factory=dict)
|
|
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
|
tool_results: list[Any] = field(default_factory=list)
|
|
tool_events: list[dict[str, str]] = field(default_factory=list)
|
|
streamed_content: bool = False
|
|
streamed_reasoning: bool = False
|
|
final_content: str | None = None
|
|
stop_reason: str | None = None
|
|
error: str | None = None
|
|
|
|
|
|
class AgentHook:
|
|
"""Minimal lifecycle surface for shared runner customization."""
|
|
|
|
def __init__(self, reraise: bool = False) -> None:
|
|
self._reraise = reraise
|
|
|
|
def wants_streaming(self) -> bool:
|
|
return False
|
|
|
|
async def before_iteration(self, context: AgentHookContext) -> None:
|
|
pass
|
|
|
|
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
|
pass
|
|
|
|
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
|
pass
|
|
|
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
|
pass
|
|
|
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
|
pass
|
|
|
|
async def emit_reasoning_end(self) -> None:
|
|
"""Mark the end of an in-flight reasoning stream.
|
|
|
|
Hooks that buffer ``emit_reasoning`` chunks (for in-place UI updates)
|
|
flush and freeze the rendered group here. One-shot hooks ignore.
|
|
"""
|
|
pass
|
|
|
|
async def after_iteration(self, context: AgentHookContext) -> None:
|
|
pass
|
|
|
|
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
|
return content
|
|
|
|
|
|
class CompositeHook(AgentHook):
|
|
"""Fan-out hook that delegates to an ordered list of hooks.
|
|
|
|
Error isolation: async methods catch and log per-hook exceptions
|
|
so a faulty custom hook cannot crash the agent loop.
|
|
``finalize_content`` is a pipeline (no isolation — bugs should surface).
|
|
"""
|
|
|
|
__slots__ = ("_hooks",)
|
|
|
|
def __init__(self, hooks: list[AgentHook]) -> None:
|
|
super().__init__()
|
|
self._hooks = list(hooks)
|
|
|
|
def wants_streaming(self) -> bool:
|
|
return any(h.wants_streaming() for h in self._hooks)
|
|
|
|
async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None:
|
|
for h in self._hooks:
|
|
if getattr(h, "_reraise", False):
|
|
await getattr(h, method_name)(*args, **kwargs)
|
|
continue
|
|
|
|
try:
|
|
await getattr(h, method_name)(*args, **kwargs)
|
|
except Exception:
|
|
logger.exception("AgentHook.{} error in {}", method_name, type(h).__name__)
|
|
|
|
async def before_iteration(self, context: AgentHookContext) -> None:
|
|
await self._for_each_hook_safe("before_iteration", context)
|
|
|
|
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
|
await self._for_each_hook_safe("on_stream", context, delta)
|
|
|
|
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
|
await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
|
|
|
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
|
await self._for_each_hook_safe("before_execute_tools", context)
|
|
|
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
|
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
|
|
|
|
async def emit_reasoning_end(self) -> None:
|
|
await self._for_each_hook_safe("emit_reasoning_end")
|
|
|
|
async def after_iteration(self, context: AgentHookContext) -> None:
|
|
await self._for_each_hook_safe("after_iteration", context)
|
|
|
|
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
|
for h in self._hooks:
|
|
content = h.finalize_content(context, content)
|
|
return content
|
|
|
|
|
|
class SDKCaptureHook(AgentHook):
|
|
"""Record tool names and the final message list for ``RunResult``.
|
|
|
|
The runner mutates ``context.messages`` in place across iterations, so the
|
|
snapshot is refreshed on every ``after_iteration`` call; the last call
|
|
reflects the end-of-turn state the SDK caller cares about.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.tools_used: list[str] = []
|
|
self.messages: list[dict[str, Any]] = []
|
|
|
|
async def after_iteration(self, context: AgentHookContext) -> None:
|
|
for call in context.tool_calls:
|
|
self.tools_used.append(call.name)
|
|
self.messages = list(context.messages)
|