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 (