Compare commits

..
93 changed files with 1187 additions and 2671 deletions
-1
View File
@@ -107,7 +107,6 @@ File operations have path traversal protection, but:
**API Calls:**
- All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests
- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp:**
+41 -59
View File
@@ -103,8 +103,7 @@ class WebhookChannel(BaseChannel):
msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — channel routing context such as message/thread ids
msg.event — typed runtime event for progress/status messages
msg.metadata — may contain "_progress": True for streaming chunks
"""
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
# In a real plugin: POST to a callback URL, send via SDK, etc.
@@ -239,15 +238,15 @@ nanobot channels login <channel_name> --force # re-authenticate
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. |
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning_delta(chat_id, delta, metadata?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional (streaming)
| Method | Description |
|--------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
### Message Types
@@ -258,12 +257,10 @@ class OutboundMessage:
chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # channel routing context, e.g. "message_id" for threading
event: object | None # typed runtime/UI event; usually inspect with isinstance()
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
# "message_id" for reply threading
```
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
## Streaming Support
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
@@ -282,18 +279,10 @@ If either is missing, the agent falls back to the normal one-shot `send()` path.
Override `send_delta` to handle two types of calls:
```python
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
# Streaming finished — do final formatting, cleanup, etc.
return
@@ -301,7 +290,12 @@ async def send_delta(
# delta contains a small chunk of text (a few tokens)
```
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
**Metadata flags:**
| Flag | Meaning |
|------|---------|
| `_stream_delta: True` | A content chunk (delta contains the new text) |
| `_stream_end: True` | Streaming finished (delta is empty) |
### Example: Webhook with Streaming
@@ -316,27 +310,18 @@ class WebhookChannel(BaseChannel):
super().__init__(config, bus)
self._buffers: dict[str, str] = {}
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
text = self._buffers.pop(buffer_key, "")
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
text = self._buffers.pop(chat_id, "")
# Final delivery — format and send the complete message
await self._deliver(chat_id, text, final=True)
return
self._buffers.setdefault(buffer_key, "")
self._buffers[buffer_key] += delta
self._buffers.setdefault(chat_id, "")
self._buffers[chat_id] += delta
# Incremental update — push partial text to the client
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
await self._deliver(chat_id, self._buffers[chat_id], final=False)
async def send(self, msg: OutboundMessage) -> None:
# Non-streaming path — unchanged
@@ -365,7 +350,7 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
| Method / Property | Description |
|-------------------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
## Progress, Tool Hints, and Reasoning
@@ -374,20 +359,18 @@ Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These
### Progress and Tool Hints
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering:
```python
from nanobot.bus.outbound_events import ProgressEvent
async def send(self, msg: OutboundMessage) -> None:
event = msg.event
meta = msg.metadata or {}
if isinstance(event, ProgressEvent) and event.tool_hint:
if meta.get("_tool_hint"):
# A short tool breadcrumb, e.g. read_file("config.json")
await self._send_trace(msg.chat_id, msg.content, kind="tool")
return
if isinstance(event, ProgressEvent):
if meta.get("_progress"):
# Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress")
return
@@ -429,33 +412,32 @@ class WebhookChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
text = self._reasoning_buffers.pop(buffer_key, "")
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
text = self._reasoning_buffers.pop(stream_id, "")
if text:
await self._update_reasoning_block(chat_id, text, final=True)
```
**Reasoning arguments:**
**Reasoning metadata flags:**
| Argument | Meaning |
| Flag | Meaning |
|------|---------|
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
| `send_reasoning_end()` | The current reasoning block is complete. |
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. |
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
Reasoning visibility is controlled by `showReasoning` globally or per channel:
-26
View File
@@ -12,32 +12,6 @@ Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or c
For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
## Authentication
Local-only `127.0.0.1` usage does not require an API key. If you bind the API
server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires
`api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent
endpoint on the network.
```json
{
"api": {
"host": "0.0.0.0",
"port": 8900,
"apiKey": "${NANOBOT_API_KEY}"
}
}
```
When `api.apiKey` is set, send it as a Bearer token on API routes. The health
endpoint remains unauthenticated so local probes and load balancers can still
check process health.
```bash
curl http://127.0.0.1:8900/v1/models \
-H "Authorization: Bearer $NANOBOT_API_KEY"
```
## Behavior
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
+34 -34
View File
@@ -31,13 +31,6 @@ from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
RetryWaitEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_message_for_event,
)
from nanobot.bus.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import (
@@ -574,12 +567,14 @@ class AgentLoop:
"""Build a retry-wait callback that publishes to the message bus."""
async def _on_retry_wait(content: str) -> None:
meta = dict(msg.metadata or {})
meta["_retry_wait"] = True
await self.bus.publish_outbound(
outbound_message_for_event(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
event=RetryWaitEvent(content=content),
metadata=msg.metadata,
content=content,
metadata=meta,
)
)
@@ -1004,31 +999,26 @@ class AgentLoop:
return f"{stream_base_id}:{stream_segment}"
async def on_stream(delta: str) -> None:
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=StreamDeltaEvent(
meta = dict(msg.metadata or {})
meta["_stream_delta"] = True
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content=delta,
stream_id=_current_stream_id(),
),
metadata=msg.metadata,
)
)
metadata=meta,
))
async def on_stream_end(*, resuming: bool = False) -> None:
nonlocal stream_segment
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=StreamEndEvent(
stream_id=_current_stream_id(),
resuming=resuming,
),
metadata=msg.metadata,
)
)
meta = dict(msg.metadata or {})
meta["_stream_end"] = True
meta["_resuming"] = resuming
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="",
metadata=meta,
))
stream_segment += 1
response = await self._process_message(
@@ -1381,10 +1371,9 @@ class AgentLoop:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
event = None
meta = dict(msg.metadata or {})
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
event = StreamedResponseEvent()
meta["_streamed"] = True
if turn_latency_ms is not None:
meta["latency_ms"] = int(turn_latency_ms)
@@ -1392,7 +1381,6 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
event=event,
metadata=meta,
)
@@ -1864,13 +1852,17 @@ class AgentLoop:
)
# Share the dispatch lock so direct calls serialize with bus turns.
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
pending: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=20)
try:
async with lock:
self._pending_queues[session_key] = pending
self.subagents.set_direct_result_queue(session_key, pending)
kwargs: dict[str, Any] = {
"session_key": session_key,
"on_progress": on_progress,
"on_stream": on_stream,
"on_stream_end": on_stream_end,
"pending_queue": pending,
"ephemeral": ephemeral,
}
if _run_extra_hooks_for_ephemeral:
@@ -1884,5 +1876,13 @@ class AgentLoop:
**kwargs,
)
finally:
self.subagents.clear_direct_result_queue(session_key, pending)
if self._pending_queues.get(session_key) is pending:
self._pending_queues.pop(session_key, None)
while True:
try:
await self.bus.publish_inbound(pending.get_nowait())
except asyncio.QueueEmpty:
break
await self._runtime_events().run_status_changed(msg, session_key, "idle")
self._runtime_events().clear_turn(session_key)
+2 -2
View File
@@ -18,7 +18,7 @@ from nanobot.agent.context_governance import (
ContextGovernor,
)
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
@@ -1266,7 +1266,7 @@ class AgentRunner:
return payload, event, exc
return payload, event, None
if is_tool_error_result(tool_call.name, result):
if isinstance(result, str) and result.startswith("Error"):
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
+20
View File
@@ -118,6 +118,22 @@ class SubagentManager:
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
self._direct_result_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
def set_direct_result_queue(
self,
session_key: str,
queue: asyncio.Queue[InboundMessage],
) -> None:
self._direct_result_queues[session_key] = queue
def clear_direct_result_queue(
self,
session_key: str,
queue: asyncio.Queue[InboundMessage],
) -> None:
if self._direct_result_queues.get(session_key) is queue:
self._direct_result_queues.pop(session_key, None)
def _subagent_tools_config(self) -> ToolsConfig:
"""Build a ToolsConfig scoped for subagent use."""
@@ -335,6 +351,10 @@ class SubagentManager:
metadata=metadata,
)
if queue := self._direct_result_queues.get(override):
await queue.put(msg)
logger.debug("Subagent [{}] queued result directly for {}", task_id, override)
return
await self.bus.publish_inbound(msg)
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
+1 -2
View File
@@ -1,6 +1,6 @@
"""Agent tools module."""
from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Schema, Tool, tool_parameters
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
@@ -25,7 +25,6 @@ __all__ = [
"Tool",
"ToolContext",
"ToolLoader",
"ToolResult",
"ToolRegistry",
"tool_parameters",
"tool_parameters_schema",
+4 -4
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import ToolResult, tool_parameters
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import (
ArraySchema,
@@ -289,8 +289,8 @@ class ApplyPatchTool(_FsTool):
_format_summary(summary) for summary in summaries
)
except PermissionError as exc:
return ToolResult.error(f"Error: {exc}")
return f"Error: {exc}"
except _PatchError as exc:
return ToolResult.error(f"Error applying patch: {exc}")
return f"Error applying patch: {exc}"
except Exception as exc:
return ToolResult.error(f"Error applying patch: {exc}")
return f"Error applying patch: {exc}"
+1 -20
View File
@@ -128,21 +128,6 @@ class Schema(ABC):
return Schema.validate_json_schema_value(value, self.to_json_schema(), path)
class ToolResult(str):
"""String-compatible tool output with structured status."""
is_error: bool
def __new__(cls, content: str, *, is_error: bool = False) -> ToolResult:
obj = str.__new__(cls, content)
obj.is_error = is_error
return obj
@classmethod
def error(cls, content: str) -> ToolResult:
return cls(content, is_error=True)
class Tool(ABC):
"""Agent capability: read files, run commands, etc."""
@@ -208,13 +193,9 @@ class Tool(ABC):
@abstractmethod
async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
"""Run the tool; returns a string or list of content blocks."""
...
@staticmethod
def error(content: str) -> ToolResult:
return ToolResult.error(content)
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
if not isinstance(obj, dict):
return obj
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
@@ -136,4 +136,4 @@ class CliAppsTool(Tool):
restrict_to_workspace=access.restrict_to_workspace,
)
except CliAppError as exc:
return ToolResult.error(f"Error: {exc.message}")
return f"Error: {exc.message}"
+10 -10
View File
@@ -6,7 +6,7 @@ from contextvars import ContextVar
from datetime import datetime
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import (
IntegerSchema,
@@ -99,7 +99,7 @@ class CronTool(Tool, ContextAware):
try:
ZoneInfo(tz)
except (KeyError, Exception):
return ToolResult.error(f"Error: unknown timezone '{tz}'")
return f"Error: unknown timezone '{tz}'"
return None
def _display_timezone(self, schedule: CronSchedule) -> str:
@@ -148,7 +148,7 @@ class CronTool(Tool, ContextAware):
) -> str:
if action == "add":
if self._in_cron_context.get():
return ToolResult.error("Error: cannot schedule new jobs from within a cron job execution")
return "Error: cannot schedule new jobs from within a cron job execution"
return self._add_job(name, message, every_seconds, cron_expr, tz, at)
elif action == "list":
return self._list_jobs()
@@ -166,20 +166,20 @@ class CronTool(Tool, ContextAware):
at: str | None,
) -> str:
if not message:
return ToolResult.error(
return (
"Error: cron action='add' requires a non-empty 'message' parameter "
"describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"."
)
session_key = self._session_key.get()
if not session_key:
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
return "Error: scheduled cron jobs must be created from a chat session"
origin_channel = self._origin_channel.get()
origin_chat_id = self._origin_chat_id.get()
if not origin_channel or not origin_chat_id:
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
return "Error: scheduled cron jobs must be created from a chat session"
if tz and not cron_expr:
return ToolResult.error("Error: tz can only be used with cron_expr")
return "Error: tz can only be used with cron_expr"
if tz:
if err := self._validate_timezone(tz):
return err
@@ -199,7 +199,7 @@ class CronTool(Tool, ContextAware):
try:
dt = datetime.fromisoformat(at)
except ValueError:
return ToolResult.error(f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS")
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS"
if dt.tzinfo is None:
if err := self._validate_timezone(self._default_timezone):
return err
@@ -208,7 +208,7 @@ class CronTool(Tool, ContextAware):
schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True
else:
return ToolResult.error("Error: either every_seconds, cron_expr, or at is required")
return "Error: either every_seconds, cron_expr, or at is required"
job = self._cron.add_job(
name=name or message[:30],
@@ -279,7 +279,7 @@ class CronTool(Tool, ContextAware):
def _remove_job(self, job_id: str | None) -> str:
if not job_id:
return ToolResult.error("Error: job_id is required for remove")
return "Error: job_id is required for remove"
result = self._cron.remove_job(job_id)
if result == "removed":
return f"Removed job {job_id}"
+7 -9
View File
@@ -9,7 +9,7 @@ from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.schema import (
BooleanSchema,
@@ -492,12 +492,11 @@ class WriteStdinTool(Tool):
max_output_chars=output_limit,
owner_session_key=current_request_session_key(),
)
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
return format_session_poll(session_id, poll)
except KeyError:
return ToolResult.error(f"Error: exec session not found: {session_id!r}")
return f"Error: exec session not found: {session_id}"
except Exception as exc:
return ToolResult.error(f"Error writing to exec session: {exc}")
return f"Error writing to exec session: {exc}"
async def _wait_for_output(
self,
@@ -533,14 +532,13 @@ class WriteStdinTool(Tool):
joined = "".join(aggregate)
if wait_for in joined:
poll.output = joined
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
return format_session_poll(session_id, poll)
if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate)
result = format_session_poll(session_id, poll)
if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}"
return ToolResult.error(result) if poll.timed_out else result
return result
@tool_parameters(tool_parameters_schema())
@@ -608,4 +606,4 @@ class ListExecSessionsTool(Tool):
)
return "\n".join(lines)
except Exception as exc:
return ToolResult.error(f"Error listing exec sessions: {exc}")
return f"Error listing exec sessions: {exc}"
+40 -40
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import (
@@ -268,19 +268,19 @@ class ReadFileTool(_FsTool):
) -> Any:
try:
if not path:
return ToolResult.error("Error reading file: Unknown path")
return "Error reading file: Unknown path"
# Device path blacklist
if _is_blocked_device(path):
return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
fp = self._resolve_read(path)
if _is_blocked_device(fp):
return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
if not fp.exists():
return ToolResult.error(f"Error: File not found: {path}")
return f"Error: File not found: {path}"
if not fp.is_file():
return ToolResult.error(f"Error: Not a file: {path}")
return f"Error: Not a file: {path}"
# PDF support
if fp.suffix.lower() == ".pdf":
@@ -343,7 +343,7 @@ class ReadFileTool(_FsTool):
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if mime and mime.startswith("image/"):
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
return ToolResult.error(f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported.")
return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported."
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
# concern (git checkouts with autocrlf, editors saving CRLF) but
@@ -357,7 +357,7 @@ class ReadFileTool(_FsTool):
if offset < 1:
offset = 1
if offset > total:
return ToolResult.error(f"Error: offset {offset} is beyond end of file ({total} lines)")
return f"Error: offset {offset} is beyond end of file ({total} lines)"
start = offset - 1
end = min(start + (limit or self._DEFAULT_LIMIT), total)
@@ -381,20 +381,20 @@ class ReadFileTool(_FsTool):
self._file_states.record_read(fp, offset=offset, limit=limit)
return result
except PermissionError as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
except Exception as e:
return ToolResult.error(f"Error reading file: {e}")
return f"Error reading file: {e}"
def _read_pdf(self, fp: Path, pages: str | None) -> str:
try:
import fitz # pymupdf
except ImportError:
return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf"
try:
doc = fitz.open(str(fp))
except Exception as e:
return ToolResult.error(f"Error reading PDF: {e}")
return f"Error reading PDF: {e}"
total_pages = len(doc)
if pages:
@@ -402,10 +402,10 @@ class ReadFileTool(_FsTool):
start, end = _parse_page_range(pages, total_pages)
except (ValueError, IndexError):
doc.close()
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
return f"Error: Invalid page range '{pages}'. Use format like '1-5'."
if start > end or start >= total_pages:
doc.close()
return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)."
else:
start = 0
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
@@ -437,10 +437,10 @@ class ReadFileTool(_FsTool):
result = extract_text(fp)
if result is None:
return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
return f"Error: Unsupported file format: {fp.suffix}"
if result.startswith("[error:"):
return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {result}")
return f"Error reading {fp.suffix.upper()} file: {result}"
if not result:
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
@@ -492,9 +492,9 @@ class WriteFileTool(_FsTool):
self._file_states.record_write(fp)
return f"Successfully wrote {len(content)} characters to {fp}"
except PermissionError as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
except Exception as e:
return ToolResult.error(f"Error writing file: {e}")
return f"Error writing file: {e}"
# ---------------------------------------------------------------------------
@@ -830,11 +830,11 @@ class EditFileTool(_FsTool):
if new_text is None:
raise ValueError("Unknown new_text")
if occurrence is not None and occurrence < 1:
return ToolResult.error("Error: occurrence must be >= 1.")
return "Error: occurrence must be >= 1."
if line_hint is not None and line_hint < 1:
return ToolResult.error("Error: line_hint must be >= 1.")
return "Error: line_hint must be >= 1."
if expected_replacements is not None and expected_replacements < 1:
return ToolResult.error("Error: expected_replacements must be >= 1.")
return "Error: expected_replacements must be >= 1."
fp = self._resolve_write(path)
@@ -853,14 +853,14 @@ class EditFileTool(_FsTool):
except OSError:
fsize = 0
if fsize > self._MAX_EDIT_FILE_SIZE:
return ToolResult.error(f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB.")
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB."
# Create-file: old_text='' but file exists and not empty → reject
if old_text == "":
raw = fp.read_bytes()
content = raw.decode("utf-8")
if content.strip():
return ToolResult.error(f"Error: Cannot create file — {path} already exists and is not empty.")
return f"Error: Cannot create file — {path} already exists and is not empty."
fp.write_text(new_text, encoding="utf-8")
self._file_states.record_write(fp)
return f"Successfully edited {fp}"
@@ -878,15 +878,15 @@ class EditFileTool(_FsTool):
return self._not_found_msg(old_text, content, path)
count = len(matches)
if replace_all and occurrence is not None:
return ToolResult.error("Error: occurrence cannot be used with replace_all=true.")
return "Error: occurrence cannot be used with replace_all=true."
if replace_all and line_hint is not None:
return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
return "Error: line_hint cannot be used with replace_all=true."
if occurrence is not None and line_hint is not None:
return ToolResult.error("Error: line_hint cannot be used with occurrence.")
return "Error: line_hint cannot be used with occurrence."
if count > 1 and not replace_all:
if occurrence is not None:
if occurrence > count:
return ToolResult.error(
return (
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} times."
)
@@ -894,7 +894,7 @@ class EditFileTool(_FsTool):
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
distance = abs(nearest.line - line_hint)
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
return ToolResult.error(
return (
f"Error: line_hint {line_hint} is ambiguous; "
f"old_text appears {count} times."
)
@@ -910,7 +910,7 @@ class EditFileTool(_FsTool):
"or set replace_all=true."
)
elif occurrence is not None and occurrence > count:
return ToolResult.error(
return (
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} time."
)
@@ -928,7 +928,7 @@ class EditFileTool(_FsTool):
else:
selected = [matches[occurrence - 1 if occurrence else 0]]
if expected_replacements is not None and len(selected) != expected_replacements:
return ToolResult.error(
return (
f"Error: expected {expected_replacements} replacements but "
f"would make {len(selected)}."
)
@@ -954,9 +954,9 @@ class EditFileTool(_FsTool):
msg = f"{warning}\n{msg}"
return msg
except PermissionError as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
except Exception as e:
return ToolResult.error(f"Error editing file: {e}")
return f"Error editing file: {e}"
def _file_not_found_msg(self, path: str, fp: Path) -> str:
"""Build an error message with 'Did you mean ...?' suggestions."""
@@ -969,7 +969,7 @@ class EditFileTool(_FsTool):
parts = [f"Error: File not found: {path}"]
if suggestions:
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
return ToolResult.error("\n".join(parts))
return "\n".join(parts)
@staticmethod
def _not_found_msg(old_text: str, content: str, path: str) -> str:
@@ -985,18 +985,18 @@ class EditFileTool(_FsTool):
hint_text = ""
if hints:
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
return ToolResult.error(
return (
f"Error: old_text not found in {path}."
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
)
if hints:
return ToolResult.error(
return (
f"Error: old_text not found in {path}. "
f"Possible cause: {', '.join(hints)}. "
"Copy the exact text from read_file and try again."
)
return ToolResult.error(f"Error: old_text not found in {path}. No similar text found. Verify the file content.")
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
# ---------------------------------------------------------------------------
@@ -1051,9 +1051,9 @@ class ListDirTool(_FsTool):
raise ValueError("Unknown path")
dp = self._resolve(path)
if not dp.exists():
return ToolResult.error(f"Error: Directory not found: {path}")
return f"Error: Directory not found: {path}"
if not dp.is_dir():
return ToolResult.error(f"Error: Not a directory: {path}")
return f"Error: Not a directory: {path}"
cap = max_entries or self._DEFAULT_MAX
items: list[str] = []
@@ -1084,6 +1084,6 @@ class ListDirTool(_FsTool):
result += f"\n\n(truncated, showing first {cap} of {total} entries)"
return result
except PermissionError as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
except Exception as e:
return ToolResult.error(f"Error listing directory: {e}")
return f"Error listing directory: {e}"
+4 -4
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import (
ArraySchema,
IntegerSchema,
@@ -172,11 +172,11 @@ class ImageGenerationTool(Tool):
) -> str:
client = self._provider_client()
if client is None:
return ToolResult.error(f"Error: unsupported image generation provider '{self.config.provider}'")
return f"Error: unsupported image generation provider '{self.config.provider}'"
requested = count or 1
if requested > self.config.max_images_per_turn:
return ToolResult.error(
return (
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
f"({self.config.max_images_per_turn})"
)
@@ -206,4 +206,4 @@ class ImageGenerationTool(Tool):
break
return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc:
return ToolResult.error(f"Error: {exc}")
return f"Error: {exc}"
+1 -67
View File
@@ -8,7 +8,7 @@ from typing import Any
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
_SKIP_MODULES = frozenset({
@@ -96,8 +96,6 @@ class ToolLoader:
if not tool_cls.enabled(ctx):
continue
tool = tool_cls.create(ctx)
if is_plugin_source:
tool = _LegacyErrorPrefixTool(tool)
if registry.has(tool.name):
if is_plugin_source and tool.name in builtin_names:
logger.warning(
@@ -116,67 +114,3 @@ class ToolLoader:
except Exception:
logger.exception("Failed to register tool: %s", cls_label)
return registered
class _LegacyErrorPrefixTool(Tool):
"""Compatibility wrapper for external tools using the old error-string contract."""
_plugin_discoverable = False
def __init__(self, wrapped: Tool) -> None:
self._wrapped = wrapped
@property
def name(self) -> str:
return self._wrapped.name
@property
def description(self) -> str:
return self._wrapped.description
@property
def parameters(self) -> dict[str, Any]:
return self._wrapped.parameters
@property
def read_only(self) -> bool:
return self._wrapped.read_only
@property
def exclusive(self) -> bool:
return self._wrapped.exclusive
@property
def concurrency_safe(self) -> bool:
return self._wrapped.concurrency_safe
@property
def config_key(self) -> str:
return getattr(self._wrapped, "config_key", "")
def set_context(self, ctx: Any) -> None:
set_context = getattr(self._wrapped, "set_context", None)
if callable(set_context):
set_context(ctx)
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
return self._wrapped.cast_params(params)
def validate_params(self, params: dict[str, Any]) -> list[str]:
return self._wrapped.validate_params(params)
def to_schema(self) -> dict[str, Any]:
return self._wrapped.to_schema()
async def execute(self, **kwargs: Any) -> Any:
result = await self._wrapped.execute(**kwargs)
if (
isinstance(result, str)
and not isinstance(result, ToolResult)
and result.startswith("Error:")
):
return ToolResult.error(result)
return result
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
+8 -5
View File
@@ -20,7 +20,7 @@ from contextvars import ContextVar
from datetime import datetime
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
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.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
@@ -97,7 +97,8 @@ class _GoalToolsMixin(ContextAware):
"Sustained objective for this chat thread. First read the built-in **long-goal** skill, "
"especially its Start fast section, then call this promptly once the user's intent is clear. "
"The goal must still be idempotent, self-contained, bounded, and explicit about done-ness; "
"do not delay this tool call to over-plan, research, or decide execution details.",
"do not delay this tool call to over-plan, research, or decide execution details. "
"Do not use this for a single current-turn answer, including one that uses spawn subagents.",
max_length=12_000,
),
ui_summary=StringSchema(
@@ -139,6 +140,8 @@ class LongTaskTool(Tool, _GoalToolsMixin):
def description(self) -> str:
return (
"Mark this thread as a sustained long-running task. "
"Use only when the user wants work to persist across future turns or background check-ins; "
"do not use for a single current-turn answer, including one that uses spawn subagents. "
"First read the built-in **long-goal** skill, especially its Start fast section; then call this "
"as soon as the user's intent is clear. Write a good idempotent goal, but do not delay the tool "
"call with long planning, research, or execution-detail thinking. "
@@ -150,12 +153,12 @@ class LongTaskTool(Tool, _GoalToolsMixin):
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return ToolResult.error(
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 ToolResult.error(
return (
"Error: a sustained goal is already active. "
"Use complete_goal when finished, or ask the user before replacing it."
)
@@ -230,7 +233,7 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return ToolResult.error("Error: complete_goal requires an active chat session.")
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."
+2 -5
View File
@@ -14,7 +14,7 @@ from weakref import WeakKeyDictionary
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
@@ -461,10 +461,7 @@ class MCPToolWrapper(_MCPWrapperBase):
return f"(MCP tool call failed: {type(exc).__name__})"
else:
# Success — extract text and persist any image content as artifacts.
rendered = self._render_call_result(result.content, kwargs)
if getattr(result, "isError", False):
return ToolResult.error(rendered)
return rendered
return self._render_call_result(result.content, kwargs)
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
+7 -7
View File
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
@@ -198,7 +198,7 @@ class MessageTool(Tool, ContextAware):
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons
):
return ToolResult.error("Error: buttons must be a list of list of strings")
return "Error: buttons must be a list of list of strings"
default_channel = self._default_channel.get()
default_chat_id = self._default_chat_id.get()
channel = channel or default_channel
@@ -210,7 +210,7 @@ class MessageTool(Tool, ContextAware):
and str(explicit_chat_id).strip() != ""
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
):
return ToolResult.error(
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 "
@@ -229,16 +229,16 @@ class MessageTool(Tool, ContextAware):
message_id = None
if not channel or not chat_id:
return ToolResult.error("Error: No target channel/chat specified")
return "Error: No target channel/chat specified"
if not self._send_callback:
return ToolResult.error("Error: Message sending not configured")
return "Error: Message sending not configured"
if media:
try:
media = self._resolve_media(media)
except (OSError, PermissionError, ValueError) as e:
return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
return f"Error: media path is not allowed: {str(e)}"
metadata = dict(self._default_metadata.get()) if same_target else {}
if message_id:
@@ -270,4 +270,4 @@ class MessageTool(Tool, ContextAware):
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}"
except Exception as e:
return ToolResult.error(f"Error sending message: {str(e)}")
return f"Error sending message: {str(e)}"
+6 -14
View File
@@ -3,11 +3,7 @@
import json
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult
def is_tool_error_result(name: str, result: Any) -> bool:
return isinstance(result, ToolResult) and result.is_error
from nanobot.agent.tools.base import Tool
class ToolRegistry:
@@ -104,26 +100,22 @@ class ToolRegistry:
suggestion = self._suggest_name(str(name))
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
return None, params, (
ToolResult.error(
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
)
)
params = self._coerce_params(tool, params)
if not isinstance(params, dict):
return tool, params, (
ToolResult.error(
f"Error: Tool '{name}' parameters must be a JSON object, got "
f"{type(params).__name__}. Use named parameters like "
'tool_name(param1="value1", param2="value2") matching the tool schema.'
)
)
cast_params = tool.cast_params(params)
errors = tool.validate_params(cast_params)
if errors:
return tool, cast_params, (
ToolResult.error(f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors))
f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
)
return tool, cast_params, None
@@ -167,16 +159,16 @@ class ToolRegistry:
hint = "\n\n[Analyze the error above and try a different approach.]"
tool, params, error = self.prepare_call(name, params)
if error:
return ToolResult.error(str(error) + hint)
return error + hint
try:
assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params)
if is_tool_error_result(name, result):
return ToolResult.error(str(result) + hint)
if isinstance(result, str) and result.startswith("Error"):
return result + hint
return result
except Exception as e:
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
return f"Error executing {name}: {str(e)}" + hint
@property
def tool_names(self) -> list[str]:
+10 -11
View File
@@ -9,7 +9,6 @@ from contextlib import suppress
from pathlib import Path, PurePosixPath
from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250
@@ -219,12 +218,12 @@ class FindFilesTool(_SearchTool):
try:
target = self._resolve(path or ".")
if not target.exists():
return ToolResult.error(f"Error: Path not found: {path}")
return f"Error: Path not found: {path}"
if not (target.is_dir() or target.is_file()):
return ToolResult.error(f"Error: Unsupported path: {path}")
return f"Error: Unsupported path: {path}"
if sort not in {"path", "modified"}:
return ToolResult.error("Error: sort must be 'path' or 'modified'")
return "Error: sort must be 'path' or 'modified'"
limit = (
_DEFAULT_FILE_HEAD_LIMIT
@@ -272,9 +271,9 @@ class FindFilesTool(_SearchTool):
result += "\n\n" + note
return result
except PermissionError as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
except Exception as e:
return ToolResult.error(f"Error finding files: {e}")
return f"Error finding files: {e}"
class GrepTool(_SearchTool):
@@ -426,16 +425,16 @@ class GrepTool(_SearchTool):
try:
target = self._resolve(path or ".")
if not target.exists():
return ToolResult.error(f"Error: Path not found: {path}")
return f"Error: Path not found: {path}"
if not (target.is_dir() or target.is_file()):
return ToolResult.error(f"Error: Unsupported path: {path}")
return f"Error: Unsupported path: {path}"
flags = re.IGNORECASE if case_insensitive else 0
try:
needle = re.escape(pattern) if fixed_strings else pattern
regex = re.compile(needle, flags)
except re.error as e:
return ToolResult.error(f"Error: invalid regex pattern: {e}")
return f"Error: invalid regex pattern: {e}"
if head_limit is not None:
limit = None if head_limit == 0 else head_limit
@@ -580,6 +579,6 @@ class GrepTool(_SearchTool):
result += "\n\n" + "\n".join(notes)
return result
except PermissionError as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
except Exception as e:
return ToolResult.error(f"Error searching files: {e}")
return f"Error searching files: {e}"
+24 -24
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base
@@ -216,7 +216,7 @@ class MyTool(Tool, ContextAware):
@staticmethod
def _validate_key(key: str | None, label: str = "key") -> str | None:
if not key or not key.strip():
return ToolResult.error(f"Error: '{label}' cannot be empty or whitespace")
return f"Error: '{label}' cannot be empty or whitespace"
return None
# ------------------------------------------------------------------
@@ -321,7 +321,7 @@ class MyTool(Tool, ContextAware):
if action in ("inspect", "check"):
return self._inspect(key)
if not self._modify_allowed:
return ToolResult.error("Error: set is disabled (tools.my.allow_set is false)")
return "Error: set is disabled (tools.my.allow_set is false)"
if action in ("modify", "set"):
return self._modify(key, value)
return f"Unknown action: {action}"
@@ -333,7 +333,7 @@ class MyTool(Tool, ContextAware):
return self._inspect_all()
top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"):
return ToolResult.error(f"Error: '{top}' is not accessible")
return f"Error: '{top}' is not accessible"
obj, err = self._resolve_path(key)
if err:
# "scratchpad" alias for _runtime_vars
@@ -343,12 +343,12 @@ class MyTool(Tool, ContextAware):
# Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return ToolResult.error(f"Error: {err}")
return f"Error: {err}"
# Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return ToolResult.error(f"Error: '{key}' not found")
return f"Error: '{key}' not found"
return self._format_value(obj, key)
def _inspect_all(self) -> str:
@@ -379,21 +379,21 @@ class MyTool(Tool, ContextAware):
top = key.split(".")[0]
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED {key}")
return ToolResult.error(f"Error: '{key}' is protected and cannot be modified")
return f"Error: '{key}' is protected and cannot be modified"
if top in self.READ_ONLY:
self._audit("modify", f"READ_ONLY {key}")
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
return f"Error: '{key}' is read-only and cannot be modified"
if "." in key:
parent_path, leaf = key.rsplit(".", 1)
if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
self._audit("modify", f"BLOCKED leaf '{leaf}'")
return ToolResult.error(f"Error: '{leaf}' is not accessible")
return f"Error: '{leaf}' is not accessible"
if leaf.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
return ToolResult.error(f"Error: '{leaf}' is not accessible")
return f"Error: '{leaf}' is not accessible"
parent, err = self._resolve_path(parent_path)
if err:
return ToolResult.error(f"Error: {err}")
return f"Error: {err}"
if isinstance(parent, dict):
parent[leaf] = value
else:
@@ -408,11 +408,11 @@ class MyTool(Tool, ContextAware):
def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
return "Error: 'model_preset' must be a non-empty string"
name = value.strip()
result = self._modify_free("model_preset", name)
if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
if result.startswith("Error:"):
return result if result.endswith((".", "!", "?")) else f"{result}."
return (
f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
@@ -422,19 +422,19 @@ class MyTool(Tool, ContextAware):
spec = self.RESTRICTED[key]
expected = spec["type"]
if expected is int and isinstance(value, bool):
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
return f"Error: '{key}' must be {expected.__name__}, got bool"
if not isinstance(value, expected):
try:
value = expected(value)
except (ValueError, TypeError):
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
old = getattr(self._runtime_state, key)
if "min" in spec and value < spec["min"]:
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
return f"Error: '{key}' must be >= {spec['min']}"
if "max" in spec and value > spec["max"]:
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
return f"Error: '{key}' must be <= {spec['max']}"
if "min_len" in spec and len(str(value)) < spec["min_len"]:
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
return f"Error: '{key}' must be at least {spec['min_len']} characters"
setattr(self._runtime_state, key, value)
if key == "model":
self._runtime_state._active_preset = None
@@ -458,25 +458,25 @@ class MyTool(Tool, ContextAware):
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
try:
setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}")
return ToolResult.error(f"Error: {message}")
return f"Error: {message}"
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
if callable(value):
self._audit("modify", f"REJECTED callable {key}")
return ToolResult.error("Error: cannot store callable values")
return "Error: cannot store callable values"
err = self._validate_json_safe(value)
if err:
self._audit("modify", f"REJECTED {key}: {err}")
return ToolResult.error(f"Error: {err}")
return f"Error: {err}"
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first."
old = self._runtime_state._runtime_vars.get(key)
self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
+27 -48
View File
@@ -8,7 +8,6 @@ import re
import shutil
import sys
from contextlib import suppress
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -16,7 +15,7 @@ from typing import Any
from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER,
@@ -74,9 +73,12 @@ class _PreparedCommand:
login: bool
_EXEC_TOOL_PARAMETERS = tool_parameters_schema(
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
cmd=StringSchema("Compatibility alias for command"),
working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema(
60,
description=(
@@ -115,14 +117,7 @@ _EXEC_TOOL_PARAMETERS = tool_parameters_schema(
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
)
_EXEC_TOOL_COMPAT_PARAMETERS = deepcopy(_EXEC_TOOL_PARAMETERS)
_EXEC_TOOL_COMPAT_PARAMETERS["properties"].update(
{
"cmd": StringSchema("Compatibility alias for command").to_json_schema(),
"workdir": StringSchema("Compatibility alias for working_dir").to_json_schema(),
"max_output_tokens": IntegerSchema(
max_output_tokens=IntegerSchema(
description=(
"Compatibility alias for max_output_chars. The current runtime "
"uses a character budget."
@@ -130,12 +125,9 @@ _EXEC_TOOL_COMPAT_PARAMETERS["properties"].update(
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
).to_json_schema(),
}
),
)
)
@tool_parameters(_EXEC_TOOL_PARAMETERS)
class ExecTool(Tool):
"""Tool to execute shell commands."""
_scopes = {"core", "subagent"}
@@ -252,18 +244,6 @@ class ExecTool(Tool):
def exclusive(self) -> bool:
return True
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
return self._cast_object(params, _EXEC_TOOL_COMPAT_PARAMETERS)
def validate_params(self, params: dict[str, Any]) -> list[str]:
if not isinstance(params, dict):
return [f"parameters must be an object, got {type(params).__name__}"]
return Schema.validate_json_schema_value(
params,
{**_EXEC_TOOL_COMPAT_PARAMETERS, "type": "object"},
"",
)
async def execute(
self, command: str | None = None, cmd: str | None = None,
working_dir: str | None = None, workdir: str | None = None,
@@ -276,7 +256,7 @@ class ExecTool(Tool):
command = command or cmd
working_dir = working_dir or workdir
if not command:
return ToolResult.error("Error: Missing command. Provide command or cmd.")
return "Error: Missing command. Provide command or cmd."
if max_output_chars is None:
max_output_chars = max_output_tokens
@@ -303,7 +283,7 @@ class ExecTool(Tool):
)
except asyncio.TimeoutError:
await self._kill_process(process)
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
return f"Error: Command timed out after {prepared.timeout} seconds"
except asyncio.CancelledError:
await self._kill_process(process)
raise
@@ -334,7 +314,7 @@ class ExecTool(Tool):
return result
except Exception as e:
return ToolResult.error(f"Error executing command: {str(e)}")
return f"Error executing command: {str(e)}"
async def _execute_session(
self,
@@ -359,10 +339,9 @@ class ExecTool(Tool):
MAX_OUTPUT_CHARS,
),
)
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
return format_session_poll(session_id, poll)
except Exception as exc:
return ToolResult.error(f"Error executing command: {exc}")
return f"Error executing command: {exc}"
def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit).
@@ -404,12 +383,12 @@ class ExecTool(Tool):
requested = Path(cwd).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve()
except Exception:
return ToolResult.error(
return (
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if not is_path_within(requested, resolved_root):
return ToolResult.error(
return (
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
@@ -525,24 +504,24 @@ class ExecTool(Tool):
if not shell:
return None, None
if _IS_WINDOWS:
return None, ToolResult.error("Error: shell parameter is not supported on Windows")
return None, "Error: shell parameter is not supported on Windows"
if "\0" in shell or "\n" in shell or "\r" in shell:
return None, ToolResult.error("Error: shell contains invalid characters")
return None, "Error: shell contains invalid characters"
allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser()
if path.is_absolute():
if path.name not in allowed:
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
if not path.is_file() or not os.access(path, os.X_OK):
return None, ToolResult.error(f"Error: shell is not executable: {shell}")
return None, f"Error: shell is not executable: {shell}"
return str(path), None
if "/" in shell or "\\" in shell:
return None, ToolResult.error("Error: shell must be a shell name or absolute path")
return None, "Error: shell must be a shell name or absolute path"
if shell not in allowed:
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
resolved = shutil.which(shell)
if not resolved:
return None, ToolResult.error(f"Error: shell not found: {shell}")
return None, f"Error: shell not found: {shell}"
return resolved, None
@staticmethod
@@ -629,10 +608,10 @@ class ExecTool(Tool):
if not explicitly_allowed:
for pattern in self.deny_patterns:
if re.search(pattern, lower):
return ToolResult.error("Error: Command blocked by deny pattern filter")
return "Error: Command blocked by deny pattern filter"
if self.allow_patterns:
return ToolResult.error("Error: Command blocked by allowlist filter (not in allowlist)")
return "Error: Command blocked by allowlist filter (not in allowlist)"
from nanobot.security.network import contains_internal_url
if contains_internal_url(
@@ -642,12 +621,12 @@ class ExecTool(Tool):
),
):
# The runner turns this marker into a non-retryable security hint.
return ToolResult.error("Error: Command blocked by safety guard (internal/private URL detected)")
return "Error: Command blocked by safety guard (internal/private URL detected)"
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
if should_restrict:
if "..\\" in cmd or "../" in cmd:
return ToolResult.error(
return (
"Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE
)
@@ -682,7 +661,7 @@ class ExecTool(Tool):
if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace)
if p.is_absolute() and not allowed:
return ToolResult.error(
return (
"Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
+3
View File
@@ -63,6 +63,9 @@ class SpawnTool(Tool, ContextAware):
return (
"Spawn a subagent to handle a task in the background. "
"Use this for complex or time-consuming tasks that can run independently. "
"For MapReduce-style work, spawn only independent map slices with clear "
"boundaries; keep reduction, conflict resolution, and final user-facing "
"synthesis in the main agent. "
"The subagent will complete the task and report back when done. "
"For deliverables or existing projects, inspect the workspace first "
"and use a dedicated subdirectory when helpful."
+28 -28
View File
@@ -14,7 +14,7 @@ import httpx
from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@@ -395,13 +395,13 @@ class WebSearchTool(Tool):
elif provider == "keenable":
return await self._search_keenable(query, n)
else:
return ToolResult.error(f"Error: unknown search provider '{provider}'")
return f"Error: unknown search provider '{provider}'"
async def _search_olostep(self, query: str, n: int) -> str:
try:
from olostep import AsyncOlostep, Olostep_BaseError
except ImportError:
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
return "Error: olostep package not installed. Run: pip install olostep"
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
if not api_key:
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
@@ -445,9 +445,9 @@ class WebSearchTool(Tool):
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
return _format_results(query, items, n)
except Olostep_BaseError as e:
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
return f"Olostep search error: {type(e).__name__}: {e}"
except Exception as e:
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
return f"Olostep search error: {type(e).__name__}: {e}"
async def _search_brave(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
@@ -481,13 +481,13 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return ToolResult.error(
return (
"Error: Brave search rate limited after retry. "
"Retry later or reduce consecutive web_search calls."
)
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
except Exception as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
async def _search_tavily(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
@@ -505,7 +505,7 @@ class WebSearchTool(Tool):
r.raise_for_status()
return _format_results(query, r.json().get("results", []), n)
except Exception as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
async def _search_keenable(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "")
@@ -540,10 +540,10 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return ToolResult.error("Error: Keenable search rate limited. Try again later or reduce search frequency.")
return ToolResult.error(f"Error: Keenable search failed ({e.response.status_code}): {e}")
return "Error: Keenable search rate limited. Try again later or reduce search frequency."
return f"Error: Keenable search failed ({e.response.status_code}): {e}"
except Exception as e:
return ToolResult.error(f"Error: Keenable search failed: {e}")
return f"Error: Keenable search failed: {e}"
async def _search_searxng(self, query: str, n: int) -> str:
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
@@ -553,7 +553,7 @@ class WebSearchTool(Tool):
endpoint = f"{base_url.rstrip('/')}/search"
is_valid, error_msg = _validate_url(endpoint)
if not is_valid:
return ToolResult.error(f"Error: invalid SearXNG URL: {error_msg}")
return f"Error: invalid SearXNG URL: {error_msg}"
try:
async with httpx.AsyncClient(proxy=self.proxy) as client:
r = await client.get(
@@ -565,7 +565,7 @@ class WebSearchTool(Tool):
r.raise_for_status()
return _format_results(query, r.json().get("results", []), n)
except Exception as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
async def _search_jina(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "")
@@ -616,7 +616,7 @@ class WebSearchTool(Tool):
]
return _format_results(query, items, n)
except Exception as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
async def _search_exa(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
@@ -663,10 +663,10 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return ToolResult.error("Error: Exa search rate limited. Try again later or reduce search frequency.")
return ToolResult.error(f"Error: Exa search failed ({e.response.status_code}): {e}")
return "Error: Exa search rate limited. Try again later or reduce search frequency."
return f"Error: Exa search failed ({e.response.status_code}): {e}"
except Exception as e:
return ToolResult.error(f"Error: Exa search failed: {e}")
return f"Error: Exa search failed: {e}"
async def _search_volcengine(
self,
@@ -690,7 +690,7 @@ class WebSearchTool(Tool):
normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None
normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None
except ValueError as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
body: dict[str, Any] = {
"Query": query,
@@ -723,18 +723,18 @@ class WebSearchTool(Tool):
data = r.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
return ToolResult.error(f"Error: Volcengine search failed ({e.response.status_code}): {e}")
return "Error: Volcengine search rate limited. Try again later or reduce search frequency."
return f"Error: Volcengine search failed ({e.response.status_code}): {e}"
except Exception as e:
return ToolResult.error(f"Error: Volcengine search failed: {e}")
return f"Error: Volcengine search failed: {e}"
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
if error:
if isinstance(error, dict):
code = error.get("Code") or error.get("code") or "unknown"
message = error.get("Message") or error.get("message") or error
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
return ToolResult.error(f"Error: Volcengine search error: {error}")
return f"Error: Volcengine search error {code}: {message}"
return f"Error: Volcengine search error: {error}"
result = data.get("Result") or data
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
@@ -791,7 +791,7 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except Exception as e:
logger.warning("DuckDuckGo search failed: {}", e)
return ToolResult.error(f"Error: DuckDuckGo search failed ({e})")
return f"Error: DuckDuckGo search failed ({e})"
async def _search_bocha(self, query: str, n: int, freshness: str = "noLimit") -> str:
api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "")
@@ -819,7 +819,7 @@ class WebSearchTool(Tool):
timeout=self.config.timeout,
)
if r.status_code == 429:
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
return "Error: Bocha search rate-limited (HTTP 429). Wait and retry."
r.raise_for_status()
data = r.json()
wrapped_data = data.get("data") if isinstance(data, dict) else None
@@ -839,9 +839,9 @@ class WebSearchTool(Tool):
]
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
return ToolResult.error(f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}")
return f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}"
except Exception as e:
return ToolResult.error(f"Error: {e}")
return f"Error: {e}"
@tool_parameters(
+1 -22
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
import asyncio
import contextlib
import hmac
import json as _json
import time
import uuid
@@ -393,10 +392,7 @@ async def handle_health(request: web.Request) -> web.Response:
def create_app(
agent_loop,
model_name: str = "nanobot",
request_timeout: float = 120.0,
api_key: str = "",
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0
) -> web.Application:
"""Create the aiohttp application.
@@ -404,7 +400,6 @@ def create_app(
agent_loop: An initialized AgentLoop instance.
model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds.
api_key: Optional API key for Bearer-token authentication.
"""
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
app["agent_loop"] = agent_loop
@@ -412,22 +407,6 @@ def create_app(
app["request_timeout"] = request_timeout
app["session_locks"] = {} # per-user locks, keyed by session_key
@web.middleware
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
if not api_key:
return await handler(request)
# Allow unauthenticated health checks.
if request.path == "/health":
return await handler(request)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")
if not hmac.compare_digest(auth[len("Bearer "):], api_key):
return _error_json(401, "Invalid API key")
return await handler(request)
app.middlewares.append(auth_middleware)
app.router.add_post("/v1/chat/completions", handle_chat_completions)
app.router.add_get("/v1/models", handle_models)
app.router.add_get("/health", handle_health)
+4 -8
View File
@@ -2,10 +2,7 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from nanobot.bus.outbound_events import OutboundEvent
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
@@ -42,9 +39,9 @@ class InboundMessage:
class OutboundMessage:
"""Message to send to a chat channel.
``event`` carries internal runtime/UI semantics. ``metadata`` is reserved
for channel routing context (``message_id``, thread ids, etc.) and optional
``OUTBOUND_META_AGENT_UI`` blobs for rich clients.
``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
@@ -54,4 +51,3 @@ class OutboundMessage:
media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list)
event: "OutboundEvent | None" = None
-226
View File
@@ -1,226 +0,0 @@
"""Typed outbound events carried by :class:`OutboundMessage`.
The message bus still transports :class:`nanobot.bus.events.OutboundMessage`
because channels need chat routing fields. Runtime/UI semantics live on the
message's explicit ``event`` field rather than in reserved metadata flags.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import Any
from nanobot.bus.events import OutboundMessage
class OutboundEvent:
"""Marker base for internal outbound runtime events."""
@dataclass(frozen=True)
class ProgressEvent(OutboundEvent):
content: str = ""
tool_hint: bool = False
reasoning: bool = False
reasoning_delta: bool = False
reasoning_end: bool = False
stream_id: str | None = None
tool_events: list[dict[str, Any]] | None = None
file_edit_events: list[dict[str, Any]] | None = None
@dataclass(frozen=True)
class RetryWaitEvent(OutboundEvent):
content: str = ""
@dataclass(frozen=True)
class StreamDeltaEvent(OutboundEvent):
content: str = ""
stream_id: str | None = None
@dataclass(frozen=True)
class StreamEndEvent(OutboundEvent):
content: str = ""
stream_id: str | None = None
resuming: bool = False
@dataclass(frozen=True)
class StreamedResponseEvent(OutboundEvent):
pass
@dataclass(frozen=True)
class TurnEndEvent(OutboundEvent):
latency_ms: int | None = None
goal_state: dict[str, Any] | None = None
@dataclass(frozen=True)
class GoalStatusEvent(OutboundEvent):
status: str
started_at: float | None = None
@dataclass(frozen=True)
class GoalStateSyncEvent(OutboundEvent):
goal_state: dict[str, Any]
@dataclass(frozen=True)
class SessionUpdatedEvent(OutboundEvent):
scope: str | None = None
@dataclass(frozen=True)
class RuntimeModelUpdatedEvent(OutboundEvent):
model: str | None
model_preset: str | None = None
def outbound_message_for_event(
*,
channel: str,
chat_id: str,
event: OutboundEvent,
content: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> OutboundMessage:
"""Build an :class:`OutboundMessage` for a typed event."""
return OutboundMessage(
channel=channel,
chat_id=chat_id,
content=_event_content(event) if content is None else content,
event=event,
metadata=dict(metadata or {}),
)
def outbound_event_from_message(msg: OutboundMessage) -> OutboundEvent | None:
"""Return the typed outbound event carried by *msg*, if any."""
if msg.event is not None:
return msg.event
return _legacy_event_from_metadata(msg)
def replace_outbound_event(
msg: OutboundMessage,
event: OutboundEvent,
*,
content: str | None = None,
) -> OutboundMessage:
"""Return *msg* with a new event and optional content."""
return replace(
msg,
content=_event_content(event) if content is None else content,
event=event,
)
def _event_content(event: OutboundEvent) -> str:
if isinstance(event, ProgressEvent | RetryWaitEvent | StreamDeltaEvent | StreamEndEvent):
return event.content
return ""
def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
"""Bridge pre-typed outbound metadata flags into typed events.
New code should set ``OutboundMessage.event`` directly. The fallback keeps
older in-process extensions and channel plugins from losing runtime events
while they migrate off reserved metadata flags.
"""
meta = msg.metadata or {}
if meta.get("_runtime_model_updated"):
return RuntimeModelUpdatedEvent(
model=_metadata_str(meta, "model"),
model_preset=_metadata_str(meta, "model_preset"),
)
if meta.get("_goal_state_sync"):
goal_state = meta.get("goal_state")
return GoalStateSyncEvent(goal_state if isinstance(goal_state, dict) else {"active": False})
if meta.get("_goal_status"):
status = meta.get("goal_status")
if not isinstance(status, str) or not status:
return None
return GoalStatusEvent(
status=status,
started_at=_metadata_float(meta, "started_at", "goal_started_at"),
)
if meta.get("_turn_end"):
goal_state = meta.get("goal_state")
return TurnEndEvent(
latency_ms=_metadata_int(meta, "latency_ms"),
goal_state=goal_state if isinstance(goal_state, dict) else None,
)
if meta.get("_session_updated"):
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
if meta.get("_retry_wait"):
return RetryWaitEvent(content=msg.content)
if meta.get("_stream_end"):
return StreamEndEvent(
content=msg.content,
stream_id=_metadata_str(meta, "_stream_id"),
resuming=bool(meta.get("_resuming")),
)
if meta.get("_stream_delta"):
return StreamDeltaEvent(
content=msg.content,
stream_id=_metadata_str(meta, "_stream_id"),
)
if meta.get("_streamed"):
return StreamedResponseEvent()
if (
meta.get("_progress")
or meta.get("_reasoning_delta")
or meta.get("_reasoning_end")
or meta.get("_reasoning")
or meta.get("_file_edit_events")
or meta.get("_tool_events")
):
tool_events = meta.get("_tool_events")
file_edit_events = meta.get("_file_edit_events")
return ProgressEvent(
content=msg.content,
tool_hint=bool(meta.get("_tool_hint")),
reasoning=bool(meta.get("_reasoning")),
reasoning_delta=bool(meta.get("_reasoning_delta")),
reasoning_end=bool(meta.get("_reasoning_end")),
stream_id=_metadata_str(meta, "_stream_id"),
tool_events=tool_events if isinstance(tool_events, list) else None,
file_edit_events=file_edit_events if isinstance(file_edit_events, list) else None,
)
return None
def _metadata_str(meta: Mapping[str, Any], key: str) -> str | None:
value = meta.get(key)
return value if isinstance(value, str) and value else None
def _metadata_int(meta: Mapping[str, Any], key: str) -> int | None:
value = meta.get(key)
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float) and value.is_integer():
return int(value)
return None
def _metadata_float(meta: Mapping[str, Any], *keys: str) -> float | None:
for key in keys:
value = meta.get(key)
if isinstance(value, bool):
continue
if isinstance(value, int | float):
return float(value)
return None
+14 -11
View File
@@ -10,8 +10,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
@@ -30,19 +29,23 @@ def build_bus_progress_callback(
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
if file_edit_events:
meta["_file_edit_events"] = file_edit_events
await bus.publish_outbound(
outbound_message_for_event(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
event=ProgressEvent(
content=content,
tool_hint=tool_hint,
reasoning_delta=reasoning,
reasoning_end=reasoning_end,
tool_events=tool_events,
file_edit_events=file_edit_events,
),
metadata=msg.metadata,
metadata=meta,
)
)
+17 -37
View File
@@ -101,33 +101,20 @@ class BaseChannel(ABC):
"""
pass
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
"""Deliver a streaming text chunk.
Override in subclasses to enable streaming. Implementations should
raise on delivery failure so the channel manager can retry.
Stateful implementations should key buffers by ``stream_id`` rather
than only by ``chat_id`` when it is provided.
Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends
the current segment, and stateful implementations must key buffers by
``_stream_id`` rather than only by ``chat_id``.
"""
pass
async def send_reasoning_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Stream a chunk of model reasoning/thinking content.
@@ -136,17 +123,15 @@ class BaseChannel(ABC):
subtext, WebUI italic bubble, ...) override to render reasoning
as a subordinate trace that updates in place as the model thinks.
Streaming contract mirrors :meth:`send_delta`: stateful implementations
should key buffers by ``stream_id`` rather than only by ``chat_id``.
Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta``
is a chunk, ``_reasoning_end`` ends the current reasoning segment,
and stateful implementations should key buffers by ``_stream_id``
rather than only by ``chat_id``.
"""
return
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
self, chat_id: str, metadata: dict[str, Any] | None = None
) -> None:
"""Mark the end of a reasoning stream segment.
@@ -180,18 +165,13 @@ class BaseChannel(ABC):
"""
if not msg.content:
return
stream_id = getattr(msg.event, "stream_id", None)
await self.send_reasoning_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=stream_id,
)
await self.send_reasoning_end(
msg.chat_id,
msg.metadata,
stream_id=stream_id,
)
meta = dict(msg.metadata or {})
meta.setdefault("_reasoning_delta", True)
await self.send_reasoning_delta(msg.chat_id, msg.content, meta)
end_meta = dict(meta)
end_meta.pop("_reasoning_delta", None)
end_meta["_reasoning_end"] = True
await self.send_reasoning_end(msg.chat_id, end_meta)
@property
def supports_streaming(self) -> bool:
+6 -11
View File
@@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text
@@ -459,7 +458,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping outbound message")
return
is_progress = isinstance(msg.event, ProgressEvent)
is_progress = bool((msg.metadata or {}).get("_progress"))
try:
await client.send_outbound(msg)
@@ -472,14 +471,7 @@ class DiscordChannel(BaseChannel):
await self._clear_reactions(msg.chat_id)
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client
@@ -487,7 +479,10 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping stream delta")
return
if stream_end:
meta = metadata or {}
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"):
buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text:
return
+1 -2
View File
@@ -23,7 +23,6 @@ from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -219,7 +218,7 @@ class EmailChannel(BaseChannel):
return
# Skip progress messages to prevent sending an empty email after each tool call
if isinstance(msg.event, ProgressEvent):
if (msg.metadata or {}).get("_progress"):
self.logger.debug("Skip progress message to {}", msg.chat_id)
return
+9 -18
View File
@@ -22,7 +22,6 @@ from rich.panel import Panel
from rich.text import Text
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -1798,19 +1797,14 @@ class FeishuChannel(BaseChannel):
return self._stream_update_text_sync(card_id, content, sequence), sequence
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
Supported metadata keys:
message_id: Original message id (used with stream end for reaction cleanup).
_stream_end: Finalize the streaming card.
_tool_hint: Delta is a formatted tool hint (for display only).
message_id: Original message id (used with _stream_end for reaction cleanup).
chat_type: "group" or "p2p" controls reply-in-thread for streaming cards.
"""
if not self._client:
@@ -1821,14 +1815,14 @@ class FeishuChannel(BaseChannel):
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback ---
if stream_end:
if meta.get("_stream_end"):
message_id = meta.get("message_id")
# Only finalize the OnIt -> DONE reaction transition on the truly
# final stream end. resuming=True means the agent will keep
# final stream end. _resuming=True means the agent will keep
# working (more tool-call rounds), so leave the reaction state
# in place — otherwise the OnIt indicator disappears prematurely
# and the DONE reaction fires after every tool call.
if message_id and not resuming:
if message_id and not meta.get("_resuming"):
reaction_id = self._reaction_ids.pop(message_id, None)
if reaction_id:
await self._remove_reaction(message_id, reaction_id)
@@ -1971,9 +1965,7 @@ class FeishuChannel(BaseChannel):
# Handle tool hint messages. When a streaming card is active for
# this chat, inline the hint into the card instead of sending a
# separate message so the user experience stays cohesive.
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
if progress_event and progress_event.tool_hint:
if msg.metadata.get("_tool_hint"):
hint = (msg.content or "").strip()
if not hint:
return
@@ -1984,7 +1976,6 @@ class FeishuChannel(BaseChannel):
await self.send_delta(
msg.chat_id,
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
metadata=msg.metadata,
)
return
# No active streaming card — send as a regular interactive card
@@ -2018,7 +2009,7 @@ class FeishuChannel(BaseChannel):
reply_message_id: str | None = None
_msg_id = msg.metadata.get("message_id")
has_thread_id = msg.metadata.get("thread_id")
if self.config.reply_to_message and progress_event is None:
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
reply_message_id = _msg_id
# For topic group messages, always reply to keep context in thread
elif has_thread_id:
+50 -153
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
@@ -13,16 +12,6 @@ from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
RetryWaitEvent,
RuntimeModelUpdatedEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
replace_outbound_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
@@ -277,7 +266,7 @@ class ChannelManager:
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
metadata = msg.metadata or {}
if isinstance(outbound_event_from_message(msg), ProgressEvent):
if metadata.get("_progress"):
return False
fingerprint = self._fingerprint_content(msg.content)
if not fingerprint:
@@ -316,59 +305,57 @@ class ChannelManager:
timeout=1.0
)
event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
if progress_event and (
progress_event.reasoning_delta
or progress_event.reasoning_end
or progress_event.reasoning
if (
msg.metadata.get("_reasoning_delta")
or msg.metadata.get("_reasoning_end")
or msg.metadata.get("_reasoning")
):
# Reasoning rides its own plugin channel: only delivered
# when the destination channel opts in via ``show_reasoning``
# and overrides the streaming primitives. Channels without
# a low-emphasis UI affordance keep the base no-op and the
# content silently drops here.
# content silently drops here. ``_reasoning`` (one-shot)
# is accepted for backward compatibility with hooks that
# haven't migrated to delta/end yet.
channel = self.channels.get(msg.channel)
if channel is not None and channel.show_reasoning:
await self._send_with_retry(channel, msg)
continue
if progress_event:
if progress_event.tool_hint and not self._should_send_progress(
if msg.metadata.get("_progress"):
if msg.metadata.get("_tool_hint") and not self._should_send_progress(
msg.channel, tool_hint=True,
):
continue
if not progress_event.tool_hint and not self._should_send_progress(
if not msg.metadata.get("_tool_hint") and not self._should_send_progress(
msg.channel, tool_hint=False,
):
continue
if isinstance(event, RetryWaitEvent):
if msg.metadata.get("_retry_wait"):
continue
if (
isinstance(event, RuntimeModelUpdatedEvent)
msg.metadata.get("_runtime_model_updated")
and msg.channel == "websocket"
and "websocket" not in self.channels
):
continue
# Coalesce consecutive stream delta messages for the same (channel, chat_id)
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
# to reduce API calls and improve streaming latency
if isinstance(event, StreamDeltaEvent):
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
msg, extra_pending = self._coalesce_stream_deltas(msg)
pending.extend(extra_pending)
event = outbound_event_from_message(msg)
channel = self.channels.get(msg.channel)
if channel:
# Duplicate suppression is scoped to a known source message
# so repeated content from separate turns is still delivered.
if (
not isinstance(
event,
StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent,
)
not msg.metadata.get("_stream_delta")
and not msg.metadata.get("_stream_end")
and not msg.metadata.get("_streamed")
):
if self._should_suppress_outbound(msg):
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
@@ -382,116 +369,34 @@ class ChannelManager:
except asyncio.CancelledError:
break
@staticmethod
def _accepts_keyword(callable_obj: Callable[..., Any], name: str) -> bool:
try:
signature = inspect.signature(callable_obj)
except (TypeError, ValueError):
return True
return any(
parameter.kind is inspect.Parameter.VAR_KEYWORD or parameter.name == name
for parameter in signature.parameters.values()
)
@classmethod
async def _send_reasoning_delta(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_reasoning_delta, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
metadata["_reasoning_delta"] = True
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
await channel.send_reasoning_delta(
msg.chat_id,
msg.content,
metadata,
**kwargs,
)
@classmethod
async def _send_reasoning_end(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_reasoning_end, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
metadata["_reasoning_end"] = True
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
await channel.send_reasoning_end(
msg.chat_id,
metadata,
**kwargs,
)
@classmethod
async def _send_stream_event(
cls,
channel: BaseChannel,
msg: OutboundMessage,
event: StreamDeltaEvent | StreamEndEvent,
) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_delta, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
if isinstance(event, StreamEndEvent):
if cls._accepts_keyword(channel.send_delta, "stream_end"):
kwargs["stream_end"] = True
else:
metadata = dict(metadata or {})
metadata["_stream_end"] = True
if cls._accepts_keyword(channel.send_delta, "resuming"):
kwargs["resuming"] = event.resuming
elif not kwargs:
metadata = dict(metadata or {})
metadata["_stream_delta"] = True
await channel.send_delta(
msg.chat_id,
msg.content,
metadata,
**kwargs,
)
@staticmethod
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
"""Send one outbound message without retry policy."""
event = outbound_event_from_message(msg)
if isinstance(event, ProgressEvent) and event.reasoning_end:
await ChannelManager._send_reasoning_end(channel, msg, event)
elif isinstance(event, ProgressEvent) and event.reasoning_delta:
await ChannelManager._send_reasoning_delta(channel, msg, event)
elif isinstance(event, ProgressEvent) and event.reasoning:
# BaseChannel translates one-shot reasoning to a single delta +
# end pair so plugins only implement the streaming primitives.
if msg.metadata.get("_reasoning_end"):
await channel.send_reasoning_end(msg.chat_id, msg.metadata)
elif msg.metadata.get("_reasoning_delta"):
await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata)
elif msg.metadata.get("_reasoning"):
# Back-compat: one-shot reasoning. BaseChannel translates this
# to a single delta + end pair so plugins only implement the
# streaming primitives.
await channel.send_reasoning(msg)
elif isinstance(event, ProgressEvent) and event.file_edit_events:
elif msg.metadata.get("_file_edit_events"):
edits = msg.metadata.get("_file_edit_events")
await channel.send_file_edit_events(
msg.chat_id,
event.file_edit_events,
edits if isinstance(edits, list) else [],
msg.metadata,
)
elif isinstance(event, StreamDeltaEvent):
await ChannelManager._send_stream_event(channel, msg, event)
elif isinstance(event, StreamEndEvent):
await ChannelManager._send_stream_event(channel, msg, event)
elif not isinstance(event, StreamedResponseEvent):
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"):
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
elif not msg.metadata.get("_streamed"):
await channel.send(msg)
def _coalesce_stream_deltas(
self, first_msg: OutboundMessage
) -> tuple[OutboundMessage, list[OutboundMessage]]:
"""Merge consecutive stream deltas for the same (channel, chat_id, stream_id).
"""Merge consecutive _stream_delta messages for the same (channel, chat_id, _stream_id).
This reduces the number of API calls when the queue has accumulated multiple
deltas, which happens when LLM generates faster than the channel can process.
@@ -499,15 +404,10 @@ class ChannelManager:
Returns:
tuple of (merged_message, list_of_non_matching_messages)
"""
first_event = outbound_event_from_message(first_msg)
first_stream_id = first_event.stream_id if isinstance(first_event, StreamDeltaEvent) else None
target_key = (first_msg.channel, first_msg.chat_id, first_stream_id)
first_metadata = first_msg.metadata or {}
target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_id"))
combined_content = first_msg.content
final_event: StreamDeltaEvent | StreamEndEvent = (
first_event
if isinstance(first_event, StreamDeltaEvent)
else StreamDeltaEvent(stream_id=first_stream_id)
)
final_metadata = dict(first_msg.metadata or {})
non_matching: list[OutboundMessage] = []
# Only merge consecutive deltas. As soon as we hit any other message,
@@ -519,29 +419,21 @@ class ChannelManager:
break
# Check if this message belongs to the same stream
next_event = outbound_event_from_message(next_msg)
next_stream_id = (
next_event.stream_id
if isinstance(next_event, StreamDeltaEvent | StreamEndEvent)
else None
)
next_metadata = next_msg.metadata or {}
same_target = (
next_msg.channel,
next_msg.chat_id,
next_stream_id,
next_metadata.get("_stream_id"),
) == target_key
is_delta = isinstance(next_event, StreamDeltaEvent)
is_end = isinstance(next_event, StreamEndEvent)
is_delta = next_metadata.get("_stream_delta")
is_end = next_metadata.get("_stream_end")
if same_target and (is_delta or (is_end and next_msg.content)):
if same_target and is_delta and not final_metadata.get("_stream_end"):
# Accumulate content
combined_content += next_msg.content
# If we see stream_end, remember it and stop coalescing this stream
if isinstance(next_event, StreamEndEvent):
final_event = StreamEndEvent(
stream_id=next_stream_id,
resuming=next_event.resuming,
)
# If we see _stream_end, remember it and stop coalescing this stream
if is_end:
final_metadata["_stream_end"] = True
# Stream ended - stop coalescing this stream
break
else:
@@ -549,7 +441,12 @@ class ChannelManager:
non_matching.append(next_msg)
break
merged = replace_outbound_event(first_msg, final_event, content=combined_content)
merged = OutboundMessage(
channel=first_msg.channel,
chat_id=first_msg.chat_id,
content=combined_content,
metadata=final_metadata,
)
return merged, non_matching
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
+4 -13
View File
@@ -49,7 +49,6 @@ except ImportError as e:
) from e
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir
@@ -505,7 +504,7 @@ class MatrixChannel(BaseChannel):
text = msg.content or ""
candidates = self._collect_outbound_media_candidates(msg.media)
relates_to = self._build_thread_relates_to(msg.metadata)
is_progress = isinstance(msg.event, ProgressEvent)
is_progress = bool((msg.metadata or {}).get("_progress"))
try:
failures: list[str] = []
if candidates:
@@ -529,19 +528,11 @@ class MatrixChannel(BaseChannel):
if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
relates_to = self._build_thread_relates_to(metadata)
if stream_end:
if meta.get("_stream_end"):
buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.event_id or not buf.text:
return
+1 -2
View File
@@ -18,7 +18,6 @@ import httpx
from pydantic import Field, computed_field, field_validator
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -540,7 +539,7 @@ class SignalChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Signal."""
is_progress_message = isinstance(msg.event, ProgressEvent)
is_progress_message = bool(msg.metadata.get("_progress"))
try:
plain_text, text_styles = _markdown_to_signal(msg.content)
if not plain_text and not msg.media:
+2 -3
View File
@@ -14,7 +14,6 @@ from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -165,7 +164,7 @@ class SlackChannel(BaseChannel):
# only makes sense within the originating conversation.
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
is_progress = isinstance(msg.event, ProgressEvent)
is_progress = (msg.metadata or {}).get("_progress", False)
if is_progress and not msg.content:
pass # skip empty progress messages (e.g. tool-event-only updates)
elif msg.content or not (msg.media or []):
@@ -191,7 +190,7 @@ class SlackChannel(BaseChannel):
self.logger.exception("Failed to upload file {}", media_path)
# Update reaction emoji when the final (non-progress) response is sent
if not is_progress:
if not (msg.metadata or {}).get("_progress"):
event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts"))
+6 -17
View File
@@ -26,7 +26,6 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag
from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text
@@ -37,7 +36,7 @@ from nanobot.utils.helpers import split_message
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
# safety margin for mid-stream edits (plain text). On stream end, we split
# safety margin for mid-stream edits (plain text). For _stream_end, we split
# raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char
# boundary so the final rendered message never overflows.
TELEGRAM_HTML_MAX_LEN = 4096
@@ -707,10 +706,8 @@ class TelegramChannel(BaseChannel):
self.logger.warning("bot not running")
return
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
# Only stop typing indicator and remove reaction for final responses
if progress_event is None:
if not msg.metadata.get("_progress", False):
self._stop_typing(msg.chat_id)
if reply_to_message_id := msg.metadata.get("message_id"):
with suppress(ValueError):
@@ -795,7 +792,7 @@ class TelegramChannel(BaseChannel):
# Send text content
if msg.content and msg.content != "[empty message]":
render_as_blockquote = bool(progress_event and progress_event.tool_hint)
render_as_blockquote = bool(msg.metadata.get("_tool_hint"))
buttons = getattr(msg, "buttons", None) or []
reply_markup = self._build_keyboard(buttons) if buttons else None
text = msg.content
@@ -890,23 +887,15 @@ class TelegramChannel(BaseChannel):
def _is_not_modified_error(exc: Exception) -> bool:
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower()
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
"""Progressive message editing: send on first delta, edit on subsequent ones."""
if not self._app:
return
meta = metadata or {}
int_chat_id = int(chat_id)
stream_id = meta.get("_stream_id")
if stream_end:
if meta.get("_stream_end"):
buf = self._stream_bufs.get(chat_id)
if not buf or not buf.message_id or not buf.text:
return
+50 -56
View File
@@ -19,16 +19,6 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -158,13 +148,16 @@ def publish_runtime_model_update(
model_preset: str | None,
) -> None:
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(
outbound_message_for_event(
bus.outbound.put_nowait(OutboundMessage(
channel="websocket",
chat_id="*",
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
)
)
content="",
metadata={
"_runtime_model_updated": True,
"model": model,
"model_preset": model_preset,
},
))
def _parse_inbound_payload(raw: str) -> str | None:
@@ -858,63 +851,70 @@ class WebSocketChannel(BaseChannel):
raise
async def send(self, msg: OutboundMessage) -> None:
event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
if isinstance(event, RuntimeModelUpdatedEvent):
if msg.metadata.get("_runtime_model_updated"):
await self.send_runtime_model_updated(
model_name=event.model,
model_preset=event.model_preset,
model_name=msg.metadata.get("model"),
model_preset=msg.metadata.get("model_preset"),
)
return
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ()))
if not conns:
if isinstance(
event,
ProgressEvent
| TurnEndEvent
| SessionUpdatedEvent
| GoalStatusEvent
| GoalStateSyncEvent,
if (
msg.metadata.get("_progress")
or msg.metadata.get("_file_edit_events")
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)
if isinstance(event, GoalStateSyncEvent):
if msg.metadata.get("_goal_state_sync"):
if conns:
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
blob = msg.metadata.get("goal_state")
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
return
if isinstance(event, GoalStatusEvent):
if msg.metadata.get("_goal_status"):
if conns:
if event.status in ("running", "idle"):
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,
event.status,
started_at=event.started_at,
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 isinstance(event, TurnEndEvent):
if msg.metadata.get("_turn_end"):
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=event.latency_ms,
goal_state=event.goal_state,
latency_ms=lat_i,
goal_state=gs_blob,
metadata=msg.metadata,
)
await self.send_session_updated(msg.chat_id, scope="thread")
return
if isinstance(event, SessionUpdatedEvent):
if msg.metadata.get("_session_updated"):
if conns:
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated(
msg.chat_id,
scope=event.scope,
scope=scope if isinstance(scope, str) else None,
)
return
if progress_event and progress_event.file_edit_events:
if msg.metadata.get("_file_edit_events"):
edits = msg.metadata.get("_file_edit_events")
await self.send_file_edit_events(
msg.chat_id,
progress_event.file_edit_events,
edits if isinstance(edits, list) else [],
msg.metadata,
)
return
@@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel):
lat = msg.metadata.get("latency_ms")
if isinstance(lat, (int, float)):
payload["latency_ms"] = int(lat)
if progress_event and progress_event.tool_events:
payload["tool_events"] = progress_event.tool_events
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.
if progress_event and progress_event.tool_hint:
if msg.metadata.get("_tool_hint"):
payload["kind"] = "tool_hint"
elif progress_event:
elif msg.metadata.get("_progress"):
payload["kind"] = "progress"
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
self._transcripts.prepare_and_append(
@@ -971,8 +971,6 @@ class WebSocketChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
clients receive a stream that opens, updates in place, and closes
@@ -988,6 +986,7 @@ class WebSocketChannel(BaseChannel):
"chat_id": chat_id,
"text": delta,
}
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
@@ -1006,8 +1005,6 @@ class WebSocketChannel(BaseChannel):
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Close the current reasoning stream segment for in-place renderers."""
conns = list(self._subs.get(chat_id, ()))
@@ -1016,6 +1013,7 @@ class WebSocketChannel(BaseChannel):
"event": "reasoning_end",
"chat_id": chat_id,
}
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
@@ -1059,15 +1057,11 @@ class WebSocketChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
conns = list(self._subs.get(chat_id, ()))
meta = metadata or {}
stream_key = (chat_id, str(stream_id or ""))
if stream_end:
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
if meta.get("_stream_end"):
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = self._stream_text_buffers.pop(stream_key, [])
if delta:
@@ -1083,8 +1077,8 @@ class WebSocketChannel(BaseChannel):
"text": delta,
}
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if stream_id is not None:
body["stream_id"] = stream_id
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
self._transcripts.prepare_and_append(
chat_id,
body,
+1 -2
View File
@@ -13,7 +13,6 @@ from typing import Any
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -498,7 +497,7 @@ class WecomChannel(BaseChannel):
try:
content = (msg.content or "").strip()
is_progress = isinstance(msg.event, ProgressEvent)
is_progress = bool(msg.metadata.get("_progress"))
# Get the stored frame for this chat
frame = self._chat_frames.get(msg.chat_id)
+14 -23
View File
@@ -29,7 +29,6 @@ from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir
@@ -1102,13 +1101,11 @@ class WeixinChannel(BaseChannel):
raise RuntimeError("WeChat client not initialized or not authenticated")
self._assert_session_active()
event = getattr(msg, "event", None)
progress_event = event if isinstance(event, ProgressEvent) else None
is_progress = progress_event is not None
is_progress = bool((msg.metadata or {}).get("_progress", False))
# Buffer tool hints to coalesce consecutive ones and avoid burning
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
if progress_event and progress_event.tool_hint:
if is_progress and (msg.metadata or {}).get("_tool_hint"):
if not self.send_tool_hints:
return
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
@@ -1121,7 +1118,7 @@ class WeixinChannel(BaseChannel):
# Reasoning deltas are invisible in WeChat (there is no reasoning
# UI). Skip them entirely — do not send and do not flush buffer.
if progress_event and (progress_event.reasoning_delta or progress_event.reasoning):
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
self.logger.debug(
"Dropped invisible reasoning delta for {}", msg.chat_id
)
@@ -1235,46 +1232,40 @@ class WeixinChannel(BaseChannel):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Deliver a streamed reply to WeChat.
WeChat iLink has no native incremental delivery, and the manager
bypasses :meth:`send` for the ``_streamed`` final answer. So we
accumulate content deltas and flush the full reply as a single message
at stream end. Reasoning deltas are invisible in WeChat and are dropped.
accumulate the content deltas here and flush the full reply as a
single message at ``_stream_end`` otherwise a streamed reply would
never reach the user. Reasoning deltas are invisible in WeChat and are
dropped.
"""
meta = metadata or {}
if meta.get("_reasoning_delta") or meta.get("_reasoning"):
return
is_end = stream_end or bool(meta.get("_stream_end"))
buffer_key = stream_id or chat_id
# Accumulate intermediate deltas. The stream_end message's own content
is_end = meta.get("_stream_end")
# Accumulate intermediate deltas. The _stream_end message's own content
# (present when the manager coalesces deltas into the end message) is
# folded into `full` below instead of appended here, so a send retry
# recomputes the same `full` from an unchanged buffer rather than
# double-counting that delta.
if delta and not is_end:
self._stream_buffers.setdefault(buffer_key, []).append(delta)
self._stream_buffers.setdefault(chat_id, []).append(delta)
if not is_end:
return
full = ("".join(self._stream_buffers.get(buffer_key, [])) + (delta or "")).strip()
full = ("".join(self._stream_buffers.get(chat_id, [])) + (delta or "")).strip()
await self._flush_tool_hints(chat_id)
if full:
# Send before clearing the buffer: if the send raises, the buffer is
# left intact so ChannelManager._send_with_retry can re-deliver the
# same stream_end message instead of silently losing the reply.
# same _stream_end message instead of silently losing the reply.
await self.send(
OutboundMessage(channel=self.name, chat_id=chat_id, content=full)
)
self._stream_buffers.pop(buffer_key, None)
self._stream_buffers.pop(chat_id, None)
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
"""Start typing indicator immediately when a message is received."""
+17 -38
View File
@@ -50,14 +50,6 @@ from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.bus.outbound_events import ( # noqa: E402
ProgressEvent,
RetryWaitEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
@@ -469,25 +461,25 @@ async def _maybe_print_interactive_progress(
renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool:
event = outbound_event_from_message(msg)
if isinstance(event, RetryWaitEvent):
metadata = msg.metadata or {}
if metadata.get("_retry_wait"):
await _print_interactive_progress_line(msg.content, thinking, renderer)
return True
if not isinstance(event, ProgressEvent):
if not metadata.get("_progress"):
return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if event.reasoning_end:
if metadata.get("_reasoning_end"):
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True
is_tool_hint = event.tool_hint
is_reasoning = event.reasoning or event.reasoning_delta
is_tool_hint = metadata.get("_tool_hint", False)
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
if is_reasoning:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
@@ -806,24 +798,14 @@ def serve(
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
console.print(" [cyan]Session[/cyan] : api:default")
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
if host in {"0.0.0.0", "::"}:
if not api_key:
console.print(
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
"Set api.api_key in config to prevent unauthenticated access.[/red]"
)
raise typer.Exit(1)
console.print(
"[yellow]API is bound to all interfaces "
"(authentication required).[/yellow]"
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
)
console.print()
api_app = create_app(
agent_loop, model_name=model_name, request_timeout=timeout,
api_key=api_key,
)
api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout)
async def on_startup(_app):
await agent_loop._connect_mcp()
@@ -1464,7 +1446,7 @@ def agent(
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[Any] = []
turn_response: list[tuple[str, dict]] = []
renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer()
@@ -1472,19 +1454,18 @@ def agent(
while True:
try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
event = outbound_event_from_message(msg)
if isinstance(event, StreamDeltaEvent):
if msg.metadata.get("_stream_delta"):
if renderer:
await renderer.on_delta(msg.content)
continue
if isinstance(event, StreamEndEvent):
if msg.metadata.get("_stream_end"):
if renderer:
await renderer.on_end(
resuming=event.resuming,
resuming=msg.metadata.get("_resuming", False),
)
continue
if isinstance(event, StreamedResponseEvent):
if msg.metadata.get("_streamed"):
turn_done.set()
continue
@@ -1499,7 +1480,7 @@ def agent(
if not turn_done.is_set():
if msg.content:
turn_response.append(msg)
turn_response.append((msg.content, dict(msg.metadata or {})))
turn_done.set()
elif msg.content:
await _print_interactive_response(
@@ -1552,10 +1533,8 @@ def agent(
await turn_done.wait()
if turn_response:
response_msg = turn_response[0]
content = response_msg.content
meta = response_msg.metadata
if content and not isinstance(response_msg.event, StreamedResponseEvent):
content, meta = turn_response[0]
if content and not meta.get("_streamed"):
if renderer:
await renderer.close()
print_kwargs: dict[str, Any] = {}
-12
View File
@@ -307,18 +307,6 @@ class ApiConfig(Base):
host: str = "127.0.0.1" # Safer default: local-only bind.
port: int = 8900
timeout: float = 120.0 # Per-request timeout in seconds.
api_key: str = Field(default="", repr=False)
@model_validator(mode="after")
def wildcard_host_requires_auth(self) -> "ApiConfig":
if self.host not in ("0.0.0.0", "::"):
return self
if self.api_key.strip():
return self
raise ValueError(
"host is 0.0.0.0 (all interfaces) but api_key is not set "
"- set api.api_key to prevent unauthenticated access"
)
class GatewayConfig(Base):
+1 -6
View File
@@ -1,7 +1,6 @@
"""Cron service for scheduling agent tasks."""
import asyncio
import errno
import json
import os
import time
@@ -457,15 +456,11 @@ class CronService:
os.replace(tmp_path, path)
# fsync the parent directory so the rename itself is durable.
# Skip on Windows where opening a directory raises PermissionError;
# some shared filesystems reject directory fsync with EINVAL.
# NTFS journals metadata synchronously so this is a no-op there.
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
try:
os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(fd)
except BaseException:
-2
View File
@@ -32,7 +32,6 @@ class ProviderSpec:
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
display_name: str = "" # shown in `nanobot status`
model_catalog: str = "auto" # WebUI model-list source
# which provider implementation to use
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
@@ -222,7 +221,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("skywork", "skyclaw", "apifree"),
env_key="SKYWORK_API_KEY",
display_name="Skywork",
model_catalog="official",
backend="openai_compat",
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
is_gateway=True,
+4
View File
@@ -29,6 +29,10 @@ _GOAL_CONTINUATION_SENDER = "system:continuation"
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = {
"_stream_id",
"_stream_delta",
"_stream_end",
"_resuming",
INTERNAL_CONTINUATION_PENDING_META,
}
+47 -55
View File
@@ -11,15 +11,7 @@ from typing import Any
from loguru import logger
from nanobot.bus import progress as bus_progress
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
outbound_message_for_event,
)
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import (
GoalStateChanged,
@@ -214,22 +206,26 @@ async def publish_turn_run_status(
if msg.channel != "websocket":
return
cid = str(msg.chat_id)
started_at_event: float | None = None
meta: dict[str, Any] = {
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
if status == "running":
if isinstance(started_at, int | float) and started_at > 0:
t0 = float(started_at)
else:
t0 = time.time()
started_at_event = t0
meta["started_at"] = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound(
outbound_message_for_event(
OutboundMessage(
channel=msg.channel,
chat_id=cid,
event=GoalStatusEvent(status=status, started_at=started_at_event),
metadata=msg.metadata,
content="",
metadata=meta,
),
)
@@ -322,25 +318,28 @@ class WebuiTurnCoordinator:
if not cid:
return
await self.bus.publish_outbound(
outbound_message_for_event(
OutboundMessage(
channel=event.context.channel,
chat_id=cid,
event=GoalStateSyncEvent(
goal_state=goal_state_ws_blob(event.session_metadata),
),
metadata=event.context.metadata,
content="",
metadata={
"_goal_state_sync": True,
"goal_state": goal_state_ws_blob(event.session_metadata),
},
),
)
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
await self.bus.publish_outbound(
outbound_message_for_event(
OutboundMessage(
channel="websocket",
chat_id="*",
event=RuntimeModelUpdatedEvent(
model=event.model,
model_preset=event.model_preset,
),
content="",
metadata={
"_runtime_model_updated": True,
"model": event.model,
"model_preset": event.model_preset,
},
)
)
@@ -375,18 +374,17 @@ class WebuiTurnCoordinator:
if msg.channel != "websocket":
return
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if latency_ms is not None:
turn_metadata["latency_ms"] = int(latency_ms)
session = self.sessions.get_or_create(session_key)
await self.bus.publish_outbound(
outbound_message_for_event(
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
event=TurnEndEvent(
latency_ms=latency_ms,
goal_state=goal_state_ws_blob(session.metadata),
),
metadata=msg.metadata,
)
)
content="",
metadata=turn_metadata,
))
self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
@@ -406,11 +404,16 @@ class WebuiTurnCoordinator:
model=title_llm.model,
)
if generated:
await self._publish_session_metadata_updated(
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
metadata=msg.metadata,
)
content="",
metadata={
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify())
@@ -435,26 +438,15 @@ class WebuiTurnCoordinator:
model=title_llm.model,
)
if generated:
await self._publish_session_metadata_updated(
await self.bus.publish_outbound(OutboundMessage(
channel=event.context.channel,
chat_id=event.context.chat_id,
metadata=event.context.metadata,
)
content="",
metadata={
**event.context.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify())
async def _publish_session_metadata_updated(
self,
*,
channel: str,
chat_id: str,
metadata: dict[str, Any],
) -> None:
await self.bus.publish_outbound(
outbound_message_for_event(
channel=channel,
chat_id=chat_id,
event=SessionUpdatedEvent(scope="metadata"),
metadata=metadata,
)
)
+4 -1
View File
@@ -5,4 +5,7 @@ Task: {{ task }}
Result:
{{ result }}
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs.
Use this result as evidence for the current turn. For MapReduce-style work,
preserve any Summary / Evidence / Open issues structure when reducing multiple
results. Mention gaps or failures if they affect the answer; avoid exposing
internal task IDs unless they are needed for clarity.
@@ -4,6 +4,15 @@
You are a subagent spawned by the main agent to complete a specific task.
Stay focused on the assigned task. Your final response will be reported back to the main agent.
If this task is one slice of a larger MapReduce-style effort, treat yourself as
the map step: do only the assigned slice, avoid cross-slice coordination, and
leave reduction or final synthesis to the main agent.
For MapReduce-style slices, end with a compact, mergeable result:
- Summary: what you found or changed
- Evidence: relevant files, commands, URLs, or observations
- Open issues: blockers, failures, or "none"
{% include 'agent/_snippets/untrusted_content.md' %}
+55 -10
View File
@@ -99,6 +99,47 @@ _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
"anthropic",
"azure_openai",
"bedrock",
"github_copilot",
"openai_codex",
}
_MODEL_LIST_CATALOG_PROVIDERS = {
"aihubmix",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"novita",
"openrouter",
"siliconflow",
"volcengine",
"volcengine_coding_plan",
}
_MODEL_LIST_OFFICIAL_PROVIDERS = {
"ant_ling",
"dashscope",
"deepseek",
"gemini",
"groq",
"longcat",
"minimax",
"minimax_anthropic",
"mistral",
"moonshot",
"nvidia",
"openai",
"qianfan",
"skywork",
"stepfun",
"xiaomi_mimo",
"zhipu",
}
class WebUISettingsError(ValueError):
"""User-facing settings validation failure."""
@@ -353,13 +394,10 @@ def _provider_settings_row(
def _model_catalog_kind(spec: Any) -> str:
catalog = getattr(spec, "model_catalog", "auto")
if catalog != "auto":
return catalog
if spec.is_transcription_only or spec.is_oauth:
return "unsupported"
if spec.backend != "openai_compat" and spec.name != "minimax_anthropic":
return "unsupported"
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
return "catalog"
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
return "official"
if spec.is_local:
return "local"
if spec.is_direct:
@@ -452,20 +490,27 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
raise WebUISettingsError("unknown provider")
spec, provider_key, provider_config = resolved_provider
catalog_kind = _model_catalog_kind(spec)
base_payload: dict[str, Any] = {
"provider": provider_key,
"label": spec.label,
"catalog_kind": catalog_kind,
"catalog_kind": _model_catalog_kind(spec),
"models": [],
"model_count": 0,
"message": None,
"fetched_at": time.time(),
}
if catalog_kind == "unsupported":
if (
spec.is_transcription_only
or (
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
and spec.name != "minimax_anthropic"
)
or spec.is_oauth
):
return {
**base_payload,
"status": "unsupported",
"catalog_kind": "unsupported",
"message": "Model list is not available for this provider. Type a model ID manually.",
}
@@ -5,7 +5,6 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -55,13 +54,13 @@ async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
events.append(await loop.bus.consume_outbound())
statuses = [
event.event
event.metadata
for event in events
if isinstance(event.event, GoalStatusEvent)
if event.metadata.get("_goal_status") is True
]
assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].started_at, float)
assert statuses[1].started_at is None
assert [status["goal_status"] for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].get("started_at"), float)
assert "started_at" not in statuses[1]
@pytest.mark.asyncio
+43 -77
View File
@@ -9,15 +9,6 @@ import pytest
import nanobot.agent.runner as runner_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
ProgressEvent,
SessionUpdatedEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -269,45 +260,25 @@ class TestToolEventProgress:
)
await loop._dispatch(msg)
# Drain all outbound messages and find the one carrying tool events.
# Drain all outbound messages and find the one carrying _tool_events
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
tool_event_msgs = [
m
for m in outbound
if isinstance(m.event, ProgressEvent) and m.event.tool_events
]
assert tool_event_msgs, "expected at least one outbound message with tool events"
tool_event_msgs = [m for m in outbound if m.metadata and m.metadata.get("_tool_events")]
assert tool_event_msgs, "expected at least one outbound message with _tool_events"
start_msgs = [
m
for m in tool_event_msgs
if isinstance(m.event, ProgressEvent)
and m.event.tool_events
and m.event.tool_events[0]["phase"] == "start"
]
finish_msgs = [
m
for m in tool_event_msgs
if isinstance(m.event, ProgressEvent)
and m.event.tool_events
and m.event.tool_events[0]["phase"] in ("end", "error")
]
start_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] == "start"]
finish_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] in ("end", "error")]
assert start_msgs, "expected a start-phase tool event"
assert finish_msgs, "expected a finish-phase tool event"
assert isinstance(start_msgs[0].event, ProgressEvent)
assert start_msgs[0].event.tool_events is not None
start = start_msgs[0].event.tool_events[0]
start = start_msgs[0].metadata["_tool_events"][0]
assert start["name"] == "exec"
assert start["call_id"] == "tc1"
assert start["result"] is None
assert isinstance(finish_msgs[0].event, ProgressEvent)
assert finish_msgs[0].event.tool_events is not None
finish = finish_msgs[0].event.tool_events[0]
finish = finish_msgs[0].metadata["_tool_events"][0]
assert finish["phase"] == "end"
assert finish["result"] == "file.txt"
@@ -338,8 +309,7 @@ class TestToolEventProgress:
await invoke_file_edit_progress(progress, edit_events)
outbound = await bus.consume_outbound()
assert outbound.channel == "telegram"
assert isinstance(outbound.event, ProgressEvent)
assert outbound.event.file_edit_events == edit_events
assert outbound.metadata["_file_edit_events"] == edit_events
@pytest.mark.asyncio
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
@@ -419,8 +389,7 @@ class TestToolEventProgress:
edit_events = [
event
for msg in outbound
if isinstance(msg.event, ProgressEvent)
for event in msg.event.file_edit_events or []
for event in msg.metadata.get("_file_edit_events", [])
]
assert any(
event["status"] == "editing"
@@ -464,8 +433,8 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound())
assert [m.content for m in outbound] == ["Hello"]
assert not any(isinstance(m.event, ProgressEvent) for m in outbound)
assert not any(isinstance(m.event, StreamedResponseEvent) for m in outbound)
assert not any(m.metadata.get("_progress") for m in outbound)
assert not any(m.metadata.get("_streamed") for m in outbound)
provider.chat_stream_with_retry.assert_not_awaited()
provider.chat_with_retry.assert_awaited_once()
@@ -474,7 +443,7 @@ class TestToolEventProgress:
self,
tmp_path: Path,
) -> None:
"""Streaming channels still receive provider deltas through stream events."""
"""Streaming channels still receive provider deltas through _stream_delta messages."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
@@ -504,19 +473,21 @@ class TestToolEventProgress:
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
final = [
m for m in outbound
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
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 isinstance(final[-1].event, StreamedResponseEvent)
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
assert final[-1].metadata.get("_streamed") 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()
@@ -557,28 +528,23 @@ class TestToolEventProgress:
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
final = [
m for m in outbound
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
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] == ["partial", "full retry response"]
assert [m.event.resuming for m in stream_end if isinstance(m.event, StreamEndEvent)] == [
True,
False,
]
assert isinstance(deltas[0].event, StreamDeltaEvent)
assert isinstance(deltas[1].event, StreamDeltaEvent)
assert isinstance(stream_end[0].event, StreamEndEvent)
assert isinstance(stream_end[1].event, StreamEndEvent)
assert deltas[0].event.stream_id == stream_end[0].event.stream_id
assert deltas[1].event.stream_id == stream_end[1].event.stream_id
assert deltas[0].event.stream_id != deltas[1].event.stream_id
assert [m.metadata.get("_resuming") for m in stream_end] == [True, False]
assert deltas[0].metadata.get("_stream_id") == stream_end[0].metadata.get("_stream_id")
assert deltas[1].metadata.get("_stream_id") == stream_end[1].metadata.get("_stream_id")
assert deltas[0].metadata.get("_stream_id") != deltas[1].metadata.get("_stream_id")
assert final[-1].content == "full retry response"
assert isinstance(final[-1].event, StreamedResponseEvent)
assert final[-1].metadata.get("_streamed") is True
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
@@ -657,9 +623,9 @@ class TestToolEventProgress:
done_msgs = [m for m in outbound if m.content == "Done"]
assert len(done_msgs) == 1
assert not isinstance(done_msgs[0].event, TurnEndEvent)
assert not done_msgs[0].metadata.get("_turn_end")
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
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"
@@ -693,14 +659,14 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound())
error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."]
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
statuses = [m for m in outbound if isinstance(m.event, GoalStatusEvent)]
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
statuses = [m for m in outbound if m.metadata.get("_goal_status")]
assert len(error_msgs) == 1
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1"
assert [m.event.status for m in statuses if isinstance(m.event, GoalStatusEvent)] == ["idle"]
assert [m.metadata["goal_status"] for m in statuses] == ["idle"]
assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0])
assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1])
@@ -739,27 +705,27 @@ class TestToolEventProgress:
outbound: list = []
for _ in range(12):
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
if isinstance(outbound[-1].event, TurnEndEvent):
if outbound[-1].metadata.get("_turn_end"):
break
else:
raise AssertionError("turn-end event not found")
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 isinstance(outbound[-1].event, TurnEndEvent)
assert outbound[-1].metadata.get("_turn_end") is True
await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set()
session_updated = None
for _ in range(10):
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
if isinstance(candidate.event, SessionUpdatedEvent):
if (candidate.metadata or {}).get("_session_updated"):
session_updated = candidate
break
assert session_updated is not None
assert isinstance(session_updated.event, SessionUpdatedEvent)
assert session_updated.event.scope == "metadata"
assert (session_updated.metadata or {}).get("_session_updated") is True
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio
@@ -871,4 +837,4 @@ class TestToolEventProgress:
assert len(outbound) == 1
assert outbound[0].content == "Done"
assert not isinstance(outbound[0].event, TurnEndEvent)
assert (outbound[0].metadata or {}).get("_turn_end") is not True
+5 -7
View File
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.bus.outbound_events import StreamedResponseEvent
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -24,8 +23,8 @@ def _make_loop(tmp_path):
with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
return loop
@@ -194,9 +193,8 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
assert result is not None
assert "503" in result.content
assert not isinstance(result.event, StreamedResponseEvent), (
"streamed response event must not be set when stop_reason is error"
)
assert not result.metadata.get("_streamed"), \
"_streamed must not be set when stop_reason is error"
@pytest.mark.asyncio
@@ -241,7 +239,7 @@ async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
assert result is not None
assert result.content == "I cannot access private URLs. Please share the local file."
assert isinstance(result.event, StreamedResponseEvent)
assert result.metadata.get("_streamed") is True
@pytest.mark.asyncio
+16 -23
View File
@@ -8,13 +8,6 @@ import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
@@ -772,6 +765,7 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
"_wants_stream": True,
"message_id": "om_001",
"origin_message_id": "root_001",
"_stream_id": "old-stream",
},
))
@@ -781,23 +775,23 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
assert queued.metadata["_wants_stream"] is True
assert queued.metadata["message_id"] == "om_001"
assert queued.metadata["origin_message_id"] == "root_001"
assert "_stream_id" not in queued.metadata
await loop._dispatch(queued)
outbound = []
while loop.bus.outbound_size:
outbound.append(await loop.bus.consume_outbound())
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
ends = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
streamed_markers = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)]
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
ends = [m for m in outbound if m.metadata.get("_stream_end")]
streamed_markers = [m for m in outbound if m.metadata.get("_streamed")]
assert [m.content for m in deltas] == ["done"]
assert len(ends) == 1
assert isinstance(ends[0].event, StreamEndEvent)
assert ends[0].event.resuming is False
assert ends[0].metadata["_resuming"] is False
assert ends[0].metadata["message_id"] == "om_001"
assert ends[0].metadata["origin_message_id"] == "root_001"
assert isinstance(ends[0].event.stream_id, str)
assert isinstance(ends[0].metadata.get("_stream_id"), str)
assert streamed_markers and streamed_markers[-1].content == "done"
@@ -848,10 +842,10 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
first_outbound = []
while loop.bus.outbound_size:
first_outbound.append(await loop.bus.consume_outbound())
first_statuses = [m.event for m in first_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m.status for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if isinstance(m.event, TurnEndEvent)]
started_at = first_statuses[0].started_at
first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if m.metadata.get("_turn_end")]
started_at = first_statuses[0]["started_at"]
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
@@ -862,13 +856,12 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
second_outbound = []
while loop.bus.outbound_size:
second_outbound.append(await loop.bus.consume_outbound())
second_statuses = [m.event for m in second_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m.status for m in second_statuses] == ["running", "idle"]
assert second_statuses[0].started_at == started_at
turn_end = [m for m in second_outbound if isinstance(m.event, TurnEndEvent)]
second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in second_statuses] == ["running", "idle"]
assert second_statuses[0]["started_at"] == started_at
turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")]
assert len(turn_end) == 1
assert isinstance(turn_end[0].event, TurnEndEvent)
assert isinstance(turn_end[0].event.latency_ms, int)
assert isinstance(turn_end[0].metadata.get("latency_ms"), int)
@pytest.mark.asyncio
+1 -2
View File
@@ -853,11 +853,10 @@ class TestApiServerRegistration:
config = Config()
from nanobot.config.schema import ApiConfig
new_api = ApiConfig(host="0.0.0.0", port=9999, api_key="secret")
new_api = ApiConfig(host="0.0.0.0", port=9999)
_SETTINGS_SETTER["API Server"](config, new_api)
assert config.api.host == "0.0.0.0"
assert config.api.port == 9999
assert config.api.api_key == "secret"
class TestMainMenuUpdate:
-40
View File
@@ -135,46 +135,6 @@ async def test_runner_tool_error_sets_final_content():
assert result.stop_reason == "tool_error"
@pytest.mark.asyncio
async def test_runner_preserves_successful_exec_output_that_starts_with_error():
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock(spec=LLMProvider)
async def chat_with_retry(*, messages, **kwargs):
if not any(msg.get("role") == "tool" for msg in messages):
return LLMResponse(
content="working",
tool_calls=[
ToolCallRequest(id="call_1", name="exec", arguments={"command": "report"})
],
usage={},
)
return LLMResponse(content="done", usage={})
provider.chat_with_retry = chat_with_retry
output = "Error: generated report successfully\n\nExit code: 0"
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=output)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "run report"}],
tools=tools,
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
fail_on_tool_error=True,
))
assert result.final_content == "done"
assert result.stop_reason == "completed"
assert result.tool_events == [
{"name": "exec", "status": "ok", "detail": "Error: generated report successfully Exit code: 0"}
]
@pytest.mark.asyncio
async def test_runner_tool_error_preserves_tool_results_in_messages():
"""When a tool raises a fatal error, its results must still be appended
+13 -9
View File
@@ -6,8 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools import ToolResult
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -22,6 +20,8 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
we now hand the error back to the LLM as a recoverable tool result and
rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(
@@ -64,6 +64,8 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
def test_is_ssrf_violation_recognizes_private_url_blocks():
"""SSRF rejections are classified separately from workspace boundaries."""
from nanobot.agent.runner import AgentRunner
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation(
@@ -86,6 +88,8 @@ def test_is_ssrf_violation_recognizes_private_url_blocks():
@pytest.mark.asyncio
async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(
@@ -103,7 +107,7 @@ async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
])
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=ToolResult.error(
tools.execute = AsyncMock(return_value=(
"Error: Command blocked by safety guard (internal/private URL detected)"
))
@@ -137,6 +141,8 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
turn (silent hang on Telegram per #3605); now the LLM gets the soft
error back and can finalize on the next iteration.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
captured_second_call: list[dict] = []
@@ -157,9 +163,7 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(
return_value=ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
)
return_value="Error: Command blocked by safety guard (path outside working dir)"
)
runner = AgentRunner(provider)
@@ -191,6 +195,8 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
the runner replaces the tool result with a hard "stop trying" message
so the model finally gives up and surfaces the boundary to the user.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
bypass_attempts = [
ToolCallRequest(
id=f"a{i}", name="exec",
@@ -209,9 +215,7 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(
return_value=ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
)
return_value="Error: Command blocked by safety guard (path outside working dir)"
)
runner = AgentRunner(provider)
+1 -108
View File
@@ -8,9 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -63,40 +61,6 @@ class _DelayTool(Tool):
return self._name
class _LegacyErrorPluginTool(Tool):
@property
def name(self) -> str:
return "legacy_plugin"
@property
def description(self) -> str:
return "legacy entry-point plugin"
@property
def parameters(self) -> dict:
return {"type": "object", "properties": {}, "required": []}
async def execute(self, **kwargs):
return "Error: legacy plugin failed"
class _StructuredSuccessPluginTool(Tool):
@property
def name(self) -> str:
return "structured_success_plugin"
@property
def description(self) -> str:
return "structured entry-point plugin"
@property
def parameters(self) -> dict:
return {"type": "object", "properties": {}, "required": []}
async def execute(self, **kwargs):
return ToolResult("Error: generated report successfully")
async def _run_optional_tool_response(response: LLMResponse):
provider = MagicMock()
calls = {"n": 0}
@@ -127,20 +91,6 @@ async def _run_optional_tool_response(response: LLMResponse):
return result, shared_events
def _load_entry_point_plugin(tool_cls: type[Tool], tmp_path) -> ToolRegistry:
mock_ep = MagicMock()
mock_ep.name = tool_cls.__name__
mock_ep.load.return_value = tool_cls
registry = ToolRegistry()
with patch("nanobot.agent.tools.loader.entry_points", return_value=[mock_ep]):
ToolLoader(test_classes=[]).load(
ToolContext(config=None, workspace=str(tmp_path)),
registry,
)
return registry
def _tool_message(result, tool_call_id: str) -> dict:
return [
msg for msg in result.messages
@@ -370,63 +320,6 @@ async def test_runner_rejects_openai_responses_array_arguments_without_executing
assert "parameters must be a JSON object" in tool_message["content"]
@pytest.mark.asyncio
async def test_runner_treats_legacy_entry_point_error_prefix_as_tool_error(tmp_path):
provider = MagicMock()
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id="call_1", name="legacy_plugin", arguments={})],
usage={},
))
result = await AgentRunner(provider).run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "run plugin"}],
tools=_load_entry_point_plugin(_LegacyErrorPluginTool, tmp_path),
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
fail_on_tool_error=True,
))
assert result.stop_reason == "tool_error"
assert result.tool_events == [
{"name": "legacy_plugin", "status": "error", "detail": "Error: legacy plugin failed"}
]
@pytest.mark.asyncio
async def test_runner_preserves_structured_plugin_success_that_starts_with_error(tmp_path):
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(
content="working",
tool_calls=[
ToolCallRequest(id="call_1", name="structured_success_plugin", arguments={})
],
usage={},
),
LLMResponse(content="done", tool_calls=[], usage={}),
])
result = await AgentRunner(provider).run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "run plugin"}],
tools=_load_entry_point_plugin(_StructuredSuccessPluginTool, tmp_path),
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
fail_on_tool_error=True,
))
assert result.stop_reason == "completed"
assert result.tool_events == [
{
"name": "structured_success_plugin",
"status": "ok",
"detail": "Error: generated report successfully",
}
]
@pytest.mark.asyncio
async def test_runner_blocks_repeated_external_fetches():
provider = MagicMock()
+2 -3
View File
@@ -127,7 +127,6 @@ class TestDispatch:
@pytest.mark.asyncio
async def test_dispatch_streaming_preserves_message_metadata(self):
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import StreamDeltaEvent, StreamEndEvent
loop, bus = _make_loop()
msg = InboundMessage(
@@ -157,10 +156,10 @@ class TestDispatch:
assert first.metadata["thread_root_event_id"] == "$root1"
assert first.metadata["thread_reply_to_event_id"] == "$reply1"
assert isinstance(first.event, StreamDeltaEvent)
assert first.metadata["_stream_delta"] is True
assert second.metadata["thread_root_event_id"] == "$root1"
assert second.metadata["thread_reply_to_event_id"] == "$reply1"
assert isinstance(second.event, StreamEndEvent)
assert second.metadata["_stream_end"] is True
@pytest.mark.asyncio
async def test_processing_lock_serializes(self):
@@ -1,11 +1,7 @@
from unittest.mock import MagicMock, patch
import pytest
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
def test_loader_discovers_entry_point_tools():
@@ -78,67 +74,3 @@ def test_loader_skips_abstract_entry_point_tools():
discovered = loader._discover_plugins()
assert "abstract_plugin" not in discovered
@pytest.mark.asyncio
async def test_loader_entry_point_error_wrapper_preserves_tool_api(tmp_path):
"""Only adapt legacy plugin error strings; keep the wrapped tool API intact."""
mock_ep = MagicMock()
mock_ep.name = "api_plugin"
class _ApiPluginTool(Tool):
config_key = "api_plugin"
@property
def name(self) -> str:
return "api_plugin"
@property
def description(self) -> str:
return "Entry-point plugin with custom tool API methods."
@property
def parameters(self) -> dict:
return {"type": "object", "properties": {"value": {"type": "string"}}}
@property
def read_only(self) -> bool:
return True
@property
def concurrency_safe(self) -> bool:
return False
def cast_params(self, params: dict) -> dict:
return {"value": str(params["value"])}
def validate_params(self, params: dict) -> list[str]:
return [] if params == {"value": "1"} else ["bad value"]
def to_schema(self) -> dict:
return {"name": self.name, "custom": True}
async def execute(self, **_):
return "Error: plugin failed"
mock_ep.load.return_value = _ApiPluginTool
registry = ToolRegistry()
with patch("nanobot.agent.tools.loader.entry_points", return_value=[mock_ep]):
ToolLoader(test_classes=[]).load(
ToolContext(config=None, workspace=str(tmp_path)),
registry,
)
tool = registry.get("api_plugin")
assert tool is not None
assert tool.config_key == "api_plugin"
assert tool.read_only is True
assert tool.concurrency_safe is False
assert tool.cast_params({"value": 1}) == {"value": "1"}
assert tool.validate_params({"value": "1"}) == []
assert tool.to_schema() == {"name": "api_plugin", "custom": True}
result = await tool.execute(value="1")
assert is_tool_error_result("api_plugin", result) is True
assert str(result) == "Error: plugin failed"
+3 -5
View File
@@ -13,7 +13,6 @@ from nanobot.agent.tools.long_task import (
CompleteGoalTool,
LongTaskTool,
)
from nanobot.bus.outbound_events import GoalStateSyncEvent
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.session.goal_state import GOAL_STATE_KEY
@@ -145,8 +144,8 @@ async def test_long_task_publishes_goal_state_ws_after_save(tmp_path):
call = bus.publish_outbound.await_args.args[0]
assert call.channel == "websocket"
assert call.chat_id == "chat-99"
assert isinstance(call.event, GoalStateSyncEvent)
assert call.event.goal_state == {
assert call.metadata.get("_goal_state_sync") is True
assert call.metadata["goal_state"] == {
"active": True,
"ui_summary": "alpha",
"objective": "Objective alpha",
@@ -181,8 +180,7 @@ async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path):
bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0]
assert isinstance(call.event, GoalStateSyncEvent)
assert call.event.goal_state == {"active": False}
assert call.metadata["goal_state"] == {"active": False}
@pytest.mark.asyncio
+47
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.config.schema import AgentDefaults
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -482,3 +483,49 @@ async def test_drain_pending_timeout(tmp_path):
await hang_task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_process_direct_routes_subagent_results_to_pending_queue(tmp_path):
"""Single-message CLI mode should consume subagent announcements mid-turn."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
loop = AgentLoop(
bus=MessageBus(),
provider=MagicMock(),
workspace=tmp_path,
model="test-model",
)
loop._connect_mcp = AsyncMock() # type: ignore[method-assign]
async def fake_process_message(msg, **kwargs):
pending_queue = kwargs["pending_queue"]
await loop.bus.publish_inbound(InboundMessage(
channel="other",
sender_id="u",
chat_id="room",
content="unrelated",
))
await loop.subagents._announce_result(
"sub-1",
"label",
"task",
"subagent result",
{"channel": "cli", "chat_id": "direct", "session_key": "cli:direct"},
"ok",
)
routed = await asyncio.wait_for(pending_queue.get(), timeout=1)
assert "subagent result" in routed.content
assert routed.metadata["subagent_task_id"] == "sub-1"
return OutboundMessage(channel="cli", chat_id="direct", content="done")
loop._process_message = fake_process_message # type: ignore[method-assign]
response = await loop.process_direct("start", session_key="cli:direct")
assert response is not None
assert response.content == "done"
unrelated = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=1)
assert unrelated.content == "unrelated"
-244
View File
@@ -1,244 +0,0 @@
from __future__ import annotations
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RetryWaitEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
TurnEndEvent,
outbound_event_from_message,
outbound_message_for_event,
replace_outbound_event,
)
def test_progress_event_lives_on_outbound_message_event_field() -> None:
tool_events = [{"phase": "start", "name": "read_file"}]
file_edit_events = [{"phase": "end", "path": "app.py"}]
msg = outbound_message_for_event(
channel="websocket",
chat_id="chat-1",
event=ProgressEvent(
content="working",
tool_hint=True,
reasoning_delta=True,
stream_id="r1",
tool_events=tool_events,
file_edit_events=file_edit_events,
),
metadata={"origin_message_id": "m1"},
)
assert msg.content == "working"
assert msg.metadata == {"origin_message_id": "m1"}
event = outbound_event_from_message(msg)
assert isinstance(event, ProgressEvent)
assert event.content == "working"
assert event.tool_hint is True
assert event.reasoning_delta is True
assert event.stream_id == "r1"
assert event.tool_events == tool_events
assert event.file_edit_events == file_edit_events
def test_normal_outbound_message_has_no_runtime_event() -> None:
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
assert outbound_event_from_message(msg) is None
def test_legacy_progress_metadata_flags_create_runtime_event() -> None:
tool_events = [{"phase": "start", "name": "read_file"}]
file_edit_events = [{"phase": "end", "path": "app.py"}]
msg = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="legacy progress",
metadata={
"_progress": True,
"_tool_hint": True,
"_reasoning_delta": True,
"_stream_id": "r1",
"_tool_events": tool_events,
"_file_edit_events": file_edit_events,
"message_id": "platform-routing-context",
},
)
event = outbound_event_from_message(msg)
assert isinstance(event, ProgressEvent)
assert event.content == "legacy progress"
assert event.tool_hint is True
assert event.reasoning_delta is True
assert event.stream_id == "r1"
assert event.tool_events == tool_events
assert event.file_edit_events == file_edit_events
def test_legacy_stream_metadata_flags_create_runtime_events() -> None:
delta = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="hello",
metadata={"_stream_delta": True, "_stream_id": "s1"},
)
end = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_stream_end": True, "_stream_id": "s1", "_resuming": True},
)
delta_event = outbound_event_from_message(delta)
assert isinstance(delta_event, StreamDeltaEvent)
assert delta_event.content == "hello"
assert delta_event.stream_id == "s1"
end_event = outbound_event_from_message(end)
assert isinstance(end_event, StreamEndEvent)
assert end_event.stream_id == "s1"
assert end_event.resuming is True
def test_legacy_webui_runtime_metadata_flags_create_runtime_events() -> None:
runtime = OutboundMessage(
channel="websocket",
chat_id="*",
content="",
metadata={
"_runtime_model_updated": True,
"model": "gpt-5.5",
"model_preset": "high",
},
)
goal_state = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_goal_state_sync": True, "goal_state": {"active": True}},
)
goal_status = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_goal_status": True, "goal_status": "running", "started_at": 1.25},
)
turn_end = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 42.0, "goal_state": {"active": False}},
)
session_updated = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_session_updated": True, "_session_update_scope": "metadata"},
)
runtime_event = outbound_event_from_message(runtime)
assert isinstance(runtime_event, RuntimeModelUpdatedEvent)
assert runtime_event.model == "gpt-5.5"
assert runtime_event.model_preset == "high"
goal_state_event = outbound_event_from_message(goal_state)
assert isinstance(goal_state_event, GoalStateSyncEvent)
assert goal_state_event.goal_state == {"active": True}
goal_status_event = outbound_event_from_message(goal_status)
assert isinstance(goal_status_event, GoalStatusEvent)
assert goal_status_event.status == "running"
assert goal_status_event.started_at == 1.25
turn_end_event = outbound_event_from_message(turn_end)
assert isinstance(turn_end_event, TurnEndEvent)
assert turn_end_event.latency_ms == 42
assert turn_end_event.goal_state == {"active": False}
session_updated_event = outbound_event_from_message(session_updated)
assert isinstance(session_updated_event, SessionUpdatedEvent)
assert session_updated_event.scope == "metadata"
def test_legacy_metadata_numbers_ignore_bool_values() -> None:
goal_status = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_goal_status": True, "goal_status": "running", "started_at": True},
)
turn_end = OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": True},
)
goal_status_event = outbound_event_from_message(goal_status)
assert isinstance(goal_status_event, GoalStatusEvent)
assert goal_status_event.started_at is None
turn_end_event = outbound_event_from_message(turn_end)
assert isinstance(turn_end_event, TurnEndEvent)
assert turn_end_event.latency_ms is None
def test_legacy_retry_wait_and_streamed_flags_create_runtime_events() -> None:
retry = OutboundMessage(
channel="cli",
chat_id="direct",
content="waiting",
metadata={"_retry_wait": True},
)
streamed = OutboundMessage(
channel="cli",
chat_id="direct",
content="final answer",
metadata={"_streamed": True},
)
retry_event = outbound_event_from_message(retry)
assert isinstance(retry_event, RetryWaitEvent)
assert retry_event.content == "waiting"
assert isinstance(outbound_event_from_message(streamed), StreamedResponseEvent)
def test_replace_outbound_event_keeps_routing_metadata() -> None:
msg = outbound_message_for_event(
channel="websocket",
chat_id="chat-1",
event=StreamDeltaEvent(content="hello", stream_id="s1"),
metadata={"message_id": "m1"},
)
updated = replace_outbound_event(
msg,
StreamEndEvent(stream_id="s1", resuming=True),
content="hello world",
)
assert updated.content == "hello world"
assert updated.metadata == {"message_id": "m1"}
assert isinstance(updated.event, StreamEndEvent)
assert updated.event.stream_id == "s1"
assert updated.event.resuming is True
def test_streamed_response_event_keeps_final_content_outside_event_payload() -> None:
msg = outbound_message_for_event(
channel="cli",
chat_id="direct",
event=StreamedResponseEvent(),
content="final answer",
)
assert msg.content == "final answer"
assert isinstance(outbound_event_from_message(msg), StreamedResponseEvent)
@@ -1,19 +1,10 @@
"""Tests for ChannelManager delta coalescing to reduce streaming latency."""
import asyncio
from unittest.mock import AsyncMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
RetryWaitEvent,
StreamDeltaEvent,
StreamEndEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
@@ -38,187 +29,221 @@ class MockChannel(BaseChannel):
pass
async def send(self, msg):
"""Implement abstract method."""
return await self._send_mock(msg)
async def send_delta(
self,
chat_id,
delta,
metadata=None,
*,
stream_id=None,
stream_end=False,
resuming=False,
):
return await self._send_delta_mock(
chat_id,
delta,
metadata,
stream_id=stream_id,
stream_end=stream_end,
resuming=resuming,
)
async def send_delta(self, chat_id, delta, metadata=None):
"""Override send_delta for testing."""
return await self._send_delta_mock(chat_id, delta, metadata)
@pytest.fixture
def config():
"""Create a minimal config for testing."""
return Config()
@pytest.fixture
def bus():
"""Create a message bus for testing."""
return MessageBus()
@pytest.fixture
def manager(config, bus):
"""Create a channel manager with a mock channel."""
manager = ChannelManager(config, bus)
manager.channels["mock"] = MockChannel({}, bus)
return manager
def _delta(content: str, *, chat_id: str = "chat1", stream_id: str | None = None):
return outbound_message_for_event(
channel="mock",
chat_id=chat_id,
event=StreamDeltaEvent(content=content, stream_id=stream_id),
)
def _end(
content: str = "",
*,
chat_id: str = "chat1",
stream_id: str | None = None,
resuming: bool = False,
):
return outbound_message_for_event(
channel="mock",
chat_id=chat_id,
event=StreamEndEvent(content=content, stream_id=stream_id, resuming=resuming),
)
class TestDeltaCoalescing:
"""Tests for stream delta message coalescing."""
"""Tests for _stream_delta message coalescing."""
@pytest.mark.asyncio
async def test_single_delta_not_coalesced(self, manager, bus):
msg = _delta("Hello")
"""A single delta should be sent as-is."""
msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
)
await bus.publish_outbound(msg)
# Process one message
async def process_one():
try:
m = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1)
event = outbound_event_from_message(m)
if isinstance(event, StreamDeltaEvent):
if m.metadata.get("_stream_delta"):
m, pending = manager._coalesce_stream_deltas(m)
# Put pending back (none expected)
for p in pending:
await bus.publish_outbound(p)
channel = manager.channels.get(m.channel)
event = outbound_event_from_message(m)
if channel and isinstance(event, StreamDeltaEvent):
await channel.send_delta(
m.chat_id,
m.content,
m.metadata,
stream_id=event.stream_id,
)
if channel:
await channel.send_delta(m.chat_id, m.content, m.metadata)
except asyncio.TimeoutError:
pass
await process_one()
manager.channels["mock"]._send_delta_mock.assert_called_once_with(
"chat1",
"Hello",
{},
stream_id=None,
stream_end=False,
resuming=False,
"chat1", "Hello", {"_stream_delta": True}
)
@pytest.mark.asyncio
async def test_multiple_deltas_coalesced(self, manager, bus):
"""Multiple consecutive deltas for same chat should be merged."""
# Put multiple deltas in queue
for text in ["Hello", " ", "world", "!"]:
await bus.publish_outbound(_delta(text))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content=text,
metadata={"_stream_delta": True},
))
# Process using coalescing logic
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged all deltas
assert merged.content == "Hello world!"
assert isinstance(merged.event, StreamDeltaEvent)
assert merged.metadata.get("_stream_delta") is True
# No pending messages (all were coalesced)
assert len(pending) == 0
@pytest.mark.asyncio
async def test_deltas_different_chats_not_coalesced(self, manager, bus):
await bus.publish_outbound(_delta("Hello", chat_id="chat1"))
await bus.publish_outbound(_delta("World", chat_id="chat2"))
"""Deltas for different chats should not be merged."""
# Put deltas for different chats
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat2",
content="World",
metadata={"_stream_delta": True},
))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
# First chat should not include second chat's content
assert merged.content == "Hello"
assert merged.chat_id == "chat1"
# Second chat should be in pending
assert len(pending) == 1
assert pending[0].chat_id == "chat2"
assert pending[0].content == "World"
@pytest.mark.asyncio
async def test_deltas_different_stream_ids_not_coalesced(self, manager, bus):
await bus.publish_outbound(_delta("A1", stream_id="stream-a"))
await bus.publish_outbound(_delta("B1", stream_id="stream-b"))
"""Deltas for the same chat but different streams should not be merged."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="A1",
metadata={"_stream_delta": True, "_stream_id": "stream-a"},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="B1",
metadata={"_stream_delta": True, "_stream_id": "stream-b"},
))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "A1"
assert isinstance(merged.event, StreamDeltaEvent)
assert merged.event.stream_id == "stream-a"
assert merged.metadata.get("_stream_id") == "stream-a"
assert len(pending) == 1
assert pending[0].content == "B1"
assert isinstance(pending[0].event, StreamDeltaEvent)
assert pending[0].event.stream_id == "stream-b"
assert pending[0].metadata.get("_stream_id") == "stream-b"
@pytest.mark.asyncio
async def test_stream_end_terminates_coalescing(self, manager, bus):
await bus.publish_outbound(_delta("Hello"))
await bus.publish_outbound(_end(" world"))
"""_stream_end should stop coalescing and be included in final message."""
# Put deltas with stream_end at the end
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content=" world",
metadata={"_stream_delta": True, "_stream_end": True},
))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged content
assert merged.content == "Hello world"
assert isinstance(merged.event, StreamEndEvent)
# Should have stream_end flag
assert merged.metadata.get("_stream_end") is True
# No pending
assert len(pending) == 0
@pytest.mark.asyncio
async def test_coalescing_stops_at_first_non_matching_boundary(self, manager, bus):
await bus.publish_outbound(_delta("Hello", stream_id="seg-1"))
await bus.publish_outbound(_end(stream_id="seg-1"))
await bus.publish_outbound(_delta("world", stream_id="seg-2"))
"""Only consecutive deltas should be merged; later deltas stay queued."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True, "_stream_id": "seg-1"},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="",
metadata={"_stream_end": True, "_stream_id": "seg-1"},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="world",
metadata={"_stream_delta": True, "_stream_id": "seg-2"},
))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "Hello"
assert isinstance(merged.event, StreamDeltaEvent)
assert merged.metadata.get("_stream_end") is None
assert len(pending) == 1
assert isinstance(pending[0].event, StreamEndEvent)
assert pending[0].event.stream_id == "seg-1"
assert pending[0].metadata.get("_stream_end") is True
assert pending[0].metadata.get("_stream_id") == "seg-1"
# The next stream segment must remain in queue order for later dispatch.
remaining = await bus.consume_outbound()
assert remaining.content == "world"
assert isinstance(remaining.event, StreamDeltaEvent)
assert remaining.event.stream_id == "seg-2"
assert remaining.metadata.get("_stream_id") == "seg-2"
@pytest.mark.asyncio
async def test_non_delta_message_preserved(self, manager, bus):
await bus.publish_outbound(_delta("Delta"))
"""Non-delta messages should be preserved in pending list."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Delta",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Final message",
metadata={}, # Not a delta
))
first_msg = await bus.consume_outbound()
@@ -227,11 +252,17 @@ class TestDeltaCoalescing:
assert merged.content == "Delta"
assert len(pending) == 1
assert pending[0].content == "Final message"
assert pending[0].event is None
assert pending[0].metadata.get("_stream_delta") is None
@pytest.mark.asyncio
async def test_empty_queue_stops_coalescing(self, manager, bus):
await bus.publish_outbound(_delta("Only message"))
"""Coalescing should stop when queue is empty."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Only message",
metadata={"_stream_delta": True},
))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
@@ -245,35 +276,49 @@ class TestDispatchOutboundWithCoalescing:
@pytest.mark.asyncio
async def test_dispatch_coalesces_and_processes_pending(self, manager, bus):
await bus.publish_outbound(_delta("A"))
await bus.publish_outbound(_delta("B"))
"""_dispatch_outbound should coalesce deltas and process pending messages."""
# Put multiple deltas followed by a regular message
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="A",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="B",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Final",
metadata={}, # Regular message
))
# Run one iteration of dispatch logic manually
pending = []
processed = []
msg = pending.pop(0) if pending else await bus.consume_outbound()
event = outbound_event_from_message(msg)
if isinstance(event, StreamDeltaEvent):
# First iteration: should coalesce A+B
if pending:
msg = pending.pop(0)
else:
msg = await bus.consume_outbound()
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
msg, extra_pending = manager._coalesce_stream_deltas(msg)
pending.extend(extra_pending)
channel = manager.channels.get(msg.channel)
event = outbound_event_from_message(msg)
if channel and isinstance(event, StreamDeltaEvent):
await channel.send_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=event.stream_id,
)
if channel:
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
processed.append(("delta", msg.content))
# Should have sent coalesced delta
assert processed == [("delta", "AB")]
# Should have pending regular message
assert len(pending) == 1
assert pending[0].content == "Final"
@@ -309,20 +354,23 @@ class TestProgressFiltering:
assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False
assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True
# Missing attribute falls back to default
assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True
@pytest.mark.asyncio
async def test_channel_override_can_drop_progress_message(self, manager, bus):
manager.channels["mock"].send_progress = False
await bus.publish_outbound(outbound_message_for_event(
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
event=ProgressEvent(content="thinking"),
content="thinking",
metadata={"_progress": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="final answer",
metadata={},
))
task = asyncio.create_task(manager._dispatch_outbound())
@@ -343,37 +391,13 @@ class TestProgressFiltering:
assert send_mock.await_args_list[0].args[0].content == "final answer"
@pytest.mark.asyncio
async def test_legacy_progress_flag_uses_runtime_progress_filter(self, manager, bus):
manager.channels["mock"].send_progress = False
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
manager.channels["mock"].send_tool_hints = True
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="legacy progress-shaped message",
metadata={"_progress": True},
))
task = asyncio.create_task(manager._dispatch_outbound())
try:
for _ in range(30):
if manager.channels["mock"]._send_mock.await_count >= 1:
break
await asyncio.sleep(0.05)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert manager.channels["mock"]._send_mock.await_count == 0
@pytest.mark.asyncio
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
manager.channels["mock"].send_tool_hints = True
await bus.publish_outbound(outbound_message_for_event(
channel="mock",
chat_id="chat1",
event=ProgressEvent(content="read_file(foo.py)", tool_hint=True),
content="read_file(foo.py)",
metadata={"_progress": True, "_tool_hint": True},
))
task = asyncio.create_task(manager._dispatch_outbound())
@@ -399,15 +423,24 @@ class TestRetryWaitFiltering:
@pytest.mark.asyncio
async def test_retry_wait_message_dropped(self, manager, bus):
retry_msg = outbound_message_for_event(
"""A ``_retry_wait`` message must be filtered before channel dispatch.
Regression: provider retry diagnostics like
``Model request failed, retry in 1s (attempt 1).`` were being
delivered to end-user channels because the runner bound
``on_retry_wait`` to the progress callback.
"""
retry_msg = OutboundMessage(
channel="mock",
chat_id="chat1",
event=RetryWaitEvent(content="Model request failed, retry in 1s (attempt 1)."),
content="Model request failed, retry in 1s (attempt 1).",
metadata={"_retry_wait": True},
)
real_msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="final answer",
metadata={},
)
await bus.publish_outbound(retry_msg)
await bus.publish_outbound(real_msg)
@@ -429,4 +462,4 @@ class TestRetryWaitFiltering:
assert send_mock.await_count == 1
sent = send_mock.await_args_list[0].args[0]
assert sent.content == "final answer"
assert sent.event is None
assert not sent.metadata.get("_retry_wait")
@@ -8,9 +8,10 @@ channels that opt in via ``channel.show_reasoning``; plugins without a
low-emphasis UI primitive keep the base no-op and the content silently
drops at dispatch.
One-shot reasoning frames are represented as typed progress events and
``BaseChannel.send_reasoning`` expands them to a single delta + end pair so
plugins only implement the streaming primitives.
One-shot ``_reasoning`` frames are accepted for back-compat with hooks
that haven't migrated yet — ``BaseChannel.send_reasoning`` expands them
to a single delta + end pair so plugins only implement the streaming
primitives.
"""
from __future__ import annotations
@@ -21,7 +22,6 @@ from unittest.mock import AsyncMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
@@ -48,11 +48,11 @@ class _MockChannel(BaseChannel):
async def send(self, msg):
return await self._send_mock(msg)
async def send_reasoning_delta(self, chat_id, delta, metadata=None, *, stream_id=None):
return await self._delta_mock(chat_id, delta, metadata, stream_id=stream_id)
async def send_reasoning_delta(self, chat_id, delta, metadata=None):
return await self._delta_mock(chat_id, delta, metadata)
async def send_reasoning_end(self, chat_id, metadata=None, *, stream_id=None):
return await self._end_mock(chat_id, metadata, stream_id=stream_id)
async def send_reasoning_end(self, chat_id, metadata=None):
return await self._end_mock(chat_id, metadata)
async def send_file_edit_events(self, chat_id, edits, metadata=None):
return await self._file_edit_mock(chat_id, edits, metadata)
@@ -94,17 +94,17 @@ def test_websocket_gateway_uses_configured_workspace_restriction(tmp_path, monke
@pytest.mark.asyncio
async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
channel = manager.channels["mock"]
msg = outbound_message_for_event(
msg = OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(content="step-by-step", reasoning_delta=True, stream_id="r1"),
content="step-by-step",
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
)
await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once()
args = channel._delta_mock.await_args.args
assert args[0] == "c1"
assert args[1] == "step-by-step"
assert channel._delta_mock.await_args.kwargs["stream_id"] == "r1"
channel._send_mock.assert_not_awaited()
channel._end_mock.assert_not_awaited()
@@ -112,10 +112,11 @@ async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
@pytest.mark.asyncio
async def test_reasoning_end_routes_to_send_reasoning_end(manager):
channel = manager.channels["mock"]
msg = outbound_message_for_event(
msg = OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
content="",
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
)
await manager._send_once(channel, msg)
channel._end_mock.assert_awaited_once()
@@ -123,13 +124,16 @@ async def test_reasoning_end_routes_to_send_reasoning_end(manager):
@pytest.mark.asyncio
async def test_one_shot_reasoning_expands_to_delta_plus_end(manager):
"""One-shot reasoning expands to a single delta + end."""
async def test_legacy_one_shot_reasoning_expands_to_delta_plus_end(manager):
"""`_reasoning` (no delta/end pair) falls back through `send_reasoning`
which the base class expands to a single delta + end. Hooks that haven't
migrated still surface in WebUI as a complete stream segment."""
channel = manager.channels["mock"]
msg = outbound_message_for_event(
msg = OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(content="one-shot reasoning", reasoning=True),
content="one-shot reasoning",
metadata={"_progress": True, "_reasoning": True},
)
await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once()
@@ -140,10 +144,11 @@ async def test_one_shot_reasoning_expands_to_delta_plus_end(manager):
async def test_dispatch_drops_reasoning_when_channel_opts_out(manager):
channel = manager.channels["mock"]
channel.show_reasoning = False
msg = outbound_message_for_event(
msg = OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(content="hidden thinking", reasoning_delta=True),
content="hidden thinking",
metadata={"_progress": True, "_reasoning_delta": True},
)
await manager.bus.publish_outbound(msg)
@@ -159,15 +164,17 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
channel = manager.channels["mock"]
channel.show_reasoning = True
for chunk in ("first ", "second"):
await manager.bus.publish_outbound(outbound_message_for_event(
await manager.bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(content=chunk, reasoning_delta=True, stream_id="r1"),
content=chunk,
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
))
await manager.bus.publish_outbound(outbound_message_for_event(
await manager.bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
content="",
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
))
await _pump_one(manager)
@@ -178,10 +185,11 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
@pytest.mark.asyncio
async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager):
msg = outbound_message_for_event(
msg = OutboundMessage(
channel="ghost",
chat_id="c1",
event=ProgressEvent(content="nobody home", reasoning_delta=True),
content="nobody home",
metadata={"_progress": True, "_reasoning_delta": True},
)
await manager.bus.publish_outbound(msg)
@@ -221,34 +229,17 @@ async def test_base_channel_reasoning_primitives_are_noop_safe():
async def test_file_edit_events_route_to_channel_capability(manager):
channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = outbound_message_for_event(
msg = OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(file_edit_events=edits),
content="",
metadata={"_progress": True, "_file_edit_events": edits},
)
await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with(
"c1", edits, msg.metadata
)
channel._send_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_typed_file_edit_event_routes_to_channel_capability(manager):
channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
event=ProgressEvent(file_edit_events=edits),
)
await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with(
"c1", edits, msg.metadata
"c1", edits, {"_progress": True, "_file_edit_events": edits}
)
channel._send_mock.assert_not_awaited()
@@ -279,10 +270,11 @@ async def test_reasoning_routing_does_not_consult_send_progress(manager):
channel = manager.channels["mock"]
channel.send_progress = False
channel.show_reasoning = True
await manager.bus.publish_outbound(outbound_message_for_event(
await manager.bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="c1",
event=ProgressEvent(content="still surfaces", reasoning_delta=True),
content="still surfaces",
metadata={"_progress": True, "_reasoning_delta": True},
))
await _pump_one(manager)
+11 -166
View File
@@ -9,13 +9,6 @@ from unittest.mock import AsyncMock, patch
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
@@ -725,7 +718,7 @@ async def test_send_with_retry_no_retry_when_max_is_zero():
@pytest.mark.asyncio
async def test_send_with_retry_calls_send_delta():
"""_send_with_retry should call send_delta for stream delta events."""
"""_send_with_retry should call send_delta when metadata has _stream_delta."""
send_delta_called = False
class _StreamingChannel(BaseChannel):
@@ -741,16 +734,7 @@ async def test_send_with_retry_calls_send_delta():
async def send(self, msg: OutboundMessage) -> None:
pass # Should not be called
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None:
nonlocal send_delta_called
send_delta_called = True
@@ -765,147 +749,18 @@ async def test_send_with_retry_calls_send_delta():
mgr.channels = {"streaming": _StreamingChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None
msg = outbound_message_for_event(
channel="streaming",
chat_id="123",
event=StreamDeltaEvent(content="test delta"),
msg = OutboundMessage(
channel="streaming", chat_id="123", content="test delta",
metadata={"_stream_delta": True}
)
await mgr._send_with_retry(mgr.channels["streaming"], msg)
assert send_delta_called is True
@pytest.mark.asyncio
async def test_send_with_retry_supports_legacy_stream_delta_signature():
"""External plugins with the old send_delta signature should keep working."""
calls: list[tuple[str, str, dict]] = []
class _LegacyStreamingChannel(BaseChannel):
name = "legacy_streaming"
display_name = "Legacy Streaming"
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict | None = None,
) -> None:
calls.append((chat_id, delta, dict(metadata or {})))
fake_config = SimpleNamespace(
channels=ChannelsConfig(send_max_retries=3),
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
)
mgr = ChannelManager.__new__(ChannelManager)
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {"legacy_streaming": _LegacyStreamingChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None
await mgr._send_with_retry(
mgr.channels["legacy_streaming"],
outbound_message_for_event(
channel="legacy_streaming",
chat_id="123",
event=StreamDeltaEvent(content="hello", stream_id="s1"),
),
)
await mgr._send_with_retry(
mgr.channels["legacy_streaming"],
outbound_message_for_event(
channel="legacy_streaming",
chat_id="123",
event=StreamEndEvent(content="", stream_id="s1", resuming=True),
),
)
assert calls == [
("123", "hello", {"_stream_id": "s1", "_stream_delta": True}),
("123", "", {"_stream_id": "s1", "_stream_end": True}),
]
@pytest.mark.asyncio
async def test_send_with_retry_supports_legacy_reasoning_signature():
"""External plugins with the old reasoning hook signature should keep working."""
deltas: list[tuple[str, str, dict]] = []
ends: list[tuple[str, dict]] = []
class _LegacyReasoningChannel(BaseChannel):
name = "legacy_reasoning"
display_name = "Legacy Reasoning"
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
async def send_reasoning_delta(
self,
chat_id: str,
delta: str,
metadata: dict | None = None,
) -> None:
deltas.append((chat_id, delta, dict(metadata or {})))
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict | None = None,
) -> None:
ends.append((chat_id, dict(metadata or {})))
fake_config = SimpleNamespace(
channels=ChannelsConfig(send_max_retries=3),
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
)
mgr = ChannelManager.__new__(ChannelManager)
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {"legacy_reasoning": _LegacyReasoningChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None
await mgr._send_with_retry(
mgr.channels["legacy_reasoning"],
outbound_message_for_event(
channel="legacy_reasoning",
chat_id="123",
event=ProgressEvent(content="thinking", reasoning_delta=True, stream_id="r1"),
),
)
await mgr._send_with_retry(
mgr.channels["legacy_reasoning"],
outbound_message_for_event(
channel="legacy_reasoning",
chat_id="123",
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
),
)
assert deltas == [
("123", "thinking", {"_reasoning_delta": True, "_stream_id": "r1"}),
]
assert ends == [
("123", {"_reasoning_end": True, "_stream_id": "r1"}),
]
@pytest.mark.asyncio
async def test_send_with_retry_skips_send_when_streamed():
"""_send_with_retry should not call send for streamed response events."""
"""_send_with_retry should not call send when metadata has _streamed flag."""
send_called = False
send_delta_called = False
@@ -923,16 +778,7 @@ async def test_send_with_retry_skips_send_when_streamed():
nonlocal send_called
send_called = True
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None:
nonlocal send_delta_called
send_delta_called = True
@@ -947,11 +793,10 @@ async def test_send_with_retry_skips_send_when_streamed():
mgr.channels = {"streamed": _StreamedChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None
msg = outbound_message_for_event(
channel="streamed",
chat_id="123",
event=StreamedResponseEvent(),
content="test",
# _streamed means message was already sent via send_delta, so skip send
msg = OutboundMessage(
channel="streamed", chat_id="123", content="test",
metadata={"_streamed": True}
)
await mgr._send_with_retry(mgr.channels["streamed"], msg)
+7 -8
View File
@@ -10,7 +10,6 @@ pytest.importorskip("discord")
import discord
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.discord import (
MAX_MESSAGE_LEN,
@@ -719,9 +718,9 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
times = iter([1.0, 3.0, 5.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
await owner.send_delta("123", "hel", stream_id="s1")
await owner.send_delta("123", "lo", stream_id="s1")
await owner.send_delta("123", "", stream_id="s1", stream_end=True)
await owner.send_delta("123", "hel", {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
assert target.sent_payloads[0] == {"content": "hel"}
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
@@ -746,9 +745,9 @@ async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None
times = iter([1.0, 3.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
await owner.send_delta("123", prefix, stream_id="s1")
await owner.send_delta("123", suffix, stream_id="s1")
await owner.send_delta("123", "", stream_id="s1", stream_end=True)
await owner.send_delta("123", prefix, {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
@@ -1074,7 +1073,7 @@ async def test_send_stops_typing_after_send() -> None:
channel="discord",
chat_id="123",
content="progress",
event=ProgressEvent(content="progress"),
metadata={"_progress": True},
)
)
+4 -2
View File
@@ -6,7 +6,6 @@ from pathlib import Path
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.email import EmailChannel, EmailConfig
@@ -869,7 +868,10 @@ async def test_send_skips_progress_messages_before_smtp(monkeypatch) -> None:
channel="email",
chat_id="alice@example.com",
content="",
event=ProgressEvent(tool_events=[{"phase": "end", "name": "exec"}]),
metadata={
"_progress": True,
"_tool_events": [{"phase": "end", "name": "exec"}],
},
)
)
+9 -17
View File
@@ -193,8 +193,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
metadata={"_stream_end": True, "message_id": "om_001"},
)
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
@@ -211,7 +210,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
stream_end=True,
metadata={"_stream_end": True},
)
ch._remove_reaction.assert_not_called()
@@ -228,8 +227,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
metadata={"_stream_end": True, "message_id": "om_001"},
)
ch._remove_reaction.assert_not_called()
@@ -244,7 +242,7 @@ class TestStreamEndReactionCleanup:
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta("oc_chat1", "", stream_end=True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
ch._remove_reaction.assert_not_called()
@@ -262,7 +260,7 @@ class TestStreamEndReactionCleanup:
@pytest.mark.asyncio
async def test_no_removal_when_resuming(self):
"""resuming=True means more tool-call rounds follow; reaction must persist."""
"""_resuming=True means more tool-call rounds follow; reaction must persist."""
ch = _make_channel()
ch.config.done_emoji = "DONE"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
@@ -276,9 +274,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"},
)
ch._remove_reaction.assert_not_called()
@@ -303,23 +299,19 @@ class TestStreamEndReactionCleanup:
# Intermediate stream end (more tool calls coming).
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"},
)
ch._remove_reaction.assert_not_called()
ch._add_reaction.assert_not_called()
# Re-prime the stream buffer for the final round (the previous stream end popped it).
# Re-prime the stream buffer for the final round (the previous _stream_end popped it).
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="t", card_id="card_1", sequence=5, last_edit=0.0,
)
# Final stream end (resuming=False): OnIt removed, done_emoji added.
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
resuming=False,
metadata={"_stream_end": True, "_resuming": False, "message_id": "om_001"},
)
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
ch._add_reaction.assert_called_once_with("om_001", "DONE")
+1 -3
View File
@@ -18,7 +18,6 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
@@ -333,8 +332,7 @@ async def test_send_skips_reply_for_progress_messages() -> None:
channel="feishu",
chat_id="oc_abc",
content="thinking...",
event=ProgressEvent(content="thinking..."),
metadata={"message_id": "om_001"},
metadata={"message_id": "om_001", "_progress": True},
))
channel._client.im.v1.message.create.assert_called_once()
+18 -26
View File
@@ -6,7 +6,6 @@ from unittest.mock import MagicMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
@@ -273,7 +272,7 @@ class TestSendDelta:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", stream_end=True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_called_once()
@@ -290,7 +289,7 @@ class TestSendDelta:
)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", stream_end=True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_not_called()
@@ -307,8 +306,7 @@ class TestSendDelta:
await ch.send_delta(
"oc_chat1",
"",
metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
)
ch._client.im.v1.message.create.assert_called_once()
@@ -328,11 +326,11 @@ class TestSendDelta:
"oc_chat1",
"",
metadata={
"_stream_end": True,
"message_id": "om_001",
"chat_type": "group",
"thread_id": "ot_001",
},
stream_end=True,
)
ch._client.im.v1.message.reply.assert_called_once()
@@ -353,8 +351,7 @@ class TestSendDelta:
await ch.send_delta(
"oc_chat1",
"",
metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
)
ch._client.im.v1.message.reply.assert_called_once()
@@ -372,7 +369,7 @@ class TestSendDelta:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", stream_end=True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card.settings.call_count == 2
@@ -391,7 +388,7 @@ class TestSendDelta:
]
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
await ch.send_delta("oc_chat1", "", stream_end=True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card_element.content.call_count == 2
@@ -401,7 +398,7 @@ class TestSendDelta:
@pytest.mark.asyncio
async def test_stream_end_without_buf_is_noop(self):
ch = _make_channel()
await ch.send_delta("oc_chat1", "", stream_end=True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
ch._client.cardkit.v1.card_element.content.assert_not_called()
@pytest.mark.asyncio
@@ -449,7 +446,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='web_fetch("https://example.com")',
event=ProgressEvent(content='web_fetch("https://example.com")', tool_hint=True),
metadata={"_tool_hint": True},
)
await ch.send(msg)
@@ -485,7 +482,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"_tool_hint": True},
)
await ch.send(msg)
@@ -500,8 +497,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
)
await ch.send(msg)
@@ -518,8 +514,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={
"_tool_hint": True,
"message_id": "om_001",
"chat_type": "group",
"thread_id": "ot_001",
@@ -542,8 +538,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
)
await ch.send(msg)
@@ -563,15 +558,13 @@ class TestToolHintInlineStreaming:
msg1 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ cd /project',
event=ProgressEvent(content='$ cd /project', tool_hint=True),
content='$ cd /project', metadata={"_tool_hint": True},
)
await ch.send(msg1)
msg2 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ git status',
event=ProgressEvent(content='$ git status', tool_hint=True),
content='$ git status', metadata={"_tool_hint": True},
)
await ch.send(msg2)
@@ -584,7 +577,7 @@ class TestToolHintInlineStreaming:
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_final_stream_end(self):
"""When stream end closes the card, tool hint is kept in the final text."""
"""When final _stream_end closes the card, tool hint is kept in the final text."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
@@ -593,7 +586,7 @@ class TestToolHintInlineStreaming:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", stream_end=True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
assert "oc_chat1" not in ch._stream_bufs
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
@@ -610,8 +603,7 @@ class TestToolHintInlineStreaming:
for content in ("", " ", "\t\n"):
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content=content,
event=ProgressEvent(content=content, tool_hint=True),
content=content, metadata={"_tool_hint": True},
)
await ch.send(msg)
@@ -1,6 +1,7 @@
"""Tests for FeishuChannel tool hint formatting."""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -17,7 +18,6 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.feishu import FeishuChannel
@@ -51,7 +51,7 @@ async def test_tool_hint_sends_interactive_card(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='web_search("test query")',
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -72,7 +72,7 @@ async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content=" ", # whitespace only
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -107,7 +107,7 @@ async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='web_search("query"), read_file("/path/to/file")',
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -127,7 +127,7 @@ async def test_tool_hint_new_format_basic(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='read src/main.py, grep "TODO"',
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -146,7 +146,7 @@ async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='grep "hello, world", $ echo test',
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -165,7 +165,7 @@ async def test_tool_hint_new_format_with_folding(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='read path × 3, grep "pattern"',
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -184,7 +184,7 @@ async def test_tool_hint_new_format_mcp(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='4_5v::analyze_image("photo.jpg")',
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
@@ -202,7 +202,7 @@ async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
channel="feishu",
chat_id="oc_123456",
content='web_search("foo, bar"), read_file("/path/to/file")',
event=ProgressEvent(tool_hint=True),
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
+6 -7
View File
@@ -11,7 +11,6 @@ from nio import RoomSendResponse, SyncError
import nanobot.channels.matrix as matrix_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.matrix import (
MATRIX_HTML_FORMAT,
@@ -1523,7 +1522,7 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None:
channel="matrix",
chat_id="!room:matrix.org",
content="working...",
event=ProgressEvent(content="working..."),
metadata={"_progress": True, "_progress_kind": "reasoning"},
)
)
@@ -1545,7 +1544,7 @@ async def test_send_empty_content_does_not_call_room_send() -> None:
channel="matrix",
chat_id="!room:matrix.org",
content="",
event=ProgressEvent(),
metadata={"_progress": True},
)
)
@@ -1564,7 +1563,7 @@ async def test_send_whitespace_only_content_does_not_call_room_send() -> None:
channel="matrix",
chat_id="!room:matrix.org",
content=" \n\n ",
event=ProgressEvent(content=" \n\n "),
metadata={"_progress": True},
)
)
@@ -1884,7 +1883,7 @@ async def test_send_delta_stream_end_replaces_existing_message() -> None:
last_edit=100.0,
)
await channel.send_delta("!room:matrix.org", "", stream_end=True)
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
assert "!room:matrix.org" not in channel._stream_bufs
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
@@ -1934,7 +1933,7 @@ async def test_send_delta_threaded_edit_keeps_replace_and_thread_relation(monkey
}
await channel.send_delta("!room:matrix.org", "Hello", metadata)
await channel.send_delta("!room:matrix.org", " world", metadata)
await channel.send_delta("!room:matrix.org", "", metadata, stream_end=True)
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True, **metadata})
edit_content = client.room_send_calls[1]["content"]
final_content = client.room_send_calls[2]["content"]
@@ -1967,7 +1966,7 @@ async def test_send_delta_stream_end_noop_when_buffer_missing() -> None:
client = _FakeAsyncClient("", "", "", None)
channel.client = client
await channel.send_delta("!room:matrix.org", "", stream_end=True)
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
assert client.room_send_calls == []
assert client.typing_calls == []
+1 -2
View File
@@ -10,7 +10,6 @@ from unittest.mock import MagicMock
import pytest
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.signal import (
SignalChannel,
@@ -1342,7 +1341,7 @@ class TestSend:
channel="signal",
chat_id="+19995550001",
content="working...",
event=ProgressEvent(content="working..."),
metadata={"_progress": True},
)
await ch.send(msg)
# Progress messages should NOT stop the typing indicator
+13 -16
View File
@@ -12,7 +12,6 @@ except ImportError:
pytest.skip("Telegram dependencies not installed (python-telegram-bot)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.telegram import (
TELEGRAM_REPLY_CONTEXT_MAX_LEN,
@@ -605,7 +604,7 @@ async def test_send_delta_stream_end_raises_and_keeps_buffer_on_failure() -> Non
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(RuntimeError, match="boom"):
await channel.send_delta("123", "", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True})
assert "123" in channel._stream_bufs
@@ -622,7 +621,7 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("Message is not modified"))
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0, stream_id="s:0")
await channel.send_delta("123", "", stream_id="s:0", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True, "_stream_id": "s:0"})
assert "123" not in channel._stream_bufs
@@ -643,7 +642,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> N
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(TimedOut, match="network timeout"):
await channel.send_delta("123", "", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True})
# Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made.
@@ -667,7 +666,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_error() -> Non
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(NetworkError, match="connection reset"):
await channel.send_delta("123", "", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True})
# Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made.
@@ -694,7 +693,7 @@ async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
)
channel._stream_bufs["123"] = _StreamBuf(text="hello <bad>", message_id=7, last_edit=0.0)
await channel.send_delta("123", "", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True})
# edit_message_text should have been called twice: once for HTML, once for plain fallback
assert channel._app.bot.edit_message_text.call_count == 2
@@ -725,7 +724,7 @@ async def test_send_delta_stream_end_splits_oversized_reply() -> None:
oversized = "x" * (4000 + 500)
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True})
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
@@ -763,7 +762,7 @@ async def test_send_delta_stream_end_html_expansion_does_not_overflow() -> None:
channel._stream_bufs["123"] = _StreamBuf(text=markdown_text, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True})
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
@@ -790,7 +789,7 @@ async def test_send_delta_stream_end_splits_long_code_block_before_html_renderin
raw_text = "```python\n" + ("print(\"line\")\n" * 450) + "```\nDone"
channel._stream_bufs["123"] = _StreamBuf(text=raw_text, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", stream_end=True)
await channel.send_delta("123", "", {"_stream_end": True})
html_chunks = [
channel._app.bot.edit_message_text.call_args.kwargs.get("text", ""),
@@ -820,7 +819,7 @@ async def test_send_delta_new_stream_id_replaces_stale_buffer() -> None:
stream_id="old:0",
)
await channel.send_delta("123", "world", stream_id="new:0")
await channel.send_delta("123", "world", {"_stream_delta": True, "_stream_id": "new:0"})
buf = channel._stream_bufs["123"]
assert buf.text == "world"
@@ -840,7 +839,7 @@ async def test_send_delta_incremental_edit_treats_not_modified_as_success() -> N
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0, stream_id="s:0")
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("Message is not modified"))
await channel.send_delta("123", "", stream_id="s:0")
await channel.send_delta("123", "", {"_stream_delta": True, "_stream_id": "s:0"})
assert channel._stream_bufs["123"].last_edit > 0.0
@@ -865,7 +864,7 @@ async def test_send_delta_incremental_edit_splits_oversized_buffer() -> None:
text=oversized, message_id=7, last_edit=0.0, stream_id="s:0"
)
await channel.send_delta("123", "y", stream_id="s:0")
await channel.send_delta("123", "y", {"_stream_delta": True, "_stream_id": "s:0"})
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
@@ -889,8 +888,7 @@ async def test_send_delta_initial_send_keeps_message_in_thread() -> None:
await channel.send_delta(
"123",
"hello",
{"message_thread_id": 42},
stream_id="s:0",
{"_stream_delta": True, "_stream_id": "s:0", "message_thread_id": 42},
)
assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42
@@ -964,8 +962,7 @@ async def test_send_progress_keeps_message_in_topic() -> None:
channel="telegram",
chat_id="123",
content="hello",
event=ProgressEvent(content="hello"),
metadata={"message_thread_id": 42},
metadata={"_progress": True, "message_thread_id": 42},
)
)
+58 -56
View File
@@ -15,14 +15,6 @@ from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket import (
WebSocketChannel,
@@ -861,10 +853,11 @@ async def test_runtime_model_update_publisher_uses_websocket_outbound_event() ->
assert event.channel == "websocket"
assert event.chat_id == "*"
assert event.content == ""
assert event.metadata == {}
assert isinstance(event.event, RuntimeModelUpdatedEvent)
assert event.event.model == "openai/gpt-4.1"
assert event.event.model_preset == "fast"
assert event.metadata == {
"_runtime_model_updated": True,
"model": "openai/gpt-4.1",
"model_preset": "fast",
}
@pytest.mark.asyncio
@@ -936,10 +929,11 @@ async def test_send_progress_includes_structured_tool_events() -> None:
channel="websocket",
chat_id="chat-1",
content='search "hermes"',
event=ProgressEvent(
content='search "hermes"',
tool_hint=True,
tool_events=[
metadata={
"_progress": True,
"_tool_hint": True,
"webui_turn_id": "turn-1",
"_tool_events": [
{
"version": 1,
"phase": "start",
@@ -952,9 +946,6 @@ async def test_send_progress_includes_structured_tool_events() -> None:
"embeds": [],
}
],
),
metadata={
"webui_turn_id": "turn-1",
},
))
@@ -990,8 +981,9 @@ async def test_send_file_edit_progress_uses_file_edit_event() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=ProgressEvent(
file_edit_events=[
metadata={
"_progress": True,
"_file_edit_events": [
{
"version": 1,
"phase": "start",
@@ -1004,7 +996,7 @@ async def test_send_file_edit_progress_uses_file_edit_event() -> None:
"status": "editing",
}
],
),
},
))
payload = json.loads(mock_ws.send.await_args.args[0])
@@ -1042,8 +1034,7 @@ async def test_send_progress_includes_agent_ui_blob() -> None:
channel="websocket",
chat_id="chat-1",
content="progress · panel",
event=ProgressEvent(content="progress · panel"),
metadata={OUTBOUND_META_AGENT_UI: blob},
metadata={"_progress": True, OUTBOUND_META_AGENT_UI: blob},
))
payload = json.loads(mock_ws.send.await_args.args[0])
@@ -1060,7 +1051,7 @@ async def test_send_delta_removes_connection_on_connection_closed() -> None:
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "chunk", stream_id="s1")
await channel.send_delta("chat-1", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
assert "chat-1" not in channel._subs
assert mock_ws not in channel._conn_chats
@@ -1073,8 +1064,8 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "part", stream_id="sid")
await channel.send_delta("chat-1", "", stream_id="sid", stream_end=True)
await channel.send_delta("chat-1", "part", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
assert mock_ws.send.await_count == 2
first = json.loads(mock_ws.send.call_args_list[0][0][0])
@@ -1099,8 +1090,7 @@ async def test_send_delta_stream_end_includes_inline_final_text() -> None:
await channel.send_delta(
"chat-1",
"merged plain text",
stream_id="sid",
stream_end=True,
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
)
mock_ws.send.assert_awaited_once()
@@ -1134,9 +1124,9 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "![Diagram](", stream_id="sid")
await channel.send_delta("chat-1", "diagram.png)", stream_id="sid")
await channel.send_delta("chat-1", "", stream_id="sid", stream_end=True)
await channel.send_delta("chat-1", "![Diagram](", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
assert mock_ws.send.await_count == 3
final = json.loads(mock_ws.send.call_args_list[2][0][0])
@@ -1170,8 +1160,7 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
await channel.send_delta(
"chat-1",
"![Diagram](diagram.png)",
stream_id="sid",
stream_end=True,
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
)
mock_ws.send.assert_awaited_once()
@@ -1190,7 +1179,7 @@ async def test_send_reasoning_delta_emits_streaming_frame() -> None:
await channel.send_reasoning_delta(
"chat-1",
"step-by-step thinking",
stream_id="r1",
{"_reasoning_delta": True, "_stream_id": "r1"},
)
mock_ws.send.assert_awaited_once()
@@ -1208,7 +1197,7 @@ async def test_send_reasoning_end_emits_close_frame() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_reasoning_end("chat-1", stream_id="r1")
await channel.send_reasoning_end("chat-1", {"_reasoning_end": True, "_stream_id": "r1"})
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload == {"event": "reasoning_end", "chat_id": "chat-1", "stream_id": "r1"}
@@ -1216,7 +1205,9 @@ async def test_send_reasoning_end_emits_close_frame() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
"""``send_reasoning`` produces one delta and one end."""
"""``send_reasoning`` is back-compat for hooks that haven't migrated:
the base implementation must produce one delta and one end so the
WebUI sees the same shape either way."""
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
@@ -1226,7 +1217,7 @@ async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
channel="websocket",
chat_id="chat-1",
content="thinking",
event=ProgressEvent(content="thinking", reasoning=True),
metadata={"_reasoning": True},
))
assert mock_ws.send.await_count == 2
@@ -1244,7 +1235,7 @@ async def test_send_reasoning_delta_drops_empty_chunks() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_reasoning_delta("chat-1", "")
await channel.send_reasoning_delta("chat-1", "", {"_reasoning_delta": True})
mock_ws.send.assert_not_awaited()
@@ -1270,14 +1261,14 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-1", "hello", stream_id="s1")
await channel.send_delta("chat-1", " world", stream_id="s1")
await channel.send_delta("chat-1", "", stream_id="s1", stream_end=True)
await channel.send_delta("chat-1", "hello", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", " world", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "s1"})
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
event=TurnEndEvent(latency_ms=42),
metadata={"_turn_end": True, "latency_ms": 42},
))
assert channel._subs == {}
@@ -1301,7 +1292,7 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=TurnEndEvent(),
metadata={"_turn_end": True},
))
assert _sent_ws_payloads(mock_ws) == [
@@ -1321,7 +1312,7 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=TurnEndEvent(latency_ms=1500),
metadata={"_turn_end": True, "latency_ms": 1500},
))
assert _sent_ws_payloads(mock_ws) == [
@@ -1342,7 +1333,7 @@ async def test_send_turn_end_includes_goal_state_when_present() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=TurnEndEvent(goal_state=blob),
metadata={"_turn_end": True, "goal_state": blob},
))
assert _sent_ws_payloads(mock_ws) == [
@@ -1362,7 +1353,11 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=GoalStatusEvent(status="running", started_at=1_700_000_000.5),
metadata={
"_goal_status": True,
"goal_status": "running",
"started_at": 1_700_000_000.5,
},
))
mock_ws.send.assert_awaited_once()
@@ -1386,7 +1381,11 @@ async def test_send_goal_status_idle_omits_started_at() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=GoalStatusEvent(status="idle", started_at=99.0),
metadata={
"_goal_status": True,
"goal_status": "idle",
"goal_started_at": 99.0,
},
))
mock_ws.send.assert_awaited_once()
@@ -1407,7 +1406,10 @@ async def test_send_goal_state_emits_blob_per_chat() -> None:
channel="websocket",
chat_id="chat-a",
content="",
event=GoalStateSyncEvent(goal_state={"active": True, "ui_summary": "A"}),
metadata={
"_goal_state_sync": True,
"goal_state": {"active": True, "ui_summary": "A"},
},
))
mock_a.send.assert_awaited_once()
@@ -1526,7 +1528,7 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=SessionUpdatedEvent(),
metadata={"_session_updated": True},
))
mock_ws.send.assert_awaited_once()
@@ -1545,7 +1547,7 @@ async def test_send_session_updated_includes_scope_when_present() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=SessionUpdatedEvent(scope="metadata"),
metadata={"_session_updated": True, "_session_update_scope": "metadata"},
))
mock_ws.send.assert_awaited_once()
@@ -1571,7 +1573,7 @@ async def test_send_delta_missing_connection_is_noop() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
# No exception, no error — just a no-op
await channel.send_delta("nonexistent", "chunk", stream_id="s1")
await channel.send_delta("nonexistent", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
assert channel._subs == {}
@@ -2189,13 +2191,13 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc
# Server pushes deltas directly
await channel.send_delta(
chat_id, "Hello ", stream_id="s1"
chat_id, "Hello ", {"_stream_delta": True, "_stream_id": "s1"}
)
await channel.send_delta(
chat_id, "world", stream_id="s1"
chat_id, "world", {"_stream_delta": True, "_stream_id": "s1"}
)
await channel.send_delta(
chat_id, "", stream_id="s1", stream_end=True
chat_id, "", {"_stream_end": True, "_stream_id": "s1"}
)
delta1 = json.loads(await client.recv())
@@ -2216,7 +2218,7 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc
channel="websocket",
chat_id=chat_id,
content="",
event=TurnEndEvent(),
metadata={"_turn_end": True},
))
turn_end = json.loads(await client.recv())
+12 -12
View File
@@ -16,7 +16,6 @@ import websockets
from ws_test_client import WsTestClient, issue_token, issue_token_ok
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
@@ -214,7 +213,8 @@ async def test_server_send_message(bus: MagicMock) -> None:
@pytest.mark.asyncio
async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
"""Tool-hint progress events surface as ``kind: "tool_hint"``."""
"""``_tool_hint`` metadata must surface as ``kind: "tool_hint"`` so WS
clients render breadcrumbs separately from conversational replies."""
ch = _ch(bus, 29919)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
@@ -232,7 +232,7 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content='weather("get")',
event=ProgressEvent(content='weather("get")', tool_hint=True),
metadata={"_progress": True, "_tool_hint": True},
))
hint = await c.recv_message()
assert hint.raw.get("kind") == "tool_hint"
@@ -242,7 +242,7 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content="thinking…",
event=ProgressEvent(content="thinking…"),
metadata={"_progress": True},
))
prog = await c.recv_message()
assert prog.raw.get("kind") == "progress"
@@ -284,8 +284,8 @@ async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c:
cid = (await c.recv_ready()).chat_id
for part in ("Hello", " ", "world", "!"):
await ch.send_delta(cid, part, stream_id="s1")
await ch.send_delta(cid, "", stream_id="s1", stream_end=True)
await ch.send_delta(cid, part, {"_stream_delta": True, "_stream_id": "s1"})
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "s1"})
msgs = await c.collect_stream()
deltas = [m for m in msgs if m.event == "delta"]
@@ -305,12 +305,12 @@ async def test_interleaved_streams(bus: MagicMock) -> None:
try:
async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c:
cid = (await c.recv_ready()).chat_id
await ch.send_delta(cid, "A1", stream_id="sa")
await ch.send_delta(cid, "B1", stream_id="sb")
await ch.send_delta(cid, "A2", stream_id="sa")
await ch.send_delta(cid, "", stream_id="sa", stream_end=True)
await ch.send_delta(cid, "B2", stream_id="sb")
await ch.send_delta(cid, "", stream_id="sb", stream_end=True)
await ch.send_delta(cid, "A1", {"_stream_delta": True, "_stream_id": "sa"})
await ch.send_delta(cid, "B1", {"_stream_delta": True, "_stream_id": "sb"})
await ch.send_delta(cid, "A2", {"_stream_delta": True, "_stream_id": "sa"})
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "sa"})
await ch.send_delta(cid, "B2", {"_stream_delta": True, "_stream_id": "sb"})
await ch.send_delta(cid, "", {"_stream_end": True, "_stream_id": "sb"})
msgs = await c.recv_n(6)
sa = "".join(m.text for m in msgs if m.event == "delta" and m.stream_id == "sa")
+2 -8
View File
@@ -18,7 +18,6 @@ if not WECOM_AVAILABLE:
pytest.skip("WeCom dependencies not installed (wecom_aibot_sdk)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.wecom import (
WecomChannel,
@@ -317,7 +316,7 @@ async def test_send_text_with_frame() -> None:
@pytest.mark.asyncio
async def test_send_progress_with_frame() -> None:
"""Progress events use reply_stream with finish=False."""
"""When metadata has _progress, send uses reply_stream with finish=False."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
@@ -325,12 +324,7 @@ async def test_send_progress_with_frame() -> None:
channel._chat_frames["chat1"] = _FakeFrame()
await channel.send(
OutboundMessage(
channel="wecom",
chat_id="chat1",
content="thinking...",
event=ProgressEvent(content="thinking..."),
)
OutboundMessage(channel="wecom", chat_id="chat1", content="thinking...", metadata={"_progress": True})
)
client.reply_stream.assert_called_once()
+14 -28
View File
@@ -10,7 +10,6 @@ import httpx
import pytest
import nanobot.channels.weixin as weixin_mod
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin import (
ITEM_IMAGE,
@@ -687,8 +686,7 @@ async def test_send_progress_message_keeps_typing_indicator() -> None:
"chat_id": "wx-user",
"content": "thinking",
"media": [],
"event": ProgressEvent(content="thinking"),
"metadata": {},
"metadata": {"_progress": True},
},
)()
)
@@ -1411,8 +1409,7 @@ async def test_buffer_single_tool_hint_not_sent_immediately() -> None:
"chat_id": "wx-user",
"content": "Using tool",
"media": [],
"event": ProgressEvent(content="Using tool", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
@@ -1440,8 +1437,7 @@ async def test_buffer_multiple_tool_hints_flushed_on_final_answer() -> None:
"chat_id": "wx-user",
"content": hint,
"media": [],
"event": ProgressEvent(content=hint, tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
@@ -1486,8 +1482,7 @@ async def test_thought_progress_flushes_tool_hints() -> None:
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"event": ProgressEvent(content="search 'foo'", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
@@ -1502,8 +1497,7 @@ async def test_thought_progress_flushes_tool_hints() -> None:
"chat_id": "wx-user",
"content": "Let me think...",
"media": [],
"event": ProgressEvent(content="Let me think..."),
"metadata": {},
"metadata": {"_progress": True},
},
)()
)
@@ -1553,8 +1547,7 @@ async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"event": ProgressEvent(content="search 'foo'", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
@@ -1568,8 +1561,7 @@ async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "Thinking step 1...",
"media": [],
"event": ProgressEvent(content="Thinking step 1...", reasoning_delta=True),
"metadata": {},
"metadata": {"_progress": True, "_reasoning_delta": True},
},
)()
)
@@ -1618,8 +1610,7 @@ async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"event": ProgressEvent(content="search 'foo'", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
@@ -1633,8 +1624,7 @@ async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
"chat_id": "wx-user",
"content": "",
"media": [],
"event": ProgressEvent(tool_events=[{"phase": "end"}]),
"metadata": {},
"metadata": {"_progress": True, "_tool_events": [{"phase": "end"}]},
},
)()
)
@@ -1681,8 +1671,7 @@ async def test_buffer_flush_refreshes_context_token() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
@@ -1723,8 +1712,7 @@ async def test_buffer_flush_failure_does_not_block_final_answer() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
@@ -1765,13 +1753,12 @@ async def test_buffer_flushed_on_stream_end() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send_delta("wx-user", "", stream_end=True)
await channel.send_delta("wx-user", "", {"_stream_end": True})
channel._send_text.assert_awaited_once_with("wx-user", "hint", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@@ -1839,8 +1826,7 @@ async def test_send_tool_hints_false_drops_tool_hints() -> None:
"chat_id": "wx-user",
"content": "hint",
"media": [],
"event": ProgressEvent(content="hint", tool_hint=True),
"metadata": {},
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
+1 -37
View File
@@ -1561,16 +1561,10 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
async def close_mcp(self) -> None:
return None
def _fake_create_app(
agent_loop,
model_name: str,
request_timeout: float,
api_key: str = "",
):
def _fake_create_app(agent_loop, model_name: str, request_timeout: float):
seen["agent_loop"] = agent_loop
seen["model_name"] = model_name
seen["request_timeout"] = request_timeout
seen["api_key"] = api_key
return _FakeApiApp()
def _fake_run_app(api_app, host: str, port: int, print):
@@ -2513,7 +2507,6 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
assert seen["host"] == "127.0.0.2"
assert seen["port"] == 18900
assert seen["request_timeout"] == 45.0
assert seen["api_key"] == ""
def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None:
@@ -2545,35 +2538,6 @@ def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> N
assert seen["host"] == "127.0.0.1"
assert seen["port"] == 18901
assert seen["request_timeout"] == 46.0
assert seen["api_key"] == ""
def test_serve_passes_configured_api_key(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
config.api.api_key = " secret "
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(app, ["serve", "--config", str(config_file)])
assert result.exit_code == 0
assert seen["api_key"] == "secret"
def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(app, ["serve", "--config", str(config_file), "--host", "0.0.0.0"])
assert result.exit_code == 1
assert "api_key is not set" in result.stdout
assert "api_app" not in seen
def test_channels_login_requires_channel_name() -> None:
+10 -21
View File
@@ -3,7 +3,6 @@ from unittest.mock import patch
import pytest
from nanobot.bus.outbound_events import ProgressEvent, RetryWaitEvent
from nanobot.cli import commands
@@ -15,8 +14,7 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress
channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False)
msg = SimpleNamespace(
content="Model request failed, retry in 2s (attempt 1).",
event=RetryWaitEvent(content="Model request failed, retry in 2s (attempt 1)."),
metadata={},
metadata={"_retry_wait": True},
)
async def fake_print(text: str, active_thinking: object | None, renderer=None) -> None:
@@ -42,8 +40,7 @@ async def test_reasoning_displayed_when_show_reasoning_enabled():
)
msg = SimpleNamespace(
content="Let me think about this...",
event=ProgressEvent(content="Let me think about this...", reasoning=True),
metadata={},
metadata={"_progress": True, "_reasoning": True},
)
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
@@ -62,8 +59,7 @@ async def test_reasoning_delta_displayed_when_show_reasoning_enabled():
)
msg = SimpleNamespace(
content="I should search first.",
event=ProgressEvent(content="I should search first.", reasoning_delta=True),
metadata={},
metadata={"_progress": True, "_reasoning_delta": True},
)
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
@@ -85,8 +81,7 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
first = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="The",
event=ProgressEvent(content="The", reasoning_delta=True),
metadata={},
metadata={"_progress": True, "_reasoning_delta": True},
),
None,
channels_config,
@@ -95,8 +90,7 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
second = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content=" user asked.",
event=ProgressEvent(content=" user asked.", reasoning_delta=True),
metadata={},
metadata={"_progress": True, "_reasoning_delta": True},
),
None,
channels_config,
@@ -120,8 +114,7 @@ async def test_reasoning_end_flushes_buffered_delta():
delta = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="The user asked",
event=ProgressEvent(content="The user asked", reasoning_delta=True),
metadata={},
metadata={"_progress": True, "_reasoning_delta": True},
),
None,
channels_config,
@@ -130,8 +123,7 @@ async def test_reasoning_end_flushes_buffered_delta():
end = await commands._maybe_print_interactive_progress(
SimpleNamespace(
content="",
event=ProgressEvent(reasoning_end=True),
metadata={},
metadata={"_progress": True, "_reasoning_end": True},
),
None,
channels_config,
@@ -151,8 +143,7 @@ async def test_reasoning_hidden_when_show_reasoning_disabled():
)
msg = SimpleNamespace(
content="Let me think about this...",
event=ProgressEvent(content="Let me think about this...", reasoning=True),
metadata={},
metadata={"_progress": True, "_reasoning": True},
)
with patch("nanobot.cli.commands._print_cli_reasoning") as mock_reasoning:
@@ -171,8 +162,7 @@ async def test_non_reasoning_progress_not_affected_by_show_reasoning():
)
msg = SimpleNamespace(
content="working on it...",
event=ProgressEvent(content="working on it..."),
metadata={},
metadata={"_progress": True},
)
async def fake_print(text: str, thinking=None, renderer=None):
@@ -195,8 +185,7 @@ async def test_reasoning_shown_when_send_progress_disabled():
)
msg = SimpleNamespace(
content="Let me think about this...",
event=ProgressEvent(content="Let me think about this...", reasoning=True),
metadata={},
metadata={"_progress": True, "_reasoning": True},
)
with patch(
-14
View File
@@ -3,7 +3,6 @@ import json
import pytest
from nanobot.config.loader import load_config
from nanobot.config.schema import ApiConfig
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
@@ -29,16 +28,3 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
with pytest.raises(ValueError, match="Failed to load config"):
load_config(config_path)
@pytest.mark.parametrize("host", ["0.0.0.0", "::"])
def test_api_config_requires_key_for_wildcard_hosts(host: str) -> None:
with pytest.raises(ValueError, match="api_key is not set"):
ApiConfig(host=host)
def test_api_config_allows_wildcard_host_with_key() -> None:
config = ApiConfig(host="0.0.0.0", api_key="secret")
assert config.host == "0.0.0.0"
assert config.api_key == "secret"
-29
View File
@@ -8,7 +8,6 @@ jobs.json + don't silently overwrite corrupt store``.
from __future__ import annotations
import errno
import json
from pathlib import Path
from typing import Callable
@@ -108,34 +107,6 @@ def test_save_store_failure_does_not_corrupt_existing_file(
assert store_path.read_bytes() == original
def test_atomic_write_ignores_unsupported_directory_fsync(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""vboxsf-like filesystems can open directories but reject directory fsync."""
store_path = tmp_path / "cron" / "jobs.json"
dir_fd = 987654
def fake_open(path: str, flags: int) -> int:
assert Path(path) == store_path.parent
return dir_fd
def fake_fsync(fd: int) -> None:
if fd == dir_fd:
raise OSError(errno.EINVAL, "Invalid argument")
def fake_close(fd: int) -> None:
assert fd == dir_fd
monkeypatch.setattr("os.open", fake_open)
monkeypatch.setattr("os.fsync", fake_fsync)
monkeypatch.setattr("os.close", fake_close)
CronService._atomic_write(store_path, '{"version": 1, "jobs": []}')
assert store_path.read_text(encoding="utf-8") == '{"version": 1, "jobs": []}'
assert list(store_path.parent.glob("*.tmp")) == []
def test_load_jobs_preserves_corrupt_store_and_returns_none(
tmp_path: Path,
) -> None:
+8
View File
@@ -49,6 +49,10 @@ async def test_maybe_continue_turn_queues_internal_message():
"message_id": "msg-1",
"origin_message_id": "msg-0",
"_wants_stream": True,
"_stream_id": "stream-1",
"_stream_delta": True,
"_stream_end": True,
"_resuming": True,
"webui": True,
},
),
@@ -74,6 +78,10 @@ async def test_maybe_continue_turn_queues_internal_message():
assert queued.metadata["message_id"] == "msg-1"
assert queued.metadata["origin_message_id"] == "msg-0"
assert queued.metadata["_wants_stream"] is True
assert "_stream_id" not in queued.metadata
assert "_stream_delta" not in queued.metadata
assert "_stream_end" not in queued.metadata
assert "_resuming" not in queued.metadata
assert "Finish the migration." in queued.content
assert ctx.all_messages == messages[:-1]
assert ctx.final_content == ""
-19
View File
@@ -108,25 +108,6 @@ async def test_missing_messages_returns_400(aiohttp_client, app) -> None:
assert resp.status == 400
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_api_key_protects_api_routes_but_not_health(aiohttp_client, mock_agent) -> None:
app = create_app(mock_agent, model_name="test-model", api_key="secret")
client = await aiohttp_client(app)
health = await client.get("/health")
missing = await client.get("/v1/models")
wrong = await client.get("/v1/models", headers={"Authorization": "Bearer wrong"})
ok = await client.get("/v1/models", headers={"Authorization": "Bearer secret"})
assert health.status == 200
assert missing.status == 401
assert wrong.status == 401
assert ok.status == 200
assert (await missing.json())["error"]["message"].startswith("Missing Authorization")
assert (await wrong.json())["error"]["message"] == "Invalid API key"
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
+2 -36
View File
@@ -11,7 +11,6 @@ from nanobot.agent.tools.exec_session import (
ListExecSessionsTool,
WriteStdinTool,
)
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.agent.tools.shell import ExecTool
@@ -53,38 +52,6 @@ def test_exec_accepts_command_aliases(tmp_path):
assert "Exit code: 0" in result
def test_exec_schema_hides_compatibility_aliases():
props = ExecTool().parameters["properties"]
assert "command" in props
assert "working_dir" in props
assert "max_output_chars" in props
assert "cmd" not in props
assert "workdir" not in props
assert "max_output_tokens" not in props
def test_exec_registry_accepts_hidden_compatibility_aliases(tmp_path):
async def run() -> str:
registry = ToolRegistry()
registry.register(ExecTool(working_dir="/", timeout=5))
command = _python_command("import os; print(os.getcwd()); print('A' * 2000)")
return await registry.execute(
"exec",
{
"cmd": command,
"workdir": str(tmp_path),
"max_output_tokens": 1000,
},
)
result = asyncio.run(run())
assert str(tmp_path) in result
assert "chars truncated" in result
assert "Exit code: 0" in result
def test_exec_returns_completed_session_output_when_yield_time_ms_is_used(tmp_path):
async def run() -> str:
manager = ExecSessionManager()
@@ -367,10 +334,9 @@ def test_write_stdin_reports_missing_session(tmp_path):
manager = ExecSessionManager()
tool = WriteStdinTool(manager=manager)
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
result = asyncio.run(tool.execute(session_id="missing", chars=""))
assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
assert is_tool_error_result("write_stdin", result)
assert "exec session not found" in result
def test_list_exec_sessions_reports_running_commands(tmp_path):
+1 -33
View File
@@ -19,7 +19,7 @@ from nanobot.agent.tools.mcp import (
_sanitize_name,
connect_mcp_servers,
)
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import MCPServerConfig
@@ -304,38 +304,6 @@ async def test_execute_returns_text_blocks() -> None:
assert result == "hello\n42"
@pytest.mark.asyncio
async def test_execute_wraps_mcp_is_error_result() -> None:
async def call_tool(_name: str, arguments: dict) -> object:
return SimpleNamespace(
content=[_FakeTextContent("Error: server-side MCP failure")],
isError=True,
)
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
result = await wrapper.execute()
assert result == "Error: server-side MCP failure"
assert is_tool_error_result(wrapper.name, result)
@pytest.mark.asyncio
async def test_execute_preserves_success_text_that_starts_with_error() -> None:
async def call_tool(_name: str, arguments: dict) -> object:
return SimpleNamespace(
content=[_FakeTextContent("Error: generated report successfully")],
isError=False,
)
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
result = await wrapper.execute()
assert result == "Error: generated report successfully"
assert not is_tool_error_result(wrapper.name, result)
# Smallest valid 1x1 PNG, base64 without the data: prefix.
_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8"
+1 -37
View File
@@ -1,9 +1,8 @@
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.filesystem import ReadFileTool
from nanobot.agent.tools.registry import ToolRegistry
@@ -258,41 +257,6 @@ async def test_registry_rejects_unknown_builtin_tool_parameters(tmp_path) -> Non
assert "one" not in result
async def test_registry_preserves_successful_exec_output_that_starts_with_error() -> None:
registry = ToolRegistry()
output = "Error: generated report successfully\n\nExit code: 0"
tool = _FakeTool("exec")
tool.execute = AsyncMock(return_value=output)
registry.register(tool)
result = await registry.execute("exec", {})
assert result == output
async def test_registry_uses_structured_tool_result_for_errors() -> None:
registry = ToolRegistry()
output = "Error: plain tool output, not a structured failure"
raw_tool = _FakeTool("raw_output")
raw_tool.execute = AsyncMock(return_value=output)
registry.register(raw_tool)
raw_result = await registry.execute("raw_output", {})
assert raw_result == output
failing_tool = _FakeTool("failing_tool")
failing_tool.execute = AsyncMock(return_value=ToolResult.error("Error: real failure"))
registry.register(failing_tool)
error_result = await registry.execute("failing_tool", {})
assert isinstance(error_result, ToolResult)
assert error_result.is_error
assert error_result.startswith("Error: real failure")
assert "[Analyze the error above" in error_result
def test_get_definitions_returns_cached_result() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
+2 -5
View File
@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.session import webui_turns as wth
@@ -29,8 +28,7 @@ async def test_publish_turn_run_status_running_records_wall_clock() -> None:
assert isinstance(t0, float)
call = bus.publish_outbound.await_args[0][0]
assert call.chat_id == "chat-a"
assert isinstance(call.event, GoalStatusEvent)
assert call.event.started_at == t0
assert call.metadata.get("started_at") == t0
@pytest.mark.asyncio
@@ -43,8 +41,7 @@ async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
assert wth.websocket_turn_wall_started_at("chat-a") == 1234.5
call = bus.publish_outbound.await_args[0][0]
assert isinstance(call.event, GoalStatusEvent)
assert call.event.started_at == 1234.5
assert call.metadata.get("started_at") == 1234.5
@pytest.mark.asyncio
-8
View File
@@ -11,7 +11,6 @@ from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
_model_catalog_kind,
_oauth_provider_status,
create_model_configuration,
login_oauth_provider,
@@ -999,16 +998,9 @@ def test_provider_models_payload_requires_gateway_key(
payload = provider_models_payload({"provider": ["openrouter"]})
assert payload["status"] == "not_configured"
assert payload["catalog_kind"] == "catalog"
assert payload["models"] == []
def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
assert _model_catalog_kind(find_by_name("skywork")) == "official"
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
def test_create_model_configuration_accepts_configured_oauth_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
-1
View File
@@ -1586,7 +1586,6 @@ function Shell({
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
/>
</div>
{view !== "chat" && (
+3 -59
View File
@@ -74,7 +74,6 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
SlashCommand,
SkillSummary,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
@@ -160,7 +159,6 @@ interface ThreadComposerProps {
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
skills?: SkillSummary[];
onStop?: () => void;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */
@@ -774,7 +772,6 @@ export function ThreadComposer({
slashCommands = [],
cliApps = [],
mcpPresets = [],
skills = [],
onStop,
onTranscribeAudio,
runStartedAt = null,
@@ -912,19 +909,6 @@ export function ThreadComposer({
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
const skillQuery = useMemo(() => {
if (disabled || slashMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
if (!match) return null;
return {
end: caret,
start: match.index,
text: match[1].toLowerCase(),
};
}, [cursorPosition, disabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
const baseCommands = slashCommands.filter((command) => command.command !== "/stop");
if (!(isStreaming && onStop)) return baseCommands;
@@ -941,31 +925,6 @@ export function ThreadComposer({
}, [isStreaming, onStop, slashCommands]);
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
if (skillQuery !== null) {
const query = skillQuery.text;
return skills
.filter((skill) => skill.available)
.filter((skill) => {
const haystack = [
skill.name,
skill.description,
].join(" ").toLowerCase();
return haystack.includes(query);
})
.map((skill) => {
const command = `$${skill.name}`;
const description = skill.description || skill.name;
return {
command,
title: skill.name,
description,
detail: description,
icon: "brain",
recent: recentSlashCommands.includes(command),
};
})
.slice(0, 8);
}
if (slashQuery === null) return [];
const withDetails = visibleSlashCommands
.filter((command) => {
@@ -1030,7 +989,7 @@ export function ThreadComposer({
return withDetails
.slice(0, 8);
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, skills, skillQuery, slashQuery, t, visibleSlashCommands]);
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
@@ -1273,7 +1232,7 @@ export function ThreadComposer({
}, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]);
const chooseSlashCommand = useCallback(
(command: SlashPaletteCommand) => {
(command: SlashCommand) => {
if (command.command === "/stop" && isStreaming && onStop) {
onStop();
setValue("");
@@ -1291,28 +1250,13 @@ export function ThreadComposer({
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
if (skillQuery !== null) {
const suffix = value.slice(skillQuery.end);
const inserted = `${command.command}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, skillQuery.start)}${inserted}${suffix}`;
const nextCursor = skillQuery.start + inserted.length;
setValue(next);
setCursorPosition(nextCursor);
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(nextCursor, nextCursor);
});
} else {
setValue(command.argHint ? `${command.command} ` : command.command);
}
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
},
[isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
);
const chooseMentionCandidate = useCallback(
@@ -32,7 +32,6 @@ import type {
ChatSummary,
SettingsPayload,
SlashCommand,
SkillSummary,
UIMessage,
WorkspaceScopePayload,
WorkspacesPayload,
@@ -143,7 +142,6 @@ interface ThreadShellProps {
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
skills?: SkillSummary[];
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -294,7 +292,6 @@ export function ThreadShell({
onWorkspaceScopeChange,
settingsSnapshot = null,
onOpenModelSettings,
skills = [],
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -729,7 +726,6 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
runStartedAt={runStartedAt}
@@ -762,7 +758,6 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
runStartedAt={runStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={goalState}
-31
View File
@@ -120,7 +120,6 @@ const MCP_PRESETS: McpPresetInfo[] = [
connection_summary: "",
},
];
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices;
@@ -1115,36 +1114,6 @@ describe("ThreadComposer", () => {
});
});
it("opens skills only from a $ reference anywhere", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
skills={[{
name: "github",
description: "Work with pull requests and issues",
source: "builtin",
available: true,
}]}
slashCommands={COMMANDS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/git", selectionStart: 4 } });
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: "please use $git", selectionStart: 15 } });
const palette = screen.getByRole("listbox", { name: "Slash commands" });
expect(within(palette).getByRole("option", { name: /github/i })).toHaveTextContent("$github");
expect(within(palette).queryByText("/model")).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("please use $github ");
});
it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => {
render(
<ThreadComposer