From 1c2ea1aad239d2480335cee61097ad1eaf838d19 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 16 May 2026 01:14:11 +0800 Subject: [PATCH] feat(goal): /goal command & long-running tasks (long_task) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(long-task): add LongTaskTool for multi-step agent tasks Implements a meta-ReAct loop where long-running tasks are broken into sequential subagent steps, each starting fresh with the original goal and progress from the previous step. This prevents context drift when agents work on complex, multi-step tasks. - Extract build_tool_registry() from SubagentManager for reuse - Add run_step() for synchronous subagent execution (no bus announcement) - Add HandoffTool and CompleteTool as signal mechanisms via shared dict - Add LongTaskTool orchestrator with simplified prompt (8 iterations/step) - Register LongTaskTool in main agent loop - Add _extract_handoff_from_messages fallback for robustness * fix(long-task): add debug logging for step-level observability * feat(long-task): major overhaul with structured handoffs, validation, and observability - Structured HandoffState: HandoffTool now accepts files_created, files_modified, next_step_hint, and verification fields instead of a plain string. Progress is passed between steps as structured data. - Completion validation round: After complete() is called, a dedicated validator step runs to verify the claim against the original goal. If validation fails, the task continues rather than returning a false completion. - Dynamic prompt system: 3 Jinja2 templates (step_start, step_middle, step_final) selected based on step number. Final steps get tighter budget and stronger "wrap up" guidance. - Automatic file change tracking: Extracts write_file/edit_file events from tool_events and injects them into the next step's context if the subagent forgot to report them explicitly. - Budget tracking & adaptive strategy: Cumulative token usage is tracked across steps. Per-step tool budget drops from 8 to 4 in the last two steps to force handoff/completion. - Crash retry with graceful degradation: A step that crashes is retried once. Persistent crashes terminate the task and return partial progress. - Full observability hooks for future WebUI integration: - set_hooks() with on_step_start, on_step_complete, on_handoff, on_validation_started, on_validation_passed, on_validation_failed, on_task_complete, on_task_error, and catch-all on_event. - Readable state properties: current_step, total_steps, status, last_handoff, cumulative_usage, goal. - inject_correction() allows external code to send user corrections that are injected into the next step's prompt. - run_step() accepts optional max_iterations for dynamic budget control. All 27 long-task tests and 11 subagent tests pass. * test(long-task): add boundary tests and fix race conditions - Add 7 edge-case tests: validation crash resilience, hook exception safety, mid-run correction injection, FIFO correction ordering, explicit file changes overriding auto-detection, final budget for max_steps=1, and dynamic budget switching boundaries - Fix assertion in test_long_task_completes_after_multiple_handoffs to match exact prompt format - Remove asyncio timing hack from test_state_exposure - Add asyncio.sleep(0) yield in test_inject_correction_during_execution to prevent race between signal injection and step continuation - All 34 tests passing * fix(long-task): address code review findings - Declare _scopes = {"core"} explicitly to prevent recursive nesting in subagent scope - Document fragile coupling in _extract_file_changes: path extraction depends on write_file/edit_file detail format; add debug log for unexpected formats - Align final-template threshold (max_steps - 2) with budget switch threshold - Eliminate hasattr(self, "_state") in _reset_state by initializing in __init__ * fix(long-task): honor final signal and file tracking Co-authored-by: Cursor * feat(long-task): improve prompt structure and agent contract - Expand LongTaskTool.description to instruct parent agent on goal construction, return value semantics, and how to handle results. - Expand CompleteTool.description to emphasize that the summary IS the final answer returned to the parent agent. - Prefix validated return value with an explicit "final answer" directive to stop parent agent from re-running work. - Redesign step_start.md: Step 1 is now explicitly for exploration, planning, and skeleton-building. complete() is discouraged. - Remove bulky payload debug logging from _emit(); add targeted info/warning/error logs at key state transitions instead. - Add signal_type to HandoffState for cleaner signal detection. * test(long-task): expect wrapped completion message after validation Align assertions with LongTaskTool final return shape on main. Co-authored-by: Cursor * feat(webui): turn timing strip, latency, and session-switch restore - Agent loop: publish goal_status run/idle for WebSocket turns; attach wall-clock latency_ms on turn_end and persisted assistant metadata. - WebSocket channel: forward goal_status and latency fields to clients. - NanobotClient: track goal_status started_at per chat without requiring onChat; useNanobotStream restores run strip when returning to a chat. - Thread UI: composer/shell viewport hooks for run duration and latency; format helpers and i18n strings. - MessageBubble: drop trailing StreamCursor (layout artifact vs block markdown). - Builtin / tests: model command coverage, websocket and loop tests. Covers multi-session UX and round-trip timing visibility for the WebUI. Co-authored-by: Cursor * fix: keep message-tool file attachments after canonical history hydrate - MessageTool records per-turn media paths delivered to the active chat. - nanobot.utils.session_attachments stages out-of-media-root files and merges into the last assistant message before save (loop stays a thin call). - WebUI MediaCell: use a signed URL as a real download link when present. Fixes attachments flashing then vanishing on turn_end when paths lived outside get_media_dir (e.g. workspace files). Co-authored-by: Cursor * feat(webui): agent activity cluster, stable keys, LTR sheen labels - Group reasoning and tool traces in AgentActivityCluster with i18n summaries - Stabilize React list keys for activity clusters (first message id anchor) - Replace background-clip shimmer with overlay sheen for streaming labels - ThreadMessages/MessageList integration and locale strings Co-authored-by: Cursor * fix(webui): render assistant reasoning with Markdown + deferred stream - Use MarkdownText for ReasoningBubble body (same GFM/KaTeX path as replies) - Apply muted/italic prose tokens so thinking stays visually subordinate - useDeferredValue while reasoningStreaming to ease parser work during deltas - Preload markdown chunk when trace opens; add regression test with preloaded renderer Co-authored-by: Cursor * fix(webui): default-collapse agent activity cluster while Working Outer fold no longer auto-expands during isTurnStreaming; user opens to see traces. Header sheen and live summary unchanged. Co-authored-by: Cursor * feat(long_task): cumulative run history, file union, and prompt tuning Inject cross-step summaries and merged file paths into middle/final step templates so chains do not lose early context. Strip the last run-history block when it duplicates Previous Progress to save tokens. Add optional cumulative_prompt_max_chars and cumulative_step_body_max_chars parameters with clamped defaults. Co-authored-by: Cursor * fix(webui): session switch keeps in-flight thread and replays buffered WS Save the prior chat message list to the per-chat cache in a layout effect when chatId changes (before stale writes could corrupt another chat). Skip one post-switch layout cache tick so we do not snapshot the wrong tab. Buffer inbound events per chat_id when no onChat subscriber is registered (e.g. user focused another session) and drain on resubscribe up to a cap, so streaming deltas are not lost while off-tab. Co-authored-by: Cursor * fix(webui): snap thread scroll to bottom on session open (no smooth glide) Use scroll-behavior auto on the viewport, instant programmatic scroll when following new messages and on scrollToBottomSignal. Keep smooth only for the explicit scroll-to-bottom button. Co-authored-by: Cursor * fix(webui): respect manual scroll-up after opening a session Track when the user leaves the bottom with a ref and skip ResizeObserver and deferred bottom snaps until they return or the conversation is reset. Remove the time-based force-bottom window that overrode atBottom. Multi-frame scrollToBottom honours the same guard unless force (scroll button). Co-authored-by: Cursor * Publish long_task UI snapshots on outbound metadata - Add OUTBOUND_META_AGENT_UI (_agent_ui) for channel-agnostic structured state - LongTaskTool publishes {kind: long_task, data: snapshot} on the bus with _progress - WebSocket send forwards metadata as agent_ui for WebUI clients - Tests for bus payload, WS frame, and progress assertions - Fix loop progress tests: ignore _goal_status in streaming final filter and avoid brittle outbound[-1] ordering after goal status idle messages Co-authored-by: Cursor * feat: WebUI long_task activity card and resilient history merge Add optional ui_summary to the long_task tool for one-line UI labels. Stream long_task agent_ui into a dedicated message row with timeline, markdown peek, and a right sheet for details. Merge canonical history after turn_end while re-inserting long_task rows before the final assistant reply. Collapse duplicate task_start/step_start steps in the timeline and extend i18n. Co-authored-by: Cursor * refactor: align long_task with thread_goal and drop orchestrator UI - Persist sustained objectives via session metadata (long_task / complete_goal); no subagent wiring or tool-driven agent_ui payloads.\n- Remove WebUI long-task activity UI, types, and translations; history merge preserves trace replay only, with legacy long_task rows normalized to traces.\n- Drop long_task prompt templates and get_long_task_run_dir; add webui thread disk helper for gateway persistence tests. Co-authored-by: Cursor * feat(agent): thread goal runtime context, tools, and skill - Add thread_goal_state helper and mirror active objectives into Runtime Context - Wire loop/context/memory/events as needed for goal metadata in turns - Expand long_task / complete_goal semantics (pivot/cancel/honest recap) - Add always-on thread-goal SKILL.md; align /goal command prompt - Tests for context builder and thread goal state - Remove unused webui ChatPane component Co-authored-by: Cursor * feat(thread-goal): add websocket snapshot helper and publish goal updates from long_task Introduce thread_goal_ws_blob for bounded JSON snapshots, attach snapshots to websocket turn_end metadata in AgentLoop, and let long_task fan-out dedicated thread_goal frames on the websocket channel after persisting session metadata. Co-authored-by: Cursor * feat(channels): websocket thread_goal frames, turn_end replay, and session API scrub for subagent inject Emit thread_goal events and optional thread_goal on turn_end; scrub persisted subagent announce blobs on GET /api/sessions/.../messages and shorten session list previews so WebUI does not surface full Task/Summarize scaffolding. Co-authored-by: Cursor * feat(webui): merge ephemeral traces per user turn when reconciling canonical history Preserve disk/live trace rows inside the matching user–assistant segment instead of stacking every trace before the final assistant reply (fixes inflated tool counts after refresh or session switch). Co-authored-by: Cursor * feat(webui): show assistant reply copy only on the last slice before the next user turn Avoid duplicate copy affordances on intermediate assistant bubbles that precede more agent activity in the same turn (tools or further assistant text). Co-authored-by: Cursor * feat(webui): thread_goal stream plumbing, composer goal strip, sky glow, and client-side subagent scrub projection Track thread_goal and turn_goal snapshots in NanobotClient, hydrate React state from thread_goal frames and turn_end, surface objective/elapsed in the composer, add breathing sky halo CSS while goals are active, mirror server scrub logic on history hydration and webui_thread snapshots, and extend tests/client mocks. Co-authored-by: Cursor * feat(channels): add Slack Socket Mode connect timeout with actionable timeout errors Abort hung websockets.connect handshakes after a bounded wait, log REST-vs-WSS guidance, surface RuntimeError to channel startup, and log successful WSS setup. Co-authored-by: Cursor * webui: expand thread goal in composer bottom sheet Add ChevronUp control on the run/goal strip that opens a bottom Sheet with full ui_summary and objective. Inline preview logic in RunElapsedStrip, add i18n strings across locales, and a composer unit test. Co-authored-by: Cursor * fix(webui): widen dedupeToolCallsForUi input for session API typing fetchSessionMessages types tool_calls as unknown; accept unknown so tsc build passes when passing message.tool_calls through. Co-authored-by: Cursor * refactor(agent): extract WebSocket turn run status to webui_turn_helpers * refactor(skills): rename thread-goal to long-task and document idempotent goals * feat(skills): rename sustained-goal skill to long-goal and tighten long_task guidance * chore: remove unused subagent/context/router helpers * feat(session): rename sustained goal to goal_state and align WS/WebUI - Move helpers from agent/thread_goal_state to session/goal_state: GOAL_STATE_KEY, goal_state_runtime_lines, goal_state_ws_blob, parse_goal_state. - Session metadata now uses "goal_state"; still read legacy "thread_goal"; long_task writes drop the legacy key after save. - WebSocket: event/field goal_state, _goal_state_sync; turn_end carries goal_state; accept legacy _thread_goal_sync/thread_goal inbound metadata for dispatch. - WebUI: GoalStateWsPayload, goalState hook/client props, i18n keys goalState*. - Runtime Context copy uses "Goal (active):" instead of "Thread goal". * feat(agent): stream Anthropic thinking deltas and fix stream idle timeout * refactor(webui): transcript jsonl as sole timeline source * fix(agent): reject mismatched WS message chat_id and stream reasoning deltas * feat(webui): hydrate sustained goal and run timer after websocket subscribe * chore(webui,websocket): remove unused fetch helpers and legacy thread_goal WS paths * Raise default max_tokens and context window in agent schema. Align AgentDefaults and ModelPresetConfig with typical Claude-scale usage (32k completion budget, 256k context window) and update migration tests. Co-authored-by: Cursor * feat(gateway): bootstrap prefers in-memory model; clarify websocket naming * fix(websocket): websocket _handle_message passes is_dm; refresh /status test expectations --------- Co-authored-by: chengyongru <2755839590@qq.com> Co-authored-by: chengyongru Co-authored-by: Cursor --- nanobot/agent/context.py | 28 +- nanobot/agent/loop.py | 66 ++- nanobot/agent/memory.py | 1 + nanobot/agent/runner.py | 7 + nanobot/agent/subagent.py | 12 +- nanobot/agent/tools/context.py | 1 + nanobot/agent/tools/filesystem.py | 5 - nanobot/agent/tools/loader.py | 2 +- nanobot/agent/tools/long_task.py | 233 ++++++++++ nanobot/agent/tools/message.py | 28 ++ nanobot/bus/events.py | 12 +- nanobot/channels/manager.py | 16 +- nanobot/channels/slack.py | 22 +- nanobot/channels/websocket.py | 266 +++++++++-- nanobot/cli/commands.py | 14 +- nanobot/cli/models.py | 2 +- nanobot/cli/onboard.py | 2 +- nanobot/command/builtin.py | 50 +++ nanobot/command/router.py | 14 +- nanobot/config/__init__.py | 2 + nanobot/config/paths.py | 5 + nanobot/config/schema.py | 8 +- nanobot/providers/anthropic_provider.py | 27 +- nanobot/providers/azure_openai_provider.py | 2 + nanobot/providers/base.py | 11 +- nanobot/providers/bedrock_provider.py | 2 + nanobot/providers/github_copilot_provider.py | 4 +- nanobot/providers/openai_codex_provider.py | 2 + nanobot/providers/openai_compat_provider.py | 18 +- nanobot/session/goal_state.py | 85 ++++ nanobot/session/manager.py | 13 +- nanobot/skills/README.md | 3 +- nanobot/skills/long-goal/SKILL.md | 42 ++ nanobot/utils/session_attachments.py | 74 +++ nanobot/utils/subagent_channel_display.py | 59 +++ nanobot/utils/webui_thread_disk.py | 31 ++ nanobot/utils/webui_transcript.py | 423 ++++++++++++++++++ nanobot/utils/webui_turn_helpers.py | 48 ++ tests/agent/test_context_builder.py | 43 +- tests/agent/test_loop_progress.py | 41 +- tests/agent/test_loop_save_turn.py | 19 + tests/agent/test_runner_reasoning.py | 66 ++- tests/agent/test_session_media_persist.py | 34 ++ tests/agent/tools/test_long_task.py | 155 +++++++ tests/channels/test_websocket_channel.py | 260 ++++++++++- tests/channels/test_websocket_http_routes.py | 75 +++- tests/cli/test_restart_command.py | 4 +- tests/command/test_model_command.py | 54 +++ tests/command/test_router_dispatchable.py | 2 + tests/config/test_config_migration.py | 4 +- tests/providers/test_anthropic_stream_idle.py | 149 ++++++ tests/providers/test_litellm_kwargs.py | 104 +++++ tests/session/test_goal_state.py | 90 ++++ tests/tools/test_message_tool.py | 130 ++++++ tests/utils/test_subagent_channel_display.py | 57 +++ tests/utils/test_webui_thread_disk.py | 20 + tests/utils/test_webui_transcript.py | 55 +++ tests/utils/test_webui_turn_helpers.py | 55 +++ webui/src/components/ChatList.tsx | 31 +- webui/src/components/ChatPane.tsx | 115 ----- webui/src/components/MessageBubble.tsx | 325 +++++++++----- webui/src/components/Sidebar.tsx | 4 +- .../thread/AgentActivityCluster.tsx | 150 +++++++ .../src/components/thread/ThreadComposer.tsx | 148 +++++- .../src/components/thread/ThreadMessages.tsx | 118 ++++- webui/src/components/thread/ThreadShell.tsx | 86 +++- .../src/components/thread/ThreadViewport.tsx | 59 ++- webui/src/components/ui/scroll-area.tsx | 2 +- webui/src/globals.css | 93 +++- webui/src/hooks/useNanobotStream.ts | 57 ++- webui/src/hooks/useSessions.ts | 103 +---- webui/src/i18n/locales/en/common.json | 23 +- webui/src/i18n/locales/es/common.json | 25 +- webui/src/i18n/locales/fr/common.json | 25 +- webui/src/i18n/locales/id/common.json | 25 +- webui/src/i18n/locales/ja/common.json | 25 +- webui/src/i18n/locales/ko/common.json | 25 +- webui/src/i18n/locales/vi/common.json | 25 +- webui/src/i18n/locales/zh-CN/common.json | 23 +- webui/src/i18n/locales/zh-TW/common.json | 25 +- webui/src/lib/api.ts | 45 +- webui/src/lib/format.ts | 30 ++ webui/src/lib/nanobot-client.ts | 70 ++- webui/src/lib/subagent-channel-display.ts | 59 +++ webui/src/lib/thread-display-compat.ts | 22 + webui/src/lib/tool-traces.ts | 21 + webui/src/lib/types.ts | 48 +- webui/src/tests/api.test.ts | 9 +- webui/src/tests/format.i18n.test.ts | 20 +- webui/src/tests/message-bubble.test.tsx | 36 +- webui/src/tests/nanobot-client.test.ts | 111 +++++ .../tests/subagent-channel-display.test.ts | 41 ++ webui/src/tests/thread-composer.test.tsx | 44 ++ webui/src/tests/thread-display-compat.test.ts | 20 + webui/src/tests/thread-messages.test.tsx | 54 ++- webui/src/tests/thread-shell.test.tsx | 104 +++-- webui/src/tests/thread-viewport.test.tsx | 2 +- webui/src/tests/useNanobotStream.test.tsx | 188 +++++++- webui/src/tests/useSessions.test.tsx | 241 ++++------ 99 files changed, 4886 insertions(+), 848 deletions(-) create mode 100644 nanobot/agent/tools/long_task.py create mode 100644 nanobot/session/goal_state.py create mode 100644 nanobot/skills/long-goal/SKILL.md create mode 100644 nanobot/utils/session_attachments.py create mode 100644 nanobot/utils/subagent_channel_display.py create mode 100644 nanobot/utils/webui_thread_disk.py create mode 100644 nanobot/utils/webui_transcript.py create mode 100644 nanobot/utils/webui_turn_helpers.py create mode 100644 tests/agent/test_session_media_persist.py create mode 100644 tests/agent/tools/test_long_task.py create mode 100644 tests/providers/test_anthropic_stream_idle.py create mode 100644 tests/session/test_goal_state.py create mode 100644 tests/utils/test_subagent_channel_display.py create mode 100644 tests/utils/test_webui_thread_disk.py create mode 100644 tests/utils/test_webui_transcript.py create mode 100644 tests/utils/test_webui_turn_helpers.py delete mode 100644 webui/src/components/ChatPane.tsx create mode 100644 webui/src/components/thread/AgentActivityCluster.tsx create mode 100644 webui/src/lib/subagent-channel-display.ts create mode 100644 webui/src/lib/thread-display-compat.ts create mode 100644 webui/src/tests/subagent-channel-display.test.ts create mode 100644 webui/src/tests/thread-display-compat.test.ts diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 42a07afe..19ee935c 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -6,10 +6,11 @@ import platform from contextlib import suppress from importlib.resources import files as pkg_files from pathlib import Path -from typing import Any +from typing import Any, Mapping, Sequence from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader +from nanobot.session.goal_state import goal_state_runtime_lines from nanobot.utils.helpers import ( current_time_str, detect_image_mime, @@ -90,8 +91,11 @@ class ContextBuilder: @staticmethod def _build_runtime_context( - channel: str | None, chat_id: str | None, timezone: str | None = None, + channel: str | None, + chat_id: str | None, + timezone: str | None = None, sender_id: str | None = None, + supplemental_lines: Sequence[str] | None = None, ) -> str: """Build untrusted runtime metadata block appended after user content.""" lines = [f"Current Time: {current_time_str(timezone)}"] @@ -99,6 +103,8 @@ class ContextBuilder: lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] if sender_id: lines += [f"Sender ID: {sender_id}"] + if supplemental_lines: + lines.extend(supplemental_lines) return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END @staticmethod @@ -147,9 +153,17 @@ class ContextBuilder: current_role: str = "user", sender_id: str | None = None, session_summary: str | None = None, + session_metadata: Mapping[str, Any] | None = None, ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" - runtime_ctx = self._build_runtime_context(channel, chat_id, self.timezone, sender_id=sender_id) + extra = goal_state_runtime_lines(session_metadata) + runtime_ctx = self._build_runtime_context( + channel, + chat_id, + self.timezone, + sender_id=sender_id, + supplemental_lines=extra or None, + ) user_content = self._build_user_content(current_message, media) # Merge runtime context and user content into a single user message @@ -197,11 +211,3 @@ class ContextBuilder: return text return images + [{"type": "text", "text": text}] - def add_tool_result( - self, messages: list[dict[str, Any]], - tool_call_id: str, tool_name: str, result: Any, - ) -> list[dict[str, Any]]: - """Add a tool result to the message list.""" - messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result}) - return messages - diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a24feb57..d87c748e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -32,6 +32,7 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm 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_runtime_lines, goal_state_ws_blob from nanobot.session.manager import Session, SessionManager from nanobot.utils.artifacts import generated_image_paths_from_messages from nanobot.utils.document import extract_documents @@ -39,7 +40,9 @@ from nanobot.utils.helpers import image_placeholder_text 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 if TYPE_CHECKING: from nanobot.config.schema import ( @@ -104,6 +107,9 @@ class TurnContext: pending_queue: asyncio.Queue | None = None pending_summary: str | None = None + turn_wall_started_at: float = field(default_factory=time.time) + turn_latency_ms: int | None = None + trace: list[StateTraceEntry] = field(default_factory=list) @@ -223,6 +229,7 @@ class AgentLoop: self.restrict_to_workspace = restrict_to_workspace self._start_time = time.time() self._last_usage: dict[str, int] = {} + self._pending_turn_latency_ms: dict[str, int] = {} self._extra_hooks: list[AgentHook] = hooks or [] self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) @@ -437,6 +444,7 @@ class AgentLoop: bus=self.bus, subagent_manager=self.subagents, cron_service=self.cron_service, + sessions=self.sessions, provider_snapshot_loader=self._provider_snapshot_loader, image_generation_provider_configs=self._image_generation_provider_configs, timezone=self.context.timezone or "UTC", @@ -598,6 +606,7 @@ class AgentLoop: chat_id=self._runtime_chat_id(msg), sender_id=msg.sender_id, session_summary=pending_summary, + session_metadata=session.metadata, ) async def _dispatch_command_inline( @@ -714,10 +723,13 @@ class AgentLoop: content, media = extract_documents(content, media) media = media or None user_content = self.context._build_user_content(content, media) + extra = goal_state_runtime_lines(session.metadata) if session is not None else [] runtime_ctx = self.context._build_runtime_context( pending_msg.channel, self._runtime_chat_id(pending_msg), self.context.timezone, + sender_id=pending_msg.sender_id, + supplemental_lines=extra or None, ) if isinstance(user_content, str): merged: str | list[dict[str, Any]] = f"{user_content}\n\n{runtime_ctx}" @@ -930,9 +942,15 @@ class AgentLoop: # 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={**msg.metadata, "_turn_end": True}, + content="", metadata=turn_metadata, )) if msg.metadata.get("webui") is True: async def _generate_title_and_notify() -> None: @@ -1004,6 +1022,8 @@ class AgentLoop: "Re-published {} leftover message(s) to bus for session {}", leftover, session_key, ) + await publish_turn_run_status(self.bus, msg, "idle") + self._pending_turn_latency_ms.pop(session_key, None) async def close_mcp(self) -> None: """Drain pending background archives, then close MCP connections.""" @@ -1081,7 +1101,9 @@ class AgentLoop: current_role=current_role, sender_id=msg.sender_id, session_summary=pending, + session_metadata=session.metadata, ) + t_wall = time.time() final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop( messages, session=session, channel=channel, chat_id=chat_id, message_id=msg.metadata.get("message_id"), @@ -1089,7 +1111,11 @@ class AgentLoop: session_key=key, pending_queue=pending_queue, ) - self._save_turn(session, all_msgs, 1 + len(history)) + wall_done = time.time() + latency_ms = max(0, int((wall_done - t_wall) * 1000)) + self._save_turn(session, all_msgs, 1 + len(history), turn_latency_ms=latency_ms) + if channel == "websocket": + self._pending_turn_latency_ms[key] = latency_ms session.enforce_file_cap(on_archive=self.context.memory.raw_archive) self._clear_runtime_checkpoint(session) self.sessions.save(session) @@ -1210,6 +1236,8 @@ class AgentLoop: had_injections: bool, generated_media: list[str], on_stream: Callable[[str], Awaitable[None]] | None, + *, + turn_latency_ms: int | None = None, ) -> OutboundMessage | None: """Assemble the final outbound message from turn results.""" # MessageTool suppression @@ -1223,6 +1251,8 @@ class AgentLoop: meta = dict(msg.metadata or {}) if on_stream is not None and stop_reason not in {"error", "tool_error"}: meta["_streamed"] = True + if turn_latency_ms is not None: + meta["latency_ms"] = int(turn_latency_ms) return OutboundMessage( channel=msg.channel, @@ -1325,6 +1355,7 @@ class AgentLoop: return "ok" async def _state_run(self, ctx: TurnContext) -> str: + await publish_turn_run_status(self.bus, ctx.msg, "running") result = await self._run_agent_loop( ctx.initial_messages, on_progress=ctx.on_progress, @@ -1354,13 +1385,17 @@ class AgentLoop: ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0) skip_msgs = ctx.all_messages[ctx.save_skip:] ctx.generated_media = generated_image_paths_from_messages(skip_msgs) - last_msg = ctx.all_messages[-1] if ctx.all_messages else None - if ctx.generated_media and last_msg and last_msg.get("role") == "assistant": - existing_media = last_msg.get("media") - media = existing_media if isinstance(existing_media, list) else [] - last_msg["media"] = list(dict.fromkeys([*media, *ctx.generated_media])) + mt = self.tools.get("message") + extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else [] + merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra) - self._save_turn(ctx.session, ctx.all_messages, ctx.save_skip) + ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000)) + self._save_turn( + ctx.session, ctx.all_messages, ctx.save_skip, + turn_latency_ms=ctx.turn_latency_ms, + ) + if ctx.msg.channel == "websocket": + self._pending_turn_latency_ms[ctx.session_key] = ctx.turn_latency_ms ctx.session.enforce_file_cap(on_archive=self.context.memory.raw_archive) self._clear_pending_user_turn(ctx.session) self._clear_runtime_checkpoint(ctx.session) @@ -1382,6 +1417,7 @@ class AgentLoop: ctx.had_injections, ctx.generated_media, ctx.on_stream, + turn_latency_ms=ctx.turn_latency_ms, ) return "ok" @@ -1425,10 +1461,18 @@ class AgentLoop: return filtered - def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None: + def _save_turn( + self, + session: Session, + messages: list[dict], + skip: int, + *, + turn_latency_ms: int | None = None, + ) -> None: """Save new-turn messages into session, truncating large tool results.""" from datetime import datetime + last_assistant_idx: int | None = None for m in messages[skip:]: entry = dict(m) role, content = entry.get("role"), entry.get("content") @@ -1458,6 +1502,10 @@ class AgentLoop: entry["content"] = filtered entry.setdefault("timestamp", datetime.now().isoformat()) session.messages.append(entry) + if role == "assistant": + last_assistant_idx = len(session.messages) - 1 + if turn_latency_ms is not None and last_assistant_idx is not None: + session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms) session.updated_at = datetime.now() def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool: diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 271fb3f6..fd233bfa 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -604,6 +604,7 @@ class Consolidator: chat_id=chat_id, sender_id=None, session_summary=summary, + session_metadata=session.metadata, ) return estimate_prompt_tokens_chain( self.provider, diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 64709afe..d5aa05f5 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -626,9 +626,16 @@ class AgentRunner: context.streamed_content = True await hook.on_stream(context, delta) + async def _thinking(delta: str) -> None: + if not delta: + return + context.streamed_reasoning = True + await hook.emit_reasoning(delta) + coro = self.provider.chat_stream_with_retry( **kwargs, on_content_delta=_stream, + on_thinking_delta=_thinking, ) elif wants_progress_streaming: stream_buf = "" diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index e71eb483..c57edca5 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -108,12 +108,18 @@ class SubagentManager: restrict_to_workspace=self.restrict_to_workspace, ) - def _build_tools(self) -> ToolRegistry: + def _build_tools( + self, + workspace: Path | None = None, + tools_config: ToolsConfig | None = None, + ) -> ToolRegistry: """Build an isolated subagent tool registry via ToolLoader.""" + root = self.workspace if workspace is None else workspace registry = ToolRegistry() + cfg = tools_config if tools_config is not None else self._subagent_tools_config() ctx = ToolContext( - config=self._subagent_tools_config(), - workspace=str(self.workspace), + config=cfg, + workspace=str(root.resolve()), file_state_store=FileStates(), ) ToolLoader().load(ctx, registry, scope="subagent") diff --git a/nanobot/agent/tools/context.py b/nanobot/agent/tools/context.py index 78e268ac..bd9898a0 100644 --- a/nanobot/agent/tools/context.py +++ b/nanobot/agent/tools/context.py @@ -28,6 +28,7 @@ class ToolContext: bus: Any | None = None subagent_manager: Any | None = None cron_service: Any | None = None + sessions: Any | None = None file_state_store: Any = field(default=None) provider_snapshot_loader: Callable[[], Any] | None = None image_generation_provider_configs: dict[str, Any] | None = None diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 4ff61a89..8f4f660d 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -594,11 +594,6 @@ def _find_matches(content: str, old_text: str) -> list[_MatchSpan]: return [] -def _find_match_line_numbers(content: str, old_text: str) -> list[int]: - """Return 1-based starting line numbers for the current matching strategies.""" - return [match.line for match in _find_matches(content, old_text)] - - def _collapse_internal_whitespace(text: str) -> str: return "\n".join(" ".join(line.split()) for line in text.splitlines()) diff --git a/nanobot/agent/tools/loader.py b/nanobot/agent/tools/loader.py index d35e3c75..85086c16 100644 --- a/nanobot/agent/tools/loader.py +++ b/nanobot/agent/tools/loader.py @@ -112,5 +112,5 @@ class ToolLoader: if not is_plugin_source: builtin_names.add(tool.name) except Exception: - logger.error("Failed to register tool: %s", cls_label) + logger.exception("Failed to register tool: %s", cls_label) return registered diff --git a/nanobot/agent/tools/long_task.py b/nanobot/agent/tools/long_task.py new file mode 100644 index 00000000..ba543dd4 --- /dev/null +++ b/nanobot/agent/tools/long_task.py @@ -0,0 +1,233 @@ +"""Sustained goal tools on the main agent (Codex-style). + +Follow the built-in **long-goal** skill for lifecycle rules and how to phrase +objectives (especially **idempotent**, compaction-safe goals). Load that skill +from the skills listing (path shown there) before composing ``long_task.goal`` text. + +``long_task`` registers an objective on the session (JSON-serializable metadata). +Active objectives are mirrored each turn into the Runtime Context block (see +``nanobot.session.goal_state.goal_state_runtime_lines``) so compaction cannot hide them. +Work proceeds in ordinary agent turns (same runner, compaction as configured). +Call ``complete_goal`` when the sustained objective should stop being tracked: +finished successfully, or cancelled / superseded / redirected—in every case the recap should match reality. + +There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui`` stream. +""" + +from __future__ import annotations + +from datetime import datetime +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.session.goal_state import ( + GOAL_STATE_KEY, + discard_legacy_goal_state_key, + goal_state_raw, + goal_state_ws_blob, + parse_goal_state, +) + +if TYPE_CHECKING: + from nanobot.session.manager import SessionManager + + +def _iso_now() -> str: + return datetime.now().isoformat() + + +class _GoalToolsMixin(ContextAware): + """Shared routing context + Session lookup.""" + + def __init__(self, sessions: SessionManager, bus: Any | None = None) -> None: + self._sessions = sessions + self._bus = bus + self._request_ctx: RequestContext | None = None + + def set_context(self, ctx: RequestContext) -> None: + self._request_ctx = ctx + + def _session(self): + if self._request_ctx is None: + return None + key = self._request_ctx.session_key + if not key: + 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 + rc = self._request_ctx + if bus is None or rc is None or rc.channel != "websocket": + 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), + }, + ), + ) + + +@tool_parameters( + tool_parameters_schema( + goal=StringSchema( + "Full objective text for sustained execution on this chat thread. " + "Required: read the entire **long-goal** skill before composing this argument " + "(locate **long-goal** in the skills listing and open its file path, e.g. read_file)—do **not** " + "call `long_task` until you have read it. " + "Apply that skill literally: desired outcomes and acceptance criteria; " + "idempotent, self-contained wording (safe across compaction and resume; " + "no duplicate destructive steps); explicit deliverables, scope boundaries, and verification.", + max_length=12_000, + ), + ui_summary=StringSchema( + "Optional one-line label for session lists / logs (≤120 chars).", + max_length=120, + nullable=True, + ), + required=["goal"], + ) +) +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) + + @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)) + + @classmethod + def enabled(cls, ctx: Any) -> bool: + return getattr(ctx, "sessions", None) is not None + + @property + def name(self) -> str: + return "long_task" + + @property + def description(self) -> str: + return ( + "Declare a sustained objective for this conversation. " + "Before calling: read the **long-goal** skill from its path in the skills listing—goals must be " + "idempotent and self-contained (clear end state, scope, verification), " + "not brittle step lists that break on retry or compaction. " + "Execution stays on the main agent across turns (use normal tools). " + "The active objective is mirrored each turn under Runtime Context as " + "\"Goal (active):\" plus the stored text. " + "When—and only when—the objective is fully satisfied, call complete_goal. " + "Do not call complete_goal for partial progress or because you are tired. " + "If an objective is already active, finish or complete_goal before starting another." + ) + + async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str: + sess = self._session() + if sess is None: + return ( + "Error: long_task requires an active chat session (missing routing context)." + ) + prior = parse_goal_state(goal_state_raw(sess.metadata)) + if isinstance(prior, dict) and prior.get("status") == "active": + return ( + "Error: a sustained goal is already active. " + "Use complete_goal when finished, or ask the user before replacing it." + ) + + summary = (ui_summary or "").strip()[:120] + blob = { + "status": "active", + "objective": goal.strip(), + "ui_summary": summary, + "started_at": _iso_now(), + } + 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) + extra = f"\nSummary line: {summary}" if summary else "" + return ( + "Goal recorded. Keep working toward the objective using ordinary tools. " + "When fully done (verified against what was asked), call complete_goal with a " + f"short recap.{extra}" + ) + + +@tool_parameters( + tool_parameters_schema( + recap=StringSchema( + "Brief recap for the user (plain text). When the goal succeeded, confirm outcomes; " + "if the user cancelled, pivoted, or replaced the objective, say so honestly.", + max_length=8000, + nullable=True, + ), + required=[], + ) +) +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) + + @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)) + + @classmethod + def enabled(cls, ctx: Any) -> bool: + return getattr(ctx, "sessions", None) is not None + + @property + def name(self) -> str: + return "complete_goal" + + @property + def description(self) -> str: + return ( + "End bookkeeping for the active sustained goal. " + "Use when the objective is fully achieved and verified—recap what was delivered. " + "Also call when the user cancels, redirects, or replaces the goal: recap must reflect " + "what actually happened (not necessarily success). " + "If no goal is active, the tool reports that and leaves metadata unchanged." + ) + + async def execute(self, recap: str | None = None, **kwargs: Any) -> str: + sess = self._session() + if sess is None: + return "Error: complete_goal requires an active chat session." + prior = parse_goal_state(goal_state_raw(sess.metadata)) + if not isinstance(prior, dict) or prior.get("status") != "active": + return "No active goal to complete." + + ended = _iso_now() + sess.metadata[GOAL_STATE_KEY] = { + **prior, + "status": "completed", + "completed_at": ended, + "recap": (recap or "").strip(), + } + discard_legacy_goal_state_key(sess.metadata) + self._sessions.save(sess) + await self._publish_goal_state_ws(sess.metadata) + tail = (recap or "").strip() + if tail: + return f"Goal marked complete ({ended}). Recap:\n{tail}" + return f"Goal marked complete ({ended})." + diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 9d154837..725e824e 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -24,6 +24,8 @@ from nanobot.config.paths import get_workspace_path ), chat_id=StringSchema( "Optional target chat/user ID for cross-channel/proactive delivery. " + "On WebSocket/WebUI turns: omit chat_id to use the server's conversation id " + "(never pass client_id values like anon-…). " "Do not set this to the current runtime chat for a normal reply." ), media=ArraySchema( @@ -72,6 +74,10 @@ class MessageTool(Tool, ContextAware): default={}, ) self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False) + self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar( + "message_turn_delivered_media", + default=(), + ) self._record_channel_delivery_var: ContextVar[bool] = ContextVar( "message_record_channel_delivery", default=False, @@ -100,6 +106,11 @@ class MessageTool(Tool, ContextAware): def start_turn(self) -> None: """Reset per-turn send tracking.""" self._sent_in_turn = False + self._turn_delivered_media_var.set(()) + + def turn_delivered_media_paths(self) -> list[str]: + """Absolute paths attached via this tool to the active chat in the current turn.""" + return list(self._turn_delivered_media_var.get()) def set_record_channel_delivery(self, active: bool): """Mark tool-sent messages as proactive channel deliveries.""" @@ -172,6 +183,20 @@ class MessageTool(Tool, ContextAware): default_channel = self._default_channel.get() default_chat_id = self._default_chat_id.get() channel = channel or default_channel + explicit_chat_id = chat_id + if ( + default_channel == "websocket" + and channel == "websocket" + and explicit_chat_id is not None + and str(explicit_chat_id).strip() != "" + and str(explicit_chat_id).strip() != str(default_chat_id).strip() + ): + return ( + "Error: chat_id does not match the active WebSocket conversation. " + "Omit chat_id (and usually channel) so delivery uses the current " + "conversation id from context — WebSocket client_id strings " + "(e.g. anon-…) are not chat ids." + ) chat_id = chat_id or default_chat_id # Only inherit default message_id when targeting the same channel+chat. # Cross-chat sends must not carry the original message_id, because @@ -215,6 +240,9 @@ class MessageTool(Tool, ContextAware): await self._send_callback(msg) if channel == default_channel and chat_id == default_chat_id: self._sent_in_turn = True + if media: + prev = self._turn_delivered_media_var.get() + self._turn_delivered_media_var.set(prev + tuple(str(p) for p in media)) media_info = f" with {len(media)} attachments" if media else "" button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else "" return f"Message sent to {channel}:{chat_id}{media_info}{button_info}" diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index 44fba848..636f9755 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -4,6 +4,11 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Any +# Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI +# payloads. Value is JSON-serializable with at least ``kind``; rich clients may +# render it and other channels may ignore unknown keys. +OUTBOUND_META_AGENT_UI = "_agent_ui" + @dataclass class InboundMessage: @@ -26,7 +31,12 @@ class InboundMessage: @dataclass class OutboundMessage: - """Message to send to a chat channel.""" + """Message to send to a chat channel. + + ``metadata`` can carry routing (``message_id``, …), trace flags (``_progress``), + and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI + channels may ignore unknown keys. + """ channel: str chat_id: str diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index c310943c..5bd2ef33 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import hashlib +from collections.abc import Callable from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Any @@ -55,10 +56,12 @@ class ChannelManager: bus: MessageBus, *, session_manager: "SessionManager | None" = None, + webui_runtime_model_name: Callable[[], str | None] | None = None, ): self.config = config self.bus = bus self._session_manager = session_manager + self._webui_runtime_model_name = webui_runtime_model_name self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {} @@ -89,11 +92,14 @@ class ChannelManager: kwargs: dict[str, Any] = {} # Only the WebSocket channel currently hosts the embedded webui # surface; other channels stay oblivious to these knobs. - if cls.name == "websocket" and self._session_manager is not None: - kwargs["session_manager"] = self._session_manager - static_path = _default_webui_dist() - if static_path is not None: - kwargs["static_dist_path"] = static_path + if cls.name == "websocket": + if self._session_manager is not None: + kwargs["session_manager"] = self._session_manager + static_path = _default_webui_dist() + if static_path is not None: + kwargs["static_dist_path"] = static_path + if self._webui_runtime_model_name is not None: + kwargs["runtime_model_name"] = self._webui_runtime_model_name channel = cls(section, self.bus, **kwargs) channel.transcription_provider = transcription_provider channel.transcription_api_key = transcription_key diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 5bb5d40a..757b05f2 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -52,6 +52,10 @@ class SlackConfig(Base): SLACK_MAX_MESSAGE_LEN = 39_000 # Slack API allows ~40k; leave margin SLACK_DOWNLOAD_TIMEOUT = 30.0 +# Abort Socket Mode WSS handshake after this many seconds. REST auth_test can still +# succeed while WSS blocks (firewall / region). slack-sdk does not apply HTTP(S)_PROXY +# to websockets.connect — see slack_sdk.socket_mode.websockets.SocketModeClient.connect. +SLACK_SOCKET_CONNECT_TIMEOUT_S = 45.0 _HTML_DOWNLOAD_PREFIXES = (b" None: - """Publish a WebUI runtime-model update onto the outbound bus.""" + """Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel).""" bus.outbound.put_nowait(OutboundMessage( channel="websocket", chat_id="*", @@ -165,18 +171,35 @@ def publish_runtime_model_update( )) -def _read_webui_model_name() -> str | None: - """Return the resolved startup model for readonly WebUI display.""" +def _default_model_name_from_config() -> str | None: + """Resolved model string from on-disk config (bootstrap fallback).""" try: from nanobot.config.loader import load_config model = load_config().resolve_preset().model.strip() return model or None except Exception as e: - logger.debug("webui bootstrap could not load model name: {}", e) + logger.debug("bootstrap model_name could not load from config: {}", e) return None +def _resolve_bootstrap_model_name( + runtime_name: Callable[[], str | None] | None, +) -> str | None: + """Prefer an in-process resolver (e.g. AgentLoop); else config-derived default.""" + if runtime_name is not None: + try: + raw = runtime_name() + except Exception as e: + logger.debug("bootstrap runtime model resolver failed: {}", e) + else: + if isinstance(raw, str): + stripped = raw.strip() + if stripped: + return stripped + return _default_model_name_from_config() + + def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]: """Parse normalized path and query parameters in one pass.""" parsed = urlparse("ws://x" + path_with_query) @@ -436,6 +459,7 @@ class WebSocketChannel(BaseChannel): *, session_manager: "SessionManager | None" = None, static_dist_path: Path | None = None, + runtime_model_name: Callable[[], str | None] | None = None, ): if isinstance(config, dict): config = WebSocketConfig.model_validate(config) @@ -449,7 +473,7 @@ class WebSocketChannel(BaseChannel): self._conn_default: dict[Any, str] = {} # Single-use tokens consumed at WebSocket handshake. self._issued_tokens: dict[str, float] = {} - # Multi-use tokens for the embedded webui's REST surface; checked but not consumed. + # Multi-use tokens for HTTP routes served beside WS; checked but not consumed. self._api_tokens: dict[str, float] = {} self._stop_event: asyncio.Event | None = None self._server_task: asyncio.Task[None] | None = None @@ -457,6 +481,7 @@ class WebSocketChannel(BaseChannel): self._static_dist_path: Path | None = ( static_dist_path.resolve() if static_dist_path is not None else None ) + self._runtime_model_name = runtime_model_name # Process-local secret used to HMAC-sign media URLs. The signed URL is # the capability — anyone who holds a valid URL can fetch that one # file, nothing else. The secret regenerates on restart so links @@ -482,6 +507,36 @@ class WebSocketChannel(BaseChannel): self._subs.pop(cid, None) self._conn_default.pop(connection, None) + async def _maybe_push_active_goal_state(self, chat_id: str) -> None: + """Replay an active sustained goal from session metadata after *chat_id* is subscribed. + + Goal metadata lives on the session JSONL and survives gateway restarts, but + connected clients normally see it via ``goal_state`` / ``turn_end`` frames. + Pushing here makes refresh + reconnect restore the strip without a new model turn. + """ + if self._session_manager is None: + return + row = self._session_manager.read_session_file(f"websocket:{chat_id}") + meta = row.get("metadata", {}) if isinstance(row, dict) else {} + if not isinstance(meta, dict): + meta = {} + blob = goal_state_ws_blob(meta) + if not blob.get("active"): + return + await self.send_goal_state(chat_id, blob) + + async def _maybe_push_turn_run_wall_clock(self, chat_id: str) -> None: + """Replay ``goal_status: running`` when a turn is still active (same-process refresh).""" + t0 = websocket_turn_wall_started_at(chat_id) + if t0 is None: + return + await self.send_goal_status(chat_id, "running", started_at=t0) + + async def _hydrate_after_subscribe(self, chat_id: str) -> None: + """Replay goal/run strip state after subscribe (same-process refresh).""" + await self._maybe_push_active_goal_state(chat_id) + await self._maybe_push_turn_run_wall_clock(chat_id) + async def _send_event(self, connection: Any, event: str, **fields: Any) -> None: """Send a control event (attached, error, ...) to a single connection.""" payload: dict[str, Any] = {"event": event} @@ -575,11 +630,11 @@ class WebSocketChannel(BaseChannel): if got == issue_expected: return self._handle_token_issue_http(connection, request) - # 2. WebUI bootstrap: mints tokens for the embedded UI. + # 2. Bootstrap (`/webui/bootstrap`): mint WS/API tokens + shared session metadata. if got == "/webui/bootstrap": - return self._handle_webui_bootstrap(connection, request) + return self._handle_bootstrap(connection, request) - # 3. REST surface for the embedded UI. + # 3. REST handlers co-located with this channel (sessions, settings, …). if got == "/api/sessions": return self._handle_sessions_list(request) @@ -602,6 +657,10 @@ class WebSocketChannel(BaseChannel): if m: return self._handle_session_messages(request, m.group(1)) + m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got) + if m: + return self._handle_webui_thread_get(request, m.group(1)) + # NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a # true ``DELETE`` verb. The action is folded into the path instead. m = re.match(r"^/api/sessions/([^/]+)/delete$", got) @@ -659,7 +718,7 @@ class WebSocketChannel(BaseChannel): if now > expiry: self._api_tokens.pop(token_key, None) - def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response: + def _handle_bootstrap(self, connection: Any, request: Any) -> Response: # When a secret is configured (token_issue_secret or static token), # validate it regardless of source IP. This secures deployments # behind a reverse proxy where all connections appear as localhost. @@ -669,7 +728,7 @@ class WebSocketChannel(BaseChannel): return _http_error(401, "Unauthorized") elif not _is_localhost(connection): # No secret configured: only allow localhost (local dev mode). - return _http_error(403, "webui bootstrap is localhost-only") + return _http_error(403, "bootstrap is localhost-only") # Cap outstanding tokens to avoid runaway growth from a misbehaving client. self._purge_expired_issued_tokens() self._purge_expired_api_tokens() @@ -693,7 +752,7 @@ class WebSocketChannel(BaseChannel): "token": token, "ws_path": self._expected_path(), "expires_in": self.config.token_ttl_s, - "model_name": _read_webui_model_name(), + "model_name": _resolve_bootstrap_model_name(self._runtime_model_name), } ) @@ -703,10 +762,8 @@ class WebSocketChannel(BaseChannel): if self._session_manager is None: return _http_error(503, "session manager unavailable") sessions = self._session_manager.list_sessions() - # The webui is only meaningful for websocket-channel chats — CLI / - # Slack / Lark / Discord sessions can't be resumed from the browser, - # so leaking them into the sidebar is just noise. Filter to the - # ``websocket:`` prefix and strip absolute paths on the way out. + # Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc. + # keys are not intended for resume over this HTTP surface. cleaned = [ {k: v for k, v in s.items() if k != "path"} for s in sessions @@ -918,8 +975,8 @@ class WebSocketChannel(BaseChannel): return _http_json_response(self._settings_payload(requires_restart=False)) @staticmethod - def _is_webui_session_key(key: str) -> bool: - """Return True when *key* belongs to the webui's websocket-only surface.""" + def _is_websocket_channel_session_key(key: str) -> bool: + """True when *key* is a ``websocket:…`` session exposed on this HTTP surface.""" return key.startswith("websocket:") def _handle_session_messages(self, request: WsRequest, key: str) -> Response: @@ -930,14 +987,16 @@ class WebSocketChannel(BaseChannel): decoded_key = _decode_api_key(key) if decoded_key is None: return _http_error(400, "invalid session key") - # The embedded webui only understands websocket-channel sessions. Keep - # its read surface aligned with ``/api/sessions`` instead of letting a - # caller probe arbitrary CLI / Slack / Lark history by handcrafted URL. - if not self._is_webui_session_key(decoded_key): + # Only ``websocket:…`` sessions are listed/served here — same boundary as + # ``/api/sessions``. Block handcrafted URLs from probing CLI / Slack / etc. + if not self._is_websocket_channel_session_key(decoded_key): return _http_error(404, "session not found") data = self._session_manager.read_session_file(decoded_key) if data is None: return _http_error(404, "session not found") + messages = data.get("messages") + if isinstance(messages, list): + scrub_subagent_messages_for_channel(messages) # Decorate persisted user messages with signed media URLs so the # client can render previews. The raw on-disk ``media`` paths are # stripped on the way out — they leak server filesystem layout and @@ -945,6 +1004,74 @@ class WebSocketChannel(BaseChannel): self._augment_media_urls(data) return _http_json_response(data) + def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + decoded_key = _decode_api_key(key) + if decoded_key is None: + return _http_error(400, "invalid session key") + if not self._is_websocket_channel_session_key(decoded_key): + return _http_error(404, "session not found") + data = build_webui_thread_response( + decoded_key, + augment_user_media=self._augment_transcript_user_media, + ) + if data is None: + return _http_error(404, "webui thread not found") + return _http_json_response(data) + + def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None: + sk = f"websocket:{chat_id}" + try: + dup = json.loads(json.dumps(wire, ensure_ascii=False)) + append_transcript_object(sk, dup) + except (ValueError, TypeError) as e: + self.logger.warning("webui transcript append failed: {}", e) + + def _augment_transcript_user_media(self, paths: list[str]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for pstr in paths: + path = Path(pstr) + att = self._sign_or_stage_media_path(path) + if att is None: + continue + mime, _ = mimetypes.guess_type(path.name) + kind = "video" if mime and mime.startswith("video/") else "image" + out.append( + {"kind": kind, "url": att["url"], "name": att.get("name", path.name)}, + ) + return out + + async def _handle_message( + self, + sender_id: str, + chat_id: str, + content: str, + media: list[str] | None = None, + metadata: dict[str, Any] | None = None, + session_key: str | None = None, + is_dm: bool = False, + ) -> None: + meta = metadata or {} + if meta.get("webui"): + user_obj: dict[str, Any] = { + "event": "user", + "chat_id": chat_id, + "text": content, + } + if media: + user_obj["media_paths"] = list(media) + self._try_append_webui_transcript(chat_id, user_obj) + await super()._handle_message( + sender_id, + chat_id, + content, + media, + metadata, + session_key, + is_dm, + ) + def _augment_media_urls(self, payload: dict[str, Any]) -> None: """Mutate *payload* in place: each message's ``media`` path list is replaced by a parallel ``media_urls`` list of signed fetch URLs. @@ -983,7 +1110,7 @@ class WebSocketChannel(BaseChannel): The URL is self-authenticating: the signature binds the payload to this process's ``_media_secret``, so only paths we chose to sign can be fetched. The returned path is relative to the server origin; the - client joins it against the existing webui base. + client joins it against this server's HTTP origin (same host as WS). """ try: media_root = get_media_dir().resolve() @@ -1079,12 +1206,12 @@ class WebSocketChannel(BaseChannel): decoded_key = _decode_api_key(key) if decoded_key is None: return _http_error(400, "invalid session key") - # Same boundary as ``_handle_session_messages``: the webui may only - # mutate websocket sessions, and deletion really does unlink the local - # JSONL, so keep the blast radius narrow and explicit. - if not self._is_webui_session_key(decoded_key): + # Same boundary as ``_handle_session_messages``: mutations apply only to + # websocket-channel sessions; deletion unlinks local JSONL — keep scope narrow. + if not self._is_websocket_channel_session_key(decoded_key): return _http_error(404, "session not found") deleted = self._session_manager.delete_session(decoded_key) + delete_webui_thread(decoded_key) return _http_json_response({"deleted": bool(deleted)}) def _serve_static(self, request_path: str) -> Response | None: @@ -1232,6 +1359,7 @@ class WebSocketChannel(BaseChannel): # Register only after ready is successfully sent to avoid out-of-order sends self._conn_default[connection] = default_chat_id self._attach(connection, default_chat_id) + await self._hydrate_after_subscribe(default_chat_id) async for raw in connection: if isinstance(raw, bytes): @@ -1344,6 +1472,7 @@ class WebSocketChannel(BaseChannel): new_id = str(uuid.uuid4()) self._attach(connection, new_id) await self._send_event(connection, "attached", chat_id=new_id) + await self._hydrate_after_subscribe(new_id) return if t == "attach": cid = envelope.get("chat_id") @@ -1352,6 +1481,7 @@ class WebSocketChannel(BaseChannel): return self._attach(connection, cid) await self._send_event(connection, "attached", chat_id=cid) + await self._hydrate_after_subscribe(cid) return if t == "message": cid = envelope.get("chat_id") @@ -1387,6 +1517,7 @@ class WebSocketChannel(BaseChannel): # Auto-attach on first use so clients can one-shot without a separate attach. self._attach(connection, cid) + await self._hydrate_after_subscribe(cid) metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} if envelope.get("webui") is True: metadata["webui"] = True @@ -1452,14 +1583,34 @@ class WebSocketChannel(BaseChannel): msg.metadata.get("_progress") or msg.metadata.get("_turn_end") or msg.metadata.get("_session_updated") + or msg.metadata.get("_goal_status") + or msg.metadata.get("_goal_state_sync") ): self.logger.debug("no active subscribers for chat_id={}", msg.chat_id) else: self.logger.warning("no active subscribers for chat_id={}", msg.chat_id) return + if msg.metadata.get("_goal_state_sync"): + blob = msg.metadata.get("goal_state") + await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False}) + return + if msg.metadata.get("_goal_status"): + status = msg.metadata.get("goal_status") + if status in ("running", "idle"): + started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at")) + await self.send_goal_status( + msg.chat_id, + status, + started_at=float(started_raw) if isinstance(started_raw, int | float) else None, + ) + return # Signal that the agent has fully finished processing the current turn. if msg.metadata.get("_turn_end"): - await self.send_turn_end(msg.chat_id) + lat = msg.metadata.get("latency_ms") + lat_i = int(lat) if isinstance(lat, (int, float)) else None + gs = msg.metadata.get("goal_state") + gs_blob = gs if isinstance(gs, dict) else None + await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob) return if msg.metadata.get("_session_updated"): await self.send_session_updated(msg.chat_id) @@ -1481,8 +1632,14 @@ class WebSocketChannel(BaseChannel): payload["media_urls"] = urls if msg.reply_to: payload["reply_to"] = msg.reply_to + lat = msg.metadata.get("latency_ms") + if isinstance(lat, (int, float)): + payload["latency_ms"] = int(lat) if msg.metadata.get("_tool_events"): payload["tool_events"] = msg.metadata["_tool_events"] + agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI) + if agent_ui is not None: + payload["agent_ui"] = agent_ui # Mark intermediate agent breadcrumbs (tool-call hints, generic # progress strings) so WS clients can render them as subordinate # trace rows rather than conversational replies. @@ -1490,6 +1647,7 @@ class WebSocketChannel(BaseChannel): payload["kind"] = "tool_hint" elif msg.metadata.get("_progress"): payload["kind"] = "progress" + self._try_append_webui_transcript(msg.chat_id, payload) raw = json.dumps(payload, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" ") @@ -1501,7 +1659,7 @@ class WebSocketChannel(BaseChannel): metadata: dict[str, Any] | None = None, ) -> None: """Push one chunk of model reasoning. Mirrors ``send_delta`` shape so - WebUI receives a stream that opens, updates in place, and closes — + clients receive a stream that opens, updates in place, and closes — rendered above the active assistant bubble with a shimmer header until the matching ``reasoning_end`` arrives. """ @@ -1517,6 +1675,7 @@ class WebSocketChannel(BaseChannel): stream_id = meta.get("_stream_id") if stream_id is not None: body["stream_id"] = stream_id + self._try_append_webui_transcript(chat_id, body) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" reasoning ") @@ -1538,6 +1697,7 @@ class WebSocketChannel(BaseChannel): stream_id = meta.get("_stream_id") if stream_id is not None: body["stream_id"] = stream_id + self._try_append_webui_transcript(chat_id, body) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" reasoning_end ") @@ -1562,20 +1722,64 @@ class WebSocketChannel(BaseChannel): } if meta.get("_stream_id") is not None: body["stream_id"] = meta["_stream_id"] + self._try_append_webui_transcript(chat_id, body) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" stream ") - async def send_turn_end(self, chat_id: str) -> None: + async def send_turn_end( + self, + chat_id: str, + latency_ms: int | None = None, + *, + goal_state: dict[str, Any] | None = None, + ) -> None: """Signal that the agent has fully finished processing the current turn.""" conns = list(self._subs.get(chat_id, ())) if not conns: return body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id} + if latency_ms is not None: + body["latency_ms"] = int(latency_ms) + if goal_state is not None: + body["goal_state"] = goal_state + self._try_append_webui_transcript(chat_id, body) raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" turn_end ") + async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None: + """Push persisted goal-state snapshot for *chat_id* (multi-chat isolation).""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body = {"event": "goal_state", "chat_id": chat_id, "goal_state": blob} + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" goal_state ") + + async def send_goal_status( + self, + chat_id: str, + status: str, + *, + started_at: float | None = None, + ) -> None: + """Notify subscribed clients that a turn started or finished (wall-clock hint).""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body: dict[str, Any] = { + "event": "goal_status", + "chat_id": chat_id, + "status": status, + } + if status == "running" and started_at is not None: + body["started_at"] = started_at + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" goal_status ") + async def send_session_updated(self, chat_id: str) -> None: """Notify clients that session metadata changed outside the main turn.""" conns = list(self._subs.get(chat_id, ())) @@ -1592,7 +1796,7 @@ class WebSocketChannel(BaseChannel): model_name: Any, model_preset: Any = None, ) -> None: - """Broadcast runtime model changes to all active WebUI clients.""" + """Broadcast runtime model changes to every open websocket connection.""" conns = list(self._conn_chats) if not conns or not isinstance(model_name, str) or not model_name.strip(): return diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index e02653bf..cc14f52c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -829,9 +829,21 @@ def _run_gateway( cron.on_job = on_cron_job + def _webui_runtime_model_name() -> str | None: + model = getattr(agent, "model", None) + if isinstance(model, str): + stripped = model.strip() + return stripped or None + return None + # Create channel manager (forwards SessionManager so the WebSocket channel # can serve the embedded webui's REST surface). - channels = ChannelManager(config, bus, session_manager=session_manager) + channels = ChannelManager( + config, + bus, + session_manager=session_manager, + webui_runtime_model_name=_webui_runtime_model_name, + ) def _pick_heartbeat_target() -> tuple[str, str]: """Pick a routable channel/chat target for heartbeat-triggered messages.""" diff --git a/nanobot/cli/models.py b/nanobot/cli/models.py index 0ba24018..129169ee 100644 --- a/nanobot/cli/models.py +++ b/nanobot/cli/models.py @@ -22,7 +22,7 @@ def get_model_context_limit(model: str, provider: str = "auto") -> int | None: return None -def get_model_suggestions(partial: str, provider: str = "auto", limit: int = 20) -> list[str]: +def get_model_suggestions(_partial: str, provider: str = "auto", limit: int = 20) -> list[str]: return [] diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 13b2a978..96c97c08 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -486,7 +486,7 @@ def _input_model_with_autocomplete( def __init__(self, provider_name: str): self.provider = provider_name - def get_completions(self, document, complete_event): + def get_completions(self, document, _complete_event): text = document.text_before_cursor suggestions = get_model_suggestions(text, provider=self.provider, limit=50) for model in suggestions: diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 27dbdbe7..4646df38 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import os import sys +import time from contextlib import suppress from dataclasses import dataclass @@ -72,6 +73,13 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "history", "[n]", ), + BuiltinCommandSpec( + "/goal", + "Start long-running goal", + "Tell the agent to treat the request as a long-running goal.", + "activity", + "", + ), BuiltinCommandSpec( "/dream", "Run Dream", @@ -546,6 +554,46 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage: ) +_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread. + +Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers. + +Goal: +{goal} +""" + + +async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None: + """Rewrite /goal into a normal agent turn that nudges long_task use.""" + goal = ctx.args.strip() + if not goal: + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content="Usage: /goal ", + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, + ) + if ctx.session is None: + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content=( + "A task is already running for this chat. " + "Use `/stop` first, then send `/goal ` again." + ), + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, + ) + + ctx.msg.metadata = { + **dict(ctx.msg.metadata or {}), + "original_command": "/goal", + "original_content": ctx.raw, + "goal_started_at": time.time(), + } + ctx.msg.content = _GOAL_PROMPT_TEMPLATE.format(goal=goal) + return None + + async def cmd_pairing(ctx: CommandContext) -> OutboundMessage: """List, approve, deny or revoke pairing requests.""" from nanobot.pairing import PAIRING_COMMAND_META_KEY, handle_pairing_command @@ -591,6 +639,8 @@ def register_builtin_commands(router: CommandRouter) -> None: router.prefix("/model ", cmd_model) router.exact("/history", cmd_history) router.prefix("/history ", cmd_history) + router.exact("/goal", cmd_goal) + router.prefix("/goal ", cmd_goal) router.exact("/dream", cmd_dream) router.exact("/dream-log", cmd_dream_log) router.prefix("/dream-log ", cmd_dream_log) diff --git a/nanobot/command/router.py b/nanobot/command/router.py index 98f938b1..362a0b14 100644 --- a/nanobot/command/router.py +++ b/nanobot/command/router.py @@ -32,14 +32,12 @@ class CommandRouter: (e.g. /stop, /restart). 2. *exact* — exact-match commands handled inside the dispatch lock. 3. *prefix* — longest-prefix-first match (e.g. "/team "). - 4. *interceptors* — fallback predicates (e.g. team-mode active check). """ def __init__(self) -> None: self._priority: dict[str, Handler] = {} self._exact: dict[str, Handler] = {} self._prefix: list[tuple[str, Handler]] = [] - self._interceptors: list[Handler] = [] def priority(self, cmd: str, handler: Handler) -> None: self._priority[cmd] = handler @@ -51,16 +49,13 @@ class CommandRouter: self._prefix.append((pfx, handler)) self._prefix.sort(key=lambda p: len(p[0]), reverse=True) - def intercept(self, handler: Handler) -> None: - self._interceptors.append(handler) - def is_priority(self, text: str) -> bool: return text.strip().lower() in self._priority def is_dispatchable_command(self, text: str) -> bool: """Check whether *text* matches any non-priority command tier (exact or prefix). - Does NOT check priority or interceptor tiers. + Does NOT check priority tier. If this returns True, ``dispatch()`` is guaranteed to match a handler. """ cmd = text.strip().lower() @@ -79,7 +74,7 @@ class CommandRouter: return None async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None: - """Try exact, prefix, then interceptors. Returns None if unhandled.""" + """Try exact, then prefix handlers. Returns None if unhandled.""" cmd = ctx.raw.lower() if handler := self._exact.get(cmd): @@ -90,9 +85,4 @@ class CommandRouter: ctx.args = ctx.raw[len(pfx):] return await handler(ctx) - for interceptor in self._interceptors: - result = await interceptor(ctx) - if result is not None: - return result - return None diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index 4b9fccec..386d9857 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -11,6 +11,7 @@ from nanobot.config.paths import ( get_logs_dir, get_media_dir, get_runtime_subdir, + get_webui_dir, get_workspace_path, ) from nanobot.config.schema import Config @@ -24,6 +25,7 @@ __all__ = [ "get_media_dir", "get_cron_dir", "get_logs_dir", + "get_webui_dir", "get_workspace_path", "is_default_workspace", "get_cli_history_path", diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py index e06f72de..5fc35420 100644 --- a/nanobot/config/paths.py +++ b/nanobot/config/paths.py @@ -43,6 +43,11 @@ def get_logs_dir() -> Path: return get_runtime_subdir("logs") +def get_webui_dir() -> Path: + """Return the directory for WebUI-only persisted display threads (JSON).""" + return get_runtime_subdir("webui") + + def get_workspace_path(workspace: str | None = None) -> Path: """Resolve and ensure the agent workspace path.""" path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index c8556ec9..8b8a0a29 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -93,8 +93,8 @@ class ModelPresetConfig(Base): model: str provider: str = "auto" - max_tokens: int = 8192 - context_window_tokens: int = 65_536 + max_tokens: int = 32_000 + context_window_tokens: int = 262_144 temperature: float = 0.1 reasoning_effort: str | None = None @@ -116,8 +116,8 @@ class AgentDefaults(Base): provider: str = ( "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection ) - max_tokens: int = 8192 - context_window_tokens: int = 65_536 + max_tokens: int = 32_000 + context_window_tokens: int = 262_144 context_block_limit: int | None = None temperature: float = 0.1 fallback_models: list[FallbackCandidate] = Field(default_factory=list) diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 2c6aa531..b667853a 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -589,6 +589,7 @@ class AnthropicProvider(LLMProvider): reasoning_effort: str | None = None, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: kwargs = self._build_kwargs( messages, tools, model, max_tokens, temperature, @@ -597,17 +598,33 @@ class AnthropicProvider(LLMProvider): idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) try: async with self._client.messages.stream(**kwargs) as stream: - if on_content_delta: - stream_iter = stream.text_stream.__aiter__() + if on_content_delta or on_thinking_delta: + # Idle timeout must track *any* SSE chunk (thinking_delta, + # tool JSON deltas, etc.), not only text_stream tokens. + # Otherwise extended thinking can stall text_stream for minutes + # while the connection is healthy (e.g. MiniMax Anthropic). while True: try: - text = await asyncio.wait_for( - stream_iter.__anext__(), + chunk = await asyncio.wait_for( + stream.__anext__(), timeout=idle_timeout_s, ) except StopAsyncIteration: break - await on_content_delta(text) + if ( + chunk.type == "content_block_delta" + and getattr(chunk.delta, "type", None) == "thinking_delta" + ): + piece = getattr(chunk.delta, "thinking", None) or "" + if piece and on_thinking_delta: + await on_thinking_delta(piece) + elif ( + chunk.type == "content_block_delta" + and getattr(chunk.delta, "type", None) == "text_delta" + ): + text = getattr(chunk.delta, "text", None) or "" + if text and on_content_delta: + await on_content_delta(text) response = await asyncio.wait_for( stream.get_final_message(), timeout=idle_timeout_s, diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index bc2a9d04..918a11ce 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -157,7 +157,9 @@ class AzureOpenAIProvider(LLMProvider): reasoning_effort: str | None = None, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: + _ = on_thinking_delta body = self._build_body( messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice, diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 1d598f20..f120fb9b 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -4,8 +4,8 @@ import asyncio import json import re from abc import ABC, abstractmethod -from contextlib import suppress from collections.abc import Awaitable, Callable +from contextlib import suppress from dataclasses import dataclass, field from datetime import datetime, timezone from email.utils import parsedate_to_datetime @@ -499,14 +499,21 @@ class LLMProvider(ABC): reasoning_effort: str | None = None, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: """Stream a chat completion, calling *on_content_delta* for each text chunk. + *on_thinking_delta* is reserved for providers that expose incremental + thinking/reasoning on the wire; the default fallback invokes neither + callback for native deltas (only the optional single *on_content_delta* + after :meth:`chat`). + Returns the same ``LLMResponse`` as :meth:`chat`. The default implementation falls back to a non-streaming call and delivers the full content as a single delta. Providers that support native streaming should override this method. """ + _ = on_thinking_delta response = await self.chat( messages=messages, tools=tools, model=model, max_tokens=max_tokens, temperature=temperature, @@ -535,6 +542,7 @@ class LLMProvider(ABC): reasoning_effort: object = _SENTINEL, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, retry_mode: str = "standard", on_retry_wait: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: @@ -551,6 +559,7 @@ class LLMProvider(ABC): max_tokens=max_tokens, temperature=temperature, reasoning_effort=reasoning_effort, tool_choice=tool_choice, on_content_delta=on_content_delta, + on_thinking_delta=on_thinking_delta, ) return await self._run_with_retry( self._safe_chat_stream, diff --git a/nanobot/providers/bedrock_provider.py b/nanobot/providers/bedrock_provider.py index 88c4ac2b..b3f4ea57 100644 --- a/nanobot/providers/bedrock_provider.py +++ b/nanobot/providers/bedrock_provider.py @@ -703,7 +703,9 @@ class BedrockProvider(LLMProvider): reasoning_effort: str | None = None, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: + _ = on_thinking_delta idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) content_parts: list[str] = [] reasoning_parts: list[str] = [] diff --git a/nanobot/providers/github_copilot_provider.py b/nanobot/providers/github_copilot_provider.py index acd5d057..fdba99eb 100644 --- a/nanobot/providers/github_copilot_provider.py +++ b/nanobot/providers/github_copilot_provider.py @@ -4,7 +4,7 @@ from __future__ import annotations import time import webbrowser -from collections.abc import Callable +from collections.abc import Awaitable, Callable from contextlib import suppress import httpx @@ -242,6 +242,7 @@ class GitHubCopilotProvider(OpenAICompatProvider): reasoning_effort: str | None = None, tool_choice: str | dict[str, object] | None = None, on_content_delta: Callable[[str], None] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, ): await self._refresh_client_api_key() return await super().chat_stream( @@ -253,4 +254,5 @@ class GitHubCopilotProvider(OpenAICompatProvider): reasoning_effort=reasoning_effort, tool_choice=tool_choice, on_content_delta=on_content_delta, + on_thinking_delta=on_thinking_delta, ) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 0d37b5ec..38209f59 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -99,7 +99,9 @@ class OpenAICodexProvider(LLMProvider): reasoning_effort: str | None = None, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: + _ = on_thinking_delta return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta) def get_default_model(self) -> str: diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index a983f63f..cf7b72ba 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -1160,6 +1160,7 @@ class OpenAICompatProvider(LLMProvider): reasoning_effort: str | None = None, tool_choice: str | dict[str, Any] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) try: @@ -1223,10 +1224,19 @@ class OpenAICompatProvider(LLMProvider): except StopAsyncIteration: break chunks.append(chunk) - if on_content_delta and chunk.choices: - text = getattr(chunk.choices[0].delta, "content", None) - if text: - await on_content_delta(text) + if chunk.choices: + delta_obj = chunk.choices[0].delta + if on_content_delta: + text = getattr(delta_obj, "content", None) + if text: + await on_content_delta(text) + if on_thinking_delta: + reasoning = getattr(delta_obj, "reasoning_content", None) or getattr( + delta_obj, "reasoning", None, + ) + r_text = self._extract_text_content(reasoning) + if r_text: + await on_thinking_delta(r_text) return self._parse_chunks(chunks) except asyncio.TimeoutError: return LLMResponse( diff --git a/nanobot/session/goal_state.py b/nanobot/session/goal_state.py new file mode 100644 index 00000000..2f32e6c2 --- /dev/null +++ b/nanobot/session/goal_state.py @@ -0,0 +1,85 @@ +"""Session metadata helpers for sustained goals (e.g. ``long_task`` / ``complete_goal``). + +Tools set ``metadata[GOAL_STATE_KEY]``. Reads accept the legacy session key ``thread_goal`` +for older sessions. The agent uses ``goal_state_runtime_lines`` and +``goal_state_ws_blob`` without importing tool implementations. +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping, MutableMapping + +GOAL_STATE_KEY = "goal_state" +# Older builds stored the same JSON blob under this key. +_LEGACY_GOAL_STATE_SESSION_KEY = "thread_goal" +_MAX_OBJECTIVE_IN_RUNTIME = 4000 +_MAX_OBJECTIVE_WS = 600 + + +def _session_goal_raw(metadata: Mapping[str, Any] | None) -> Any: + if not metadata: + return None + if GOAL_STATE_KEY in metadata: + return metadata.get(GOAL_STATE_KEY) + return metadata.get(_LEGACY_GOAL_STATE_SESSION_KEY) + + +def discard_legacy_goal_state_key(metadata: MutableMapping[str, Any]) -> None: + """Remove legacy metadata key after migrating writes to :data:`GOAL_STATE_KEY`.""" + metadata.pop(_LEGACY_GOAL_STATE_SESSION_KEY, None) + + +def goal_state_raw(metadata: Mapping[str, Any] | None) -> Any: + """Return the session goal blob under :data:`GOAL_STATE_KEY` or the legacy key.""" + return _session_goal_raw(metadata) + + +def parse_goal_state(blob: Any) -> dict[str, Any] | None: + if blob is None: + return None + if isinstance(blob, dict): + return blob + if isinstance(blob, str): + try: + parsed = json.loads(blob) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + return None + + +def goal_state_runtime_lines(metadata: Mapping[str, Any] | None) -> list[str]: + """Lines appended inside the Runtime Context block when a goal is active.""" + if not metadata: + return [] + goal = parse_goal_state(_session_goal_raw(metadata)) + if not isinstance(goal, dict) or goal.get("status") != "active": + return [] + objective = str(goal.get("objective") or "").strip() + if not objective: + return ["Goal: active (no objective text stored)."] + if len(objective) > _MAX_OBJECTIVE_IN_RUNTIME: + objective = objective[:_MAX_OBJECTIVE_IN_RUNTIME].rstrip() + "\n… (truncated)" + out = ["Goal (active):", objective] + hint = str(goal.get("ui_summary") or "").strip() + if hint: + out.append(f"Summary: {hint}") + return out + + +def goal_state_ws_blob(metadata: Mapping[str, Any] | None) -> dict[str, Any]: + """JSON-safe snapshot for WebSocket ``goal_state`` events (one chat_id per frame).""" + goal = parse_goal_state(_session_goal_raw(metadata)) if metadata else None + if isinstance(goal, dict) and goal.get("status") == "active": + objective = str(goal.get("objective") or "").strip() + if len(objective) > _MAX_OBJECTIVE_WS: + objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + "…" + summary = str(goal.get("ui_summary") or "").strip()[:120] + blob: dict[str, Any] = {"active": True} + if summary: + blob["ui_summary"] = summary + if objective: + blob["objective"] = objective + return blob + return {"active": False} diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 739007cb..26930110 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -20,6 +20,7 @@ from nanobot.utils.helpers import ( image_placeholder_text, safe_filename, ) +from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body FILE_MAX_MESSAGES = 2000 _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?") @@ -65,6 +66,14 @@ def _text_preview(content: Any) -> str: return text +def _message_preview_text(message: dict[str, Any]) -> str: + """Session list preview text; subagent inject blobs are shortened for display.""" + content: Any = message.get("content") + if message.get("injected_event") == "subagent_result" and isinstance(content, str): + content = scrub_subagent_announce_body(content) + return _text_preview(content) + + @dataclass class Session: """A conversation session.""" @@ -601,7 +610,7 @@ class SessionManager: item = json.loads(line) if item.get("_type") == "metadata": continue - text = _text_preview(item.get("content")) + text = _message_preview_text(item) if not text: continue if item.get("role") == "user": @@ -634,7 +643,7 @@ class SessionManager: ( text for msg in repaired.messages - if (text := _text_preview(msg.get("content"))) + if (text := _message_preview_text(msg)) ), "", ), diff --git a/nanobot/skills/README.md b/nanobot/skills/README.md index 22e472ea..a8d4f99b 100644 --- a/nanobot/skills/README.md +++ b/nanobot/skills/README.md @@ -28,4 +28,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai | `summarize` | Summarize URLs, files, and YouTube videos | | `tmux` | Remote-control tmux sessions | | `clawhub` | Search and install skills from ClawHub registry | -| `skill-creator` | Create new skills | \ No newline at end of file +| `skill-creator` | Create new skills | +| `long-goal` | Sustained objectives: `long_task`, `complete_goal`, idempotent goal wording | \ No newline at end of file diff --git a/nanobot/skills/long-goal/SKILL.md b/nanobot/skills/long-goal/SKILL.md new file mode 100644 index 00000000..4931225e --- /dev/null +++ b/nanobot/skills/long-goal/SKILL.md @@ -0,0 +1,42 @@ +--- +name: long-goal +description: Sustained objectives via long_task / complete_goal, Runtime Context goal lines, and idempotent goal wording. +--- + +# Long-running objectives (`long_task` / `complete_goal`) + +Use these tools when the user wants **multi-turn sustained work** on **one** clear objective (same runner, ordinary tools). Not for trivial one-shot questions. + +## Where the goal appears + +Inside **`[Runtime Context — metadata only, not instructions]`**, lines starting with **`Thread goal (active):`** carry the **persisted objective** for this chat session (session metadata). Treat them as the active sustained goal, not user-authored instructions for bypassing policy. + +Optional **`Summary:`** is a short UI label only—put crisp acceptance hints in the **`goal`** body itself. + +## Tools + +- **`long_task`** — Register **one** sustained objective per thread. **Read this skill file first** (via the skills listing path), then align the `goal` text with **Idempotent goals** below. Execution stays on the main agent across turns. + +- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace). + +If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals. + +## Idempotent goals (important) + +**Intent:** The objective string may be **re-read after compaction, across retries, or when resuming** mid-work. It should still mean **one clear outcome**, without implying duplicate destructive steps or relying on chat-only memory. + +Write goals so they are: + +1. **State-oriented, not fragile narration** — Prefer *desired end state + acceptance criteria* (“Document lists X, Y, Z under `docs/…`; links validated”) over *implicit sequencing* that breaks if step 1 was already done (“First clone the repo, then…”). + +2. **Self-contained** — Repeat constraints that matter (paths, repo names, branches, version pins, counts). Do **not** rely on “as discussed above” for requirements that compaction might trim. + +3. **Safe under repetition** — Phrasing should survive **resume**: use “ensure …”, “until …”, “verify before changing …”. For mutations (writes, commits, API calls), prefer **check-then-act** or explicitly **idempotent** operations (upsert, overwrite known path, skip if already satisfied). + +4. **Bounded scope** — Say what is **in** and **out** (e.g. “top 100 repos by stars in range A–B”, “only files under `src/`”). Reduces drift when the model re-enters the goal cold. + +5. **Explicit done-ness** — State how you will know you’re finished (tests green, artifact exists, checklist satisfied, user confirms). Avoid “when it looks good”. + +6. **`ui_summary`** — Short label for sidebars/logs; keep **non-load-bearing** (no secret requirements only in the summary). + +If you discover the objective was underspecified, you may ask the user—or **`complete_goal`** with recap and register a **narrower** replacement goal rather than overloading one ambiguous string. diff --git a/nanobot/utils/session_attachments.py b/nanobot/utils/session_attachments.py new file mode 100644 index 00000000..d761d33b --- /dev/null +++ b/nanobot/utils/session_attachments.py @@ -0,0 +1,74 @@ +"""Session replay: ensure assistant ``media`` paths are under the media root. + +WebUI history signing (``/api/.../messages``) only works for files inside +``get_media_dir``. Tool-driven attachments may live in the workspace; stage +copies into the websocket media bucket before persisting message JSON. +""" + +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path +from typing import Any + +from loguru import logger + +from nanobot.config.paths import get_media_dir +from nanobot.utils.helpers import safe_filename + + +def stage_media_paths_for_session_replay(paths: list[str]) -> list[str]: + """Keep local files only; copy anything outside the media root into ``media/websocket``.""" + root = get_media_dir().resolve() + out: list[str] = [] + seen: set[str] = set() + for raw in paths: + if not isinstance(raw, str) or not raw.strip(): + continue + if raw.startswith(("http://", "https://")): + continue + try: + p = Path(raw).expanduser().resolve() + except OSError: + continue + if not p.is_file(): + continue + try: + p.relative_to(root) + key = str(p) + except ValueError: + try: + media_dir = get_media_dir("websocket") + staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_filename(p.name) or 'attachment'}" + shutil.copyfile(p, staged) + key = str(staged.resolve()) + except OSError as exc: + logger.warning("failed to stage session media from {}: {}", raw, exc) + continue + if key not in seen: + out.append(key) + seen.add(key) + return out + + +def merge_turn_media_into_last_assistant( + all_messages: list[dict[str, Any]], + generated_image_paths: list[str], + extra_attachment_paths: list[str], +) -> None: + """Attach staged paths to the last assistant row in *all_messages* (in-place).""" + merged = list( + dict.fromkeys( + [ + *stage_media_paths_for_session_replay(generated_image_paths), + *stage_media_paths_for_session_replay(extra_attachment_paths), + ] + ) + ) + last = all_messages[-1] if all_messages else None + if not merged or not last or last.get("role") != "assistant": + return + existing = last.get("media") + base = existing if isinstance(existing, list) else [] + last["media"] = list(dict.fromkeys([*base, *merged])) diff --git a/nanobot/utils/subagent_channel_display.py b/nanobot/utils/subagent_channel_display.py new file mode 100644 index 00000000..3a939dd8 --- /dev/null +++ b/nanobot/utils/subagent_channel_display.py @@ -0,0 +1,59 @@ +"""Strip internal subagent inject scaffolding for human-facing channel surfaces. + +Persisted subagent announcements mirror ``agent/subagent_announce.md``: header, +full ``Task:`` assignment (model context), ``Result:``, and a trailing model-only +``Summarize…`` instruction. External channels (embedded WebUI, session previews) +should show only the header plus a truncated result body.""" + +from __future__ import annotations + +from typing import Any + +# Cap Result section length so WebSocket session replay stays readable; full text +# remains on disk for LLM replay (we only mutate outgoing API copies in websocket). +_SUBAGENT_CHANNEL_RESULT_MAX_CHARS = 800 + + +def scrub_subagent_announce_body(content: str) -> str: + """Return channel-safe text derived from a full subagent announce blob.""" + stripped = content.replace("\r\n", "\n").strip() + lines = stripped.splitlines() + header = "" + if lines and lines[0].startswith("[Subagent"): + header = lines[0].strip() + + lower = stripped.lower() + key = "\nresult:\n" + ri = lower.find(key) + if ri == -1: + key = "\nresult:" + ri = lower.find(key) + if ri == -1: + return header if header else stripped + + after = stripped[ri + len(key) :].lstrip() + summ_marker = "summarize this naturally" + si = after.lower().find(summ_marker) + if si != -1: + after = after[:si].rstrip() + + body = after.strip() + limit = _SUBAGENT_CHANNEL_RESULT_MAX_CHARS + if limit and len(body) > limit: + body = body[: limit - 1].rstrip() + "…" + if header and body: + return f"{header}\n\n{body}" + return header or body or stripped + + +def scrub_subagent_messages_for_channel(messages: list[dict[str, Any]]) -> None: + """Mutate message dicts in place when they carry ``subagent_result`` inject.""" + for msg in messages: + if not isinstance(msg, dict): + continue + if msg.get("injected_event") != "subagent_result": + continue + raw = msg.get("content") + if not isinstance(raw, str) or not raw.strip(): + continue + msg["content"] = scrub_subagent_announce_body(raw) diff --git a/nanobot/utils/webui_thread_disk.py b/nanobot/utils/webui_thread_disk.py new file mode 100644 index 00000000..65f12825 --- /dev/null +++ b/nanobot/utils/webui_thread_disk.py @@ -0,0 +1,31 @@ +"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use webui_transcript.""" + +from __future__ import annotations + +from pathlib import Path + +from loguru import logger + +from nanobot.config.paths import get_webui_dir +from nanobot.session.manager import SessionManager +from nanobot.utils.webui_transcript import delete_webui_transcript + + +def webui_thread_file_path(session_key: str) -> Path: + stem = SessionManager.safe_key(session_key) + return get_webui_dir() / f"{stem}.json" + + +def delete_webui_thread(session_key: str) -> bool: + """Remove legacy WebUI JSON snapshot and append-only transcript for *session_key*.""" + removed = False + path = webui_thread_file_path(session_key) + if path.is_file(): + try: + path.unlink() + removed = True + except OSError as e: + logger.warning("Failed to delete webui thread file {}: {}", path, e) + if delete_webui_transcript(session_key): + removed = True + return removed diff --git a/nanobot/utils/webui_transcript.py b/nanobot/utils/webui_transcript.py new file mode 100644 index 00000000..dde0e916 --- /dev/null +++ b/nanobot/utils/webui_transcript.py @@ -0,0 +1,423 @@ +"""Append-only WebUI display transcript (JSONL), separate from agent session.""" + +from __future__ import annotations + +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any, Callable + +from loguru import logger + +from nanobot.config.paths import get_webui_dir +from nanobot.session.manager import SessionManager + +WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 +_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024 + + +def webui_transcript_path(session_key: str) -> Path: + stem = SessionManager.safe_key(session_key) + return get_webui_dir() / f"{stem}.jsonl" + + +def read_transcript_lines(session_key: str) -> list[dict[str, Any]]: + path = webui_transcript_path(session_key) + if not path.is_file(): + return [] + size = path.stat().st_size + if size > _MAX_TRANSCRIPT_FILE_BYTES: + logger.warning("webui transcript too large, skipping: {}", path) + return [] + lines_out: list[dict[str, Any]] = [] + try: + with open(path, encoding="utf-8") as f: + for line_no, line in enumerate(f, start=1): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + logger.warning("bad jsonl at {} line {}", path, line_no) + continue + if isinstance(obj, dict): + lines_out.append(obj) + except OSError as e: + logger.warning("read transcript failed {}: {}", path, e) + return [] + return lines_out + + +def append_transcript_object(session_key: str, obj: dict[str, Any]) -> None: + raw = json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES: + msg = "webui transcript line too large" + raise ValueError(msg) + path = webui_transcript_path(session_key) + path.parent.mkdir(parents=True, exist_ok=True) + line = raw + "\n" + with open(path, "a", encoding="utf-8") as f: + f.write(line) + f.flush() + os.fsync(f.fileno()) + + +def delete_webui_transcript(session_key: str) -> bool: + path = webui_transcript_path(session_key) + if not path.is_file(): + return False + try: + path.unlink() + return True + except OSError as e: + logger.warning("Failed to delete webui transcript {}: {}", path, e) + return False + + +def _format_tool_call_trace(call: Any) -> str | None: + if not call or not isinstance(call, dict): + return None + fn = call.get("function") + name = fn.get("name") if isinstance(fn, dict) else None + if not isinstance(name, str) or not name: + raw_name = call.get("name") + name = raw_name if isinstance(raw_name, str) else "" + if not name: + return None + args = (fn.get("arguments") if isinstance(fn, dict) else None) or call.get("arguments") + if isinstance(args, str) and args.strip(): + return f"{name}({args})" + if args and isinstance(args, dict): + return f"{name}({json.dumps(args, ensure_ascii=False)})" + return f"{name}()" + + +def tool_trace_lines_from_events(events: Any) -> list[str]: + if not isinstance(events, list): + return [] + lines: list[str] = [] + for event in events: + if not event or not isinstance(event, dict): + continue + if event.get("phase") != "start": + continue + t = _format_tool_call_trace(event) + if t: + lines.append(t) + return lines + + +def replay_transcript_to_ui_messages( + lines: list[dict[str, Any]], + *, + augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None, +) -> list[dict[str, Any]]: + """Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI. + + Mirrors the core fold in ``useNanobotStream.ts`` (delta, reasoning, + message+kind, turn_end). ``augment_user_media`` maps persisted filesystem + paths to ``{url, name?}`` / attachment dicts the client expects. + """ + messages: list[dict[str, Any]] = [] + buffer_message_id: str | None = None + buffer_parts: list[str] = [] + suppress_until_turn_end = False + _ts_base = int(time.time() * 1000) + + def _new_id(prefix: str, idx: int) -> str: + return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}" + + def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None: + for i in range(len(prev) - 1, -1, -1): + candidate = prev[i] + if candidate.get("role") == "user": + break + if candidate.get("kind") == "trace": + break + if candidate.get("role") != "assistant": + continue + content = str(candidate.get("content") or "") + has_answer = len(content) > 0 + if ( + candidate.get("reasoningStreaming") + or candidate.get("reasoning") is not None + or has_answer + or candidate.get("isStreaming") + ): + prev[i] = { + **candidate, + "reasoning": (str(candidate.get("reasoning") or "")) + chunk, + "reasoningStreaming": True, + } + return + if not has_answer and candidate.get("isStreaming"): + prev[i] = {**candidate, "reasoning": chunk, "reasoningStreaming": True} + return + break + prev.append( + { + "id": _new_id("as", idx), + "role": "assistant", + "content": "", + "isStreaming": True, + "reasoning": chunk, + "reasoningStreaming": True, + "createdAt": _ts_base + idx, + }, + ) + + def find_active_placeholder(prev: list[dict[str, Any]]) -> str | None: + last = prev[-1] if prev else None + if not last: + return None + if last.get("role") != "assistant" or last.get("kind") == "trace": + return None + if str(last.get("content") or ""): + return None + if not last.get("isStreaming"): + return None + return str(last.get("id")) + + def close_reasoning(prev: list[dict[str, Any]]) -> None: + for i in range(len(prev) - 1, -1, -1): + if prev[i].get("reasoningStreaming"): + prev[i] = {**prev[i], "reasoningStreaming": False} + return + + def is_reasoning_only_placeholder(m: dict[str, Any]) -> bool: + return ( + m.get("role") == "assistant" + and m.get("kind") != "trace" + and not str(m.get("content") or "").strip() + and bool(m.get("reasoning")) + and not m.get("reasoningStreaming") + and not m.get("media") + ) + + def is_tool_trace_at(index: int) -> bool: + m = messages[index] if 0 <= index < len(messages) else None + return bool(m and m.get("kind") == "trace") + + def prune_reasoning_only() -> None: + nonlocal messages + kept: list[dict[str, Any]] = [] + for i, m in enumerate(messages): + if is_reasoning_only_placeholder(m) and not is_tool_trace_at(i + 1): + continue + kept.append(m) + messages = kept + + def stamp_latency(latency_ms: int) -> None: + for i in range(len(messages) - 1, -1, -1): + if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace": + messages[i] = { + **messages[i], + "latencyMs": latency_ms, + "isStreaming": False, + } + return + + def absorb_complete(extra: dict[str, Any], idx: int) -> None: + last = messages[-1] if messages else None + if last and is_reasoning_only_placeholder(last): + messages[-1] = { + **last, + **extra, + "isStreaming": False, + "reasoningStreaming": False, + } + else: + messages.append( + { + "id": _new_id("as", idx), + "role": "assistant", + "createdAt": _ts_base + idx, + **extra, + }, + ) + + for idx, rec in enumerate(lines): + ev = rec.get("event") + if ev == "user": + text = rec.get("text") + text_s = text if isinstance(text, str) else "" + media_paths = rec.get("media_paths") + paths: list[str] = [] + if isinstance(media_paths, list): + paths = [str(p) for p in media_paths if p] + media_att: list[dict[str, Any]] | None = None + if paths and augment_user_media is not None: + media_att = augment_user_media(paths) + row: dict[str, Any] = { + "id": _new_id("u", idx), + "role": "user", + "content": text_s, + "createdAt": _ts_base + idx, + } + if media_att: + row["media"] = media_att + if all(m.get("kind") == "image" for m in media_att): + row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att] + messages.append(row) + continue + + if ev == "delta": + if suppress_until_turn_end: + continue + chunk = rec.get("text") + if not isinstance(chunk, str): + continue + adopted = find_active_placeholder(messages) if buffer_message_id is None else None + if buffer_message_id is None: + if adopted: + buffer_message_id = adopted + else: + buffer_message_id = _new_id("buf", idx) + messages.append( + { + "id": buffer_message_id, + "role": "assistant", + "content": "", + "isStreaming": True, + "createdAt": _ts_base + idx, + }, + ) + buffer_parts.append(chunk) + combined = "".join(buffer_parts) + for i, m in enumerate(messages): + if m.get("id") == buffer_message_id: + messages[i] = {**m, "content": combined, "isStreaming": True} + break + continue + + if ev == "stream_end": + if suppress_until_turn_end: + buffer_message_id = None + buffer_parts = [] + continue + buffer_message_id = None + buffer_parts = [] + continue + + if ev == "reasoning_delta": + if suppress_until_turn_end: + continue + chunk = rec.get("text") + if not isinstance(chunk, str) or not chunk: + continue + attach_reasoning_chunk(messages, chunk, idx) + continue + + if ev == "reasoning_end": + if suppress_until_turn_end: + continue + close_reasoning(messages) + continue + + if ev == "message": + if suppress_until_turn_end and rec.get("kind") in ( + "tool_hint", + "progress", + "reasoning", + ): + continue + kind = rec.get("kind") + if kind == "reasoning": + line = rec.get("text") + if not isinstance(line, str) or not line: + continue + attach_reasoning_chunk(messages, line, idx) + close_reasoning(messages) + continue + if kind in ("tool_hint", "progress"): + structured = tool_trace_lines_from_events(rec.get("tool_events")) + text = rec.get("text") + trace_lines = structured if structured else ([text] if isinstance(text, str) and text else []) + if not trace_lines: + continue + last = messages[-1] if messages else None + if last and last.get("kind") == "trace" and not last.get("isStreaming"): + prev_traces = list(last.get("traces") or [last.get("content")]) + merged_traces = prev_traces + trace_lines + messages[-1] = { + **last, + "traces": merged_traces, + "content": trace_lines[-1], + } + else: + messages.append( + { + "id": _new_id("tr", idx), + "role": "tool", + "kind": "trace", + "content": trace_lines[-1], + "traces": trace_lines, + "createdAt": _ts_base + idx, + }, + ) + continue + + buffer_message_id = None + buffer_parts = [] + text = rec.get("text") + content_s = text if isinstance(text, str) else "" + media_urls = rec.get("media_urls") + media: list[dict[str, Any]] = [] + if isinstance(media_urls, list): + for m in media_urls: + if isinstance(m, dict) and m.get("url"): + media.append( + { + "kind": "image", + "url": str(m["url"]), + "name": str(m.get("name") or ""), + }, + ) + extra: dict[str, Any] = {"content": content_s} + if media: + extra["media"] = media + lat = rec.get("latency_ms") + if isinstance(lat, (int, float)) and lat >= 0: + extra["latencyMs"] = int(lat) + absorb_complete(extra, idx) + if media: + suppress_until_turn_end = True + continue + + if ev == "turn_end": + suppress_until_turn_end = False + for i, m in enumerate(messages): + if m.get("isStreaming"): + 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)) + buffer_message_id = None + buffer_parts = [] + continue + + for m in messages: + m.pop("isStreaming", None) + m.pop("reasoningStreaming", None) + return messages + + +def build_webui_thread_response( + session_key: str, + *, + augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None, +) -> dict[str, Any] | None: + """Return a payload compatible with ``WebuiThreadPersistedPayload``.""" + lines = read_transcript_lines(session_key) + if not lines: + return None + msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media) + return { + "schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION, + "sessionKey": session_key, + "messages": msgs, + } diff --git a/nanobot/utils/webui_turn_helpers.py b/nanobot/utils/webui_turn_helpers.py new file mode 100644 index 00000000..3fbca372 --- /dev/null +++ b/nanobot/utils/webui_turn_helpers.py @@ -0,0 +1,48 @@ +"""Outbound helpers for the WebSocket/WebUI wire contract. + +AgentLoop uses these without importing a concrete channel plugin; only +``channel == "websocket"`` messages are affected. +""" + +from __future__ import annotations + +import time +from typing import Any + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus + +# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the +# gateway process stays up; cleared on idle/stop and implicitly dropped on restart. +_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {} + + +def websocket_turn_wall_started_at(chat_id: str) -> float | None: + """Return ``time.time()`` when the active user turn began, if still running.""" + return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id) + + +async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status: str) -> None: + """Notify WebSocket clients while a user turn is executing (timing strip).""" + if msg.channel != "websocket": + return + cid = str(msg.chat_id) + meta: dict[str, Any] = { + **dict(msg.metadata or {}), + "_goal_status": True, + "goal_status": status, + } + if status == "running": + t0 = time.time() + meta["started_at"] = t0 + _WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0 + else: + _WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None) + await bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=cid, + content="", + metadata=meta, + ), + ) diff --git a/tests/agent/test_context_builder.py b/tests/agent/test_context_builder.py index 862f1ff2..93ce9cb4 100644 --- a/tests/agent/test_context_builder.py +++ b/tests/agent/test_context_builder.py @@ -1,13 +1,11 @@ """Tests for ContextBuilder — system prompt and message assembly.""" -import base64 from pathlib import Path -from unittest.mock import MagicMock, patch import pytest from nanobot.agent.context import ContextBuilder - +from nanobot.session.goal_state import GOAL_STATE_KEY # --------------------------------------------------------------------------- # Helpers @@ -285,6 +283,22 @@ class TestBuildMessages: assert "[Runtime Context" in user_msg assert "hello" in user_msg + def test_session_metadata_injects_active_goal_state(self, tmp_path): + builder = _builder(tmp_path) + meta = { + GOAL_STATE_KEY: {"status": "active", "objective": "Finish docs migration."}, + } + messages = builder.build_messages( + [], + "hi", + channel="cli", + chat_id="x", + session_metadata=meta, + ) + user_msg = str(messages[-1]["content"]) + assert "Goal (active):" in user_msg + assert "Finish docs migration." in user_msg + def test_consecutive_same_role_merged(self, tmp_path): builder = _builder(tmp_path) history = [{"role": "user", "content": "previous user message"}] @@ -308,26 +322,3 @@ class TestBuildMessages: user_msg = messages[-1]["content"] assert isinstance(user_msg, list) assert any(b.get("type") == "image_url" for b in user_msg) - - -# --------------------------------------------------------------------------- -# add_tool_result -# --------------------------------------------------------------------------- - - -class TestAddToolResult: - def test_appends_tool_message(self, tmp_path): - builder = _builder(tmp_path) - msgs = [{"role": "user", "content": "hello"}] - result = builder.add_tool_result(msgs, "call_123", "read_file", "file content") - assert len(result) == 2 - assert result[1]["role"] == "tool" - assert result[1]["tool_call_id"] == "call_123" - assert result[1]["name"] == "read_file" - assert result[1]["content"] == "file content" - - def test_returns_same_list(self, tmp_path): - builder = _builder(tmp_path) - msgs = [] - result = builder.add_tool_result(msgs, "id", "tool", "ok") - assert result is msgs diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index ee3f1e3d..fcf6198c 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -204,13 +204,16 @@ class TestToolEventProgress: if not m.metadata.get("_stream_delta") and not m.metadata.get("_stream_end") and not m.metadata.get("_turn_end") + and not m.metadata.get("_goal_status") ] assert [m.content for m in deltas] == ["Hel", "lo"] assert len(stream_end) == 1 assert final[-1].content == "Hello" assert final[-1].metadata.get("_streamed") is True - assert outbound[-1].metadata.get("_turn_end") is True + turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")] + assert len(turn_end_msgs) == 1 + assert turn_end_msgs[0].content == "" provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio @@ -286,11 +289,15 @@ class TestToolEventProgress: while bus.outbound_size > 0: outbound.append(await bus.consume_outbound()) - assert outbound[-2].content == "Done" - assert (outbound[-2].metadata or {}).get("_turn_end") is not True - assert outbound[-1].content == "" - assert (outbound[-1].metadata or {}).get("_turn_end") is True - assert outbound[-1].chat_id == "chat1" + done_msgs = [m for m in outbound if m.content == "Done"] + assert len(done_msgs) == 1 + assert not done_msgs[0].metadata.get("_turn_end") + + turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")] + assert len(turn_end_msgs) == 1 + assert turn_end_msgs[0].content == "" + assert turn_end_msgs[0].chat_id == "chat1" + assert outbound.index(done_msgs[0]) < outbound.index(turn_end_msgs[0]) @pytest.mark.asyncio async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None: @@ -323,13 +330,27 @@ class TestToolEventProgress: metadata={"webui": True}, )), timeout=0.5) - outbound = [await bus.consume_outbound(), await bus.consume_outbound()] - assert outbound[0].content == "Done" - assert (outbound[1].metadata or {}).get("_turn_end") is True + outbound: list = [] + for _ in range(12): + outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)) + if outbound[-1].metadata.get("_turn_end"): + break + else: + raise AssertionError("_turn_end message not found") + + done_with_body = [m for m in outbound if m.content == "Done"] + assert len(done_with_body) == 1 + assert outbound[-1].metadata.get("_turn_end") is True await asyncio.wait_for(title_started.wait(), timeout=0.5) release_title.set() - session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5) + session_updated = None + for _ in range(10): + candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5) + if (candidate.metadata or {}).get("_session_updated"): + session_updated = candidate + break + assert session_updated is not None assert (session_updated.metadata or {}).get("_session_updated") is True assert provider.chat_with_retry.await_count == 2 diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 35b00474..c33ecf42 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -177,6 +177,25 @@ def test_save_turn_keeps_tool_results_under_16k() -> None: assert session.messages[0]["content"] == content +def test_save_turn_stamps_latency_on_last_assistant() -> None: + loop = _mk_loop() + session = Session(key="test:latency") + + loop._save_turn( + session, + [ + {"role": "assistant", "content": "hello", "tool_calls": [{"id": "c1"}]}, + {"role": "assistant", "content": "final answer"}, + ], + skip=0, + turn_latency_ms=12345, + ) + + assert session.messages[-1]["role"] == "assistant" + assert session.messages[-1]["content"] == "final answer" + assert session.messages[-1]["latency_ms"] == 12345 + + def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() -> None: loop = _mk_loop() session = Session( diff --git a/tests/agent/test_runner_reasoning.py b/tests/agent/test_runner_reasoning.py index d971e05a..9724d2b0 100644 --- a/tests/agent/test_runner_reasoning.py +++ b/tests/agent/test_runner_reasoning.py @@ -13,7 +13,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from nanobot.agent.hook import AgentHook +from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -38,7 +38,7 @@ class _RecordingHook(AgentHook): async def test_runner_preserves_reasoning_fields_in_assistant_history(): """Reasoning fields ride along on the persisted assistant message so follow-up provider calls retain the model's prior thinking context.""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner, AgentRunSpec provider = MagicMock() captured_second_call: list[dict] = [] @@ -86,7 +86,7 @@ async def test_runner_preserves_reasoning_fields_in_assistant_history(): @pytest.mark.asyncio async def test_runner_emits_anthropic_thinking_blocks(): - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner, AgentRunSpec provider = MagicMock() @@ -126,7 +126,7 @@ async def test_runner_emits_anthropic_thinking_blocks(): async def test_runner_emits_inline_think_content_as_reasoning(): """Models embedding reasoning in ... blocks should have that content extracted and emitted, and stripped from the answer.""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner, AgentRunSpec provider = MagicMock() @@ -161,7 +161,7 @@ async def test_runner_emits_inline_think_content_as_reasoning(): async def test_runner_prefers_reasoning_content_over_inline_think(): """Fallback priority: dedicated reasoning_content wins; inline is still scrubbed from the answer content.""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner, AgentRunSpec provider = MagicMock() @@ -197,7 +197,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed(): """`reasoning_content` arrives only on the final response; streaming the answer must not suppress it (the answer stream and the reasoning channel are independent — only the reasoning-already-emitted bit matters).""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner, AgentRunSpec provider = MagicMock() provider.supports_progress_deltas = True @@ -244,7 +244,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed(): async def test_runner_does_not_double_emit_when_inline_think_already_streamed(): """Inline `` blocks streamed incrementally during the answer stream must not be re-emitted from the final response.""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner, AgentRunSpec provider = MagicMock() provider.supports_progress_deltas = True @@ -289,7 +289,7 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response(): """A non-streaming response carrying ``reasoning_content`` must emit both a reasoning delta and an end marker so channels can finalize the in-place bubble.""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner, AgentRunSpec provider = MagicMock() @@ -319,3 +319,53 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response(): assert result.final_content == "answer" assert hook.emitted == ["hidden thought"] assert hook.end_calls == 1 + + +class _StreamRecordingHook(_RecordingHook): + def wants_streaming(self) -> bool: + return True + + async def on_stream(self, _ctx: AgentHookContext, delta: str) -> None: + pass + + +@pytest.mark.asyncio +async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): + """Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``; + final ``thinking_blocks`` must not emit again when already streamed.""" + from nanobot.agent.runner import AgentRunner, AgentRunSpec + + provider = MagicMock() + + async def chat_stream_with_retry( + *, on_content_delta=None, on_thinking_delta=None, **kwargs + ): + if on_thinking_delta: + await on_thinking_delta("part1") + await on_thinking_delta("part2") + if on_content_delta: + await on_content_delta("done") + return LLMResponse( + content="done", + tool_calls=[], + thinking_blocks=[{"type": "thinking", "thinking": "part1part2"}], + usage={"prompt_tokens": 1, "completion_tokens": 2}, + ) + + provider.chat_stream_with_retry = chat_stream_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + hook = _StreamRecordingHook() + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "q"}], + tools=tools, + model="test-model", + max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + hook=hook, + )) + + assert result.final_content == "done" + assert hook.emitted == ["part1", "part2"] diff --git a/tests/agent/test_session_media_persist.py b/tests/agent/test_session_media_persist.py new file mode 100644 index 00000000..98b77ffd --- /dev/null +++ b/tests/agent/test_session_media_persist.py @@ -0,0 +1,34 @@ +"""Tests for staging attachment paths into the media bucket for session replay.""" + +from pathlib import Path + +from nanobot.config.loader import set_config_path +from nanobot.config.paths import get_media_dir +from nanobot.utils.session_attachments import stage_media_paths_for_session_replay + + +def test_persist_media_stages_workspace_file(tmp_path: Path) -> None: + set_config_path(tmp_path / "config.json") + outside = tmp_path / "workspace" / "report.md" + outside.parent.mkdir(parents=True) + outside.write_text("body", encoding="utf-8") + + out = stage_media_paths_for_session_replay([str(outside)]) + + assert len(out) == 1 + staged = Path(out[0]) + assert staged.is_file() + assert staged.read_text(encoding="utf-8") == "body" + assert staged.resolve().is_relative_to(get_media_dir().resolve()) + + +def test_persist_media_keeps_files_already_under_media_root(tmp_path: Path) -> None: + set_config_path(tmp_path / "config.json") + media = get_media_dir("websocket") + media.mkdir(parents=True, exist_ok=True) + inside = media / "keep-me.txt" + inside.write_text("x", encoding="utf-8") + + out = stage_media_paths_for_session_replay([str(inside.resolve())]) + + assert out == [str(inside.resolve())] diff --git a/tests/agent/tools/test_long_task.py b/tests/agent/tools/test_long_task.py new file mode 100644 index 00000000..15c5f8db --- /dev/null +++ b/tests/agent/tools/test_long_task.py @@ -0,0 +1,155 @@ +"""Tests for sustained goal tools (`long_task`, `complete_goal`).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.agent.loop import AgentLoop +from nanobot.agent.tools.context import RequestContext +from nanobot.agent.tools.long_task import ( + CompleteGoalTool, + LongTaskTool, +) +from nanobot.bus.queue import MessageBus +from nanobot.session.goal_state import GOAL_STATE_KEY +from nanobot.session.manager import SessionManager + + +def _tools(sm: SessionManager) -> tuple[LongTaskTool, CompleteGoalTool]: + lt = LongTaskTool(sessions=sm) + cg = CompleteGoalTool(sessions=sm) + rc = RequestContext( + channel="websocket", + chat_id="c1", + session_key="websocket:c1", + metadata={}, + ) + lt.set_context(rc) + cg.set_context(rc) + return lt, cg + + +@pytest.mark.asyncio +async def test_long_task_records_goal_metadata(tmp_path): + sm = SessionManager(tmp_path) + lt, _cg = _tools(sm) + + out = await lt.execute(goal="Do the thing", ui_summary="thing") + assert "Goal recorded" in out + + sess = sm.get_or_create("websocket:c1") + blob = sess.metadata.get(GOAL_STATE_KEY) + assert isinstance(blob, dict) + assert blob["status"] == "active" + assert blob["objective"] == "Do the thing" + assert blob["ui_summary"] == "thing" + + +@pytest.mark.asyncio +async def test_long_task_rejects_second_active_goal(tmp_path): + sm = SessionManager(tmp_path) + lt, _cg = _tools(sm) + + await lt.execute(goal="First") + out = await lt.execute(goal="Second") + assert "already active" in out + + +@pytest.mark.asyncio +async def test_complete_goal_closes_active_goal(tmp_path): + sm = SessionManager(tmp_path) + lt, cg = _tools(sm) + + await lt.execute(goal="X") + out = await cg.execute(recap="Done.") + assert "marked complete" in out + + sess = sm.get_or_create("websocket:c1") + blob = sess.metadata.get(GOAL_STATE_KEY) + assert blob["status"] == "completed" + assert blob["recap"] == "Done." + + +@pytest.mark.asyncio +async def test_long_task_publishes_goal_state_ws_after_save(tmp_path): + bus = MagicMock() + bus.publish_outbound = AsyncMock() + sm = SessionManager(tmp_path) + lt = LongTaskTool(sessions=sm, bus=bus) + rc = RequestContext( + channel="websocket", + chat_id="chat-99", + session_key="websocket:chat-99", + metadata={}, + ) + lt.set_context(rc) + + await lt.execute(goal="Objective alpha", ui_summary="alpha") + + bus.publish_outbound.assert_awaited_once() + call = bus.publish_outbound.await_args.args[0] + assert call.channel == "websocket" + assert call.chat_id == "chat-99" + assert call.metadata.get("_goal_state_sync") is True + assert call.metadata["goal_state"] == { + "active": True, + "ui_summary": "alpha", + "objective": "Objective alpha", + } + + +@pytest.mark.asyncio +async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path): + bus = MagicMock() + bus.publish_outbound = AsyncMock() + sm = SessionManager(tmp_path) + lt = LongTaskTool(sessions=sm, bus=bus) + cg = CompleteGoalTool(sessions=sm, bus=bus) + rc = RequestContext( + channel="websocket", + chat_id="chat-z", + session_key="websocket:chat-z", + metadata={}, + ) + lt.set_context(rc) + await lt.execute(goal="X") + + bus.publish_outbound.reset_mock() + cg.set_context(rc) + await cg.execute(recap="Done.") + + bus.publish_outbound.assert_awaited_once() + call = bus.publish_outbound.await_args.args[0] + assert call.metadata["goal_state"] == {"active": False} + + +@pytest.mark.asyncio +async def test_complete_goal_without_active_is_noop_message(tmp_path): + sm = SessionManager(tmp_path) + _lt, cg = _tools(sm) + + out = await cg.execute(recap="n/a") + assert "No active" in out + + +@pytest.mark.asyncio +async def test_long_task_skips_ws_publish_without_bus(tmp_path): + sm = SessionManager(tmp_path) + lt, _cg = _tools(sm) + out = await lt.execute(goal="Solo", ui_summary="s") + assert "Goal recorded" in out + + +@pytest.mark.asyncio +async def test_long_task_and_complete_goal_registered(tmp_path): + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + + lt = loop.tools.get("long_task") + cg = loop.tools.get("complete_goal") + assert lt is not None and lt.name == "long_task" + assert cg is not None and cg.name == "complete_goal" diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 2d4dd647..9b481e25 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -13,7 +13,7 @@ import websockets from websockets.exceptions import ConnectionClosed from websockets.frames import Close -from nanobot.bus.events import OutboundMessage +from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.websocket import ( WebSocketChannel, @@ -370,6 +370,30 @@ async def test_send_progress_includes_structured_tool_events() -> None: ] +@pytest.mark.asyncio +async def test_send_progress_includes_agent_ui_blob() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + blob = { + "kind": "panel", + "data": {"version": 1, "event": "tick", "id": "r1"}, + } + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="progress · panel", + metadata={"_progress": True, OUTBOUND_META_AGENT_UI: blob}, + )) + + payload = json.loads(mock_ws.send.await_args.args[0]) + assert payload["event"] == "message" + assert payload["kind"] == "progress" + assert payload["agent_ui"] == blob + + @pytest.mark.asyncio async def test_send_delta_removes_connection_on_connection_closed() -> None: bus = MagicMock() @@ -506,6 +530,215 @@ async def test_send_turn_end_emits_turn_end_event() -> None: assert body == {"event": "turn_end", "chat_id": "chat-1"} +@pytest.mark.asyncio +async def test_send_turn_end_includes_latency_ms_when_present() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_turn_end": True, "latency_ms": 1500}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500} + + +@pytest.mark.asyncio +async def test_send_turn_end_includes_goal_state_when_present() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + blob = {"active": True, "ui_summary": "Explore codebase"} + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_turn_end": True, "goal_state": blob}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "turn_end", "chat_id": "chat-1", "goal_state": blob} + + +@pytest.mark.asyncio +async def test_send_goal_status_running_emits_event_with_started_at() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={ + "_goal_status": True, + "goal_status": "running", + "started_at": 1_700_000_000.5, + }, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == { + "event": "goal_status", + "chat_id": "chat-1", + "status": "running", + "started_at": 1_700_000_000.5, + } + + +@pytest.mark.asyncio +async def test_send_goal_status_idle_omits_started_at() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={ + "_goal_status": True, + "goal_status": "idle", + "goal_started_at": 99.0, + }, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "goal_status", "chat_id": "chat-1", "status": "idle"} + + +@pytest.mark.asyncio +async def test_send_goal_state_emits_blob_per_chat() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_a = AsyncMock() + mock_b = AsyncMock() + channel._attach(mock_a, "chat-a") + channel._attach(mock_b, "chat-b") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-a", + content="", + metadata={ + "_goal_state_sync": True, + "goal_state": {"active": True, "ui_summary": "A"}, + }, + )) + + mock_a.send.assert_awaited_once() + mock_b.send.assert_not_called() + body = json.loads(mock_a.send.await_args.args[0]) + assert body == { + "event": "goal_state", + "chat_id": "chat-a", + "goal_state": {"active": True, "ui_summary": "A"}, + } + + +@pytest.mark.asyncio +async def test_maybe_push_active_goal_state_noop_without_session_manager() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + channel._session_manager = None + await channel._maybe_push_active_goal_state("chat-1") + mock_ws.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + sm = MagicMock() + sm.read_session_file.return_value = None + channel._session_manager = sm + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + await channel._maybe_push_active_goal_state("chat-1") + mock_ws.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + sm = MagicMock() + sm.read_session_file.return_value = { + "metadata": { + "goal_state": { + "status": "active", + "objective": "finish docs", + "ui_summary": "Docs", + }, + }, + "messages": [], + } + channel._session_manager = sm + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + await channel._maybe_push_active_goal_state("chat-1") + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body["event"] == "goal_state" + assert body["chat_id"] == "chat-1" + assert body["goal_state"]["active"] is True + assert body["goal_state"]["objective"] == "finish docs" + assert body["goal_state"]["ui_summary"] == "Docs" + + +@pytest.mark.asyncio +async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + from nanobot.utils import webui_turn_helpers as wth + + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + await channel._maybe_push_turn_run_wall_clock("chat-1") + mock_ws.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_maybe_push_turn_run_wall_clock_replays_running() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + from nanobot.utils import webui_turn_helpers as wth + + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + try: + wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0 + await channel._maybe_push_turn_run_wall_clock("chat-1") + finally: + wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("chat-1", None) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == { + "event": "goal_status", + "chat_id": "chat-1", + "status": "running", + "started_at": 1_700_000_000.0, + } + + @pytest.mark.asyncio async def test_send_session_updated_emits_session_updated_event() -> None: bus = MagicMock() @@ -1245,3 +1478,28 @@ def test_parse_envelope_rejects_legacy_and_garbage() -> None: ) def test_is_valid_chat_id(value: Any, expected: bool) -> None: assert _is_valid_chat_id(value) is expected + + +def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.utils.webui_transcript import append_transcript_object + + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:c1" + append_transcript_object(key, {"event": "user", "chat_id": "c1", "text": "hi"}) + bus = MagicMock() + channel = _ch(bus) + channel._api_tokens["tok"] = time.monotonic() + 300.0 + enc = quote(key, safe="") + req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")])) + resp = channel._handle_webui_thread_get(req, enc) + assert resp.status_code == 200 + body = json.loads(resp.body.decode()) + assert body["sessionKey"] == key + assert len(body["messages"]) == 1 + assert body["messages"][0]["role"] == "user" + assert body["messages"][0]["content"] == "hi" diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 40ba1928..9286670d 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -22,6 +22,7 @@ def _ch( session_manager: SessionManager | None = None, static_dist_path: Path | None = None, port: int = _PORT, + runtime_model_name: Any | None = None, **extra: Any, ) -> WebSocketChannel: cfg: dict[str, Any] = { @@ -33,11 +34,16 @@ def _ch( "websocketRequiresToken": False, } cfg.update(extra) + ws_kwargs: dict[str, Any] = { + "session_manager": session_manager, + "static_dist_path": static_dist_path, + } + if runtime_model_name is not None: + ws_kwargs["runtime_model_name"] = runtime_model_name return WebSocketChannel( cfg, bus, - session_manager=session_manager, - static_dist_path=static_dist_path, + **ws_kwargs, ) @@ -171,8 +177,14 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default( @pytest.mark.asyncio -async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> None: +async def test_session_delete_removes_file( + bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) sm = _seed_session(tmp_path, key="websocket:doomed") + from nanobot.utils.webui_transcript import append_transcript_object + + append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"}) channel = _ch(bus, session_manager=sm, port=29903) server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) @@ -183,6 +195,8 @@ async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> No path = sm._get_session_path("websocket:doomed") assert path.exists() + webui_path = tmp_path / "webui" / f"{SessionManager.safe_key('websocket:doomed')}.jsonl" + assert webui_path.is_file() resp = await _http_get( "http://127.0.0.1:29903/api/sessions/websocket:doomed/delete", headers=auth, @@ -190,6 +204,7 @@ async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> No assert resp.status_code == 200 assert resp.json()["deleted"] is True assert not path.exists() + assert not webui_path.exists() finally: await channel.stop() await server_task @@ -433,7 +448,7 @@ def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None: def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None: channel = _ch(bus, host="::", tokenIssueSecret="s3cret") - resp = channel._handle_webui_bootstrap( + resp = channel._handle_bootstrap( _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) ) assert resp.status_code == 200 @@ -442,7 +457,7 @@ def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None: def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None: """When only token (not token_issue_secret) is set, bootstrap accepts it.""" channel = _ch(bus, host="0.0.0.0", token="static-tok") - resp = channel._handle_webui_bootstrap( + resp = channel._handle_bootstrap( _REMOTE, _FakeReq({"Authorization": "Bearer static-tok"}) ) assert resp.status_code == 200 @@ -452,13 +467,53 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None: def test_localhost_without_auth_is_valid(bus: MagicMock) -> None: channel = _ch(bus, host="127.0.0.1") - resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS) assert resp.status_code == 200 +def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "nanobot.channels.websocket._default_model_name_from_config", + lambda: "from-disk", + ) + channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ") + resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["model_name"] == "live/model" + + +def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "nanobot.channels.websocket._default_model_name_from_config", + lambda: "from-disk", + ) + channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ") + resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["model_name"] == "from-disk" + + +def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "nanobot.channels.websocket._default_model_name_from_config", + lambda: "from-disk", + ) + + def boom(): + raise RuntimeError("resolver failed") + + channel = _ch(bus, host="127.0.0.1", runtime_model_name=boom) + resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["model_name"] == "from-disk" + + def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None: channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct") - resp = channel._handle_webui_bootstrap( + resp = channel._handle_bootstrap( _REMOTE, _FakeReq({"Authorization": "Bearer wrong"}) ) assert resp.status_code == 401 @@ -466,7 +521,7 @@ def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None: def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None: channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") - resp = channel._handle_webui_bootstrap( + resp = channel._handle_bootstrap( _REMOTE, _FakeReq({"Authorization": "Bearer s3cret"}) ) assert resp.status_code == 200 @@ -476,7 +531,7 @@ def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None: def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None: channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") - resp = channel._handle_webui_bootstrap( + resp = channel._handle_bootstrap( _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) ) assert resp.status_code == 200 @@ -485,5 +540,5 @@ def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None: def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None: """When secret is set, even localhost must provide it (reverse-proxy safety).""" channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") - resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS) assert resp.status_code == 401 diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index f61e1892..9748ff55 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -176,7 +176,7 @@ class TestRestartCommand: assert response is not None assert "Model: test-model" in response.content assert "Tokens: 0 in / 0 out" in response.content - assert "Context: 20k/65k (31% of input budget)" in response.content + assert "Context: 20k/262k (7% of input budget)" in response.content assert "Session: 3 messages" in response.content assert "Uptime: 2m 5s" in response.content assert "Tasks: 0 active" in response.content @@ -240,7 +240,7 @@ class TestRestartCommand: assert response is not None assert "Tokens: 1200 in / 34 out" in response.content - assert "Context: 1k/65k (1% of input budget)" in response.content + assert "Context: 1k/262k (0% of input budget)" in response.content assert "Tasks: 0 active" in response.content @pytest.mark.asyncio diff --git a/tests/command/test_model_command.py b/tests/command/test_model_command.py index 2f6bf35b..173a2702 100644 --- a/tests/command/test_model_command.py +++ b/tests/command/test_model_command.py @@ -9,6 +9,7 @@ from nanobot.bus.queue import MessageBus from nanobot.command.builtin import ( build_help_text, builtin_command_palette, + cmd_goal, cmd_model, register_builtin_commands, ) @@ -54,6 +55,13 @@ def _ctx(loop: AgentLoop, raw: str, args: str = "") -> CommandContext: return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop) +def _ctx_session(loop: AgentLoop, raw: str, args: str = "") -> CommandContext: + msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw) + return CommandContext( + msg=msg, session=MagicMock(), key=msg.session_key, raw=raw, args=args, loop=loop, + ) + + @pytest.mark.asyncio async def test_model_command_lists_current_and_available_presets(tmp_path) -> None: loop = _make_loop(tmp_path) @@ -136,3 +144,49 @@ def test_model_command_in_help_and_palette() -> None: assert any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette) assert "/model [preset]" in build_help_text() + + +@pytest.mark.asyncio +async def test_goal_command_shows_usage_without_args(tmp_path) -> None: + loop = _make_loop(tmp_path) + out = await cmd_goal(_ctx(loop, "/goal")) + assert out is not None + assert "Usage: /goal" in out.content + + +@pytest.mark.asyncio +async def test_goal_command_rejects_mid_turn_without_session(tmp_path) -> None: + loop = _make_loop(tmp_path) + out = await cmd_goal(_ctx(loop, "/goal do work", args="do work")) + assert out is not None + assert "/stop" in out.content + + +@pytest.mark.asyncio +async def test_goal_command_rewrites_to_agent_prompt(tmp_path) -> None: + loop = _make_loop(tmp_path) + ctx = _ctx_session(loop, "/goal audit the repo", args="audit the repo") + out = await cmd_goal(ctx) + assert out is None + assert "audit the repo" in ctx.msg.content + assert "long_task" in ctx.msg.content + assert ctx.msg.metadata.get("original_command") == "/goal" + assert ctx.msg.metadata.get("original_content") == "/goal audit the repo" + assert isinstance(ctx.msg.metadata.get("goal_started_at"), int | float) + + +@pytest.mark.asyncio +async def test_goal_command_registered_on_router(tmp_path) -> None: + router = CommandRouter() + register_builtin_commands(router) + loop = _make_loop(tmp_path) + ctx = _ctx_session(loop, "/goal ship it", args="ship it") + out = await router.dispatch(ctx) + assert out is None + assert "ship it" in ctx.msg.content + + +def test_goal_command_in_help_and_palette() -> None: + palette = builtin_command_palette() + assert any(item["command"] == "/goal" and item["arg_hint"] == "" for item in palette) + assert "/goal " in build_help_text() diff --git a/tests/command/test_router_dispatchable.py b/tests/command/test_router_dispatchable.py index f0158037..2f67b50a 100644 --- a/tests/command/test_router_dispatchable.py +++ b/tests/command/test_router_dispatchable.py @@ -26,12 +26,14 @@ class TestIsDispatchableCommand: assert router.is_dispatchable_command("/dream") assert router.is_dispatchable_command("/dream-log") assert router.is_dispatchable_command("/dream-restore") + assert router.is_dispatchable_command("/goal") assert router.is_dispatchable_command("/pairing") def test_prefix_commands_match(self, router: CommandRouter) -> None: assert router.is_dispatchable_command("/dream-log abc123") assert router.is_dispatchable_command("/dream-restore def456") assert router.is_dispatchable_command("/model fast") + assert router.is_dispatchable_command("/goal migrate the database") assert router.is_dispatchable_command("/pairing list") assert router.is_dispatchable_command("/pairing approve CODE") diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index b27926ec..9e28ff66 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -34,7 +34,7 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path) config = load_config(config_path) assert config.agents.defaults.max_tokens == 1234 - assert config.agents.defaults.context_window_tokens == 65_536 + assert config.agents.defaults.context_window_tokens == 262_144 assert not hasattr(config.agents.defaults, "memory_window") @@ -60,7 +60,7 @@ def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path defaults = saved["agents"]["defaults"] assert defaults["maxTokens"] == 2222 - assert defaults["contextWindowTokens"] == 65_536 + assert defaults["contextWindowTokens"] == 262_144 assert "memoryWindow" not in defaults diff --git a/tests/providers/test_anthropic_stream_idle.py b/tests/providers/test_anthropic_stream_idle.py new file mode 100644 index 00000000..da4939bf --- /dev/null +++ b/tests/providers/test_anthropic_stream_idle.py @@ -0,0 +1,149 @@ +"""Anthropic streaming idle timeout should follow the full SSE stream, not text only.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.providers.anthropic_provider import AnthropicProvider + + +def _final_message_stub(text: str = "Hi") -> SimpleNamespace: + return SimpleNamespace( + content=[SimpleNamespace(type="text", text=text)], + stop_reason="end_turn", + usage=SimpleNamespace( + input_tokens=3, + output_tokens=2, + cache_creation_input_tokens=None, + cache_read_input_tokens=None, + ), + ) + + +class _FakeAsyncStream: + """Minimal async iterator + context manager mimicking AsyncMessageStream.""" + + def __init__(self, chunks: list[SimpleNamespace]) -> None: + self._chunks = chunks + self._idx = 0 + self.get_final_message = AsyncMock(return_value=_final_message_stub()) + + async def __anext__(self) -> SimpleNamespace: + if self._idx >= len(self._chunks): + raise StopAsyncIteration + c = self._chunks[self._idx] + self._idx += 1 + return c + + def __aiter__(self) -> _FakeAsyncStream: + return self + + async def __aenter__(self) -> _FakeAsyncStream: + return self + + async def __aexit__(self, *_exc: object) -> None: + pass + + +@pytest.mark.asyncio +async def test_chat_stream_calls_on_content_delta_only_for_text_delta() -> None: + """Thinking deltas must be consumed without invoking on_content_delta.""" + provider = AnthropicProvider(api_key="sk-test") + provider._client = MagicMock() + + chunks = [ + SimpleNamespace( + type="content_block_delta", + delta=SimpleNamespace(type="thinking_delta", thinking="think"), + ), + SimpleNamespace( + type="content_block_delta", + delta=SimpleNamespace(type="text_delta", text="Hi"), + ), + ] + fake = _FakeAsyncStream(chunks) + stream_cm = MagicMock() + stream_cm.__aenter__ = AsyncMock(return_value=fake) + stream_cm.__aexit__ = AsyncMock(return_value=None) + provider._client.messages.stream = MagicMock(return_value=stream_cm) + + out: list[str] = [] + + async def on_delta(s: str) -> None: + out.append(s) + + await provider.chat_stream( + messages=[{"role": "user", "content": "hello"}], + on_content_delta=on_delta, + on_thinking_delta=None, + ) + + assert out == ["Hi"] + fake.get_final_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_chat_stream_invokes_on_thinking_delta_for_thinking_delta() -> None: + provider = AnthropicProvider(api_key="sk-test") + provider._client = MagicMock() + + chunks = [ + SimpleNamespace( + type="content_block_delta", + delta=SimpleNamespace(type="thinking_delta", thinking="a"), + ), + SimpleNamespace( + type="content_block_delta", + delta=SimpleNamespace(type="thinking_delta", thinking="b"), + ), + SimpleNamespace( + type="content_block_delta", + delta=SimpleNamespace(type="text_delta", text="X"), + ), + ] + fake = _FakeAsyncStream(chunks) + stream_cm = MagicMock() + stream_cm.__aenter__ = AsyncMock(return_value=fake) + stream_cm.__aexit__ = AsyncMock(return_value=None) + provider._client.messages.stream = MagicMock(return_value=stream_cm) + + thinking_parts: list[str] = [] + text_parts: list[str] = [] + + async def on_thinking(s: str) -> None: + thinking_parts.append(s) + + async def on_text(s: str) -> None: + text_parts.append(s) + + await provider.chat_stream( + messages=[{"role": "user", "content": "hello"}], + on_content_delta=on_text, + on_thinking_delta=on_thinking, + ) + + assert thinking_parts == ["a", "b"] + assert text_parts == ["X"] + + +@pytest.mark.asyncio +async def test_chat_stream_without_callback_still_finalizes() -> None: + provider = AnthropicProvider(api_key="sk-test") + provider._client = MagicMock() + + fake = _FakeAsyncStream([]) + fake.get_final_message = AsyncMock(return_value=_final_message_stub("ok")) + stream_cm = MagicMock() + stream_cm.__aenter__ = AsyncMock(return_value=fake) + stream_cm.__aexit__ = AsyncMock(return_value=None) + provider._client.messages.stream = MagicMock(return_value=stream_cm) + + res = await provider.chat_stream( + messages=[{"role": "user", "content": "hello"}], + on_content_delta=None, + ) + assert res.content == "ok" + fake.get_final_message.assert_awaited_once() diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index c2e9efeb..7ae97159 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -98,6 +98,110 @@ def _fake_chat_stream(text: str = "ok"): return _stream() +def _fake_chat_stream_reasoning_chunks(): + """Mimic DeepSeek-style ``chat.completions`` stream: ``reasoning_content`` then ``content``.""" + + async def _stream(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason=None, + delta=SimpleNamespace( + content=None, + reasoning_content="step1", + reasoning=None, + tool_calls=None, + ), + ), + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason=None, + delta=SimpleNamespace( + content=None, + reasoning_content="step2", + reasoning=None, + tool_calls=None, + ), + ), + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason=None, + delta=SimpleNamespace( + content="answer", + reasoning_content=None, + tool_calls=None, + ), + ), + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + delta=SimpleNamespace( + content=None, + reasoning_content=None, + tool_calls=None, + ), + ), + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + ), + ) + + return _stream() + + +@pytest.mark.asyncio +async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -> None: + """Regression: DeepSeek-V4 / reasoner expose ``delta.reasoning_content`` during streaming.""" + mock_chat = AsyncMock(return_value=_fake_chat_stream_reasoning_chunks()) + spec = find_by_name("deepseek") + thinking: list[str] = [] + content: list[str] = [] + + async def on_thinking(d: str) -> None: + thinking.append(d) + + async def on_content(d: str) -> None: + content.append(d) + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_openai: + client_instance = mock_openai.return_value + client_instance.chat.completions.create = mock_chat + + provider = OpenAICompatProvider( + api_key="sk-test", + default_model="deepseek-v4-pro", + spec=spec, + ) + result = await provider.chat_stream( + messages=[{"role": "user", "content": "hi"}], + model="deepseek-v4-pro", + reasoning_effort="high", + on_content_delta=on_content, + on_thinking_delta=on_thinking, + ) + + assert thinking == ["step1", "step2"] + assert content == ["answer"] + assert result.reasoning_content == "step1step2" + assert result.content == "answer" + mock_chat.assert_awaited_once() + + class _FakeResponsesError(Exception): def __init__(self, status_code: int, text: str): super().__init__(text) diff --git a/tests/session/test_goal_state.py b/tests/session/test_goal_state.py new file mode 100644 index 00000000..9a83fd46 --- /dev/null +++ b/tests/session/test_goal_state.py @@ -0,0 +1,90 @@ +"""Tests for ``goal_state`` session metadata helpers.""" + +from __future__ import annotations + +from nanobot.session.goal_state import ( + GOAL_STATE_KEY, + discard_legacy_goal_state_key, + goal_state_runtime_lines, + goal_state_ws_blob, + parse_goal_state, +) + + +def test_runtime_lines_empty_when_no_metadata(): + assert goal_state_runtime_lines(None) == [] + assert goal_state_runtime_lines({}) == [] + + +def test_runtime_lines_empty_when_completed(): + meta = { + GOAL_STATE_KEY: {"status": "completed", "objective": "was doing X"}, + } + assert goal_state_runtime_lines(meta) == [] + + +def test_runtime_lines_include_objective_when_active(): + meta = { + GOAL_STATE_KEY: { + "status": "active", + "objective": "Ship the fix.", + "ui_summary": "fix", + }, + } + lines = goal_state_runtime_lines(meta) + assert "Goal (active):" in lines + assert "Ship the fix." in lines + assert any("Summary: fix" in ln for ln in lines) + + +def test_runtime_lines_read_legacy_thread_goal_key(): + meta = {"thread_goal": {"status": "active", "objective": "Legacy key.", "ui_summary": "L"}} + lines = goal_state_runtime_lines(meta) + assert "Legacy key." in lines + + +def test_goal_state_key_takes_precedence_over_legacy(): + meta = { + GOAL_STATE_KEY: {"status": "active", "objective": "New key wins.", "ui_summary": "n"}, + "thread_goal": {"status": "active", "objective": "Ignored.", "ui_summary": "o"}, + } + lines = goal_state_runtime_lines(meta) + assert "New key wins." in lines + assert "Ignored." not in "".join(lines) + + +def test_discard_legacy_goal_state_key(): + meta: dict = {"thread_goal": {"x": 1}, GOAL_STATE_KEY: {"status": "active"}} + discard_legacy_goal_state_key(meta) + assert "thread_goal" not in meta + assert GOAL_STATE_KEY in meta + + +def test_parse_goal_state_accepts_json_string(): + assert parse_goal_state('{"status":"active","objective":"x"}') == { + "status": "active", + "objective": "x", + } + + +def test_goal_state_ws_blob_inactive_when_missing_or_completed(): + assert goal_state_ws_blob(None) == {"active": False} + assert goal_state_ws_blob({}) == {"active": False} + assert goal_state_ws_blob({GOAL_STATE_KEY: {"status": "completed", "objective": "x"}}) == { + "active": False, + } + + +def test_goal_state_ws_blob_active_shape(): + meta = { + GOAL_STATE_KEY: { + "status": "active", + "objective": "Build feature.", + "ui_summary": "feat", + }, + } + assert goal_state_ws_blob(meta) == { + "active": True, + "ui_summary": "feat", + "objective": "Build feature.", + } diff --git a/tests/tools/test_message_tool.py b/tests/tools/test_message_tool.py index fc37217a..7407462e 100644 --- a/tests/tools/test_message_tool.py +++ b/tests/tools/test_message_tool.py @@ -305,3 +305,133 @@ async def test_message_tool_resolves_mixed_media_paths() -> None: "https://example.com/url.png", "http://example.com/http.png", ] + + +@pytest.mark.asyncio +async def test_message_tool_tracks_turn_media_for_same_target(tmp_path) -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool(send_callback=_send) + from nanobot.agent.tools.context import RequestContext + + tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})) + tool.start_turn() + f = tmp_path / "doc.md" + f.write_text("hello", encoding="utf-8") + await tool.execute(content="see file", channel="websocket", chat_id="chat-1", media=[str(f)]) + + assert tool.turn_delivered_media_paths() == [str(f.resolve())] + + +@pytest.mark.asyncio +async def test_message_tool_start_turn_clears_tracked_media(tmp_path) -> None: + async def _send(msg: OutboundMessage) -> None: + pass + + tool = MessageTool(send_callback=_send) + from nanobot.agent.tools.context import RequestContext + + tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})) + tool.start_turn() + f = tmp_path / "doc.md" + f.write_text("hello", encoding="utf-8") + await tool.execute(content="see file", media=[str(f)]) + tool.start_turn() + assert tool.turn_delivered_media_paths() == [] + + +@pytest.mark.asyncio +async def test_message_tool_cross_target_does_not_track_turn_media(tmp_path) -> None: + async def _send(msg: OutboundMessage) -> None: + pass + + tool = MessageTool(send_callback=_send) + from nanobot.agent.tools.context import RequestContext + + tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})) + f = tmp_path / "doc.md" + f.write_text("hello", encoding="utf-8") + await tool.execute( + content="see file", + channel="telegram", + chat_id="tg-other", + media=[str(f)], + ) + assert tool.turn_delivered_media_paths() == [] + + +@pytest.mark.asyncio +async def test_message_tool_rejects_wrong_explicit_ws_chat_id(tmp_path) -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool(send_callback=_send) + from nanobot.agent.tools.context import RequestContext + + conv = "550e8400-e29b-41d4-a716-446655440000" + tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={})) + f = tmp_path / "doc.md" + f.write_text("hello", encoding="utf-8") + result = await tool.execute( + content="see file", + channel="websocket", + chat_id="anon-deadbeefcafe", + media=[str(f)], + ) + assert result.startswith("Error: chat_id does not match") + assert sent == [] + + +@pytest.mark.asyncio +async def test_message_tool_allows_ws_explicit_when_matches_context(tmp_path) -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool(send_callback=_send) + from nanobot.agent.tools.context import RequestContext + + conv = "550e8400-e29b-41d4-a716-446655440000" + tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={})) + f = tmp_path / "doc.md" + f.write_text("hello", encoding="utf-8") + result = await tool.execute( + content="see file", + channel="websocket", + chat_id=conv, + media=[str(f)], + ) + assert result.startswith("Message sent") + assert sent[0].chat_id == conv + + +@pytest.mark.asyncio +async def test_message_tool_cli_context_may_target_other_ws_chat(tmp_path) -> None: + """Cron / CLI handlers keep non-websocket defaults; explicit websocket + uuid remains valid.""" + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool(send_callback=_send) + from nanobot.agent.tools.context import RequestContext + + target = "550e8400-e29b-41d4-a716-446655440000" + tool.set_context(RequestContext(channel="cli", chat_id="direct", metadata={})) + f = tmp_path / "doc.md" + f.write_text("hello", encoding="utf-8") + result = await tool.execute( + content="ping", + channel="websocket", + chat_id=target, + media=[str(f)], + ) + assert result.startswith("Message sent") + assert sent[0].channel == "websocket" + assert sent[0].chat_id == target diff --git a/tests/utils/test_subagent_channel_display.py b/tests/utils/test_subagent_channel_display.py new file mode 100644 index 00000000..7dba66c0 --- /dev/null +++ b/tests/utils/test_subagent_channel_display.py @@ -0,0 +1,57 @@ +"""Tests for subagent announce text shaping on external channel surfaces.""" + +from nanobot.utils.subagent_channel_display import ( + scrub_subagent_announce_body, + scrub_subagent_messages_for_channel, +) + + +def test_scrub_subagent_keeps_header_and_result_only() -> None: + raw = """[Subagent 'Phase1' failed] + +Task: Collect GitHub stats. + +Result: +gh CLI missing. + +Summarize this naturally for the user. Keep it brief.""" + + out = scrub_subagent_announce_body(raw) + assert out == "[Subagent 'Phase1' failed]\n\ngh CLI missing." + assert "Task:" not in out + assert "Summarize" not in out + + +def test_scrub_subagent_messages_mutates_matching_rows() -> None: + messages: list[dict] = [ + {"role": "assistant", "content": "hi"}, + { + "role": "assistant", + "content": ( + "[Subagent 'x' completed successfully]\n\nTask: t\n\nResult:\nr\n\nSummarize this naturally" + ), + "injected_event": "subagent_result", + }, + ] + scrub_subagent_messages_for_channel(messages) + assert messages[0]["content"] == "hi" + assert "Task:" not in messages[1]["content"] + assert "[Subagent 'x' completed successfully]" in messages[1]["content"] + assert "r" in messages[1]["content"] + + +def test_scrub_normalizes_crlf_before_result_marker() -> None: + raw = "[Subagent 'z' failed]\r\n\r\nTask: x\r\n\r\nResult:\r\none line\r\n\r\nSummarize this naturally" + out = scrub_subagent_announce_body(raw) + assert "Task:" not in out + assert out.startswith("[Subagent 'z' failed]") + assert "one line" in out + + +def test_scrub_truncates_very_long_result() -> None: + body = "x" * 900 + raw = f"[Subagent 'z' failed]\n\nTask: t\n\nResult:\n{body}\n\nSummarize this naturally" + out = scrub_subagent_announce_body(raw) + assert out.endswith("…") + assert len(out) < len(raw) + assert body not in out diff --git a/tests/utils/test_webui_thread_disk.py b/tests/utils/test_webui_thread_disk.py new file mode 100644 index 00000000..36680b45 --- /dev/null +++ b/tests/utils/test_webui_thread_disk.py @@ -0,0 +1,20 @@ +"""Tests for WebUI on-disk cleanup (legacy JSON + transcript JSONL).""" + +from __future__ import annotations + +from nanobot.utils.webui_thread_disk import delete_webui_thread, webui_thread_file_path +from nanobot.utils.webui_transcript import append_transcript_object, webui_transcript_path + + +def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:k1" + json_path = webui_thread_file_path(key) + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text('{"x":1}', encoding="utf-8") + append_transcript_object(key, {"event": "user", "chat_id": "k1", "text": "hi"}) + assert webui_transcript_path(key).is_file() + assert delete_webui_thread(key) is True + assert not json_path.is_file() + assert not webui_transcript_path(key).is_file() + assert delete_webui_thread(key) is False diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py new file mode 100644 index 00000000..419abbfc --- /dev/null +++ b/tests/utils/test_webui_transcript.py @@ -0,0 +1,55 @@ +"""Tests for append-only WebUI transcript replay.""" + +from __future__ import annotations + +from nanobot.utils.webui_transcript import ( + WEBUI_TRANSCRIPT_SCHEMA_VERSION, + append_transcript_object, + read_transcript_lines, + replay_transcript_to_ui_messages, +) + + +def test_append_and_read_roundtrip(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:t1" + append_transcript_object(key, {"event": "user", "chat_id": "t1", "text": "hello"}) + lines = read_transcript_lines(key) + assert len(lines) == 1 + assert lines[0]["text"] == "hello" + + +def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:t2" + for ev in ( + {"event": "user", "chat_id": "t2", "text": "q"}, + {"event": "reasoning_delta", "chat_id": "t2", "text": "think"}, + {"event": "reasoning_end", "chat_id": "t2"}, + {"event": "delta", "chat_id": "t2", "text": "a"}, + {"event": "stream_end", "chat_id": "t2"}, + {"event": "turn_end", "chat_id": "t2", "latency_ms": 42}, + ): + append_transcript_object(key, ev) + lines = read_transcript_lines(key) + msgs = replay_transcript_to_ui_messages(lines) + assert len(msgs) == 2 + assert msgs[0]["role"] == "user" + assert msgs[0]["content"] == "q" + assert msgs[1]["role"] == "assistant" + assert msgs[1]["content"] == "a" + assert msgs[1]["reasoning"] == "think" + assert msgs[1]["latencyMs"] == 42 + + +def test_build_response_schema(monkeypatch, tmp_path) -> None: + from nanobot.utils.webui_transcript import build_webui_thread_response + + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:t3" + append_transcript_object(key, {"event": "user", "chat_id": "t3", "text": "x"}) + out = build_webui_thread_response(key, augment_user_media=None) + assert out is not None + assert out["schemaVersion"] == WEBUI_TRANSCRIPT_SCHEMA_VERSION + assert out["sessionKey"] == key + assert len(out["messages"]) == 1 diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py new file mode 100644 index 00000000..f3c0b174 --- /dev/null +++ b/tests/utils/test_webui_turn_helpers.py @@ -0,0 +1,55 @@ +"""Tests for WebSocket turn timing strip bookkeeping.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.bus.events import InboundMessage +from nanobot.utils import webui_turn_helpers as wth + + +@pytest.fixture(autouse=True) +def _clear_turn_wall_clock() -> None: + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + yield + wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear() + + +@pytest.mark.asyncio +async def test_publish_turn_run_status_running_records_wall_clock() -> None: + bus = MagicMock() + bus.publish_outbound = AsyncMock() + msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-a", content="hi") + + await wth.publish_turn_run_status(bus, msg, "running") + + assert "chat-a" in wth._WEBSOCKET_TURN_WALL_STARTED_AT + t0 = wth.websocket_turn_wall_started_at("chat-a") + assert isinstance(t0, float) + call = bus.publish_outbound.await_args[0][0] + assert call.chat_id == "chat-a" + assert call.metadata.get("started_at") == t0 + + +@pytest.mark.asyncio +async def test_publish_turn_run_status_idle_clears_wall_clock() -> None: + bus = MagicMock() + bus.publish_outbound = AsyncMock() + msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-b", content="hi") + + await wth.publish_turn_run_status(bus, msg, "running") + assert wth.websocket_turn_wall_started_at("chat-b") is not None + + await wth.publish_turn_run_status(bus, msg, "idle") + assert wth.websocket_turn_wall_started_at("chat-b") is None + + +@pytest.mark.asyncio +async def test_publish_turn_run_status_non_websocket_noop_registry() -> None: + bus = MagicMock() + bus.publish_outbound = AsyncMock() + msg = InboundMessage(channel="telegram", sender_id="u", chat_id="1", content="hi") + + await wth.publish_turn_run_status(bus, msg, "running") + + assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {} diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index ce7bb17e..fc667883 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -7,7 +7,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; @@ -20,12 +19,6 @@ interface ChatListProps { emptyLabel?: string; } -function titleFor(s: ChatSummary, fallbackTitle: string): string { - const p = (s.title || s.preview)?.trim(); - if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p; - return fallbackTitle; -} - export function ChatList({ sessions, activeKey, @@ -58,8 +51,8 @@ export function ChatList({ }); return ( - -
+
+
{groups.map((group) => (
@@ -68,15 +61,16 @@ export function ChatList({
    {group.sessions.map((s) => { const active = s.key === activeKey; - const title = titleFor( - s, - t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), - ); + const fallbackTitle = t("chat.fallbackTitle", { + id: s.chatId.slice(0, 6), + }); + const rawLabel = (s.title || s.preview)?.trim(); + const title = rawLabel || fallbackTitle; return ( -
  • +
  • onSelect(s.key)} - className="min-w-0 flex-1 py-1.5 text-left" + title={rawLabel || fallbackTitle} + className="min-w-0 flex-1 overflow-hidden py-1.5 text-left" > {title} ))}
    - +
); } diff --git a/webui/src/components/ChatPane.tsx b/webui/src/components/ChatPane.tsx deleted file mode 100644 index 43fe6491..00000000 --- a/webui/src/components/ChatPane.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { Composer } from "@/components/Composer"; -import { MessageList } from "@/components/MessageList"; -import { useClient } from "@/providers/ClientProvider"; -import { useNanobotStream } from "@/hooks/useNanobotStream"; -import { useSessionHistory } from "@/hooks/useSessions"; -import type { ChatSummary } from "@/lib/types"; - -interface ChatPaneProps { - session: ChatSummary | null; - /** Provision a new chat and mark it active. Returns the new chat_id or null. */ - onNewChat: () => Promise; -} - -/** - * The chat surface: persisted history on top, live stream below, composer - * pinned at the bottom. When no session is active we render a centered - * welcome card with a fully-functional composer — typing a first message - * quietly provisions a new chat and routes the message through. - */ -export function ChatPane({ session, onNewChat }: ChatPaneProps) { - const chatId = session?.chatId ?? null; - const historyKey = session?.key ?? null; - const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); - const { client } = useClient(); - const [booting, setBooting] = useState(false); - const pendingFirstRef = useRef(null); - - const initial = useMemo(() => historical, [historical]); - const { messages, isStreaming, send, setMessages } = useNanobotStream( - chatId, - initial, - hasPendingToolCalls, - ); - - useEffect(() => { - if (!loading && chatId) setMessages(historical); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading, chatId, historical]); - - // Once a session becomes active, flush any first-message stashed from the - // welcome composer so the user's keystroke "just sends". - useEffect(() => { - if (!chatId) return; - const pending = pendingFirstRef.current; - if (!pending) return; - pendingFirstRef.current = null; - client.sendMessage(chatId, pending); - setMessages((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - role: "user", - content: pending, - createdAt: Date.now(), - }, - ]); - setBooting(false); - }, [chatId, client, setMessages]); - - const handleWelcomeSend = useCallback( - async (content: string) => { - if (booting) return; - setBooting(true); - pendingFirstRef.current = content; - const newId = await onNewChat(); - if (!newId) { - // Creation failed — release the lock so the user can retry. - pendingFirstRef.current = null; - setBooting(false); - } - }, - [booting, onNewChat], - ); - - if (!session) { - return ( -
-
-
-

- What can I do for you? -

-

- Your conversations are persisted locally under the nanobot - workspace. Start typing and I'll open a new chat. -

-
-
- -
-
-
- ); - } - - return ( -
- - -
- ); -} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index bd1d8c93..67d128ed 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -1,14 +1,24 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ImageLightbox } from "@/components/ImageLightbox"; -import { MarkdownText } from "@/components/MarkdownText"; +import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { cn } from "@/lib/utils"; +import { formatTurnLatency } from "@/lib/format"; import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types"; interface MessageBubbleProps { message: UIMessage; + /** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */ + showAssistantCopyAction?: boolean; } /** @@ -20,7 +30,10 @@ interface MessageBubbleProps { * Trace rows (tool-call hints, progress breadcrumbs) render as a subdued * collapsible group so intermediate steps never masquerade as replies. */ -export function MessageBubble({ message }: MessageBubbleProps) { +export function MessageBubble({ + message, + showAssistantCopyAction = true, +}: MessageBubbleProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const copyResetRef = useRef(null); @@ -89,6 +102,14 @@ export function MessageBubble({ message }: MessageBubbleProps) { const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming); const hasReasoning = reasoning.length > 0 || reasoningStreaming; const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; + const showCopyButton = showAssistantCopyAction && showAssistantActions; + const latencyMs = message.latencyMs; + const showLatencyFooter = + message.role === "assistant" + && latencyMs != null + && !message.isStreaming + && (!empty || hasReasoning || media.length > 0); + const showAssistantFooterRow = showCopyButton || showLatencyFooter; return (
{hasReasoning ? ( @@ -99,27 +120,36 @@ export function MessageBubble({ message }: MessageBubbleProps) { ) : empty && message.isStreaming ? null : ( <> {message.content} - {message.isStreaming && } {media.length > 0 ? : null} - {showAssistantActions ? ( -
- + {showAssistantFooterRow ? ( +
+ {showCopyButton ? ( + + ) : null} + {showLatencyFooter ? ( + + {formatTurnLatency(latencyMs)} + + ) : null}
) : null} @@ -187,14 +217,34 @@ function MediaCell({ media }: { media: UIMediaAttachment }) { : t("message.fileAttachment", { defaultValue: "File attachment" }); const Icon = media.kind === "video" ? PlaySquare : FileIcon; + const inner = ( + <> + + {media.name ?? label} + + ); + + if (hasUrl) { + return ( + + {inner} + + ); + } + return (
- - {media.name ?? label} + {inner}
); } @@ -338,20 +388,6 @@ function UserImageCell({ ); } -/** Blinking cursor appended at the end of streaming text. */ -function StreamCursor() { - const { t } = useTranslation(); - return ( - - ); -} - /** Pre-token-arrival placeholder: three bouncing dots. */ function TypingDots() { const { t } = useTranslation(); @@ -379,6 +415,139 @@ function Dot({ delay }: { delay: string }) { ); } +/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */ +export function StreamingLabelSheen({ + children, + active, + className, +}: { + children: ReactNode; + active: boolean; + className?: string; +}) { + return ( + + + {children} + + {active ? ( + + + + ) : null} + + ); +} + +interface ReasoningBubbleProps { + text: string; + streaming: boolean; + hasBodyBelow: boolean; + /** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */ + embeddedInCluster?: boolean; +} + +/** + * Subordinate "thinking" trace shown above an assistant turn. + * + * Lifecycle: + * - While ``streaming`` is true (``reasoning_delta`` frames still arriving), + * the bubble defaults to open and the header shows a sheen + pulse so + * the user sees the model "thinking out loud" in real time. + * - Expanded reasoning uses the same Markdown pipeline as assistant replies + * (deferred while streaming to reduce parser thrash), so headings and + * emphasis render instead of leaking raw ``###`` / ``**``. + * - On ``reasoning_end`` the bubble auto-collapses for prose density — + * the user can re-expand to inspect the chain of thought. The local + * toggle persists once the user interacts. + */ +export function ReasoningBubble({ + text, + streaming, + hasBodyBelow, + embeddedInCluster = false, +}: ReasoningBubbleProps) { + const { t } = useTranslation(); + const deferredText = useDeferredValue(text); + const markdownSource = streaming ? deferredText : text; + const [userToggled, setUserToggled] = useState(false); + const [openLocal, setOpenLocal] = useState(true); + const open = userToggled ? openLocal : streaming; + const onToggle = () => { + setUserToggled(true); + setOpenLocal((v) => (userToggled ? !v : !open)); + }; + useEffect(() => { + if (open && text.length > 0) { + preloadMarkdownText(); + } + }, [open, text.length]); + return ( +
+ + {open && text.length > 0 && ( +
+ + {markdownSource} + +
+ )} +
+ ); +} + interface TraceGroupProps { message: UIMessage; animClass: string; @@ -389,7 +558,7 @@ interface TraceGroupProps { * collapsed because tool traces are supporting evidence, not the answer. * A single click expands the exact calls when the user wants details. */ -function TraceGroup({ message, animClass }: TraceGroupProps) { +export function TraceGroup({ message, animClass }: TraceGroupProps) { const { t } = useTranslation(); const lines = message.traces ?? [message.content]; const count = lines.length; @@ -439,79 +608,3 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
); } - -interface ReasoningBubbleProps { - text: string; - streaming: boolean; - hasBodyBelow: boolean; -} - -/** - * Subordinate "thinking" trace shown above an assistant turn. - * - * Lifecycle: - * - While ``streaming`` is true (``reasoning_delta`` frames still arriving), - * the bubble defaults to open and the header runs a shimmer + pulse so - * the user sees the model "thinking out loud" in real time. - * - On ``reasoning_end`` the bubble auto-collapses for prose density — - * the user can re-expand to inspect the chain of thought. The local - * toggle persists once the user interacts. - */ -function ReasoningBubble({ text, streaming, hasBodyBelow }: ReasoningBubbleProps) { - const { t } = useTranslation(); - const [userToggled, setUserToggled] = useState(false); - const [openLocal, setOpenLocal] = useState(true); - const open = userToggled ? openLocal : streaming; - const onToggle = () => { - setUserToggled(true); - setOpenLocal((v) => (userToggled ? !v : !open)); - }; - return ( -
- - {open && text.length > 0 && ( -
- {text} -
- )} -
- ); -} diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 4bb75a3a..cf21c886 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -50,7 +50,7 @@ export function Sidebar(props: SidebarProps) { return (