mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8ec15223 | ||
|
|
c9534ef6f9 |
@@ -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:**
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+35
-35
@@ -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(
|
||||
content=delta,
|
||||
stream_id=_current_stream_id(),
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
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,
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
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.'
|
||||
)
|
||||
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]:
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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,55 +73,51 @@ class _PreparedCommand:
|
||||
login: bool
|
||||
|
||||
|
||||
_EXEC_TOOL_PARAMETERS = tool_parameters_schema(
|
||||
command=StringSchema("The shell command to execute"),
|
||||
working_dir=StringSchema("Optional working directory for the command"),
|
||||
timeout=IntegerSchema(
|
||||
60,
|
||||
description=(
|
||||
"Timeout in seconds. Increase for long-running commands "
|
||||
"like compilation or installation (default 60, max 600)."
|
||||
@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=(
|
||||
"Timeout in seconds. Increase for long-running commands "
|
||||
"like compilation or installation (default 60, max 600)."
|
||||
),
|
||||
minimum=1,
|
||||
maximum=600,
|
||||
),
|
||||
minimum=1,
|
||||
maximum=600,
|
||||
),
|
||||
shell=StringSchema(
|
||||
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
||||
nullable=True,
|
||||
),
|
||||
login=BooleanSchema(
|
||||
description="Whether to run bash/zsh with login shell semantics (default false).",
|
||||
default=False,
|
||||
nullable=True,
|
||||
),
|
||||
yield_time_ms=IntegerSchema(
|
||||
description=(
|
||||
"Optional milliseconds to wait before returning output. "
|
||||
"When set, a still-running command returns a session_id that "
|
||||
"can be polled or written to with write_stdin. Omit this field "
|
||||
"to keep one-shot exec behavior."
|
||||
shell=StringSchema(
|
||||
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
||||
nullable=True,
|
||||
),
|
||||
minimum=0,
|
||||
maximum=MAX_YIELD_MS,
|
||||
nullable=True,
|
||||
),
|
||||
max_output_chars=IntegerSchema(
|
||||
description=(
|
||||
"Maximum output characters to return when yield_time_ms is used "
|
||||
"(default 10000, max 50000)."
|
||||
login=BooleanSchema(
|
||||
description="Whether to run bash/zsh with login shell semantics (default false).",
|
||||
default=False,
|
||||
nullable=True,
|
||||
),
|
||||
minimum=1000,
|
||||
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(
|
||||
yield_time_ms=IntegerSchema(
|
||||
description=(
|
||||
"Optional milliseconds to wait before returning output. "
|
||||
"When set, a still-running command returns a session_id that "
|
||||
"can be polled or written to with write_stdin. Omit this field "
|
||||
"to keep one-shot exec behavior."
|
||||
),
|
||||
minimum=0,
|
||||
maximum=MAX_YIELD_MS,
|
||||
nullable=True,
|
||||
),
|
||||
max_output_chars=IntegerSchema(
|
||||
description=(
|
||||
"Maximum output characters to return when yield_time_ms is used "
|
||||
"(default 10000, max 50000)."
|
||||
),
|
||||
minimum=1000,
|
||||
maximum=MAX_OUTPUT_CHARS,
|
||||
nullable=True,
|
||||
),
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+15
-12
@@ -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,
|
||||
content=content,
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+17
-37
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
channel="websocket",
|
||||
chat_id="*",
|
||||
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
|
||||
)
|
||||
)
|
||||
bus.outbound.put_nowait(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="*",
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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] = {}
|
||||
|
||||
@@ -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,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
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except BaseException:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
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,
|
||||
)
|
||||
)
|
||||
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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", "
|
||||
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", "
|
||||
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",
|
||||
"",
|
||||
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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
)()
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1586,7 +1586,6 @@ function Shell({
|
||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user