Compare commits

...
Author SHA1 Message Date
chengyongru 9d6ace16ef fix(tools): hide exec compatibility aliases from schema 2026-07-02 11:54:36 +08:00
Xubin Ren c78421cf16 fix(bus): preserve legacy outbound metadata events 2026-07-01 20:17:00 +08:00
Xubin Ren 03be51ade5 fix(channels): preserve legacy stream hook signatures 2026-07-01 20:17:00 +08:00
chengyongruandXubin Ren c757c5466c docs: update channel plugin runtime event contract 2026-07-01 20:17:00 +08:00
chengyongruandXubin Ren 5f4cfbcb16 refactor(bus): type outbound runtime events 2026-07-01 20:17:00 +08:00
chengyongruandXubin Ren f6d1dba32a fix(cron): tolerate unsupported directory fsync 2026-07-01 19:51:43 +08:00
2ec4044217 feat(webui): add dollar skill shortcuts
Add a WebUI-only $<skill> completion shortcut without changing slash command behavior.

Keep slash autocomplete command-only and allow dollar skill shortcuts anywhere in the composer.

Co-authored-by: Alan Chen <zc2610@nyu.edu>
2026-07-01 19:51:01 +08:00
Xubin Ren a6d5e4f3b5 docs(api): document wildcard bind authentication 2026-07-01 13:09:49 +08:00
chengyongruandXubin Ren ed48325346 fix: cover API auth guard regressions
Maintainer edit: restore CI by updating serve/onboard tests, add auth/config coverage, and keep auth failures on the OpenAI-compatible error shape.
2026-07-01 13:09:49 +08:00
dajiaohuangandXubin Ren 56443ac6e2 @
feat(api): require api_key when binding to all interfaces (parity with WS gateway)

The OpenAI-compatible API server had no authentication option, unlike the
WebSocket gateway which already refuses wildcard binds without a token.
When bound to 0.0.0.0, any caller who could reach the port could drive
the agent with its default tool posture.

- Add api_key field to ApiConfig (schema.py).
- Add wildcard_host_requires_auth validator that rejects wildcard binds
  without api_key, mirroring the WS gateway pattern.
- Add Bearer-token auth middleware to the API server (server.py).
  /health remains unauthenticated.
- Replace the wildcard-host CLI warning with a hard error when api_key
  is unset, and pass api_key to create_app.

Fixes #4490
@
2026-07-01 13:09:49 +08:00
chengyongruandXubin Ren 21aa900d64 fix: honor MCP tool error results 2026-07-01 13:03:47 +08:00
chengyongruandXubin Ren b0258e8b20 fix: preserve legacy plugin tool errors 2026-07-01 13:03:47 +08:00
chengyongruandXubin Ren 8493560976 refactor(tools): use structured tool error results 2026-07-01 13:03:47 +08:00
chengyongruandXubin Ren 8d2c31eb6a refactor(webui): derive provider model catalog kind 2026-07-01 12:59:06 +08:00
chengyongruandXubin Ren a6a489e0fa refactor: tighten session recency cleanup
maintainer edit: remove defensive branches that normal session storage cannot produce and keep the idle-expiry helper direct.
2026-06-30 23:38:32 +08:00
chengyongruandXubin Ren 840ba5af33 fix: simplify session recency activity tracking
maintainer edit: remove the _last_compacted_at maintenance state, gate idle compaction on whether a session still has a removable tail, and sort WebUI sessions by the latest visible transcript activity.
2026-06-30 23:38:32 +08:00
chengyongruandXubin Ren 3403b87641 fix(webui): keep idle compaction out of session recency 2026-06-30 23:38:32 +08:00
hamb1yandXubin Ren bfbae5a7b3 fix(cli): refresh oauth provider default models 2026-06-30 23:02:42 +08:00
hamb1yandXubin Ren 58cce14a07 fix(cli): allow oauth login to set main provider 2026-06-30 23:02:42 +08:00
Xubin Ren f9b02496c8 fix(mcp): redact URL paths in logs 2026-06-30 22:43:12 +08:00
Xubin Ren bfc2a74e4f fix(mcp): preserve IPv6 brackets when redacting URLs 2026-06-30 22:43:12 +08:00
xiaweiwei67-stackandXubin Ren 780093d037 fix(mcp): redact credentials from URLs before logging
MCP server URLs can carry secrets in userinfo
(`https://user:token@host/sse`) or a query string (`?token=...`). A few
connect/validate paths logged the raw `cfg.url` / `request.url`, so those
secrets could land in log files that are often shared or aggregated.

Add a small `_redact_url()` helper that keeps only scheme/host/port/path
and use it at the four sites that log a server or request URL. Logging
only; no other behavior changes.
2026-06-30 22:43:12 +08:00
Xubin Ren 1873e948c3 test(weixin): cover streamed reply retry buffer 2026-06-30 22:43:03 +08:00
735a243849 fix(weixin): keep stream buffer until send succeeds so retries can re-deliver
send_delta popped the buffer before self.send ran, so a transient WeChat
send failure dropped the completed streamed reply: ChannelManager
_send_with_retry re-invokes the same _stream_end message, but the buffer
was already gone, so the retry sent empty content and returned — turning a
delivery retry into silent message loss.

Build `full` from the buffer without popping, send, then clear only after a
successful send. The _stream_end message's own content (set when the manager
coalesces deltas into the end message) is folded into `full` via addition
rather than appended to the buffer, so a retry recomputes the same `full`
from an unchanged buffer instead of double-counting it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:43:03 +08:00
edada598c8 fix(weixin): stream LLM calls + buffer reply delivery to dodge non-stream relay bug
WeixinConfig lacked a streaming field, so channels.weixin.streaming was
silently dropped by pydantic and supports_streaming stayed False, forcing the
non-streaming Messages API. Some upstream Anthropic relays drop tool_use
id/name/input on the non-stream path (but handle SSE fine), breaking WeChat
tool calls.

Two parts:
1. Add a streaming field (default True) so WeChat routes LLM calls through the
   streaming API. WeChat iLink has no native incremental delivery, so this is
   user-invisible — it only changes how the LLM is called.
2. WeChat send_delta previously dropped content, and the manager bypasses send
   for the _streamed final answer, so a streamed reply never reached the user.
   send_delta now buffers content deltas and flushes the full reply in one shot
   at _stream_end (also stopping the typing indicator via send).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:43:03 +08:00
yu-xin-candXubin Ren 2527ce5de9 test(exec): cover bwrap sandbox mounts 2026-06-30 22:34:43 +08:00
chengyongruandXubin Ren 44a5ed1bc0 feat(providers): support provider-scoped proxy config 2026-06-30 17:33:36 +08:00
chengyongruandXubin Ren 4c0e9b9f46 test: cover WhatsApp read receipts
maintainer edit: add focused coverage for the new best-effort mark_read path and remove an unused helper argument.
2026-06-30 15:21:25 +08:00
franciscomaestreandXubin Ren 839d1ecfb1 feat(whatsapp): send read receipts (blue double-check) for incoming messages
Mark each processed incoming WhatsApp message as read via neonize's
mark_read(receipt=ReceiptType.READ), so senders see the blue double-check.

The receipt is sent right after the message passes dedup and is best-effort:
any failure is logged at debug level and swallowed, so it never blocks or
breaks message handling.
2026-06-30 15:21:25 +08:00
chengyongruandXubin Ren 593b328dbb test: cover Copilot enterprise overrides
Maintainer edit: add mocked coverage for the enterprise endpoint and client ID override paths, and document the environment variables users must set before OAuth login.
2026-06-30 15:21:19 +08:00
04cbandXubin Ren 4beca25ceb feat(providers): allow GitHub Copilot endpoint overrides for enterprise/GHE (#4220) 2026-06-30 15:21:19 +08:00
chengyongruandXubin Ren 82ffce1474 docs: move restart mode docs to gateway config 2026-06-30 15:21:14 +08:00
chengyongruandXubin Ren 4726ca0478 fix(restart): add explicit restart mode 2026-06-30 15:21:14 +08:00
chengyongruandXubin Ren d979597361 fix(install): skip wizard without an interactive terminal 2026-06-30 15:21:08 +08:00
axelray-devandXubin Ren 070aed8ade fix(streaming): skip non-file-edit tools in apply_final_call_ids to prevent id corruption
apply_final_call_ids iterated over all final tool calls, including
non-file-edit tools like read_file. The greedy path-match in
matches_final_tool_call could overwrite a correct unique id with a
stale one from a different streaming state, producing duplicate
tool_use ids that poison the persisted session.

Guard the loop with is_file_edit_tool() so only tracked file-edit
tools (write_file, edit_file, apply_patch) are subject to canonical
id remapping. Non-file-edit tools keep their authoritative id from
get_final_message().

Fixes #4595
2026-06-30 15:21:02 +08:00
Xubin RenandGitHub 8df100203c feat(webui): refine prompt rail minimap 2026-06-30 10:01:53 +08:00
axelray-devandXubin Ren 8fa9eed6a8 refactor(session): trim RetentionResult to only fields callers read
Remove retained and new_last_consolidated from RetentionResult.
Both were populated but never read by any caller. The authoritative
state remains self.messages and self.last_consolidated, which the
method mutates in place. Update docstring accordingly.
2026-06-29 14:24:00 +08:00
axelray-devandXubin Ren 5692f7a68a refactor(session): return RetentionResult instead of bare tuple
Replace the tuple(list[dict], int) return of
Session.retain_recent_legal_suffix with a named RetentionResult
dataclass that exposes retained, dropped,
already_consolidated_count, and new_last_consolidated fields.

The tuple return was easy to misuse because the second value only
made sense relative to the first and the old last_consolidated
cursor. The named fields make the archive-skip semantics explicit
at every call site.

No behavior change. All existing tests pass unchanged in semantics.

Refs #4136

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-29 14:24:00 +08:00
chengyongruandXubin Ren 57f0c859fc refactor(context): trim replay cap plumbing 2026-06-29 14:23:55 +08:00
chengyongruandXubin Ren 40282e3b74 fix(context): scale replay cap with context window 2026-06-29 14:23:55 +08:00
chengyongruandXubin Ren dacc699293 fix(config): retire max messages setting 2026-06-29 14:23:55 +08:00
chengyongruandXubin Ren c8638dee46 fix(context): raise max messages fallback cap
Treat max_messages as a last-resort replay guard now that consolidation and idle auto-compact own normal history reduction. Raising the default avoids frequent sliding-window prefix churn in moderate conversations without adding a new cache-policy knob.
2026-06-29 14:23:55 +08:00
Xubin Ren 7dc45ff94e chore: tighten malformed tool-call guard wording 2026-06-28 19:46:59 +08:00
8248d075db fix(agent): harden tool-call handling against malformed upstream relays
Combine malformed tool-call handling with placeholder filtering and a
no-tools fallback so a relay that returns tool_use blocks with null
id/name/input can no longer crash a turn or permanently wedge a session.

Adapted to the ContextGovernor architecture (context governance now lives
in nanobot/agent/context_governance.py, not runner.py):

- ToolCallRequest.has_valid_name(): single source of truth for "usable
  name" (non-empty string).
- tool_hints.format_tool_hints(): skip tool calls with a non-string/empty
  name instead of raising AttributeError on the whole turn.
- ContextGovernor.strip_placeholder_assistant_messages() and
  strip_malformed_tool_calls() (plus the _tool_call_name_is_valid helper):
  history-cleaning staticmethods invoked at the START of
  prepare_for_model() — strip_placeholder, then strip_malformed, then the
  existing drop_orphan/backfill chain. Both only repair the model-facing
  copy and leave persisted history untouched (return a copy, or the same
  list when nothing changes). Also wired into runner's minimal-repair path.
- AgentRunner._drop_malformed_tool_calls(): returns
  (dropped, all_dropped, original_finish_reason); clears finish_reason to
  "stop" when all calls are dropped.
- AgentRunner._malformed_tool_call_retry_messages() + _request_model
  malformed_retry flag: when an all-dropped tool_calls response comes back,
  retry once with a corrective note; if the retry STILL comes back
  all-dropped, fall back to _request_no_tools for graceful text degradation.

Tests for the history-cleaning methods live with ContextGovernor in
tests/agent/test_runner_governance.py; response-layer and tool-hint tests
stay on AgentRunner / tool_hints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:46:59 +08:00
Xubin Ren d7152cdbdd style: format legacy session repair test 2026-06-28 19:46:55 +08:00
axelray-devandXubin Ren 89dc34df88 fix(session): repair corrupt legacy-stem files in list_sessions
list_sessions() silently dropped corrupt session files whose filename
stem was a legacy non-base64 name (e.g. telegram_12345.jsonl from the
old lossy path scheme). The repair path called _repair(fallback_key),
but _repair re-encodes the key via _storage_key(), producing a
different base64 filename that never matches the actual file on disk.

Add an optional path parameter to _repair so callers can pass the
actual file path directly, bypassing the key-to-filename round trip.

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-28 19:46:55 +08:00
codedragonandXubin Ren 67ce6822ca feat(mcp): deliver image content from MCP tools as artifacts
MCPToolWrapper.execute only handled TextContent; every other block was
rendered with str(block). An MCP ImageContent block therefore became a
large base64 string embedded in the tool result, which (a) was truncated
by max_tool_result_chars, corrupting the data, and (b) could never reach a
channel because it was plain text, not an image artifact.

Decode ImageContent (and EmbeddedResource blobs with an image/* MIME type)
and persist them via store_generated_image_artifact, returning the same
compact {artifacts, next_step} JSON the built-in image_generation tool
produces. The base64 stays out of the model context; the model delivers the
saved file via the message tool's media parameter.
2026-06-28 19:46:51 +08:00
axelray-devandXubin Ren 194e9d5f5f fix(webui): clear stale run status on reconnect 2026-06-28 19:46:47 +08:00
axelray-devandXubin Ren 5005bca353 fix(webui): clear stuck streaming after reconnect and improve stop reliability
After a gateway restart or websocket reconnect, the UI stays stuck in
processing state because reconnecting clients only replay running status
when a turn is active, never push idle when no turn is running.

Fix _hydrate_after_subscribe to always push goal_status (running with
started_at when turn is active, idle when no turn is running) so the
frontend can reset its processing indicator on reconnect.

Also fix cmd_stop reporting 'No active task to stop' when a task is
actually processing by draining the pending injection queue in addition
to cancelling active tasks. This prevents mid-turn injection deadlocks
and gives accurate task counts.
2026-06-28 19:46:47 +08:00
yorkhellenandXubin Ren e5dbb15c34 fix(cron): guard public APIs against unavailable store 2026-06-28 19:46:44 +08:00
Xubin Ren c90e433057 fix(session): guard lossy migration by stored key 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren 3ce77633c0 fix(session): add _decode_storage_key for corrupt-file repair in list_sessions 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren 00a907c493 fix(session): split safe_key and _storage_key to fix WebUI coupling (#4533) 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren cf2f589615 fix(session): prevent save from writing to legacy lossy path, add collision tests (#4533) 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren 463f536750 fix: prevent session key collision on disk (#4057)
safe_key() replaces ':' with '_', causing collisions between distinct
keys (e.g. telegram:a_b vs telegram:a:b both become telegram_a_b).

Use base64url (no padding) for collision-resistant encoding while
maintaining backward compatibility: _get_session_path and
_get_legacy_session_path check the new path first, then fall back
to the old lossy encoding for existing session files.
2026-06-27 16:52:54 +08:00
Xubin Ren 00a7de0171 fix: stringify Anthropic typeless blocks as JSON 2026-06-27 16:47:59 +08:00
efb792ff24 fix: validate content block type in Anthropic assistant blocks (#4060)
_assistant_blocks appends dict items from content lists directly
without checking for the required 'type' field. A block like
{'text': 'hi'} reaches the Anthropic payload without a 'type',
causing a 400 rejection.

Add the same missing-type check that _convert_user_content already has,
so bare dicts in assistant content lists are coerced to text blocks
instead of triggering API validation errors.

Co-authored-by: nanobot-issues <issues@nanobot.dev>
2026-06-27 16:47:59 +08:00
Xubin Ren d8601478db test: cover stream-id delta coalescing 2026-06-27 16:47:53 +08:00
axelray-devandXubin Ren 66fc54421c fix: include _stream_id in stream delta coalescing key (#4063)
ChannelManager coalesces _stream_delta messages by (channel, chat_id)
only. Overlapping streams in the same chat can be merged incorrectly
because deltas from distinct _stream_id values share one buffer.

Include _stream_id in the coalescing key so distinct streams in the
same channel and chat are delivered separately.
2026-06-27 16:47:53 +08:00
Xubin Ren 6a27c26257 test: cover non-stream duplicate tool call ids 2026-06-27 16:47:48 +08:00
axelray-devandXubin Ren 3ca82ea880 fix: deduplicate tool call IDs in non-stream parser (#4059)
Duplicate tool call ID normalization exists in the streaming parser path
but is not shared with the non-stream parser. Non-stream parsing appends
raw provider IDs into ToolCallRequest objects without deduplication.

Some OpenAI-compatible providers reuse the same tool_call_id for parallel
tool calls in non-streaming responses. Without dedup, runner executes
both tools with the same ID, producing duplicate tool results with the
same tool_call_id, which can fail strict provider validation.

Add the same _seen_tc_ids dedup pattern used in _parse_chunks to the
_parse method so both paths handle duplicate IDs consistently.
2026-06-27 16:47:48 +08:00
r4sk1nandXubin Ren 47dcc61e9b test(agent): fix flaky test_keeps_n_most_recent by ensuring sequential mtimes 2026-06-27 16:29:31 +08:00
141 changed files with 5589 additions and 1378 deletions
+1
View File
@@ -107,6 +107,7 @@ File operations have path traversal protection, but:
**API Calls:** **API Calls:**
- All external API calls use HTTPS by default - All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests - 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 - Consider using a firewall to restrict outbound connections if needed
**WhatsApp:** **WhatsApp:**
+60 -42
View File
@@ -103,7 +103,8 @@ class WebhookChannel(BaseChannel):
msg.content — markdown text (convert to platform format as needed) msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message) msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — may contain "_progress": True for streaming chunks msg.metadata — channel routing context such as message/thread ids
msg.event — typed runtime event for progress/status messages
""" """
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80]) logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
# In a real plugin: POST to a callback URL, send via SDK, etc. # In a real plugin: POST to a callback URL, send via SDK, etc.
@@ -238,15 +239,15 @@ nanobot channels login <channel_name> --force # re-authenticate
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. | | `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. | | `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. | | `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?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. | | `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?)` | Optional hook marking the end of a reasoning block. 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(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. | | `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional (streaming) ### Optional (streaming)
| Method | Description | | Method | Description |
|--------|-------------| |--------|-------------|
| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. | | `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. |
### Message Types ### Message Types
@@ -257,10 +258,12 @@ class OutboundMessage:
chat_id: str # recipient (same value you passed to _handle_message) chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs) media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # may contain: "_progress" (bool) for streaming chunks, metadata: dict # channel routing context, e.g. "message_id" for threading
# "message_id" for reply threading event: object | None # typed runtime/UI event; usually inspect with isinstance()
``` ```
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 ## 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. 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.
@@ -279,10 +282,18 @@ If either is missing, the agent falls back to the normal one-shot `send()` path.
Override `send_delta` to handle two types of calls: Override `send_delta` to handle two types of calls:
```python ```python
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: async def send_delta(
meta = metadata or {} self,
chat_id: str,
if meta.get("_stream_end"): 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:
# Streaming finished — do final formatting, cleanup, etc. # Streaming finished — do final formatting, cleanup, etc.
return return
@@ -290,12 +301,7 @@ async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] |
# delta contains a small chunk of text (a few tokens) # delta contains a small chunk of text (a few tokens)
``` ```
**Metadata flags:** 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.
| 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 ### Example: Webhook with Streaming
@@ -310,18 +316,27 @@ class WebhookChannel(BaseChannel):
super().__init__(config, bus) super().__init__(config, bus)
self._buffers: dict[str, str] = {} self._buffers: dict[str, str] = {}
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: async def send_delta(
meta = metadata or {} self,
if meta.get("_stream_end"): chat_id: str,
text = self._buffers.pop(chat_id, "") 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, "")
# Final delivery — format and send the complete message # Final delivery — format and send the complete message
await self._deliver(chat_id, text, final=True) await self._deliver(chat_id, text, final=True)
return return
self._buffers.setdefault(chat_id, "") self._buffers.setdefault(buffer_key, "")
self._buffers[chat_id] += delta self._buffers[buffer_key] += delta
# Incremental update — push partial text to the client # Incremental update — push partial text to the client
await self._deliver(chat_id, self._buffers[chat_id], final=False) await self._deliver(chat_id, self._buffers[buffer_key], final=False)
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
# Non-streaming path — unchanged # Non-streaming path — unchanged
@@ -350,7 +365,7 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
| Method / Property | Description | | Method / Property | Description |
|-------------------|-------------| |-------------------|-------------|
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. | | `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. | | `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
## Progress, Tool Hints, and Reasoning ## Progress, Tool Hints, and Reasoning
@@ -359,18 +374,20 @@ Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These
### Progress and Tool Hints ### Progress and Tool Hints
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering: Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
```python ```python
async def send(self, msg: OutboundMessage) -> None: from nanobot.bus.outbound_events import ProgressEvent
meta = msg.metadata or {}
if meta.get("_tool_hint"): async def send(self, msg: OutboundMessage) -> None:
event = msg.event
if isinstance(event, ProgressEvent) and event.tool_hint:
# A short tool breadcrumb, e.g. read_file("config.json") # A short tool breadcrumb, e.g. read_file("config.json")
await self._send_trace(msg.chat_id, msg.content, kind="tool") await self._send_trace(msg.chat_id, msg.content, kind="tool")
return return
if meta.get("_progress"): if isinstance(event, ProgressEvent):
# Generic non-final status, e.g. "Thinking..." or "Running command..." # Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress") await self._send_trace(msg.chat_id, msg.content, kind="progress")
return return
@@ -412,32 +429,33 @@ class WebhookChannel(BaseChannel):
chat_id: str, chat_id: str,
delta: str, delta: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
meta = metadata or {} buffer_key = stream_id or chat_id
stream_id = str(meta.get("_stream_id") or chat_id) self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
async def send_reasoning_end( async def send_reasoning_end(
self, self,
chat_id: str, chat_id: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
meta = metadata or {} buffer_key = stream_id or chat_id
stream_id = str(meta.get("_stream_id") or chat_id) text = self._reasoning_buffers.pop(buffer_key, "")
text = self._reasoning_buffers.pop(stream_id, "")
if text: if text:
await self._update_reasoning_block(chat_id, text, final=True) await self._update_reasoning_block(chat_id, text, final=True)
``` ```
**Reasoning metadata flags:** **Reasoning arguments:**
| Flag | Meaning | | Argument | Meaning |
|------|---------| |------|---------|
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. | | `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. | | `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. | | `send_reasoning_end()` | The current reasoning block is complete. |
| `_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: Reasoning visibility is controlled by `showReasoning` globally or per channel:
+36 -5
View File
@@ -240,6 +240,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default. > - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config. > - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`. > - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
|----------|---------|-------------| |----------|---------|-------------|
@@ -632,20 +633,37 @@ nanobot agent -m "Reply with one short sentence."
<details> <details>
<summary><b>OpenAI Codex (OAuth)</b></summary> <summary><b>OpenAI Codex (OAuth)</b></summary>
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. `nanobot provider login` stores the OAuth session outside config. A `providers.openai_codex` block is optional and is only needed for provider-specific settings such as a proxy.
**1. Login:** **1. Login:**
```bash ```bash
nanobot provider login openai-codex nanobot provider login openai-codex
``` ```
**2. Set model** (merge into `~/.nanobot/config.json`): If the machine running nanobot cannot open a graphical browser, copy the printed URL into a real browser. For remote SSH login, open the URL locally, then paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted.
**2. Optional proxy** (merge into `~/.nanobot/config.json` if Codex OAuth or Codex API traffic must use a proxy):
```json
{
"providers": {
"openai_codex": {
"proxy": "http://127.0.0.1:7890"
}
}
}
```
The proxy applies to Codex OAuth token refresh, interactive token exchange, and Codex Responses API requests. It does not affect other providers; configure `proxy` separately on each supported provider that needs it.
**3. Set model** (merge into `~/.nanobot/config.json`):
```json ```json
{ {
"modelPresets": { "modelPresets": {
"codex": { "codex": {
"provider": "openai_codex", "provider": "openai_codex",
"model": "openai-codex/gpt-5.1-codex" "model": "gpt-5.1-codex",
"reasoningEffort": "high"
} }
}, },
"agents": { "agents": {
@@ -656,7 +674,9 @@ nanobot provider login openai-codex
} }
``` ```
**3. Chat:** Use `reasoningEffort` in the preset to send a Codex reasoning effort such as `"low"`, `"medium"`, `"high"`, or another value supported by the selected model. When `provider` is explicitly `openai_codex`, the model name does not need the `openai-codex/` prefix.
**4. Chat:**
```bash ```bash
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
@@ -675,7 +695,17 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
<details> <details>
<summary><b>GitHub Copilot (OAuth)</b></summary> <summary><b>GitHub Copilot (OAuth)</b></summary>
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
```bash
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
export NANOBOT_GITHUB_DEVICE_CODE_URL="https://ghe.example/login/device/code"
export NANOBOT_GITHUB_ACCESS_TOKEN_URL="https://ghe.example/login/oauth/access_token"
export NANOBOT_GITHUB_USER_URL="https://api.ghe.example/user"
export NANOBOT_COPILOT_TOKEN_URL="https://api.ghe.example/copilot_internal/v2/token"
export NANOBOT_COPILOT_BASE_URL="https://copilot-api.ghe.example"
```
**1. Login:** **1. Login:**
```bash ```bash
@@ -1974,6 +2004,7 @@ The heartbeat job is backed by the same cron service as user-created reminders.
| `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. | | `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. |
| `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. | | `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. |
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. | | `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
## Subagent Concurrency ## Subagent Concurrency
+26
View File
@@ -12,6 +12,32 @@ 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). 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 ## Behavior
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`) - Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
+29
View File
@@ -61,9 +61,12 @@ These fields answer different questions:
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. | | `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. | | `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. | | `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`. You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
## Common Provider Patterns ## Common Provider Patterns
### OpenRouter Gateway ### OpenRouter Gateway
@@ -422,6 +425,32 @@ nanobot provider login github-copilot
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks. Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
For OpenAI Codex, add `providers.openai_codex.proxy` only when Codex OAuth/token refresh or Codex API requests must use a proxy:
```json
{
"providers": {
"openai_codex": {
"proxy": "http://127.0.0.1:7890"
}
},
"modelPresets": {
"codex": {
"provider": "openai_codex",
"model": "gpt-5.1-codex",
"reasoningEffort": "high"
}
},
"agents": {
"defaults": {
"modelPreset": "codex"
}
}
}
```
If you run the login command on a remote/headless machine and open the authorization URL in a local browser, paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted. See [`configuration.md#providers`](./configuration.md#providers) for the full OAuth provider notes.
## Provider Resolution ## Provider Resolution
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from: The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
+22 -1
View File
@@ -34,6 +34,26 @@ class AutoCompact:
ts = datetime.fromisoformat(ts) ts = datetime.fromisoformat(ts)
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60 return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
def _has_compactable_idle_tail(self, key: str) -> bool:
session = self.sessions.get_or_create(key)
tail = list(session.messages[session.last_consolidated:])
if not tail:
return False
probe = Session(
key=session.key,
messages=tail,
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
result = probe.retain_recent_legal_suffix(
self._RECENT_SUFFIX_MESSAGES,
extend_to_user=True,
)
messages_to_remove = result.dropped[result.already_consolidated_count:]
return bool(messages_to_remove)
@staticmethod @staticmethod
def _format_summary(text: str, last_active: datetime) -> str: def _format_summary(text: str, last_active: datetime) -> str:
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}" return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
@@ -52,7 +72,8 @@ class AutoCompact:
continue continue
if key in active_session_keys: if key in active_session_keys:
continue continue
if self._is_expired(info.get("updated_at"), now): updated_at = info.get("updated_at")
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
self._archiving.add(key) self._archiving.add(key)
schedule_background(self._archive(key)) schedule_background(self._archive(key))
+113 -1
View File
@@ -36,6 +36,23 @@ COMPACTABLE_TOOLS = frozenset({
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops. # read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"}) TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]" BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
PLACEHOLDER_TEXTS = frozenset({
"[Previous assistant message omitted.]",
})
def _tool_call_name_is_valid(tool_call: Any) -> bool:
"""Whether a persisted OpenAI-style tool_call carries a usable name.
Mirrors ``ToolCallRequest.has_valid_name`` for the dict shape stored in
message history: a degenerate call with ``name=None`` / ``""`` cannot be
executed and is rejected by upstream APIs if replayed.
"""
if not isinstance(tool_call, dict):
return False
fn = tool_call.get("function")
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
return isinstance(name, str) and bool(name)
@dataclass(slots=True) @dataclass(slots=True)
@@ -61,7 +78,9 @@ class ContextGovernor:
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str], compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
updated = self.drop_orphan_tool_results(messages) updated = self.strip_placeholder_assistant_messages(messages)
updated = self.strip_malformed_tool_calls(updated)
updated = self.drop_orphan_tool_results(updated)
updated = self.backfill_missing_tool_results(updated) updated = self.backfill_missing_tool_results(updated)
updated = self.apply_tool_result_budget(config, updated) updated = self.apply_tool_result_budget(config, updated)
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids) updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
@@ -116,6 +135,99 @@ class ContextGovernor:
return truncate_text(content, config.max_tool_result_chars) return truncate_text(content, config.max_tool_result_chars)
return content return content
@staticmethod
def strip_placeholder_assistant_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Remove assistant messages that are compaction placeholders.
Messages like ``[Previous assistant message omitted.]`` carry no useful
context for the model and can cause it to repeatedly attempt tool calls
that previously failed, producing malformed responses in a loop.
Consecutive same-role messages that result from removal are handled
downstream by the provider's merge-consecutive logic. Only the
model-facing copy is repaired; the persisted transcript is untouched
(a copy is returned, or the same list object when nothing changes).
"""
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
if msg.get("role") != "assistant":
if updated is not None:
updated.append(msg)
continue
content = msg.get("content", "")
text = content if isinstance(content, str) else ""
is_placeholder = text.strip() in PLACEHOLDER_TEXTS
has_tool_calls = bool(msg.get("tool_calls"))
if is_placeholder and not has_tool_calls:
if updated is None:
updated = list(messages[:idx])
logger.debug(
"Stripping placeholder assistant message from history: {!r}",
text[:60],
)
continue
if updated is not None:
updated.append(msg)
if updated is None:
return messages
return updated
@staticmethod
def strip_malformed_tool_calls(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop persisted assistant tool_calls whose name is missing/non-string.
A degenerate tool call (``name=None`` or ``""``) that slipped into the
saved history before this guard existed gets replayed on every turn and
makes upstream APIs reject the whole request
(``messages.content.N.tool_use.name: Input should be a valid string``),
permanently wedging the session. Removing the bad call here lets the
existing orphan-result cleanup drop its now-dangling tool result, so a
polluted session self-heals on its next turn. The persisted transcript
is left untouched; only the model-facing copy is repaired (a copy is
returned, or the same list object when nothing changes).
"""
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
if msg.get("role") != "assistant":
if updated is not None:
updated.append(msg)
continue
calls = msg.get("tool_calls")
if not calls:
if updated is not None:
updated.append(msg)
continue
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
if len(kept) == len(calls):
if updated is not None:
updated.append(msg)
continue
if updated is None:
updated = [dict(m) for m in messages[:idx]]
logger.warning(
"Stripping {} malformed tool_call(s) with missing/non-string "
"name from assistant history before request",
len(calls) - len(kept),
)
repaired = dict(msg)
if kept:
repaired["tool_calls"] = kept
else:
repaired.pop("tool_calls", None)
# An assistant turn with neither content nor any valid tool call is
# itself invalid upstream; drop it entirely in that case.
has_content = bool(repaired.get("content"))
if not kept and not has_content:
continue
updated.append(repaired)
if updated is None:
return messages
return updated
@staticmethod @staticmethod
def drop_orphan_tool_results( def drop_orphan_tool_results(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
+47 -26
View File
@@ -31,6 +31,13 @@ from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage 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.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import ( from nanobot.bus.runtime_events import (
@@ -57,7 +64,11 @@ from nanobot.session.goal_state import (
sustained_goal_active, sustained_goal_active,
) )
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import (
Session,
SessionManager,
replay_max_messages_for_context,
)
from nanobot.utils.document import extract_documents, reference_non_image_attachments from nanobot.utils.document import extract_documents, reference_non_image_attachments
from nanobot.utils.helpers import image_placeholder_text from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn from nanobot.utils.helpers import truncate_text as truncate_text_fn
@@ -201,7 +212,6 @@ class AgentLoop:
timezone: str | None = None, timezone: str | None = None,
session_ttl_minutes: int = 0, session_ttl_minutes: int = 0,
consolidation_ratio: float = 0.5, consolidation_ratio: float = 0.5,
max_messages: int = 120,
hooks: list[AgentHook] | None = None, hooks: list[AgentHook] | None = None,
unified_session: bool = False, unified_session: bool = False,
disabled_skills: list[str] | None = None, disabled_skills: list[str] | None = None,
@@ -215,6 +225,7 @@ class AgentLoop:
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None, preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
runtime_events: RuntimeEventBus | None = None, runtime_events: RuntimeEventBus | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None, runtime_model_publisher: Callable[[str, str | None], None] | None = None,
restart_mode: str = "auto",
): ):
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
@@ -224,6 +235,7 @@ class AgentLoop:
self.runtime_events = runtime_events or RuntimeEventBus() self.runtime_events = runtime_events or RuntimeEventBus()
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events) self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
self.channels_config = channels_config self.channels_config = channels_config
self.restart_mode = restart_mode
self.provider = provider self.provider = provider
self._provider_snapshot_loader = provider_snapshot_loader self._provider_snapshot_loader = provider_snapshot_loader
self._preset_snapshot_loader = preset_snapshot_loader self._preset_snapshot_loader = preset_snapshot_loader
@@ -292,7 +304,7 @@ class AgentLoop:
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk), llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
) )
self._unified_session = unified_session self._unified_session = unified_session
self._max_messages = max_messages if max_messages > 0 else 120 self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
self._running = False self._running = False
self._mcp_servers = mcp_servers or {} self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, AsyncExitStack] = {} self._mcp_stacks: dict[str, AsyncExitStack] = {}
@@ -390,10 +402,10 @@ class AgentLoop:
disabled_skills=defaults.disabled_skills, disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes, session_ttl_minutes=defaults.session_ttl_minutes,
consolidation_ratio=defaults.consolidation_ratio, consolidation_ratio=defaults.consolidation_ratio,
max_messages=defaults.max_messages,
tools_config=config.tools, tools_config=config.tools,
model_presets=preset_helpers.configured_model_presets(config), model_presets=preset_helpers.configured_model_presets(config),
model_preset=defaults.model_preset, model_preset=defaults.model_preset,
restart_mode=config.gateway.restart_mode,
provider_snapshot_loader=provider_snapshot_loader, provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader, preset_snapshot_loader=preset_snapshot_loader,
**extra, **extra,
@@ -421,6 +433,7 @@ class AgentLoop:
self.runner.provider = provider self.runner.provider = provider
self.subagents.set_provider(provider, model) self.subagents.set_provider(provider, model)
self.consolidator.set_provider(provider, model, context_window_tokens) self.consolidator.set_provider(provider, model, context_window_tokens)
self._sync_replay_max_messages()
self._provider_signature = snapshot.signature self._provider_signature = snapshot.signature
if publish_update and self._runtime_model_publisher is not None: if publish_update and self._runtime_model_publisher is not None:
self._runtime_model_publisher( self._runtime_model_publisher(
@@ -434,6 +447,9 @@ class AgentLoop:
) )
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model) logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
def _sync_replay_max_messages(self) -> None:
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
def _refresh_provider_snapshot(self) -> None: def _refresh_provider_snapshot(self) -> None:
if self._provider_snapshot_loader is None: if self._provider_snapshot_loader is None:
return return
@@ -558,14 +574,12 @@ class AgentLoop:
"""Build a retry-wait callback that publishes to the message bus.""" """Build a retry-wait callback that publishes to the message bus."""
async def _on_retry_wait(content: str) -> None: async def _on_retry_wait(content: str) -> None:
meta = dict(msg.metadata or {})
meta["_retry_wait"] = True
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=content, event=RetryWaitEvent(content=content),
metadata=meta, metadata=msg.metadata,
) )
) )
@@ -990,26 +1004,31 @@ class AgentLoop:
return f"{stream_base_id}:{stream_segment}" return f"{stream_base_id}:{stream_segment}"
async def on_stream(delta: str) -> None: async def on_stream(delta: str) -> None:
meta = dict(msg.metadata or {}) await self.bus.publish_outbound(
meta["_stream_delta"] = True outbound_message_for_event(
meta["_stream_id"] = _current_stream_id() channel=msg.channel,
await self.bus.publish_outbound(OutboundMessage( chat_id=msg.chat_id,
channel=msg.channel, chat_id=msg.chat_id, event=StreamDeltaEvent(
content=delta, content=delta,
metadata=meta, stream_id=_current_stream_id(),
)) ),
metadata=msg.metadata,
)
)
async def on_stream_end(*, resuming: bool = False) -> None: async def on_stream_end(*, resuming: bool = False) -> None:
nonlocal stream_segment nonlocal stream_segment
meta = dict(msg.metadata or {}) await self.bus.publish_outbound(
meta["_stream_end"] = True outbound_message_for_event(
meta["_resuming"] = resuming channel=msg.channel,
meta["_stream_id"] = _current_stream_id() chat_id=msg.chat_id,
await self.bus.publish_outbound(OutboundMessage( event=StreamEndEvent(
channel=msg.channel, chat_id=msg.chat_id, stream_id=_current_stream_id(),
content="", resuming=resuming,
metadata=meta, ),
)) metadata=msg.metadata,
)
)
stream_segment += 1 stream_segment += 1
response = await self._process_message( response = await self._process_message(
@@ -1362,9 +1381,10 @@ class AgentLoop:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
event = None
meta = dict(msg.metadata or {}) meta = dict(msg.metadata or {})
if on_stream is not None and stop_reason not in {"error", "tool_error"}: if on_stream is not None and stop_reason not in {"error", "tool_error"}:
meta["_streamed"] = True event = StreamedResponseEvent()
if turn_latency_ms is not None: if turn_latency_ms is not None:
meta["latency_ms"] = int(turn_latency_ms) meta["latency_ms"] = int(turn_latency_ms)
@@ -1372,6 +1392,7 @@ class AgentLoop:
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=final_content, content=final_content,
event=event,
metadata=meta, metadata=meta,
) )
+2 -6
View File
@@ -33,7 +33,6 @@ if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MemoryStore — pure file I/O layer # MemoryStore — pure file I/O layer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1006,7 +1005,6 @@ class Consolidator:
messages_to_summarize = list(session.messages[session.last_consolidated:]) messages_to_summarize = list(session.messages[session.last_consolidated:])
if not messages_to_summarize: if not messages_to_summarize:
session.updated_at = datetime.now()
self.sessions.save(session) self.sessions.save(session)
return "" return ""
@@ -1018,12 +1016,11 @@ class Consolidator:
metadata={}, metadata={},
last_consolidated=0, last_consolidated=0,
) )
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True) result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
messages_to_keep = probe.messages messages_to_keep = probe.messages
messages_to_remove = dropped[already_consolidated:] messages_to_remove = result.dropped[result.already_consolidated_count:]
if not messages_to_remove and not messages_to_keep: if not messages_to_remove and not messages_to_keep:
session.updated_at = datetime.now()
self.sessions.save(session) self.sessions.save(session)
return "" return ""
@@ -1046,7 +1043,6 @@ class Consolidator:
session.messages = messages_to_keep session.messages = messages_to_keep
session.last_consolidated = 0 session.last_consolidated = 0
session.updated_at = datetime.now()
self.sessions.save(session) self.sessions.save(session)
if messages_to_remove: if messages_to_remove:
+99 -3
View File
@@ -18,7 +18,7 @@ from nanobot.agent.context_governance import (
ContextGovernor, ContextGovernor,
) )
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.file_edit_events import ( from nanobot.utils.file_edit_events import (
StreamingFileEditTracker, StreamingFileEditTracker,
@@ -389,7 +389,15 @@ class AgentRunner:
spec.session_key or "default", spec.session_key or "default",
) )
try: try:
messages_for_model = ContextGovernor.drop_orphan_tool_results(messages) messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
messages
)
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
messages_for_model
)
messages_for_model = ContextGovernor.drop_orphan_tool_results(
messages_for_model
)
messages_for_model = ContextGovernor.backfill_missing_tool_results( messages_for_model = ContextGovernor.backfill_missing_tool_results(
messages_for_model messages_for_model
) )
@@ -725,6 +733,8 @@ class AgentRunner:
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
hook: AgentHook, hook: AgentHook,
context: AgentHookContext, context: AgentHookContext,
*,
malformed_retry: bool = False,
): ):
timeout_s: float | None = spec.llm_timeout_s timeout_s: float | None = spec.llm_timeout_s
if timeout_s is None: if timeout_s is None:
@@ -867,8 +877,94 @@ class AgentRunner:
) )
if progress_state and progress_state.get("reasoning_open"): if progress_state and progress_state.get("reasoning_open"):
await hook.emit_reasoning_end() await hook.emit_reasoning_end()
dropped, all_dropped, original_finish_reason = (
self._drop_malformed_tool_calls(response)
)
if (
all_dropped
and original_finish_reason in ("tool_calls", "function_call")
and not malformed_retry
):
logger.warning(
"Retrying LLM request after all {} malformed tool call(s) were dropped",
dropped,
)
retry_messages = self._malformed_tool_call_retry_messages(
messages, response.content,
)
return await self._request_model(
spec, retry_messages, hook, context,
malformed_retry=True,
)
if (
all_dropped
and original_finish_reason in ("tool_calls", "function_call")
and malformed_retry
):
logger.warning(
"Malformed tool calls persisted after retry; falling back to no-tools request",
)
fallback_messages = self._malformed_tool_call_retry_messages(
messages, response.content,
)
return await self._request_no_tools(spec, fallback_messages)
return response return response
@staticmethod
def _drop_malformed_tool_calls(
response: LLMResponse,
) -> tuple[int, bool, str | None]:
"""Strip tool calls whose name is missing/non-string from the response.
Returns (dropped_count, all_dropped, original_finish_reason).
A degenerate call (name=None or "") cannot be executed, and if it were
persisted into the assistant message it would be replayed on every
subsequent turn, causing upstream validation errors
(``tool_use.name: Input should be a valid string``) that permanently
wedge the session. Dropping it here keeps it out of execution, the
assistant message, and the saved history in one place.
"""
calls = getattr(response, "tool_calls", None)
if not calls:
return (0, False, getattr(response, "finish_reason", None))
valid = [tc for tc in calls if tc.has_valid_name()]
if len(valid) == len(calls):
return (0, False, getattr(response, "finish_reason", None))
dropped = len(calls) - len(valid)
original_finish_reason = getattr(response, "finish_reason", None)
logger.warning(
"Dropped {} malformed tool call(s) with missing/non-string name "
"from LLM response (finish_reason={!r})",
dropped,
original_finish_reason,
)
response.tool_calls = valid
if not valid:
response.finish_reason = "stop"
return (dropped, not valid, original_finish_reason)
@staticmethod
def _malformed_tool_call_retry_messages(
messages: list[dict[str, Any]],
assistant_text: str | None,
) -> list[dict[str, Any]]:
retry_messages = list(messages)
note = (
"The previous model response attempted to call tools, but every tool call "
"was malformed: the tool_use blocks had missing or non-string tool names. "
"Do not answer with a promise to use tools. Either call the required tools again "
"using valid tool names from the provided tool list and JSON object inputs, or give "
"a final answer only if no tool is required."
)
if assistant_text:
note += (
f"\n\nPrevious assistant text before the malformed calls:\n"
f"{assistant_text}"
)
retry_messages.append({"role": "user", "content": note})
return retry_messages
async def _request_finalization_retry( async def _request_finalization_retry(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
@@ -1170,7 +1266,7 @@ class AgentRunner:
return payload, event, exc return payload, event, exc
return payload, event, None return payload, event, None
if isinstance(result, str) and result.startswith("Error"): if is_tool_error_result(tool_call.name, result):
if file_edit_trackers and progress_callback is not None: if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress( await invoke_file_edit_progress(
progress_callback, progress_callback,
+2 -1
View File
@@ -1,6 +1,6 @@
"""Agent tools module.""" """Agent tools module."""
from nanobot.agent.tools.base import Schema, Tool, tool_parameters from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
@@ -25,6 +25,7 @@ __all__ = [
"Tool", "Tool",
"ToolContext", "ToolContext",
"ToolLoader", "ToolLoader",
"ToolResult",
"ToolRegistry", "ToolRegistry",
"tool_parameters", "tool_parameters",
"tool_parameters_schema", "tool_parameters_schema",
+4 -4
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import tool_parameters from nanobot.agent.tools.base import ToolResult, tool_parameters
from nanobot.agent.tools.filesystem import _FsTool from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
@@ -289,8 +289,8 @@ class ApplyPatchTool(_FsTool):
_format_summary(summary) for summary in summaries _format_summary(summary) for summary in summaries
) )
except PermissionError as exc: except PermissionError as exc:
return f"Error: {exc}" return ToolResult.error(f"Error: {exc}")
except _PatchError as exc: except _PatchError as exc:
return f"Error applying patch: {exc}" return ToolResult.error(f"Error applying patch: {exc}")
except Exception as exc: except Exception as exc:
return f"Error applying patch: {exc}" return ToolResult.error(f"Error applying patch: {exc}")
+20 -1
View File
@@ -128,6 +128,21 @@ class Schema(ABC):
return Schema.validate_json_schema_value(value, self.to_json_schema(), path) 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): class Tool(ABC):
"""Agent capability: read files, run commands, etc.""" """Agent capability: read files, run commands, etc."""
@@ -193,9 +208,13 @@ class Tool(ABC):
@abstractmethod @abstractmethod
async def execute(self, **kwargs: Any) -> Any: async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; returns a string or list of content blocks.""" """Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
... ...
@staticmethod
def error(content: str) -> ToolResult:
return ToolResult.error(content)
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]: def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
if not isinstance(obj, dict): if not isinstance(obj, dict):
return obj return obj
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Any
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
BooleanSchema, BooleanSchema,
@@ -136,4 +136,4 @@ class CliAppsTool(Tool):
restrict_to_workspace=access.restrict_to_workspace, restrict_to_workspace=access.restrict_to_workspace,
) )
except CliAppError as exc: except CliAppError as exc:
return f"Error: {exc.message}" return ToolResult.error(f"Error: {exc.message}")
+10 -10
View File
@@ -6,7 +6,7 @@ from contextvars import ContextVar
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
IntegerSchema, IntegerSchema,
@@ -99,7 +99,7 @@ class CronTool(Tool, ContextAware):
try: try:
ZoneInfo(tz) ZoneInfo(tz)
except (KeyError, Exception): except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'" return ToolResult.error(f"Error: unknown timezone '{tz}'")
return None return None
def _display_timezone(self, schedule: CronSchedule) -> str: def _display_timezone(self, schedule: CronSchedule) -> str:
@@ -148,7 +148,7 @@ class CronTool(Tool, ContextAware):
) -> str: ) -> str:
if action == "add": if action == "add":
if self._in_cron_context.get(): if self._in_cron_context.get():
return "Error: cannot schedule new jobs from within a cron job execution" return ToolResult.error("Error: cannot schedule new jobs from within a cron job execution")
return self._add_job(name, message, every_seconds, cron_expr, tz, at) return self._add_job(name, message, every_seconds, cron_expr, tz, at)
elif action == "list": elif action == "list":
return self._list_jobs() return self._list_jobs()
@@ -166,20 +166,20 @@ class CronTool(Tool, ContextAware):
at: str | None, at: str | None,
) -> str: ) -> str:
if not message: if not message:
return ( return ToolResult.error(
"Error: cron action='add' requires a non-empty 'message' parameter " "Error: cron action='add' requires a non-empty 'message' parameter "
"describing what to do when the job triggers " "describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"." "(e.g. the reminder text). Retry including message=\"...\"."
) )
session_key = self._session_key.get() session_key = self._session_key.get()
if not session_key: if not session_key:
return "Error: scheduled cron jobs must be created from a chat session" return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
origin_channel = self._origin_channel.get() origin_channel = self._origin_channel.get()
origin_chat_id = self._origin_chat_id.get() origin_chat_id = self._origin_chat_id.get()
if not origin_channel or not origin_chat_id: if not origin_channel or not origin_chat_id:
return "Error: scheduled cron jobs must be created from a chat session" return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
if tz and not cron_expr: if tz and not cron_expr:
return "Error: tz can only be used with cron_expr" return ToolResult.error("Error: tz can only be used with cron_expr")
if tz: if tz:
if err := self._validate_timezone(tz): if err := self._validate_timezone(tz):
return err return err
@@ -199,7 +199,7 @@ class CronTool(Tool, ContextAware):
try: try:
dt = datetime.fromisoformat(at) dt = datetime.fromisoformat(at)
except ValueError: except ValueError:
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" return ToolResult.error(f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS")
if dt.tzinfo is None: if dt.tzinfo is None:
if err := self._validate_timezone(self._default_timezone): if err := self._validate_timezone(self._default_timezone):
return err return err
@@ -208,7 +208,7 @@ class CronTool(Tool, ContextAware):
schedule = CronSchedule(kind="at", at_ms=at_ms) schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True delete_after = True
else: else:
return "Error: either every_seconds, cron_expr, or at is required" return ToolResult.error("Error: either every_seconds, cron_expr, or at is required")
job = self._cron.add_job( job = self._cron.add_job(
name=name or message[:30], name=name or message[:30],
@@ -279,7 +279,7 @@ class CronTool(Tool, ContextAware):
def _remove_job(self, job_id: str | None) -> str: def _remove_job(self, job_id: str | None) -> str:
if not job_id: if not job_id:
return "Error: job_id is required for remove" return ToolResult.error("Error: job_id is required for remove")
result = self._cron.remove_job(job_id) result = self._cron.remove_job(job_id)
if result == "removed": if result == "removed":
return f"Removed job {job_id}" return f"Removed job {job_id}"
+9 -7
View File
@@ -9,7 +9,7 @@ from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
@@ -492,11 +492,12 @@ class WriteStdinTool(Tool):
max_output_chars=output_limit, max_output_chars=output_limit,
owner_session_key=current_request_session_key(), owner_session_key=current_request_session_key(),
) )
return format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
except KeyError: except KeyError:
return f"Error: exec session not found: {session_id}" return ToolResult.error(f"Error: exec session not found: {session_id!r}")
except Exception as exc: except Exception as exc:
return f"Error writing to exec session: {exc}" return ToolResult.error(f"Error writing to exec session: {exc}")
async def _wait_for_output( async def _wait_for_output(
self, self,
@@ -532,13 +533,14 @@ class WriteStdinTool(Tool):
joined = "".join(aggregate) joined = "".join(aggregate)
if wait_for in joined: if wait_for in joined:
poll.output = joined poll.output = joined
return format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
if poll.done or remaining_ms <= 0: if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate) poll.output = "".join(aggregate)
result = format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
if wait_for not in poll.output: if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}" result += f"\nWait target not observed: {wait_for!r}"
return result return ToolResult.error(result) if poll.timed_out else result
@tool_parameters(tool_parameters_schema()) @tool_parameters(tool_parameters_schema())
@@ -606,4 +608,4 @@ class ListExecSessionsTool(Tool):
) )
return "\n".join(lines) return "\n".join(lines)
except Exception as exc: except Exception as exc:
return f"Error listing exec sessions: {exc}" return ToolResult.error(f"Error listing exec sessions: {exc}")
+40 -40
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states 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.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
@@ -268,19 +268,19 @@ class ReadFileTool(_FsTool):
) -> Any: ) -> Any:
try: try:
if not path: if not path:
return "Error reading file: Unknown path" return ToolResult.error("Error reading file: Unknown path")
# Device path blacklist # Device path blacklist
if _is_blocked_device(path): if _is_blocked_device(path):
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)." return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
fp = self._resolve_read(path) fp = self._resolve_read(path)
if _is_blocked_device(fp): if _is_blocked_device(fp):
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)." return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
if not fp.exists(): if not fp.exists():
return f"Error: File not found: {path}" return ToolResult.error(f"Error: File not found: {path}")
if not fp.is_file(): if not fp.is_file():
return f"Error: Not a file: {path}" return ToolResult.error(f"Error: Not a file: {path}")
# PDF support # PDF support
if fp.suffix.lower() == ".pdf": if fp.suffix.lower() == ".pdf":
@@ -343,7 +343,7 @@ class ReadFileTool(_FsTool):
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if mime and mime.startswith("image/"): if mime and mime.startswith("image/"):
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})") return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported." return ToolResult.error(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 # Normalize CRLF -> LF before line-splitting. Primarily a Windows
# concern (git checkouts with autocrlf, editors saving CRLF) but # concern (git checkouts with autocrlf, editors saving CRLF) but
@@ -357,7 +357,7 @@ class ReadFileTool(_FsTool):
if offset < 1: if offset < 1:
offset = 1 offset = 1
if offset > total: if offset > total:
return f"Error: offset {offset} is beyond end of file ({total} lines)" return ToolResult.error(f"Error: offset {offset} is beyond end of file ({total} lines)")
start = offset - 1 start = offset - 1
end = min(start + (limit or self._DEFAULT_LIMIT), total) 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) self._file_states.record_read(fp, offset=offset, limit=limit)
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error reading file: {e}" return ToolResult.error(f"Error reading file: {e}")
def _read_pdf(self, fp: Path, pages: str | None) -> str: def _read_pdf(self, fp: Path, pages: str | None) -> str:
try: try:
import fitz # pymupdf import fitz # pymupdf
except ImportError: except ImportError:
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf" return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
try: try:
doc = fitz.open(str(fp)) doc = fitz.open(str(fp))
except Exception as e: except Exception as e:
return f"Error reading PDF: {e}" return ToolResult.error(f"Error reading PDF: {e}")
total_pages = len(doc) total_pages = len(doc)
if pages: if pages:
@@ -402,10 +402,10 @@ class ReadFileTool(_FsTool):
start, end = _parse_page_range(pages, total_pages) start, end = _parse_page_range(pages, total_pages)
except (ValueError, IndexError): except (ValueError, IndexError):
doc.close() doc.close()
return f"Error: Invalid page range '{pages}'. Use format like '1-5'." return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
if start > end or start >= total_pages: if start > end or start >= total_pages:
doc.close() doc.close()
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)." return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
else: else:
start = 0 start = 0
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1) end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
@@ -437,10 +437,10 @@ class ReadFileTool(_FsTool):
result = extract_text(fp) result = extract_text(fp)
if result is None: if result is None:
return f"Error: Unsupported file format: {fp.suffix}" return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
if result.startswith("[error:"): if result.startswith("[error:"):
return f"Error reading {fp.suffix.upper()} file: {result}" return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {result}")
if not result: if not result:
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})" return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
@@ -492,9 +492,9 @@ class WriteFileTool(_FsTool):
self._file_states.record_write(fp) self._file_states.record_write(fp)
return f"Successfully wrote {len(content)} characters to {fp}" return f"Successfully wrote {len(content)} characters to {fp}"
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error writing file: {e}" return ToolResult.error(f"Error writing file: {e}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -830,11 +830,11 @@ class EditFileTool(_FsTool):
if new_text is None: if new_text is None:
raise ValueError("Unknown new_text") raise ValueError("Unknown new_text")
if occurrence is not None and occurrence < 1: if occurrence is not None and occurrence < 1:
return "Error: occurrence must be >= 1." return ToolResult.error("Error: occurrence must be >= 1.")
if line_hint is not None and line_hint < 1: if line_hint is not None and line_hint < 1:
return "Error: line_hint must be >= 1." return ToolResult.error("Error: line_hint must be >= 1.")
if expected_replacements is not None and expected_replacements < 1: if expected_replacements is not None and expected_replacements < 1:
return "Error: expected_replacements must be >= 1." return ToolResult.error("Error: expected_replacements must be >= 1.")
fp = self._resolve_write(path) fp = self._resolve_write(path)
@@ -853,14 +853,14 @@ class EditFileTool(_FsTool):
except OSError: except OSError:
fsize = 0 fsize = 0
if fsize > self._MAX_EDIT_FILE_SIZE: if fsize > self._MAX_EDIT_FILE_SIZE:
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB." return ToolResult.error(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 # Create-file: old_text='' but file exists and not empty → reject
if old_text == "": if old_text == "":
raw = fp.read_bytes() raw = fp.read_bytes()
content = raw.decode("utf-8") content = raw.decode("utf-8")
if content.strip(): if content.strip():
return f"Error: Cannot create file — {path} already exists and is not empty." return ToolResult.error(f"Error: Cannot create file — {path} already exists and is not empty.")
fp.write_text(new_text, encoding="utf-8") fp.write_text(new_text, encoding="utf-8")
self._file_states.record_write(fp) self._file_states.record_write(fp)
return f"Successfully edited {fp}" return f"Successfully edited {fp}"
@@ -878,15 +878,15 @@ class EditFileTool(_FsTool):
return self._not_found_msg(old_text, content, path) return self._not_found_msg(old_text, content, path)
count = len(matches) count = len(matches)
if replace_all and occurrence is not None: if replace_all and occurrence is not None:
return "Error: occurrence cannot be used with replace_all=true." return ToolResult.error("Error: occurrence cannot be used with replace_all=true.")
if replace_all and line_hint is not None: if replace_all and line_hint is not None:
return "Error: line_hint cannot be used with replace_all=true." return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
if occurrence is not None and line_hint is not None: if occurrence is not None and line_hint is not None:
return "Error: line_hint cannot be used with occurrence." return ToolResult.error("Error: line_hint cannot be used with occurrence.")
if count > 1 and not replace_all: if count > 1 and not replace_all:
if occurrence is not None: if occurrence is not None:
if occurrence > count: if occurrence > count:
return ( return ToolResult.error(
f"Error: occurrence {occurrence} is out of range; " f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} times." f"old_text appears {count} times."
) )
@@ -894,7 +894,7 @@ class EditFileTool(_FsTool):
nearest = min(matches, key=lambda match: abs(match.line - line_hint)) nearest = min(matches, key=lambda match: abs(match.line - line_hint))
distance = abs(nearest.line - line_hint) distance = abs(nearest.line - line_hint)
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1: if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
return ( return ToolResult.error(
f"Error: line_hint {line_hint} is ambiguous; " f"Error: line_hint {line_hint} is ambiguous; "
f"old_text appears {count} times." f"old_text appears {count} times."
) )
@@ -910,7 +910,7 @@ class EditFileTool(_FsTool):
"or set replace_all=true." "or set replace_all=true."
) )
elif occurrence is not None and occurrence > count: elif occurrence is not None and occurrence > count:
return ( return ToolResult.error(
f"Error: occurrence {occurrence} is out of range; " f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} time." f"old_text appears {count} time."
) )
@@ -928,7 +928,7 @@ class EditFileTool(_FsTool):
else: else:
selected = [matches[occurrence - 1 if occurrence else 0]] selected = [matches[occurrence - 1 if occurrence else 0]]
if expected_replacements is not None and len(selected) != expected_replacements: if expected_replacements is not None and len(selected) != expected_replacements:
return ( return ToolResult.error(
f"Error: expected {expected_replacements} replacements but " f"Error: expected {expected_replacements} replacements but "
f"would make {len(selected)}." f"would make {len(selected)}."
) )
@@ -954,9 +954,9 @@ class EditFileTool(_FsTool):
msg = f"{warning}\n{msg}" msg = f"{warning}\n{msg}"
return msg return msg
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error editing file: {e}" return ToolResult.error(f"Error editing file: {e}")
def _file_not_found_msg(self, path: str, fp: Path) -> str: def _file_not_found_msg(self, path: str, fp: Path) -> str:
"""Build an error message with 'Did you mean ...?' suggestions.""" """Build an error message with 'Did you mean ...?' suggestions."""
@@ -969,7 +969,7 @@ class EditFileTool(_FsTool):
parts = [f"Error: File not found: {path}"] parts = [f"Error: File not found: {path}"]
if suggestions: if suggestions:
parts.append("Did you mean: " + ", ".join(suggestions) + "?") parts.append("Did you mean: " + ", ".join(suggestions) + "?")
return "\n".join(parts) return ToolResult.error("\n".join(parts))
@staticmethod @staticmethod
def _not_found_msg(old_text: str, content: str, path: str) -> str: def _not_found_msg(old_text: str, content: str, path: str) -> str:
@@ -985,18 +985,18 @@ class EditFileTool(_FsTool):
hint_text = "" hint_text = ""
if hints: if hints:
hint_text = "\nPossible cause: " + ", ".join(hints) + "." hint_text = "\nPossible cause: " + ", ".join(hints) + "."
return ( return ToolResult.error(
f"Error: old_text not found in {path}." f"Error: old_text not found in {path}."
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}" f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
) )
if hints: if hints:
return ( return ToolResult.error(
f"Error: old_text not found in {path}. " f"Error: old_text not found in {path}. "
f"Possible cause: {', '.join(hints)}. " f"Possible cause: {', '.join(hints)}. "
"Copy the exact text from read_file and try again." "Copy the exact text from read_file and try again."
) )
return f"Error: old_text not found in {path}. No similar text found. Verify the file content." return ToolResult.error(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") raise ValueError("Unknown path")
dp = self._resolve(path) dp = self._resolve(path)
if not dp.exists(): if not dp.exists():
return f"Error: Directory not found: {path}" return ToolResult.error(f"Error: Directory not found: {path}")
if not dp.is_dir(): if not dp.is_dir():
return f"Error: Not a directory: {path}" return ToolResult.error(f"Error: Not a directory: {path}")
cap = max_entries or self._DEFAULT_MAX cap = max_entries or self._DEFAULT_MAX
items: list[str] = [] items: list[str] = []
@@ -1084,6 +1084,6 @@ class ListDirTool(_FsTool):
result += f"\n\n(truncated, showing first {cap} of {total} entries)" result += f"\n\n(truncated, showing first {cap} of {total} entries)"
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error listing directory: {e}" return ToolResult.error(f"Error listing directory: {e}")
+4 -4
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
IntegerSchema, IntegerSchema,
@@ -172,11 +172,11 @@ class ImageGenerationTool(Tool):
) -> str: ) -> str:
client = self._provider_client() client = self._provider_client()
if client is None: if client is None:
return f"Error: unsupported image generation provider '{self.config.provider}'" return ToolResult.error(f"Error: unsupported image generation provider '{self.config.provider}'")
requested = count or 1 requested = count or 1
if requested > self.config.max_images_per_turn: if requested > self.config.max_images_per_turn:
return ( return ToolResult.error(
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn " "Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
f"({self.config.max_images_per_turn})" f"({self.config.max_images_per_turn})"
) )
@@ -206,4 +206,4 @@ class ImageGenerationTool(Tool):
break break
return generated_image_tool_result(artifacts) return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc: except (ArtifactError, ImageGenerationError, OSError) as exc:
return f"Error: {exc}" return ToolResult.error(f"Error: {exc}")
+67 -1
View File
@@ -8,7 +8,7 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
_SKIP_MODULES = frozenset({ _SKIP_MODULES = frozenset({
@@ -96,6 +96,8 @@ class ToolLoader:
if not tool_cls.enabled(ctx): if not tool_cls.enabled(ctx):
continue continue
tool = tool_cls.create(ctx) tool = tool_cls.create(ctx)
if is_plugin_source:
tool = _LegacyErrorPrefixTool(tool)
if registry.has(tool.name): if registry.has(tool.name):
if is_plugin_source and tool.name in builtin_names: if is_plugin_source and tool.name in builtin_names:
logger.warning( logger.warning(
@@ -114,3 +116,67 @@ class ToolLoader:
except Exception: except Exception:
logger.exception("Failed to register tool: %s", cls_label) logger.exception("Failed to register tool: %s", cls_label)
return registered 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)
+4 -4
View File
@@ -20,7 +20,7 @@ from contextvars import ContextVar
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
@@ -150,12 +150,12 @@ class LongTaskTool(Tool, _GoalToolsMixin):
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str: async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
sess = self._session() sess = self._session()
if sess is None: if sess is None:
return ( return ToolResult.error(
"Error: long_task requires an active chat session (missing routing context)." "Error: long_task requires an active chat session (missing routing context)."
) )
prior = parse_goal_state(goal_state_raw(sess.metadata)) prior = parse_goal_state(goal_state_raw(sess.metadata))
if isinstance(prior, dict) and prior.get("status") == "active": if isinstance(prior, dict) and prior.get("status") == "active":
return ( return ToolResult.error(
"Error: a sustained goal is already active. " "Error: a sustained goal is already active. "
"Use complete_goal when finished, or ask the user before replacing it." "Use complete_goal when finished, or ask the user before replacing it."
) )
@@ -230,7 +230,7 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
async def execute(self, recap: str | None = None, **kwargs: Any) -> str: async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
sess = self._session() sess = self._session()
if sess is None: if sess is None:
return "Error: complete_goal requires an active chat session." return ToolResult.error("Error: complete_goal requires an active chat session.")
prior = parse_goal_state(goal_state_raw(sess.metadata)) prior = parse_goal_state(goal_state_raw(sess.metadata))
if not isinstance(prior, dict) or prior.get("status") != "active": if not isinstance(prior, dict) or prior.get("status") != "active":
return "No active goal to complete." return "No active goal to complete."
+128 -15
View File
@@ -1,6 +1,7 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" """MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio import asyncio
import json
import os import os
import re import re
import shutil import shutil
@@ -13,7 +14,7 @@ from weakref import WeakKeyDictionary
import httpx import httpx
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import ( from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL, INBOUND_META_RUNTIME_CONTROL,
@@ -165,12 +166,31 @@ async def _probe_http_url(url: str, timeout: float = 3.0) -> bool:
return False return False
def _redact_url(url: str) -> str:
"""Strip credentials and query/fragment before logging an MCP URL.
Server URLs may embed secrets (``https://user:token@host/sse`` or a
``?token=`` query). Some deployments also put opaque tokens in the path, so
log only the origin and a path placeholder.
"""
try:
parts = urllib.parse.urlsplit(url)
hostname = parts.hostname or ""
netloc = f"[{hostname}]" if ":" in hostname else hostname
if parts.port:
netloc = f"{netloc}:{parts.port}"
path = "/..." if parts.path and parts.path != "/" else parts.path
return urllib.parse.urlunsplit((parts.scheme, netloc, path, "", ""))
except Exception:
return "<redacted-url>"
async def _validate_mcp_request_url(request: httpx.Request) -> None: async def _validate_mcp_request_url(request: httpx.Request) -> None:
"""Validate each outgoing MCP HTTP request, including redirect targets.""" """Validate each outgoing MCP HTTP request, including redirect targets."""
ok, error = validate_url_target(str(request.url)) ok, error = validate_url_target(str(request.url))
if not ok: if not ok:
raise httpx.RequestError( raise httpx.RequestError(
f"Blocked unsafe MCP URL {request.url} ({error})", f"Blocked unsafe MCP URL {_redact_url(str(request.url))} ({error})",
request=request, request=request,
) )
@@ -313,6 +333,52 @@ class _MCPWrapperBase(Tool):
return True return True
def _image_block_data_url(block: Any, types: Any) -> str | None:
"""Return a base64 ``data:`` URL for an MCP image-bearing content block.
Handles ``ImageContent`` directly and ``EmbeddedResource`` wrapping a binary
blob with an ``image/*`` MIME type. Returns ``None`` for anything else.
``getattr`` guards keep this safe when the installed/faked ``mcp`` SDK does
not expose a given type.
"""
image_cls = getattr(types, "ImageContent", None)
if image_cls is not None and isinstance(block, image_cls):
mime = getattr(block, "mimeType", None) or "image/png"
return f"data:{mime};base64,{block.data}"
embedded_cls = getattr(types, "EmbeddedResource", None)
blob_cls = getattr(types, "BlobResourceContents", None)
if embedded_cls is not None and isinstance(block, embedded_cls):
resource = getattr(block, "resource", None)
if blob_cls is not None and isinstance(resource, blob_cls):
mime = getattr(resource, "mimeType", None) or ""
if isinstance(mime, str) and mime.startswith("image/"):
return f"data:{mime};base64,{resource.blob}"
return None
def _mcp_image_tool_result(text_parts: list[str], artifacts: list[dict[str, Any]]) -> str:
"""Build the compact tool result for an MCP call that returned image(s).
The base64 stays out of the model context entirely — only artifact paths and
metadata are returned, so the result is small and the channel can deliver the
saved file via the message tool.
"""
payload: dict[str, Any] = {
"artifacts": artifacts,
"next_step": (
"These images were returned by an MCP tool and saved as local artifacts. "
"Call the message tool with the artifact 'path' values in the media "
"parameter to deliver the images to the user. Do not paste base64 or raw "
"paths into your reply unless the user asks for debug details."
),
}
text = "\n".join(part for part in text_parts if part)
if text:
payload["text"] = text
return json.dumps(payload, ensure_ascii=False)
class MCPToolWrapper(_MCPWrapperBase): class MCPToolWrapper(_MCPWrapperBase):
"""Wraps a single MCP server tool as a nanobot Tool.""" """Wraps a single MCP server tool as a nanobot Tool."""
@@ -340,8 +406,6 @@ class MCPToolWrapper(_MCPWrapperBase):
return self._parameters return self._parameters
async def execute(self, **kwargs: Any) -> str: async def execute(self, **kwargs: Any) -> str:
from mcp import types
retried_transient = False retried_transient = False
refreshed_session = False refreshed_session = False
while True: while True:
@@ -396,17 +460,66 @@ class MCPToolWrapper(_MCPWrapperBase):
) )
return f"(MCP tool call failed: {type(exc).__name__})" return f"(MCP tool call failed: {type(exc).__name__})"
else: else:
# Success — extract result # Success — extract text and persist any image content as artifacts.
parts = [] rendered = self._render_call_result(result.content, kwargs)
for block in result.content: if getattr(result, "isError", False):
if isinstance(block, types.TextContent): return ToolResult.error(rendered)
parts.append(block.text) return rendered
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
def _render_call_result(self, content: Any, arguments: Mapping[str, Any]) -> str:
"""Turn MCP content blocks into a tool result string.
Text is concatenated as before. Image blocks are decoded and saved as
local artifacts (mirroring the built-in image generation tool) so the
model can deliver them via the message tool instead of trying to forward
base64 — which would be truncated and bloat the context window.
"""
from mcp import types
text_parts: list[str] = []
artifacts: list[dict[str, Any]] = []
for block in content:
if isinstance(block, types.TextContent):
text_parts.append(block.text)
continue
data_url = _image_block_data_url(block, types)
if data_url is not None:
stored = self._store_image_block(data_url, arguments)
if stored is not None:
artifacts.append(stored)
else:
text_parts.append("(MCP tool returned an image that could not be stored)")
continue
text_parts.append(str(block))
if artifacts:
return _mcp_image_tool_result(text_parts, artifacts)
return "\n".join(text_parts) or "(no output)"
def _store_image_block(
self, data_url: str, arguments: Mapping[str, Any]
) -> dict[str, Any] | None:
"""Persist one image data URL as an artifact; return its metadata or None."""
from nanobot.utils.artifacts import ArtifactError, store_generated_image_artifact
try:
return store_generated_image_artifact(
data_url,
prompt=str(arguments.get("prompt") or ""),
model=str(arguments.get("model") or ""),
save_dir="generated",
provider=f"mcp:{self._server_name}",
)
except (ArtifactError, OSError) as exc:
logger.warning(
"MCP tool '{}' returned an image that could not be stored: {}",
self._name,
exc,
)
return None
class MCPResourceWrapper(_MCPWrapperBase): class MCPResourceWrapper(_MCPWrapperBase):
"""Wraps an MCP resource URI as a read-only nanobot Tool.""" """Wraps an MCP resource URI as a read-only nanobot Tool."""
@@ -683,7 +796,7 @@ async def connect_mcp_servers(
logger.warning( logger.warning(
"MCP server '{}': blocked unsafe URL {} ({})", "MCP server '{}': blocked unsafe URL {} ({})",
name, name,
cfg.url, _redact_url(cfg.url),
error, error,
) )
await server_stack.aclose() await server_stack.aclose()
@@ -704,7 +817,7 @@ async def connect_mcp_servers(
read, write = await server_stack.enter_async_context(stdio_client(params)) read, write = await server_stack.enter_async_context(stdio_client(params))
elif transport_type == "sse": elif transport_type == "sse":
if not await _probe_http_url(cfg.url): if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url) logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
await server_stack.aclose() await server_stack.aclose()
return name, None return name, None
@@ -731,7 +844,7 @@ async def connect_mcp_servers(
) )
elif transport_type == "streamableHttp": elif transport_type == "streamableHttp":
if not await _probe_http_url(cfg.url): if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url) logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
await server_stack.aclose() await server_stack.aclose()
return name, None return name, None
+7 -7
View File
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema 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) not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons for row in buttons
): ):
return "Error: buttons must be a list of list of strings" return ToolResult.error("Error: buttons must be a list of list of strings")
default_channel = self._default_channel.get() default_channel = self._default_channel.get()
default_chat_id = self._default_chat_id.get() default_chat_id = self._default_chat_id.get()
channel = channel or default_channel 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() != ""
and str(explicit_chat_id).strip() != str(default_chat_id).strip() and str(explicit_chat_id).strip() != str(default_chat_id).strip()
): ):
return ( return ToolResult.error(
"Error: chat_id does not match the active WebSocket conversation. " "Error: chat_id does not match the active WebSocket conversation. "
"Omit chat_id (and usually channel) so delivery uses the current " "Omit chat_id (and usually channel) so delivery uses the current "
"conversation id from context — WebSocket client_id strings " "conversation id from context — WebSocket client_id strings "
@@ -229,16 +229,16 @@ class MessageTool(Tool, ContextAware):
message_id = None message_id = None
if not channel or not chat_id: if not channel or not chat_id:
return "Error: No target channel/chat specified" return ToolResult.error("Error: No target channel/chat specified")
if not self._send_callback: if not self._send_callback:
return "Error: Message sending not configured" return ToolResult.error("Error: Message sending not configured")
if media: if media:
try: try:
media = self._resolve_media(media) media = self._resolve_media(media)
except (OSError, PermissionError, ValueError) as e: except (OSError, PermissionError, ValueError) as e:
return f"Error: media path is not allowed: {str(e)}" return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
metadata = dict(self._default_metadata.get()) if same_target else {} metadata = dict(self._default_metadata.get()) if same_target else {}
if message_id: 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 "" 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}" return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e: except Exception as e:
return f"Error sending message: {str(e)}" return ToolResult.error(f"Error sending message: {str(e)}")
+14 -6
View File
@@ -3,7 +3,11 @@
import json import json
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool 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
class ToolRegistry: class ToolRegistry:
@@ -100,22 +104,26 @@ class ToolRegistry:
suggestion = self._suggest_name(str(name)) suggestion = self._suggest_name(str(name))
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else "" hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
return None, params, ( 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) params = self._coerce_params(tool, params)
if not isinstance(params, dict): if not isinstance(params, dict):
return tool, params, ( return tool, params, (
ToolResult.error(
f"Error: Tool '{name}' parameters must be a JSON object, got " f"Error: Tool '{name}' parameters must be a JSON object, got "
f"{type(params).__name__}. Use named parameters like " f"{type(params).__name__}. Use named parameters like "
'tool_name(param1="value1", param2="value2") matching the tool schema.' 'tool_name(param1="value1", param2="value2") matching the tool schema.'
) )
)
cast_params = tool.cast_params(params) cast_params = tool.cast_params(params)
errors = tool.validate_params(cast_params) errors = tool.validate_params(cast_params)
if errors: if errors:
return tool, cast_params, ( return tool, cast_params, (
f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) ToolResult.error(f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors))
) )
return tool, cast_params, None return tool, cast_params, None
@@ -159,16 +167,16 @@ class ToolRegistry:
hint = "\n\n[Analyze the error above and try a different approach.]" hint = "\n\n[Analyze the error above and try a different approach.]"
tool, params, error = self.prepare_call(name, params) tool, params, error = self.prepare_call(name, params)
if error: if error:
return error + hint return ToolResult.error(str(error) + hint)
try: try:
assert tool is not None # guarded by prepare_call() assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params) result = await tool.execute(**params)
if isinstance(result, str) and result.startswith("Error"): if is_tool_error_result(name, result):
return result + hint return ToolResult.error(str(result) + hint)
return result return result
except Exception as e: except Exception as e:
return f"Error executing {name}: {str(e)}" + hint return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
@property @property
def tool_names(self) -> list[str]: def tool_names(self) -> list[str]:
+11 -10
View File
@@ -9,6 +9,7 @@ from contextlib import suppress
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any, Iterable, TypeVar from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250 _DEFAULT_HEAD_LIMIT = 250
@@ -218,12 +219,12 @@ class FindFilesTool(_SearchTool):
try: try:
target = self._resolve(path or ".") target = self._resolve(path or ".")
if not target.exists(): if not target.exists():
return f"Error: Path not found: {path}" return ToolResult.error(f"Error: Path not found: {path}")
if not (target.is_dir() or target.is_file()): if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}" return ToolResult.error(f"Error: Unsupported path: {path}")
if sort not in {"path", "modified"}: if sort not in {"path", "modified"}:
return "Error: sort must be 'path' or 'modified'" return ToolResult.error("Error: sort must be 'path' or 'modified'")
limit = ( limit = (
_DEFAULT_FILE_HEAD_LIMIT _DEFAULT_FILE_HEAD_LIMIT
@@ -271,9 +272,9 @@ class FindFilesTool(_SearchTool):
result += "\n\n" + note result += "\n\n" + note
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error finding files: {e}" return ToolResult.error(f"Error finding files: {e}")
class GrepTool(_SearchTool): class GrepTool(_SearchTool):
@@ -425,16 +426,16 @@ class GrepTool(_SearchTool):
try: try:
target = self._resolve(path or ".") target = self._resolve(path or ".")
if not target.exists(): if not target.exists():
return f"Error: Path not found: {path}" return ToolResult.error(f"Error: Path not found: {path}")
if not (target.is_dir() or target.is_file()): if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}" return ToolResult.error(f"Error: Unsupported path: {path}")
flags = re.IGNORECASE if case_insensitive else 0 flags = re.IGNORECASE if case_insensitive else 0
try: try:
needle = re.escape(pattern) if fixed_strings else pattern needle = re.escape(pattern) if fixed_strings else pattern
regex = re.compile(needle, flags) regex = re.compile(needle, flags)
except re.error as e: except re.error as e:
return f"Error: invalid regex pattern: {e}" return ToolResult.error(f"Error: invalid regex pattern: {e}")
if head_limit is not None: if head_limit is not None:
limit = None if head_limit == 0 else head_limit limit = None if head_limit == 0 else head_limit
@@ -579,6 +580,6 @@ class GrepTool(_SearchTool):
result += "\n\n" + "\n".join(notes) result += "\n\n" + "\n".join(notes)
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error searching files: {e}" return ToolResult.error(f"Error searching files: {e}")
+27 -24
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base from nanobot.config_base import Base
@@ -216,7 +216,7 @@ class MyTool(Tool, ContextAware):
@staticmethod @staticmethod
def _validate_key(key: str | None, label: str = "key") -> str | None: def _validate_key(key: str | None, label: str = "key") -> str | None:
if not key or not key.strip(): if not key or not key.strip():
return f"Error: '{label}' cannot be empty or whitespace" return ToolResult.error(f"Error: '{label}' cannot be empty or whitespace")
return None return None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -321,7 +321,7 @@ class MyTool(Tool, ContextAware):
if action in ("inspect", "check"): if action in ("inspect", "check"):
return self._inspect(key) return self._inspect(key)
if not self._modify_allowed: if not self._modify_allowed:
return "Error: set is disabled (tools.my.allow_set is false)" return ToolResult.error("Error: set is disabled (tools.my.allow_set is false)")
if action in ("modify", "set"): if action in ("modify", "set"):
return self._modify(key, value) return self._modify(key, value)
return f"Unknown action: {action}" return f"Unknown action: {action}"
@@ -333,7 +333,7 @@ class MyTool(Tool, ContextAware):
return self._inspect_all() return self._inspect_all()
top = key.split(".")[0] top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"): if top in self._DENIED_ATTRS or top.startswith("__"):
return f"Error: '{top}' is not accessible" return ToolResult.error(f"Error: '{top}' is not accessible")
obj, err = self._resolve_path(key) obj, err = self._resolve_path(key)
if err: if err:
# "scratchpad" alias for _runtime_vars # "scratchpad" alias for _runtime_vars
@@ -343,12 +343,12 @@ class MyTool(Tool, ContextAware):
# Fallback: check _runtime_vars for simple keys stored by modify # Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._runtime_state._runtime_vars: if "." not in key and key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key) return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: {err}" return ToolResult.error(f"Error: {err}")
# Guard against mock auto-generated attributes # Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key): if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars: if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key) return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: '{key}' not found" return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(obj, key) return self._format_value(obj, key)
def _inspect_all(self) -> str: def _inspect_all(self) -> str:
@@ -379,21 +379,21 @@ class MyTool(Tool, ContextAware):
top = key.split(".")[0] 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: 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}") self._audit("modify", f"BLOCKED {key}")
return f"Error: '{key}' is protected and cannot be modified" return ToolResult.error(f"Error: '{key}' is protected and cannot be modified")
if top in self.READ_ONLY: if top in self.READ_ONLY:
self._audit("modify", f"READ_ONLY {key}") self._audit("modify", f"READ_ONLY {key}")
return f"Error: '{key}' is read-only and cannot be modified" return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
if "." in key: if "." in key:
parent_path, leaf = key.rsplit(".", 1) parent_path, leaf = key.rsplit(".", 1)
if leaf in self._DENIED_ATTRS or leaf.startswith("__"): if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
self._audit("modify", f"BLOCKED leaf '{leaf}'") self._audit("modify", f"BLOCKED leaf '{leaf}'")
return f"Error: '{leaf}' is not accessible" return ToolResult.error(f"Error: '{leaf}' is not accessible")
if leaf.lower() in self._SENSITIVE_NAMES: if leaf.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'") self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
return f"Error: '{leaf}' is not accessible" return ToolResult.error(f"Error: '{leaf}' is not accessible")
parent, err = self._resolve_path(parent_path) parent, err = self._resolve_path(parent_path)
if err: if err:
return f"Error: {err}" return ToolResult.error(f"Error: {err}")
if isinstance(parent, dict): if isinstance(parent, dict):
parent[leaf] = value parent[leaf] = value
else: else:
@@ -408,11 +408,11 @@ class MyTool(Tool, ContextAware):
def _modify_model_preset(self, value: Any) -> str: def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return "Error: 'model_preset' must be a non-empty string" return ToolResult.error("Error: 'model_preset' must be a non-empty string")
name = value.strip() name = value.strip()
result = self._modify_free("model_preset", name) result = self._modify_free("model_preset", name)
if result.startswith("Error:"): if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else f"{result}." return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
return ( return (
f"{result}; model is now {self._runtime_state.model!r}; " f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}" f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
@@ -422,22 +422,25 @@ class MyTool(Tool, ContextAware):
spec = self.RESTRICTED[key] spec = self.RESTRICTED[key]
expected = spec["type"] expected = spec["type"]
if expected is int and isinstance(value, bool): if expected is int and isinstance(value, bool):
return f"Error: '{key}' must be {expected.__name__}, got bool" return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
if not isinstance(value, expected): if not isinstance(value, expected):
try: try:
value = expected(value) value = expected(value)
except (ValueError, TypeError): except (ValueError, TypeError):
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}" return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
old = getattr(self._runtime_state, key) old = getattr(self._runtime_state, key)
if "min" in spec and value < spec["min"]: if "min" in spec and value < spec["min"]:
return f"Error: '{key}' must be >= {spec['min']}" return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
if "max" in spec and value > spec["max"]: if "max" in spec and value > spec["max"]:
return f"Error: '{key}' must be <= {spec['max']}" return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
if "min_len" in spec and len(str(value)) < spec["min_len"]: if "min_len" in spec and len(str(value)) < spec["min_len"]:
return f"Error: '{key}' must be at least {spec['min_len']} characters" return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
setattr(self._runtime_state, key, value) setattr(self._runtime_state, key, value)
if key == "model": if key == "model":
self._runtime_state._active_preset = None self._runtime_state._active_preset = None
sync_replay = getattr(self._runtime_state, "_sync_replay_max_messages", None)
if key == "context_window_tokens" and callable(sync_replay):
sync_replay()
if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"): if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"):
self._runtime_state._sync_subagent_runtime_limits() self._runtime_state._sync_subagent_runtime_limits()
self._audit("modify", f"{key}: {old!r} -> {value!r}") self._audit("modify", f"{key}: {old!r} -> {value!r}")
@@ -455,25 +458,25 @@ class MyTool(Tool, ContextAware):
"modify", "modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}", f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
) )
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}" return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
try: try:
setattr(self._runtime_state, key, value) setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"') message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}") self._audit("modify", f"REJECTED {key}: {message}")
return f"Error: {message}" return ToolResult.error(f"Error: {message}")
self._audit("modify", f"{key}: {old!r} -> {value!r}") self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})" return f"Set {key} = {value!r} (was {old!r})"
if callable(value): if callable(value):
self._audit("modify", f"REJECTED callable {key}") self._audit("modify", f"REJECTED callable {key}")
return "Error: cannot store callable values" return ToolResult.error("Error: cannot store callable values")
err = self._validate_json_safe(value) err = self._validate_json_safe(value)
if err: if err:
self._audit("modify", f"REJECTED {key}: {err}") self._audit("modify", f"REJECTED {key}: {err}")
return f"Error: {err}" return ToolResult.error(f"Error: {err}")
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS: 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") self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first." return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
old = self._runtime_state._runtime_vars.get(key) old = self._runtime_state._runtime_vars.get(key)
self._runtime_state._runtime_vars[key] = value self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}") self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
+48 -27
View File
@@ -8,6 +8,7 @@ import re
import shutil import shutil
import sys import sys
from contextlib import suppress from contextlib import suppress
from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,7 +16,7 @@ from typing import Any
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.exec_session import ( from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER, DEFAULT_EXEC_SESSION_MANAGER,
@@ -73,12 +74,9 @@ class _PreparedCommand:
login: bool login: bool
@tool_parameters( _EXEC_TOOL_PARAMETERS = tool_parameters_schema(
tool_parameters_schema(
command=StringSchema("The shell command to execute"), command=StringSchema("The shell command to execute"),
cmd=StringSchema("Compatibility alias for command"),
working_dir=StringSchema("Optional working directory for the command"), working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema( timeout=IntegerSchema(
60, 60,
description=( description=(
@@ -117,7 +115,14 @@ class _PreparedCommand:
maximum=MAX_OUTPUT_CHARS, maximum=MAX_OUTPUT_CHARS,
nullable=True, nullable=True,
), ),
max_output_tokens=IntegerSchema( )
_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(
description=( description=(
"Compatibility alias for max_output_chars. The current runtime " "Compatibility alias for max_output_chars. The current runtime "
"uses a character budget." "uses a character budget."
@@ -125,9 +130,12 @@ class _PreparedCommand:
minimum=1000, minimum=1000,
maximum=MAX_OUTPUT_CHARS, maximum=MAX_OUTPUT_CHARS,
nullable=True, nullable=True,
), ).to_json_schema(),
) }
) )
@tool_parameters(_EXEC_TOOL_PARAMETERS)
class ExecTool(Tool): class ExecTool(Tool):
"""Tool to execute shell commands.""" """Tool to execute shell commands."""
_scopes = {"core", "subagent"} _scopes = {"core", "subagent"}
@@ -244,6 +252,18 @@ class ExecTool(Tool):
def exclusive(self) -> bool: def exclusive(self) -> bool:
return True 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( async def execute(
self, command: str | None = None, cmd: str | None = None, self, command: str | None = None, cmd: str | None = None,
working_dir: str | None = None, workdir: str | None = None, working_dir: str | None = None, workdir: str | None = None,
@@ -256,7 +276,7 @@ class ExecTool(Tool):
command = command or cmd command = command or cmd
working_dir = working_dir or workdir working_dir = working_dir or workdir
if not command: if not command:
return "Error: Missing command. Provide command or cmd." return ToolResult.error("Error: Missing command. Provide command or cmd.")
if max_output_chars is None: if max_output_chars is None:
max_output_chars = max_output_tokens max_output_chars = max_output_tokens
@@ -283,7 +303,7 @@ class ExecTool(Tool):
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self._kill_process(process) await self._kill_process(process)
return f"Error: Command timed out after {prepared.timeout} seconds" return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
except asyncio.CancelledError: except asyncio.CancelledError:
await self._kill_process(process) await self._kill_process(process)
raise raise
@@ -314,7 +334,7 @@ class ExecTool(Tool):
return result return result
except Exception as e: except Exception as e:
return f"Error executing command: {str(e)}" return ToolResult.error(f"Error executing command: {str(e)}")
async def _execute_session( async def _execute_session(
self, self,
@@ -339,9 +359,10 @@ class ExecTool(Tool):
MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS,
), ),
) )
return format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
except Exception as exc: except Exception as exc:
return f"Error executing command: {exc}" return ToolResult.error(f"Error executing command: {exc}")
def _resolve_timeout(self, timeout: int | None) -> int | None: def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit). """Resolve the effective hard timeout in seconds (None = no limit).
@@ -383,12 +404,12 @@ class ExecTool(Tool):
requested = Path(cwd).expanduser().resolve() requested = Path(cwd).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve() resolved_root = Path(workspace_root).expanduser().resolve()
except Exception: except Exception:
return ( return ToolResult.error(
"Error: working_dir could not be resolved" "Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
if not is_path_within(requested, resolved_root): if not is_path_within(requested, resolved_root):
return ( return ToolResult.error(
"Error: working_dir is outside the configured workspace" "Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
@@ -504,24 +525,24 @@ class ExecTool(Tool):
if not shell: if not shell:
return None, None return None, None
if _IS_WINDOWS: if _IS_WINDOWS:
return None, "Error: shell parameter is not supported on Windows" return None, ToolResult.error("Error: shell parameter is not supported on Windows")
if "\0" in shell or "\n" in shell or "\r" in shell: if "\0" in shell or "\n" in shell or "\r" in shell:
return None, "Error: shell contains invalid characters" return None, ToolResult.error("Error: shell contains invalid characters")
allowed = {"sh", "bash", "zsh"} allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser() path = Path(shell).expanduser()
if path.is_absolute(): if path.is_absolute():
if path.name not in allowed: if path.name not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh" return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
if not path.is_file() or not os.access(path, os.X_OK): if not path.is_file() or not os.access(path, os.X_OK):
return None, f"Error: shell is not executable: {shell}" return None, ToolResult.error(f"Error: shell is not executable: {shell}")
return str(path), None return str(path), None
if "/" in shell or "\\" in shell: if "/" in shell or "\\" in shell:
return None, "Error: shell must be a shell name or absolute path" return None, ToolResult.error("Error: shell must be a shell name or absolute path")
if shell not in allowed: if shell not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh" return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
resolved = shutil.which(shell) resolved = shutil.which(shell)
if not resolved: if not resolved:
return None, f"Error: shell not found: {shell}" return None, ToolResult.error(f"Error: shell not found: {shell}")
return resolved, None return resolved, None
@staticmethod @staticmethod
@@ -608,10 +629,10 @@ class ExecTool(Tool):
if not explicitly_allowed: if not explicitly_allowed:
for pattern in self.deny_patterns: for pattern in self.deny_patterns:
if re.search(pattern, lower): if re.search(pattern, lower):
return "Error: Command blocked by deny pattern filter" return ToolResult.error("Error: Command blocked by deny pattern filter")
if self.allow_patterns: if self.allow_patterns:
return "Error: Command blocked by allowlist filter (not in allowlist)" return ToolResult.error("Error: Command blocked by allowlist filter (not in allowlist)")
from nanobot.security.network import contains_internal_url from nanobot.security.network import contains_internal_url
if contains_internal_url( if contains_internal_url(
@@ -621,12 +642,12 @@ class ExecTool(Tool):
), ),
): ):
# The runner turns this marker into a non-retryable security hint. # The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)" return ToolResult.error("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 should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
if should_restrict: if should_restrict:
if "..\\" in cmd or "../" in cmd: if "..\\" in cmd or "../" in cmd:
return ( return ToolResult.error(
"Error: Command blocked by safety guard (path traversal detected)" "Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
@@ -661,7 +682,7 @@ class ExecTool(Tool):
if not allowed and resolved_workspace is not None: if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace) allowed = is_path_within(p, resolved_workspace)
if p.is_absolute() and not allowed: if p.is_absolute() and not allowed:
return ( return ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)" "Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE + _WORKSPACE_BOUNDARY_NOTE
) )
+28 -28
View File
@@ -14,7 +14,7 @@ import httpx
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
IntegerSchema, IntegerSchema,
@@ -395,13 +395,13 @@ class WebSearchTool(Tool):
elif provider == "keenable": elif provider == "keenable":
return await self._search_keenable(query, n) return await self._search_keenable(query, n)
else: else:
return f"Error: unknown search provider '{provider}'" return ToolResult.error(f"Error: unknown search provider '{provider}'")
async def _search_olostep(self, query: str, n: int) -> str: async def _search_olostep(self, query: str, n: int) -> str:
try: try:
from olostep import AsyncOlostep, Olostep_BaseError from olostep import AsyncOlostep, Olostep_BaseError
except ImportError: except ImportError:
return "Error: olostep package not installed. Run: pip install olostep" return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
if not api_key: if not api_key:
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo") 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)}] items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
return _format_results(query, items, n) return _format_results(query, items, n)
except Olostep_BaseError as e: except Olostep_BaseError as e:
return f"Olostep search error: {type(e).__name__}: {e}" return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
except Exception as e: except Exception as e:
return f"Olostep search error: {type(e).__name__}: {e}" return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
async def _search_brave(self, query: str, n: int) -> str: async def _search_brave(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "") 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) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return ( return ToolResult.error(
"Error: Brave search rate limited after retry. " "Error: Brave search rate limited after retry. "
"Retry later or reduce consecutive web_search calls." "Retry later or reduce consecutive web_search calls."
) )
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_tavily(self, query: str, n: int) -> str: async def _search_tavily(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "") api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
@@ -505,7 +505,7 @@ class WebSearchTool(Tool):
r.raise_for_status() r.raise_for_status()
return _format_results(query, r.json().get("results", []), n) return _format_results(query, r.json().get("results", []), n)
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_keenable(self, query: str, n: int) -> str: async def _search_keenable(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "") 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) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return "Error: Keenable search rate limited. Try again later or reduce search frequency." return ToolResult.error("Error: Keenable search rate limited. Try again later or reduce search frequency.")
return f"Error: Keenable search failed ({e.response.status_code}): {e}" return ToolResult.error(f"Error: Keenable search failed ({e.response.status_code}): {e}")
except Exception as e: except Exception as e:
return f"Error: Keenable search failed: {e}" return ToolResult.error(f"Error: Keenable search failed: {e}")
async def _search_searxng(self, query: str, n: int) -> str: async def _search_searxng(self, query: str, n: int) -> str:
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip() 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" endpoint = f"{base_url.rstrip('/')}/search"
is_valid, error_msg = _validate_url(endpoint) is_valid, error_msg = _validate_url(endpoint)
if not is_valid: if not is_valid:
return f"Error: invalid SearXNG URL: {error_msg}" return ToolResult.error(f"Error: invalid SearXNG URL: {error_msg}")
try: try:
async with httpx.AsyncClient(proxy=self.proxy) as client: async with httpx.AsyncClient(proxy=self.proxy) as client:
r = await client.get( r = await client.get(
@@ -565,7 +565,7 @@ class WebSearchTool(Tool):
r.raise_for_status() r.raise_for_status()
return _format_results(query, r.json().get("results", []), n) return _format_results(query, r.json().get("results", []), n)
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_jina(self, query: str, n: int) -> str: async def _search_jina(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "") 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) return _format_results(query, items, n)
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
async def _search_exa(self, query: str, n: int) -> str: async def _search_exa(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "") 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) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return "Error: Exa search rate limited. Try again later or reduce search frequency." return ToolResult.error("Error: Exa search rate limited. Try again later or reduce search frequency.")
return f"Error: Exa search failed ({e.response.status_code}): {e}" return ToolResult.error(f"Error: Exa search failed ({e.response.status_code}): {e}")
except Exception as e: except Exception as e:
return f"Error: Exa search failed: {e}" return ToolResult.error(f"Error: Exa search failed: {e}")
async def _search_volcengine( async def _search_volcengine(
self, self,
@@ -690,7 +690,7 @@ class WebSearchTool(Tool):
normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None 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 normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None
except ValueError as e: except ValueError as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
body: dict[str, Any] = { body: dict[str, Any] = {
"Query": query, "Query": query,
@@ -723,18 +723,18 @@ class WebSearchTool(Tool):
data = r.json() data = r.json()
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
return "Error: Volcengine search rate limited. Try again later or reduce search frequency." return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
return f"Error: Volcengine search failed ({e.response.status_code}): {e}" return ToolResult.error(f"Error: Volcengine search failed ({e.response.status_code}): {e}")
except Exception as e: except Exception as e:
return f"Error: Volcengine search failed: {e}" return ToolResult.error(f"Error: Volcengine search failed: {e}")
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error") error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
if error: if error:
if isinstance(error, dict): if isinstance(error, dict):
code = error.get("Code") or error.get("code") or "unknown" code = error.get("Code") or error.get("code") or "unknown"
message = error.get("Message") or error.get("message") or error message = error.get("Message") or error.get("message") or error
return f"Error: Volcengine search error {code}: {message}" return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
return f"Error: Volcengine search error: {error}" return ToolResult.error(f"Error: Volcengine search error: {error}")
result = data.get("Result") or data result = data.get("Result") or data
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or [] 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) return _format_results(query, items, n)
except Exception as e: except Exception as e:
logger.warning("DuckDuckGo search failed: {}", e) logger.warning("DuckDuckGo search failed: {}", e)
return f"Error: DuckDuckGo search failed ({e})" return ToolResult.error(f"Error: DuckDuckGo search failed ({e})")
async def _search_bocha(self, query: str, n: int, freshness: str = "noLimit") -> str: 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", "") api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "")
@@ -819,7 +819,7 @@ class WebSearchTool(Tool):
timeout=self.config.timeout, timeout=self.config.timeout,
) )
if r.status_code == 429: if r.status_code == 429:
return "Error: Bocha search rate-limited (HTTP 429). Wait and retry." return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
wrapped_data = data.get("data") if isinstance(data, dict) else None wrapped_data = data.get("data") if isinstance(data, dict) else None
@@ -839,9 +839,9 @@ class WebSearchTool(Tool):
] ]
return _format_results(query, items, n) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
return f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}" return ToolResult.error(f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}")
except Exception as e: except Exception as e:
return f"Error: {e}" return ToolResult.error(f"Error: {e}")
@tool_parameters( @tool_parameters(
+22 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import hmac
import json as _json import json as _json
import time import time
import uuid import uuid
@@ -392,7 +393,10 @@ async def handle_health(request: web.Request) -> web.Response:
def create_app( def create_app(
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0 agent_loop,
model_name: str = "nanobot",
request_timeout: float = 120.0,
api_key: str = "",
) -> web.Application: ) -> web.Application:
"""Create the aiohttp application. """Create the aiohttp application.
@@ -400,6 +404,7 @@ def create_app(
agent_loop: An initialized AgentLoop instance. agent_loop: An initialized AgentLoop instance.
model_name: Model name reported in responses. model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds. 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 = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
app["agent_loop"] = agent_loop app["agent_loop"] = agent_loop
@@ -407,6 +412,22 @@ def create_app(
app["request_timeout"] = request_timeout app["request_timeout"] = request_timeout
app["session_locks"] = {} # per-user locks, keyed by session_key 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_post("/v1/chat/completions", handle_chat_completions)
app.router.add_get("/v1/models", handle_models) app.router.add_get("/v1/models", handle_models)
app.router.add_get("/health", handle_health) app.router.add_get("/health", handle_health)
+8 -4
View File
@@ -2,7 +2,10 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from nanobot.bus.outbound_events import OutboundEvent
# Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI # Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may # payloads. Value is JSON-serializable with at least ``kind``; rich clients may
@@ -39,9 +42,9 @@ class InboundMessage:
class OutboundMessage: class OutboundMessage:
"""Message to send to a chat channel. """Message to send to a chat channel.
``metadata`` can carry routing (``message_id``, ), trace flags (``_progress``), ``event`` carries internal runtime/UI semantics. ``metadata`` is reserved
and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI for channel routing context (``message_id``, thread ids, etc.) and optional
channels may ignore unknown keys. ``OUTBOUND_META_AGENT_UI`` blobs for rich clients.
""" """
channel: str channel: str
@@ -51,3 +54,4 @@ class OutboundMessage:
media: list[str] = field(default_factory=list) media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list) buttons: list[list[str]] = field(default_factory=list)
event: "OutboundEvent | None" = None
+226
View File
@@ -0,0 +1,226 @@
"""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
+11 -14
View File
@@ -10,7 +10,8 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -29,23 +30,19 @@ def build_bus_progress_callback(
reasoning: bool = False, reasoning: bool = False,
reasoning_end: bool = False, reasoning_end: bool = False,
) -> None: ) -> 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( await bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
event=ProgressEvent(
content=content, content=content,
metadata=meta, tool_hint=tool_hint,
reasoning_delta=reasoning,
reasoning_end=reasoning_end,
tool_events=tool_events,
file_edit_events=file_edit_events,
),
metadata=msg.metadata,
) )
) )
+37 -17
View File
@@ -101,20 +101,33 @@ class BaseChannel(ABC):
""" """
pass pass
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: 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:
"""Deliver a streaming text chunk. """Deliver a streaming text chunk.
Override in subclasses to enable streaming. Implementations should Override in subclasses to enable streaming. Implementations should
raise on delivery failure so the channel manager can retry. raise on delivery failure so the channel manager can retry.
Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends Stateful implementations should key buffers by ``stream_id`` rather
the current segment, and stateful implementations must key buffers by than only by ``chat_id`` when it is provided.
``_stream_id`` rather than only by ``chat_id``.
""" """
pass pass
async def send_reasoning_delta( async def send_reasoning_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
"""Stream a chunk of model reasoning/thinking content. """Stream a chunk of model reasoning/thinking content.
@@ -123,15 +136,17 @@ class BaseChannel(ABC):
subtext, WebUI italic bubble, ...) override to render reasoning subtext, WebUI italic bubble, ...) override to render reasoning
as a subordinate trace that updates in place as the model thinks. as a subordinate trace that updates in place as the model thinks.
Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta`` Streaming contract mirrors :meth:`send_delta`: stateful implementations
is a chunk, ``_reasoning_end`` ends the current reasoning segment, should key buffers by ``stream_id`` rather than only by ``chat_id``.
and stateful implementations should key buffers by ``_stream_id``
rather than only by ``chat_id``.
""" """
return return
async def send_reasoning_end( async def send_reasoning_end(
self, chat_id: str, metadata: dict[str, Any] | None = None self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
"""Mark the end of a reasoning stream segment. """Mark the end of a reasoning stream segment.
@@ -165,13 +180,18 @@ class BaseChannel(ABC):
""" """
if not msg.content: if not msg.content:
return return
meta = dict(msg.metadata or {}) stream_id = getattr(msg.event, "stream_id", None)
meta.setdefault("_reasoning_delta", True) await self.send_reasoning_delta(
await self.send_reasoning_delta(msg.chat_id, msg.content, meta) msg.chat_id,
end_meta = dict(meta) msg.content,
end_meta.pop("_reasoning_delta", None) msg.metadata,
end_meta["_reasoning_end"] = True stream_id=stream_id,
await self.send_reasoning_end(msg.chat_id, end_meta) )
await self.send_reasoning_end(
msg.chat_id,
msg.metadata,
stream_id=stream_id,
)
@property @property
def supports_streaming(self) -> bool: def supports_streaming(self) -> bool:
+11 -6
View File
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text from nanobot.command.builtin import build_help_text
@@ -458,7 +459,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping outbound message") self.logger.warning("client not ready; dropping outbound message")
return return
is_progress = bool((msg.metadata or {}).get("_progress")) is_progress = isinstance(msg.event, ProgressEvent)
try: try:
await client.send_outbound(msg) await client.send_outbound(msg)
@@ -471,7 +472,14 @@ class DiscordChannel(BaseChannel):
await self._clear_reactions(msg.chat_id) await self._clear_reactions(msg.chat_id)
async def send_delta( async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None 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: ) -> None:
"""Progressive Discord delivery: send once, then edit until the stream ends.""" """Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client client = self._client
@@ -479,10 +487,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping stream delta") self.logger.warning("client not ready; dropping stream delta")
return return
meta = metadata or {} if stream_end:
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"):
buf = self._stream_bufs.get(chat_id) buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text: if not buf or buf.message is None or not buf.text:
return return
+2 -1
View File
@@ -23,6 +23,7 @@ from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@@ -218,7 +219,7 @@ class EmailChannel(BaseChannel):
return return
# Skip progress messages to prevent sending an empty email after each tool call # Skip progress messages to prevent sending an empty email after each tool call
if (msg.metadata or {}).get("_progress"): if isinstance(msg.event, ProgressEvent):
self.logger.debug("Skip progress message to {}", msg.chat_id) self.logger.debug("Skip progress message to {}", msg.chat_id)
return return
+18 -9
View File
@@ -22,6 +22,7 @@ from rich.panel import Panel
from rich.text import Text from rich.text import Text
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@@ -1797,14 +1798,19 @@ class FeishuChannel(BaseChannel):
return self._stream_update_text_sync(card_id, content, sequence), sequence return self._stream_update_text_sync(card_id, content, sequence), sequence
async def send_delta( async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None 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: ) -> None:
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent. """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
Supported metadata keys: Supported metadata keys:
_stream_end: Finalize the streaming card. message_id: Original message id (used with stream end for reaction cleanup).
_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. chat_type: "group" or "p2p" controls reply-in-thread for streaming cards.
""" """
if not self._client: if not self._client:
@@ -1815,14 +1821,14 @@ class FeishuChannel(BaseChannel):
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id" rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback --- # --- stream end: final update or fallback ---
if meta.get("_stream_end"): if stream_end:
message_id = meta.get("message_id") message_id = meta.get("message_id")
# Only finalize the OnIt -> DONE reaction transition on the truly # 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 # working (more tool-call rounds), so leave the reaction state
# in place — otherwise the OnIt indicator disappears prematurely # in place — otherwise the OnIt indicator disappears prematurely
# and the DONE reaction fires after every tool call. # and the DONE reaction fires after every tool call.
if message_id and not meta.get("_resuming"): if message_id and not resuming:
reaction_id = self._reaction_ids.pop(message_id, None) reaction_id = self._reaction_ids.pop(message_id, None)
if reaction_id: if reaction_id:
await self._remove_reaction(message_id, reaction_id) await self._remove_reaction(message_id, reaction_id)
@@ -1965,7 +1971,9 @@ class FeishuChannel(BaseChannel):
# Handle tool hint messages. When a streaming card is active for # Handle tool hint messages. When a streaming card is active for
# this chat, inline the hint into the card instead of sending a # this chat, inline the hint into the card instead of sending a
# separate message so the user experience stays cohesive. # separate message so the user experience stays cohesive.
if msg.metadata.get("_tool_hint"): progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
if progress_event and progress_event.tool_hint:
hint = (msg.content or "").strip() hint = (msg.content or "").strip()
if not hint: if not hint:
return return
@@ -1976,6 +1984,7 @@ class FeishuChannel(BaseChannel):
await self.send_delta( await self.send_delta(
msg.chat_id, msg.chat_id,
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n", "\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
metadata=msg.metadata,
) )
return return
# No active streaming card — send as a regular interactive card # No active streaming card — send as a regular interactive card
@@ -2009,7 +2018,7 @@ class FeishuChannel(BaseChannel):
reply_message_id: str | None = None reply_message_id: str | None = None
_msg_id = msg.metadata.get("message_id") _msg_id = msg.metadata.get("message_id")
has_thread_id = msg.metadata.get("thread_id") has_thread_id = msg.metadata.get("thread_id")
if self.config.reply_to_message and not msg.metadata.get("_progress", False): if self.config.reply_to_message and progress_event is None:
reply_message_id = _msg_id reply_message_id = _msg_id
# For topic group messages, always reply to keep context in thread # For topic group messages, always reply to keep context in thread
elif has_thread_id: elif has_thread_id:
+157 -48
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import inspect
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -12,6 +13,16 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config from nanobot.config.schema import Config
@@ -266,7 +277,7 @@ class ChannelManager:
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool: def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
metadata = msg.metadata or {} metadata = msg.metadata or {}
if metadata.get("_progress"): if isinstance(outbound_event_from_message(msg), ProgressEvent):
return False return False
fingerprint = self._fingerprint_content(msg.content) fingerprint = self._fingerprint_content(msg.content)
if not fingerprint: if not fingerprint:
@@ -305,57 +316,59 @@ class ChannelManager:
timeout=1.0 timeout=1.0
) )
if ( event = outbound_event_from_message(msg)
msg.metadata.get("_reasoning_delta") progress_event = event if isinstance(event, ProgressEvent) else None
or msg.metadata.get("_reasoning_end") if progress_event and (
or msg.metadata.get("_reasoning") progress_event.reasoning_delta
or progress_event.reasoning_end
or progress_event.reasoning
): ):
# Reasoning rides its own plugin channel: only delivered # Reasoning rides its own plugin channel: only delivered
# when the destination channel opts in via ``show_reasoning`` # when the destination channel opts in via ``show_reasoning``
# and overrides the streaming primitives. Channels without # and overrides the streaming primitives. Channels without
# a low-emphasis UI affordance keep the base no-op and the # a low-emphasis UI affordance keep the base no-op and the
# content silently drops here. ``_reasoning`` (one-shot) # content silently drops here.
# is accepted for backward compatibility with hooks that
# haven't migrated to delta/end yet.
channel = self.channels.get(msg.channel) channel = self.channels.get(msg.channel)
if channel is not None and channel.show_reasoning: if channel is not None and channel.show_reasoning:
await self._send_with_retry(channel, msg) await self._send_with_retry(channel, msg)
continue continue
if msg.metadata.get("_progress"): if progress_event:
if msg.metadata.get("_tool_hint") and not self._should_send_progress( if progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=True, msg.channel, tool_hint=True,
): ):
continue continue
if not msg.metadata.get("_tool_hint") and not self._should_send_progress( if not progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=False, msg.channel, tool_hint=False,
): ):
continue continue
if msg.metadata.get("_retry_wait"): if isinstance(event, RetryWaitEvent):
continue continue
if ( if (
msg.metadata.get("_runtime_model_updated") isinstance(event, RuntimeModelUpdatedEvent)
and msg.channel == "websocket" and msg.channel == "websocket"
and "websocket" not in self.channels and "websocket" not in self.channels
): ):
continue 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 # to reduce API calls and improve streaming latency
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"): if isinstance(event, StreamDeltaEvent):
msg, extra_pending = self._coalesce_stream_deltas(msg) msg, extra_pending = self._coalesce_stream_deltas(msg)
pending.extend(extra_pending) pending.extend(extra_pending)
event = outbound_event_from_message(msg)
channel = self.channels.get(msg.channel) channel = self.channels.get(msg.channel)
if channel: if channel:
# Duplicate suppression is scoped to a known source message # Duplicate suppression is scoped to a known source message
# so repeated content from separate turns is still delivered. # so repeated content from separate turns is still delivered.
if ( if (
not msg.metadata.get("_stream_delta") not isinstance(
and not msg.metadata.get("_stream_end") event,
and not msg.metadata.get("_streamed") StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent,
)
): ):
if self._should_suppress_outbound(msg): if self._should_suppress_outbound(msg):
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id) logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
@@ -369,34 +382,116 @@ class ChannelManager:
except asyncio.CancelledError: except asyncio.CancelledError:
break 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 @staticmethod
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None: async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
"""Send one outbound message without retry policy.""" """Send one outbound message without retry policy."""
if msg.metadata.get("_reasoning_end"): event = outbound_event_from_message(msg)
await channel.send_reasoning_end(msg.chat_id, msg.metadata) if isinstance(event, ProgressEvent) and event.reasoning_end:
elif msg.metadata.get("_reasoning_delta"): await ChannelManager._send_reasoning_end(channel, msg, event)
await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata) elif isinstance(event, ProgressEvent) and event.reasoning_delta:
elif msg.metadata.get("_reasoning"): await ChannelManager._send_reasoning_delta(channel, msg, event)
# Back-compat: one-shot reasoning. BaseChannel translates this elif isinstance(event, ProgressEvent) and event.reasoning:
# to a single delta + end pair so plugins only implement the # BaseChannel translates one-shot reasoning to a single delta +
# streaming primitives. # end pair so plugins only implement the streaming primitives.
await channel.send_reasoning(msg) await channel.send_reasoning(msg)
elif msg.metadata.get("_file_edit_events"): elif isinstance(event, ProgressEvent) and event.file_edit_events:
edits = msg.metadata.get("_file_edit_events")
await channel.send_file_edit_events( await channel.send_file_edit_events(
msg.chat_id, msg.chat_id,
edits if isinstance(edits, list) else [], event.file_edit_events,
msg.metadata, msg.metadata,
) )
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"): elif isinstance(event, StreamDeltaEvent):
await channel.send_delta(msg.chat_id, msg.content, msg.metadata) await ChannelManager._send_stream_event(channel, msg, event)
elif not msg.metadata.get("_streamed"): elif isinstance(event, StreamEndEvent):
await ChannelManager._send_stream_event(channel, msg, event)
elif not isinstance(event, StreamedResponseEvent):
await channel.send(msg) await channel.send(msg)
def _coalesce_stream_deltas( def _coalesce_stream_deltas(
self, first_msg: OutboundMessage self, first_msg: OutboundMessage
) -> tuple[OutboundMessage, list[OutboundMessage]]: ) -> tuple[OutboundMessage, list[OutboundMessage]]:
"""Merge consecutive _stream_delta messages for the same (channel, chat_id). """Merge consecutive stream deltas for the same (channel, chat_id, stream_id).
This reduces the number of API calls when the queue has accumulated multiple 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. deltas, which happens when LLM generates faster than the channel can process.
@@ -404,9 +499,15 @@ class ChannelManager:
Returns: Returns:
tuple of (merged_message, list_of_non_matching_messages) tuple of (merged_message, list_of_non_matching_messages)
""" """
target_key = (first_msg.channel, first_msg.chat_id) 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)
combined_content = first_msg.content combined_content = first_msg.content
final_metadata = dict(first_msg.metadata or {}) final_event: StreamDeltaEvent | StreamEndEvent = (
first_event
if isinstance(first_event, StreamDeltaEvent)
else StreamDeltaEvent(stream_id=first_stream_id)
)
non_matching: list[OutboundMessage] = [] non_matching: list[OutboundMessage] = []
# Only merge consecutive deltas. As soon as we hit any other message, # Only merge consecutive deltas. As soon as we hit any other message,
@@ -418,16 +519,29 @@ class ChannelManager:
break break
# Check if this message belongs to the same stream # Check if this message belongs to the same stream
same_target = (next_msg.channel, next_msg.chat_id) == target_key next_event = outbound_event_from_message(next_msg)
is_delta = next_msg.metadata and next_msg.metadata.get("_stream_delta") next_stream_id = (
is_end = next_msg.metadata and next_msg.metadata.get("_stream_end") next_event.stream_id
if isinstance(next_event, StreamDeltaEvent | StreamEndEvent)
else None
)
same_target = (
next_msg.channel,
next_msg.chat_id,
next_stream_id,
) == target_key
is_delta = isinstance(next_event, StreamDeltaEvent)
is_end = isinstance(next_event, StreamEndEvent)
if same_target and is_delta and not final_metadata.get("_stream_end"): if same_target and (is_delta or (is_end and next_msg.content)):
# Accumulate content # Accumulate content
combined_content += next_msg.content combined_content += next_msg.content
# If we see _stream_end, remember it and stop coalescing this stream # If we see stream_end, remember it and stop coalescing this stream
if is_end: if isinstance(next_event, StreamEndEvent):
final_metadata["_stream_end"] = True final_event = StreamEndEvent(
stream_id=next_stream_id,
resuming=next_event.resuming,
)
# Stream ended - stop coalescing this stream # Stream ended - stop coalescing this stream
break break
else: else:
@@ -435,12 +549,7 @@ class ChannelManager:
non_matching.append(next_msg) non_matching.append(next_msg)
break break
merged = OutboundMessage( merged = replace_outbound_event(first_msg, final_event, content=combined_content)
channel=first_msg.channel,
chat_id=first_msg.chat_id,
content=combined_content,
metadata=final_metadata,
)
return merged, non_matching return merged, non_matching
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None: async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
+13 -4
View File
@@ -49,6 +49,7 @@ except ImportError as e:
) from e ) from e
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.config.paths import get_data_dir, get_media_dir
@@ -504,7 +505,7 @@ class MatrixChannel(BaseChannel):
text = msg.content or "" text = msg.content or ""
candidates = self._collect_outbound_media_candidates(msg.media) candidates = self._collect_outbound_media_candidates(msg.media)
relates_to = self._build_thread_relates_to(msg.metadata) relates_to = self._build_thread_relates_to(msg.metadata)
is_progress = bool((msg.metadata or {}).get("_progress")) is_progress = isinstance(msg.event, ProgressEvent)
try: try:
failures: list[str] = [] failures: list[str] = []
if candidates: if candidates:
@@ -528,11 +529,19 @@ class MatrixChannel(BaseChannel):
if not is_progress: if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) 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) -> None: async def send_delta(
meta = metadata or {} 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:
relates_to = self._build_thread_relates_to(metadata) relates_to = self._build_thread_relates_to(metadata)
if meta.get("_stream_end"): if stream_end:
buf = self._stream_bufs.pop(chat_id, None) buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.event_id or not buf.text: if not buf or not buf.event_id or not buf.text:
return return
+2 -1
View File
@@ -18,6 +18,7 @@ import httpx
from pydantic import Field, computed_field, field_validator from pydantic import Field, computed_field, field_validator
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@@ -539,7 +540,7 @@ class SignalChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Signal.""" """Send a message through Signal."""
is_progress_message = bool(msg.metadata.get("_progress")) is_progress_message = isinstance(msg.event, ProgressEvent)
try: try:
plain_text, text_styles = _markdown_to_signal(msg.content) plain_text, text_styles = _markdown_to_signal(msg.content)
if not plain_text and not msg.media: if not plain_text and not msg.media:
+3 -2
View File
@@ -14,6 +14,7 @@ from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown from slackify_markdown import slackify_markdown
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@@ -164,7 +165,7 @@ class SlackChannel(BaseChannel):
# only makes sense within the originating conversation. # only makes sense within the originating conversation.
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
is_progress = (msg.metadata or {}).get("_progress", False) is_progress = isinstance(msg.event, ProgressEvent)
if is_progress and not msg.content: if is_progress and not msg.content:
pass # skip empty progress messages (e.g. tool-event-only updates) pass # skip empty progress messages (e.g. tool-event-only updates)
elif msg.content or not (msg.media or []): elif msg.content or not (msg.media or []):
@@ -190,7 +191,7 @@ class SlackChannel(BaseChannel):
self.logger.exception("Failed to upload file {}", media_path) self.logger.exception("Failed to upload file {}", media_path)
# Update reaction emoji when the final (non-progress) response is sent # Update reaction emoji when the final (non-progress) response is sent
if not (msg.metadata or {}).get("_progress"): if not is_progress:
event = slack_meta.get("event", {}) event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts")) await self._update_react_emoji(origin_chat_id, event.get("ts"))
+17 -6
View File
@@ -26,6 +26,7 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag
from telegram.request import HTTPXRequest from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text from nanobot.command.builtin import build_help_text
@@ -36,7 +37,7 @@ from nanobot.utils.helpers import split_message
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a # Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
# safety margin for mid-stream edits (plain text). For _stream_end, we split # safety margin for mid-stream edits (plain text). On stream end, we split
# raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char # raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char
# boundary so the final rendered message never overflows. # boundary so the final rendered message never overflows.
TELEGRAM_HTML_MAX_LEN = 4096 TELEGRAM_HTML_MAX_LEN = 4096
@@ -706,8 +707,10 @@ class TelegramChannel(BaseChannel):
self.logger.warning("bot not running") self.logger.warning("bot not running")
return return
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
# Only stop typing indicator and remove reaction for final responses # Only stop typing indicator and remove reaction for final responses
if not msg.metadata.get("_progress", False): if progress_event is None:
self._stop_typing(msg.chat_id) self._stop_typing(msg.chat_id)
if reply_to_message_id := msg.metadata.get("message_id"): if reply_to_message_id := msg.metadata.get("message_id"):
with suppress(ValueError): with suppress(ValueError):
@@ -792,7 +795,7 @@ class TelegramChannel(BaseChannel):
# Send text content # Send text content
if msg.content and msg.content != "[empty message]": if msg.content and msg.content != "[empty message]":
render_as_blockquote = bool(msg.metadata.get("_tool_hint")) render_as_blockquote = bool(progress_event and progress_event.tool_hint)
buttons = getattr(msg, "buttons", None) or [] buttons = getattr(msg, "buttons", None) or []
reply_markup = self._build_keyboard(buttons) if buttons else None reply_markup = self._build_keyboard(buttons) if buttons else None
text = msg.content text = msg.content
@@ -887,15 +890,23 @@ class TelegramChannel(BaseChannel):
def _is_not_modified_error(exc: Exception) -> bool: def _is_not_modified_error(exc: Exception) -> bool:
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower() 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) -> None: 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:
"""Progressive message editing: send on first delta, edit on subsequent ones.""" """Progressive message editing: send on first delta, edit on subsequent ones."""
if not self._app: if not self._app:
return return
meta = metadata or {} meta = metadata or {}
int_chat_id = int(chat_id) int_chat_id = int(chat_id)
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"): if stream_end:
buf = self._stream_bufs.get(chat_id) buf = self._stream_bufs.get(chat_id)
if not buf or not buf.message_id or not buf.text: if not buf or not buf.message_id or not buf.text:
return return
+56 -50
View File
@@ -19,6 +19,16 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@@ -148,16 +158,13 @@ def publish_runtime_model_update(
model_preset: str | None, model_preset: str | None,
) -> None: ) -> None:
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel).""" """Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(OutboundMessage( bus.outbound.put_nowait(
outbound_message_for_event(
channel="websocket", channel="websocket",
chat_id="*", chat_id="*",
content="", event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
metadata={ )
"_runtime_model_updated": True, )
"model": model,
"model_preset": model_preset,
},
))
def _parse_inbound_payload(raw: str) -> str | None: def _parse_inbound_payload(raw: str) -> str | None:
@@ -851,70 +858,63 @@ class WebSocketChannel(BaseChannel):
raise raise
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
if msg.metadata.get("_runtime_model_updated"): event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
if isinstance(event, RuntimeModelUpdatedEvent):
await self.send_runtime_model_updated( await self.send_runtime_model_updated(
model_name=msg.metadata.get("model"), model_name=event.model,
model_preset=msg.metadata.get("model_preset"), model_preset=event.model_preset,
) )
return return
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe. # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ())) conns = list(self._subs.get(msg.chat_id, ()))
if not conns: if not conns:
if ( if isinstance(
msg.metadata.get("_progress") event,
or msg.metadata.get("_file_edit_events") ProgressEvent
or msg.metadata.get("_turn_end") | TurnEndEvent
or msg.metadata.get("_session_updated") | SessionUpdatedEvent
or msg.metadata.get("_goal_status") | GoalStatusEvent
or msg.metadata.get("_goal_state_sync") | GoalStateSyncEvent,
): ):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id) self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else: else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id) self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
if msg.metadata.get("_goal_state_sync"): if isinstance(event, GoalStateSyncEvent):
if conns: if conns:
blob = msg.metadata.get("goal_state") await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
return return
if msg.metadata.get("_goal_status"): if isinstance(event, GoalStatusEvent):
if conns: if conns:
status = msg.metadata.get("goal_status") if event.status in ("running", "idle"):
if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
await self.send_goal_status( await self.send_goal_status(
msg.chat_id, msg.chat_id,
status, event.status,
started_at=float(started_raw) if isinstance(started_raw, int | float) else None, started_at=event.started_at,
) )
return return
# Signal that the agent has fully finished processing the current turn. # Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"): if isinstance(event, TurnEndEvent):
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( await self.send_turn_end(
msg.chat_id, msg.chat_id,
latency_ms=lat_i, latency_ms=event.latency_ms,
goal_state=gs_blob, goal_state=event.goal_state,
metadata=msg.metadata, metadata=msg.metadata,
) )
await self.send_session_updated(msg.chat_id, scope="thread") await self.send_session_updated(msg.chat_id, scope="thread")
return return
if msg.metadata.get("_session_updated"): if isinstance(event, SessionUpdatedEvent):
if conns: if conns:
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated( await self.send_session_updated(
msg.chat_id, msg.chat_id,
scope=scope if isinstance(scope, str) else None, scope=event.scope,
) )
return return
if msg.metadata.get("_file_edit_events"): if progress_event and progress_event.file_edit_events:
edits = msg.metadata.get("_file_edit_events")
await self.send_file_edit_events( await self.send_file_edit_events(
msg.chat_id, msg.chat_id,
edits if isinstance(edits, list) else [], progress_event.file_edit_events,
msg.metadata, msg.metadata,
) )
return return
@@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel):
lat = msg.metadata.get("latency_ms") lat = msg.metadata.get("latency_ms")
if isinstance(lat, (int, float)): if isinstance(lat, (int, float)):
payload["latency_ms"] = int(lat) payload["latency_ms"] = int(lat)
if msg.metadata.get("_tool_events"): if progress_event and progress_event.tool_events:
payload["tool_events"] = msg.metadata["_tool_events"] payload["tool_events"] = progress_event.tool_events
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI) agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
if agent_ui is not None: if agent_ui is not None:
payload["agent_ui"] = agent_ui payload["agent_ui"] = agent_ui
# Mark intermediate agent breadcrumbs (tool-call hints, generic # Mark intermediate agent breadcrumbs (tool-call hints, generic
# progress strings) so WS clients can render them as subordinate # progress strings) so WS clients can render them as subordinate
# trace rows rather than conversational replies. # trace rows rather than conversational replies.
if msg.metadata.get("_tool_hint"): if progress_event and progress_event.tool_hint:
payload["kind"] = "tool_hint" payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"): elif progress_event:
payload["kind"] = "progress" payload["kind"] = "progress"
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer" phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
@@ -971,6 +971,8 @@ class WebSocketChannel(BaseChannel):
chat_id: str, chat_id: str,
delta: str, delta: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so """Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
clients receive a stream that opens, updates in place, and closes clients receive a stream that opens, updates in place, and closes
@@ -986,7 +988,6 @@ class WebSocketChannel(BaseChannel):
"chat_id": chat_id, "chat_id": chat_id,
"text": delta, "text": delta,
} }
stream_id = meta.get("_stream_id")
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id body["stream_id"] = stream_id
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
@@ -1005,6 +1006,8 @@ class WebSocketChannel(BaseChannel):
self, self,
chat_id: str, chat_id: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None: ) -> None:
"""Close the current reasoning stream segment for in-place renderers.""" """Close the current reasoning stream segment for in-place renderers."""
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
@@ -1013,7 +1016,6 @@ class WebSocketChannel(BaseChannel):
"event": "reasoning_end", "event": "reasoning_end",
"chat_id": chat_id, "chat_id": chat_id,
} }
stream_id = meta.get("_stream_id")
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id body["stream_id"] = stream_id
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
@@ -1057,11 +1059,15 @@ class WebSocketChannel(BaseChannel):
chat_id: str, chat_id: str,
delta: str, delta: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None: ) -> None:
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
meta = metadata or {} meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or "")) stream_key = (chat_id, str(stream_id or ""))
if meta.get("_stream_end"): if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id} body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = self._stream_text_buffers.pop(stream_key, []) buffered = self._stream_text_buffers.pop(stream_key, [])
if delta: if delta:
@@ -1077,8 +1083,8 @@ class WebSocketChannel(BaseChannel):
"text": delta, "text": delta,
} }
self._stream_text_buffers.setdefault(stream_key, []).append(delta) self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None: if stream_id is not None:
body["stream_id"] = meta["_stream_id"] body["stream_id"] = stream_id
self._transcripts.prepare_and_append( self._transcripts.prepare_and_append(
chat_id, chat_id,
body, body,
+2 -1
View File
@@ -13,6 +13,7 @@ from typing import Any
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@@ -497,7 +498,7 @@ class WecomChannel(BaseChannel):
try: try:
content = (msg.content or "").strip() content = (msg.content or "").strip()
is_progress = bool(msg.metadata.get("_progress")) is_progress = isinstance(msg.event, ProgressEvent)
# Get the stored frame for this chat # Get the stored frame for this chat
frame = self._chat_frames.get(msg.chat_id) frame = self._chat_frames.get(msg.chat_id)
+53 -9
View File
@@ -29,6 +29,7 @@ from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir from nanobot.config.paths import get_media_dir, get_runtime_subdir
@@ -129,6 +130,13 @@ class WeixinConfig(Base):
token: str = "" # Manually set token, or obtained via QR login token: str = "" # Manually set token, or obtained via QR login
state_dir: str = "" # Default: ~/.nanobot/weixin/ state_dir: str = "" # Default: ~/.nanobot/weixin/
poll_timeout: int = DEFAULT_LONG_POLL_TIMEOUT_S # seconds for long-poll poll_timeout: int = DEFAULT_LONG_POLL_TIMEOUT_S # seconds for long-poll
# Default on: WeChat iLink has no native incremental delivery (send_delta is
# buffered and the final answer is still sent in one shot), so streaming has
# zero user-facing effect here — it only switches the LLM call to the
# streaming API. That avoids upstream Anthropic relays that drop tool_use
# id/name/input on the non-streaming Messages path (a common third-party
# relay bug). Set to false only if a relay's streaming/SSE path is broken.
streaming: bool = True
class WeixinChannel(BaseChannel): class WeixinChannel(BaseChannel):
@@ -167,6 +175,10 @@ class WeixinChannel(BaseChannel):
self._typing_tickets: dict[str, dict[str, Any]] = {} self._typing_tickets: dict[str, dict[str, Any]] = {}
self._context_token_at: dict[str, float] = {} self._context_token_at: dict[str, float] = {}
self._pending_tool_hints: dict[str, list[str]] = {} self._pending_tool_hints: dict[str, list[str]] = {}
# Buffers streamed content deltas per chat. WeChat iLink has no native
# incremental delivery, so when streaming is enabled we accumulate the
# deltas and flush the full reply in one shot at _stream_end.
self._stream_buffers: dict[str, list[str]] = {}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# State persistence # State persistence
@@ -1090,11 +1102,13 @@ class WeixinChannel(BaseChannel):
raise RuntimeError("WeChat client not initialized or not authenticated") raise RuntimeError("WeChat client not initialized or not authenticated")
self._assert_session_active() self._assert_session_active()
is_progress = bool((msg.metadata or {}).get("_progress", False)) event = getattr(msg, "event", None)
progress_event = event if isinstance(event, ProgressEvent) else None
is_progress = progress_event is not None
# Buffer tool hints to coalesce consecutive ones and avoid burning # Buffer tool hints to coalesce consecutive ones and avoid burning
# WeChat iLink rate-limit quota (~7 msgs / 5 min). # WeChat iLink rate-limit quota (~7 msgs / 5 min).
if is_progress and (msg.metadata or {}).get("_tool_hint"): if progress_event and progress_event.tool_hint:
if not self.send_tool_hints: if not self.send_tool_hints:
return return
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content) self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
@@ -1107,7 +1121,7 @@ class WeixinChannel(BaseChannel):
# Reasoning deltas are invisible in WeChat (there is no reasoning # Reasoning deltas are invisible in WeChat (there is no reasoning
# UI). Skip them entirely — do not send and do not flush buffer. # UI). Skip them entirely — do not send and do not flush buffer.
if is_progress and (msg.metadata or {}).get("_reasoning_delta"): if progress_event and (progress_event.reasoning_delta or progress_event.reasoning):
self.logger.debug( self.logger.debug(
"Dropped invisible reasoning delta for {}", msg.chat_id "Dropped invisible reasoning delta for {}", msg.chat_id
) )
@@ -1221,16 +1235,46 @@ class WeixinChannel(BaseChannel):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
async def send_delta( async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None 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: ) -> None:
"""Weixin iLink does not support native streaming deltas. """Deliver a streamed reply to WeChat.
We only hook ``_stream_end`` so buffered tool hints are flushed even WeChat iLink has no native incremental delivery, and the manager
when the final answer carries the ``_streamed`` flag and bypasses bypasses :meth:`send` for the ``_streamed`` final answer. So we
:meth:`send`. accumulate content deltas and flush the full reply as a single message
at stream end. Reasoning deltas are invisible in WeChat and are dropped.
""" """
if metadata and metadata.get("_stream_end"): 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
# (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)
if not is_end:
return
full = ("".join(self._stream_buffers.get(buffer_key, [])) + (delta or "")).strip()
await self._flush_tool_hints(chat_id) 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.
await self.send(
OutboundMessage(channel=self.name, chat_id=chat_id, content=full)
)
self._stream_buffers.pop(buffer_key, None)
async def _start_typing(self, chat_id: str, context_token: str = "") -> None: async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
"""Start typing indicator immediately when a message is received.""" """Start typing indicator immediately when a message is received."""
+27
View File
@@ -499,6 +499,30 @@ class WhatsAppChannel(BaseChannel):
self._self_jids.add(jid) self._self_jids.add(jid)
self._self_jids.add(_bare_jid(jid)) self._self_jids.add(_bare_jid(jid))
async def _send_read_receipt(self, client: Any, source: Any, message_id: str) -> None:
"""Send a read receipt (blue double-check) for an incoming message.
Best-effort: any failure is logged at debug level and swallowed so it
never blocks message processing.
"""
if not message_id:
return
try:
from neonize.utils.enum import ReceiptType
chat = _safe_attr(source, "Chat")
sender = _safe_attr(source, "Sender")
if chat is None or sender is None:
return
await client.mark_read(
message_id,
chat=chat,
sender=sender,
receipt=ReceiptType.READ,
)
except Exception as exc: # noqa: BLE001 - read receipt is best-effort
self.logger.debug("Failed to send WhatsApp read receipt: {}", exc)
async def _handle_neonize_message(self, client: Any, event: Any) -> None: async def _handle_neonize_message(self, client: Any, event: Any) -> None:
info = _safe_attr(event, "Info") info = _safe_attr(event, "Info")
message = _safe_attr(event, "Message") message = _safe_attr(event, "Message")
@@ -532,6 +556,9 @@ class WhatsAppChannel(BaseChannel):
while len(self._processed_message_ids) > 1000: while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False) self._processed_message_ids.popitem(last=False)
# Mark the incoming message as read (blue double-check). Best-effort.
await self._send_read_receipt(client, source, message_id)
participant_jid = _normalize_jid(_safe_attr(source, "Sender")) participant_jid = _normalize_jid(_safe_attr(source, "Sender"))
sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt")) sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt"))
sender_candidates = [sender_alt_jid, participant_jid] sender_candidates = [sender_alt_jid, participant_jid]
+97 -18
View File
@@ -50,6 +50,14 @@ from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402 from nanobot import __logo__, __version__ # noqa: E402
from nanobot.agent.loop import AgentLoop # 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.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402 from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402 from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
@@ -461,25 +469,25 @@ async def _maybe_print_interactive_progress(
renderer: StreamRenderer | None = None, renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None, reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool: ) -> bool:
metadata = msg.metadata or {} event = outbound_event_from_message(msg)
if metadata.get("_retry_wait"): if isinstance(event, RetryWaitEvent):
await _print_interactive_progress_line(msg.content, thinking, renderer) await _print_interactive_progress_line(msg.content, thinking, renderer)
return True return True
if not metadata.get("_progress"): if not isinstance(event, ProgressEvent):
return False return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer() reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if metadata.get("_reasoning_end"): if event.reasoning_end:
if channels_config and not channels_config.show_reasoning: if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear() reasoning_buffer.clear()
else: else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer) _flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True return True
is_tool_hint = metadata.get("_tool_hint", False) is_tool_hint = event.tool_hint
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False) is_reasoning = event.reasoning or event.reasoning_delta
if is_reasoning: if is_reasoning:
if channels_config and not channels_config.show_reasoning: if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear() reasoning_buffer.clear()
@@ -798,14 +806,24 @@ def serve(
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}") console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
console.print(" [cyan]Session[/cyan] : api:default") console.print(" [cyan]Session[/cyan] : api:default")
console.print(f" [cyan]Timeout[/cyan] : {timeout}s") 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 host in {"0.0.0.0", "::"}:
if not api_key:
console.print( console.print(
"[yellow]Warning:[/yellow] API is bound to all interfaces. " "[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
"Only do this behind a trusted network boundary, firewall, or reverse proxy." "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]"
) )
console.print() console.print()
api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout) api_app = create_app(
agent_loop, model_name=model_name, request_timeout=timeout,
api_key=api_key,
)
async def on_startup(_app): async def on_startup(_app):
await agent_loop._connect_mcp() await agent_loop._connect_mcp()
@@ -1446,7 +1464,7 @@ def agent(
bus_task = asyncio.create_task(agent_loop.run()) bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event() turn_done = asyncio.Event()
turn_done.set() turn_done.set()
turn_response: list[tuple[str, dict]] = [] turn_response: list[Any] = []
renderer: StreamRenderer | None = None renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer() reasoning_buffer = _ReasoningBuffer()
@@ -1454,18 +1472,19 @@ def agent(
while True: while True:
try: try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
event = outbound_event_from_message(msg)
if msg.metadata.get("_stream_delta"): if isinstance(event, StreamDeltaEvent):
if renderer: if renderer:
await renderer.on_delta(msg.content) await renderer.on_delta(msg.content)
continue continue
if msg.metadata.get("_stream_end"): if isinstance(event, StreamEndEvent):
if renderer: if renderer:
await renderer.on_end( await renderer.on_end(
resuming=msg.metadata.get("_resuming", False), resuming=event.resuming,
) )
continue continue
if msg.metadata.get("_streamed"): if isinstance(event, StreamedResponseEvent):
turn_done.set() turn_done.set()
continue continue
@@ -1480,7 +1499,7 @@ def agent(
if not turn_done.is_set(): if not turn_done.is_set():
if msg.content: if msg.content:
turn_response.append((msg.content, dict(msg.metadata or {}))) turn_response.append(msg)
turn_done.set() turn_done.set()
elif msg.content: elif msg.content:
await _print_interactive_response( await _print_interactive_response(
@@ -1533,8 +1552,10 @@ def agent(
await turn_done.wait() await turn_done.wait()
if turn_response: if turn_response:
content, meta = turn_response[0] response_msg = turn_response[0]
if content and not meta.get("_streamed"): content = response_msg.content
meta = response_msg.metadata
if content and not isinstance(response_msg.event, StreamedResponseEvent):
if renderer: if renderer:
await renderer.close() await renderer.close()
print_kwargs: dict[str, Any] = {} print_kwargs: dict[str, Any] = {}
@@ -1744,6 +1765,11 @@ _PROVIDER_DISPLAY: dict[str, str] = {
"github_copilot": "GitHub Copilot", "github_copilot": "GitHub Copilot",
} }
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
"openai_codex": "openai-codex/gpt-5.4-mini",
"github_copilot": "github-copilot/gpt-5.4-mini",
}
def _register_login(name: str): def _register_login(name: str):
"""Register an OAuth login handler.""" """Register an OAuth login handler."""
@@ -1775,9 +1801,51 @@ def _resolve_oauth_provider(provider: str):
return spec return spec
def _set_oauth_provider_as_main(
provider_name: str,
*,
model: str | None = None,
config_path: str | None = None,
) -> None:
"""Persist an OAuth provider as the active agent provider."""
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None:
set_config_path(resolved_config_path)
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
config = load_config(resolved_config_path)
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
config.agents.defaults.model_preset = None
config.agents.defaults.provider = provider_name
config.agents.defaults.model = selected_model
save_config(config, resolved_config_path)
saved_path = resolved_config_path or get_config_path()
console.print(
f"[green]✓ Set {provider_name.replace('_', '-')} as the main provider[/green] "
f"[dim]{selected_model}[/dim]"
)
console.print(f"[dim]Saved: {saved_path}[/dim]")
@provider_app.command("login") @provider_app.command("login")
def provider_login( def provider_login(
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
set_main: bool = typer.Option(
False,
"--set-main",
"--main",
help="Set this OAuth provider as the active agent provider after login",
),
model: str | None = typer.Option(
None,
"--model",
"-m",
help="Model to use when setting this provider as the active provider",
),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
): ):
"""Authenticate with an OAuth provider.""" """Authenticate with an OAuth provider."""
spec = _resolve_oauth_provider(provider) spec = _resolve_oauth_provider(provider)
@@ -1789,6 +1857,8 @@ def provider_login(
console.print(f"{__logo__} OAuth Login - {spec.label}\n") console.print(f"{__logo__} OAuth Login - {spec.label}\n")
handler() handler()
if set_main or model:
_set_oauth_provider_as_main(spec.name, model=model, config_path=config)
@provider_app.command("logout") @provider_app.command("logout")
@@ -1812,14 +1882,23 @@ def _login_openai_codex() -> None:
try: try:
from oauth_cli_kit import get_token, login_oauth_interactive from oauth_cli_kit import get_token, login_oauth_interactive
from nanobot.config.loader import load_config, resolve_config_env_vars
proxy = None
try:
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
except ValueError as e:
console.print(f"[red]{e}[/red]")
raise typer.Exit(1) from e
token = None token = None
with suppress(Exception): with suppress(Exception):
token = get_token() token = get_token(proxy=proxy)
if not (token and token.access): if not (token and token.access):
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n") console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
token = login_oauth_interactive( token = login_oauth_interactive(
print_fn=lambda s: console.print(s), print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s), prompt_fn=lambda s: typer.prompt(s),
proxy=proxy,
) )
if not (token and token.access): if not (token and token.access):
console.print("[red]✗ Authentication failed[/red]") console.print("[red]✗ Authentication failed[/red]")
+25 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import subprocess
import sys import sys
import time import time
from contextlib import suppress from contextlib import suppress
@@ -50,7 +51,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
BuiltinCommandSpec( BuiltinCommandSpec(
"/restart", "/restart",
"Restart nanobot", "Restart nanobot",
"Restart the bot process in place.", "Restart the bot process.",
"rotate-cw", "rotate-cw",
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
@@ -130,6 +131,15 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
loop = ctx.loop loop = ctx.loop
msg = ctx.msg msg = ctx.msg
total = await loop._cancel_active_tasks(ctx.key) total = await loop._cancel_active_tasks(ctx.key)
# Also drain pending queue to prevent mid-turn injection deadlock
pending = loop._pending_queues.pop(ctx.key, None)
if pending is not None:
while not pending.empty():
try:
pending.get_nowait()
total += 1
except Exception:
break
content = f"Stopped {total} task(s)." if total else "No active task to stop." content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage( return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, channel=msg.channel, chat_id=msg.chat_id, content=content,
@@ -138,7 +148,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
async def cmd_restart(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
"""Restart the process in-place via os.execv.""" """Restart the process."""
msg = ctx.msg msg = ctx.msg
set_restart_notice_to_env( set_restart_notice_to_env(
channel=msg.channel, channel=msg.channel,
@@ -148,7 +158,19 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
async def _do_restart(): async def _do_restart():
await asyncio.sleep(1) await asyncio.sleep(1)
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:]) argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:]
mode = getattr(ctx.loop, "restart_mode", "auto") or "auto"
if mode == "auto":
mode = "spawn" if sys.platform == "win32" else "exec"
if mode == "exec":
os.execv(sys.executable, argv)
return
if mode == "spawn":
kwargs = {}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen(argv, **kwargs)
os._exit(0)
asyncio.create_task(_do_restart()) asyncio.create_task(_do_restart())
return OutboundMessage( return OutboundMessage(
+22
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Any from typing import Any
import pydantic import pydantic
from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs from nanobot.config.schema import Config, _resolve_tool_config_refs
@@ -79,6 +80,10 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
data = config.model_dump(mode="json", by_alias=True) data = config.model_dump(mode="json", by_alias=True)
if config.providers.openai_codex.proxy is not None:
data.setdefault("providers", {})["openaiCodex"] = {
"proxy": config.providers.openai_codex.proxy,
}
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2, ensure_ascii=False)
@@ -152,6 +157,23 @@ def _env_replace(match: re.Match[str]) -> str:
def _migrate_config(data: dict) -> dict: def _migrate_config(data: dict) -> dict:
"""Migrate old config formats to current.""" """Migrate old config formats to current."""
agents = data.get("agents", {})
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
if isinstance(defaults, dict):
had_legacy_max_messages = (
"maxMessages" in defaults or "max_messages" in defaults
)
defaults.pop("maxMessages", None)
defaults.pop("max_messages", None)
if had_legacy_max_messages:
# TODO(next version): Remove this legacy cleanup branch; the schema
# will silently ignore this field once the warning grace period ends.
logger.warning(
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
"replay max messages is now an internal safety cap. Remove it from "
"config. This compatibility warning will be removed in the next version."
)
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace # Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
tools = data.get("tools", {}) tools = data.get("tools", {})
exec_cfg = tools.get("exec", {}) exec_cfg = tools.get("exec", {})
+14 -4
View File
@@ -154,10 +154,6 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"), validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
serialization_alias="idleCompactAfterMinutes", serialization_alias="idleCompactAfterMinutes",
) # Auto-compact idle threshold in minutes (0 = disabled) ) # Auto-compact idle threshold in minutes (0 = disabled)
max_messages: int = Field(
default=120,
ge=0,
) # Max messages to replay from session history (0 = use default 120, respects token budget)
consolidation_ratio: float = Field( consolidation_ratio: float = Field(
default=0.5, default=0.5,
ge=0.1, ge=0.1,
@@ -183,6 +179,7 @@ class ProviderConfig(Base):
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways) extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
proxy: str | None = None # OpenAI-compatible/Codex HTTP proxy URL
thinking_style: str | None = None # Thinking/reasoning style for custom providers thinking_style: str | None = None # Thinking/reasoning style for custom providers
# Valid values mirror the keys of _THINKING_STYLE_MAP in # Valid values mirror the keys of _THINKING_STYLE_MAP in
@@ -310,6 +307,18 @@ class ApiConfig(Base):
host: str = "127.0.0.1" # Safer default: local-only bind. host: str = "127.0.0.1" # Safer default: local-only bind.
port: int = 8900 port: int = 8900
timeout: float = 120.0 # Per-request timeout in seconds. 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): class GatewayConfig(Base):
@@ -317,6 +326,7 @@ class GatewayConfig(Base):
host: str = "127.0.0.1" # Safer default: local-only bind. host: str = "127.0.0.1" # Safer default: local-only bind.
port: int = 18790 port: int = 18790
restart_mode: Literal["auto", "exec", "spawn", "exit"] = "auto"
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
+34 -10
View File
@@ -1,6 +1,7 @@
"""Cron service for scheduling agent tasks.""" """Cron service for scheduling agent tasks."""
import asyncio import asyncio
import errno
import json import json
import os import os
import time import time
@@ -357,6 +358,25 @@ class CronService:
return self._store return self._store
def _require_store(self) -> CronStore:
"""Return a usable store or raise a clear error.
``_load_store`` deliberately returns ``None`` when the first load sees
a corrupt on-disk store and no previous in-memory snapshot exists. The
public API requires a concrete store object before touching
``store.jobs``; raising here keeps callers from seeing an accidental
``AttributeError`` and, more importantly, prevents follow-up saves from
treating a corrupt store as an empty one.
"""
store = self._load_store()
if store is None:
raise RuntimeError(
f"cron store at {self.store_path} could not be loaded and was preserved "
"as a .corrupt-<ts> backup; refusing to operate to avoid overwriting "
"scheduled jobs. Inspect the corrupt backup and restore jobs.json manually."
)
return store
def _save_store(self) -> None: def _save_store(self) -> None:
"""Save jobs to disk.""" """Save jobs to disk."""
if not self._store: if not self._store:
@@ -437,11 +457,15 @@ class CronService:
os.replace(tmp_path, path) os.replace(tmp_path, path)
# fsync the parent directory so the rename itself is durable. # fsync the parent directory so the rename itself is durable.
# Skip on Windows where opening a directory raises PermissionError; # Skip on Windows where opening a directory raises PermissionError;
# NTFS journals metadata synchronously so this is a no-op there. # some shared filesystems reject directory fsync with EINVAL.
with suppress(PermissionError): with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY) fd = os.open(str(path.parent), os.O_RDONLY)
try:
try: try:
os.fsync(fd) os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally: finally:
os.close(fd) os.close(fd)
except BaseException: except BaseException:
@@ -622,7 +646,7 @@ class CronService:
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
"""List all jobs.""" """List all jobs."""
store = self._load_store() store = self._require_store()
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled] jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf')) return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
@@ -684,7 +708,7 @@ class CronService:
_normalize_agent_turn_job(job) _normalize_agent_turn_job(job)
self._enforce_agent_binding(job) self._enforce_agent_binding(job)
if self._running: if self._running:
store = self._load_store() store = self._require_store()
store.jobs.append(job) store.jobs.append(job)
self._save_store() self._save_store()
self._arm_timer() self._arm_timer()
@@ -696,7 +720,7 @@ class CronService:
def register_system_job(self, job: CronJob) -> CronJob: def register_system_job(self, job: CronJob) -> CronJob:
"""Register an internal system job (idempotent on restart).""" """Register an internal system job (idempotent on restart)."""
store = self._load_store() store = self._require_store()
now = _now_ms() now = _now_ms()
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now)) job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
job.created_at_ms = now job.created_at_ms = now
@@ -710,7 +734,7 @@ class CronService:
def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]: def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]:
"""Remove a job by ID, unless it is a protected system job.""" """Remove a job by ID, unless it is a protected system job."""
store = self._load_store() store = self._require_store()
job = next((j for j in store.jobs if j.id == job_id), None) job = next((j for j in store.jobs if j.id == job_id), None)
if job is None: if job is None:
return "not_found" return "not_found"
@@ -735,7 +759,7 @@ class CronService:
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None: def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
"""Enable or disable a job.""" """Enable or disable a job."""
store = self._load_store() store = self._require_store()
for job in store.jobs: for job in store.jobs:
if job.id == job_id: if job.id == job_id:
job.enabled = enabled job.enabled = enabled
@@ -770,7 +794,7 @@ class CronService:
For ``channel`` and ``to``, pass an explicit value (including ``None``) For ``channel`` and ``to``, pass an explicit value (including ``None``)
to update; omit (sentinel ``...``) to leave unchanged. to update; omit (sentinel ``...``) to leave unchanged.
""" """
store = self._load_store() store = self._require_store()
job = next((j for j in store.jobs if j.id == job_id), None) job = next((j for j in store.jobs if j.id == job_id), None)
if job is None: if job is None:
return "not_found" return "not_found"
@@ -815,7 +839,7 @@ class CronService:
was_running = self._running was_running = self._running
self._running = True self._running = True
try: try:
store = self._load_store() store = self._require_store()
for job in store.jobs: for job in store.jobs:
if job.id == job_id: if job.id == job_id:
if self._is_unbound_agent_job(job): if self._is_unbound_agent_job(job):
@@ -835,12 +859,12 @@ class CronService:
def get_job(self, job_id: str) -> CronJob | None: def get_job(self, job_id: str) -> CronJob | None:
"""Get a job by ID.""" """Get a job by ID."""
store = self._load_store() store = self._require_store()
return next((j for j in store.jobs if j.id == job_id), None) return next((j for j in store.jobs if j.id == job_id), None)
def status(self) -> dict: def status(self) -> dict:
"""Get service status.""" """Get service status."""
store = self._load_store() store = self._require_store()
return { return {
"enabled": self._running, "enabled": self._running,
"jobs": len(store.jobs), "jobs": len(store.jobs),
+22 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json
import re import re
import secrets import secrets
import string import string
@@ -275,7 +276,19 @@ class AnthropicProvider(LLMProvider):
blocks.append({"type": "text", "text": content}) blocks.append({"type": "text", "text": content})
elif isinstance(content, list): elif isinstance(content, list):
for item in content: for item in content:
blocks.append(item if isinstance(item, dict) else {"type": "text", "text": str(item)}) if isinstance(item, dict):
if not item.get("type"):
# Anthropic requires every content block to declare a "type".
# A tool that returned a bare dict lands here; coerce it to
# a text block instead of emitting one that the API rejects.
blocks.append({
"type": "text",
"text": AnthropicProvider._stringify_typeless_block(item),
})
else:
blocks.append(item)
else:
blocks.append({"type": "text", "text": str(item)})
for tc in msg.get("tool_calls") or []: for tc in msg.get("tool_calls") or []:
if not isinstance(tc, dict): if not isinstance(tc, dict):
@@ -315,11 +328,18 @@ class AnthropicProvider(LLMProvider):
# A tool that returned a bare dict (or a list of dicts) lands # A tool that returned a bare dict (or a list of dicts) lands
# here; coerce it to a text block instead of emitting a block # here; coerce it to a text block instead of emitting a block
# the API rejects with "content.0.type: Field required". # the API rejects with "content.0.type: Field required".
result.append({"type": "text", "text": str(item)}) result.append({
"type": "text",
"text": AnthropicProvider._stringify_typeless_block(item),
})
continue continue
result.append(item) result.append(item)
return result or "(empty)" return result or "(empty)"
@staticmethod
def _stringify_typeless_block(block: dict[str, Any]) -> str:
return json.dumps(block, ensure_ascii=False, sort_keys=True, default=str)
@staticmethod @staticmethod
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None: def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
"""Convert OpenAI image_url block to Anthropic image block.""" """Convert OpenAI image_url block to Anthropic image block."""
+12
View File
@@ -54,6 +54,18 @@ class ToolCallRequest:
provider_specific_fields: dict[str, Any] | None = None provider_specific_fields: dict[str, Any] | None = None
function_provider_specific_fields: dict[str, Any] | None = None function_provider_specific_fields: dict[str, Any] | None = None
def has_valid_name(self) -> bool:
"""Whether this call carries a usable (non-empty string) tool name.
ToolCallRequest.name is typed ``str`` but not enforced at runtime: a
model/gateway can emit a degenerate call with ``name=None`` or ``""``.
Such a call cannot be executed and, if persisted and replayed, makes
upstream APIs reject the whole request (e.g. Anthropic-style
``messages.content.N.tool_use.name: Input should be a valid string``),
which permanently wedges the session.
"""
return isinstance(self.name, str) and bool(self.name)
def to_openai_tool_call(self) -> dict[str, Any]: def to_openai_tool_call(self) -> dict[str, Any]:
"""Serialize to an OpenAI-style tool_call payload.""" """Serialize to an OpenAI-style tool_call payload."""
arguments = ( arguments = (
+12 -1
View File
@@ -58,6 +58,11 @@ def _make_provider_core(
if spec and spec.is_transcription_only: if spec and spec.is_transcription_only:
raise ValueError(f"Provider '{provider_name}' only supports transcription.") raise ValueError(f"Provider '{provider_name}' only supports transcription.")
backend = spec.backend if spec else "openai_compat" backend = spec.backend if spec else "openai_compat"
if p and p.proxy and backend not in {"openai_compat", "openai_codex"}:
raise ValueError(
f"providers.{provider_name}.proxy is only supported for "
"OpenAI-compatible providers and OpenAI Codex."
)
if backend == "azure_openai": if backend == "azure_openai":
if not p or not p.api_base: if not p or not p.api_base:
@@ -79,7 +84,10 @@ def _make_provider_core(
if backend == "openai_codex": if backend == "openai_codex":
from nanobot.providers.openai_codex_provider import OpenAICodexProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider
provider = OpenAICodexProvider(default_model=model) provider = OpenAICodexProvider(
default_model=model,
proxy=getattr(p, "proxy", None) if p else None,
)
elif backend == "azure_openai": elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
@@ -124,6 +132,7 @@ def _make_provider_core(
extra_body=p.extra_body if p else None, extra_body=p.extra_body if p else None,
api_type=p.api_type if p and provider_name == "openai" else "auto", api_type=p.api_type if p and provider_name == "openai" else "auto",
extra_query=p.extra_query if p else None, extra_query=p.extra_query if p else None,
proxy=p.proxy if p else None,
) )
provider.generation = resolved.to_generation_settings() provider.generation = resolved.to_generation_settings()
@@ -218,6 +227,7 @@ def provider_signature(
fallback.temperature, fallback.temperature,
fallback.reasoning_effort, fallback.reasoning_effort,
fallback.context_window_tokens, fallback.context_window_tokens,
getattr(fp, "proxy", None) if fp else None,
) )
provider_name = config.get_provider_name(resolved.model, preset=resolved) provider_name = config.get_provider_name(resolved.model, preset=resolved)
@@ -237,6 +247,7 @@ def provider_signature(
resolved.temperature, resolved.temperature,
resolved.reasoning_effort, resolved.reasoning_effort,
resolved.context_window_tokens, resolved.context_window_tokens,
getattr(p, "proxy", None) if p else None,
tuple(_fallback_signature(fallback) for fallback in fallback_presets), tuple(_fallback_signature(fallback) for fallback in fallback_presets),
) )
+19 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
import time import time
import webbrowser import webbrowser
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
@@ -29,6 +30,12 @@ _EXPIRY_SKEW_SECONDS = 60
_LONG_LIVED_TOKEN_SECONDS = 315360000 _LONG_LIVED_TOKEN_SECONDS = 315360000
def _resolve(env_var: str, default: str) -> str:
"""Allow GitHub Enterprise / Copilot for Business deployments to override defaults via env."""
value = os.environ.get(env_var)
return value.strip() if value and value.strip() else default
def get_storage() -> FileTokenStorage: def get_storage() -> FileTokenStorage:
return FileTokenStorage( return FileTokenStorage(
token_filename=TOKEN_FILENAME, token_filename=TOKEN_FILENAME,
@@ -68,11 +75,16 @@ def login_github_copilot(
printer = print_fn or print printer = print_fn or print
timeout = httpx.Timeout(20.0, connect=20.0) timeout = httpx.Timeout(20.0, connect=20.0)
client_id = _resolve("NANOBOT_GITHUB_COPILOT_CLIENT_ID", GITHUB_COPILOT_CLIENT_ID)
device_code_url = _resolve("NANOBOT_GITHUB_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL)
access_token_url = _resolve("NANOBOT_GITHUB_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL)
user_url = _resolve("NANOBOT_GITHUB_USER_URL", DEFAULT_GITHUB_USER_URL)
with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client: with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client:
response = client.post( response = client.post(
DEFAULT_GITHUB_DEVICE_CODE_URL, device_code_url,
headers={"Accept": "application/json", "User-Agent": USER_AGENT}, headers={"Accept": "application/json", "User-Agent": USER_AGENT},
data={"client_id": GITHUB_COPILOT_CLIENT_ID, "scope": GITHUB_COPILOT_SCOPE}, data={"client_id": client_id, "scope": GITHUB_COPILOT_SCOPE},
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
@@ -96,10 +108,10 @@ def login_github_copilot(
token_expires_in = _LONG_LIVED_TOKEN_SECONDS token_expires_in = _LONG_LIVED_TOKEN_SECONDS
while time.time() < deadline: while time.time() < deadline:
poll = client.post( poll = client.post(
DEFAULT_GITHUB_ACCESS_TOKEN_URL, access_token_url,
headers={"Accept": "application/json", "User-Agent": USER_AGENT}, headers={"Accept": "application/json", "User-Agent": USER_AGENT},
data={ data={
"client_id": GITHUB_COPILOT_CLIENT_ID, "client_id": client_id,
"device_code": device_code, "device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}, },
@@ -132,7 +144,7 @@ def login_github_copilot(
raise RuntimeError("GitHub device flow timed out.") raise RuntimeError("GitHub device flow timed out.")
user = client.get( user = client.get(
DEFAULT_GITHUB_USER_URL, user_url,
headers={ headers={
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json", "Accept": "application/vnd.github+json",
@@ -164,7 +176,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
self._copilot_expires_at: float = 0.0 self._copilot_expires_at: float = 0.0
super().__init__( super().__init__(
api_key="no-key", api_key="no-key",
api_base=DEFAULT_COPILOT_BASE_URL, api_base=_resolve("NANOBOT_COPILOT_BASE_URL", DEFAULT_COPILOT_BASE_URL),
default_model=default_model, default_model=default_model,
extra_headers={ extra_headers={
"Editor-Version": EDITOR_VERSION, "Editor-Version": EDITOR_VERSION,
@@ -186,7 +198,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
timeout = httpx.Timeout(20.0, connect=20.0) timeout = httpx.Timeout(20.0, connect=20.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client: async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
response = await client.get( response = await client.get(
DEFAULT_COPILOT_TOKEN_URL, _resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers=_copilot_headers(github_token.access), headers=_copilot_headers(github_token.access),
) )
response.raise_for_status() response.raise_for_status()
+17 -5
View File
@@ -33,9 +33,14 @@ class OpenAICodexProvider(LLMProvider):
supports_progress_deltas = True supports_progress_deltas = True
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"): def __init__(
self,
default_model: str = "openai-codex/gpt-5.1-codex",
proxy: str | None = None,
):
super().__init__(api_key=None, api_base=None) super().__init__(api_key=None, api_base=None)
self.default_model = default_model self.default_model = default_model
self.proxy = proxy or None
async def _call_codex( async def _call_codex(
self, self,
@@ -52,9 +57,6 @@ class OpenAICodexProvider(LLMProvider):
model = model or self.default_model model = model or self.default_model
system_prompt, input_items = convert_messages(messages) system_prompt, input_items = convert_messages(messages)
token = await asyncio.to_thread(get_codex_token)
headers = _build_headers(token.account_id, token.access)
body: dict[str, Any] = { body: dict[str, Any] = {
"model": _strip_model_prefix(model), "model": _strip_model_prefix(model),
"store": False, "store": False,
@@ -74,9 +76,13 @@ class OpenAICodexProvider(LLMProvider):
body["tools"] = convert_tools(tools) body["tools"] = convert_tools(tools)
try: try:
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
headers = _build_headers(token.account_id, token.access)
try: try:
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex( content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True, DEFAULT_CODEX_URL, headers, body, verify=True,
proxy=self.proxy,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta, on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
@@ -87,6 +93,7 @@ class OpenAICodexProvider(LLMProvider):
logger.warning("SSL verification failed for Codex API; retrying with verify=False") logger.warning("SSL verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex( content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False, DEFAULT_CODEX_URL, headers, body, verify=False,
proxy=self.proxy,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta, on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
@@ -199,12 +206,17 @@ async def _request_codex(
headers: dict[str, str], headers: dict[str, str],
body: dict[str, Any], body: dict[str, Any],
verify: bool, verify: bool,
proxy: str | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
idle_timeout_s = resolve_stream_idle_timeout_s() idle_timeout_s = resolve_stream_idle_timeout_s()
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client: client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
if proxy:
client_kwargs["proxy"] = proxy
client_kwargs["trust_env"] = False
async with httpx.AsyncClient(**client_kwargs) as client:
async with client.stream("POST", url, headers=headers, json=body) as response: async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code != 200: if response.status_code != 200:
text = await response.aread() text = await response.aread()
+18 -2
View File
@@ -358,6 +358,7 @@ class OpenAICompatProvider(LLMProvider):
extra_body: dict[str, Any] | None = None, extra_body: dict[str, Any] | None = None,
api_type: str = "auto", api_type: str = "auto",
extra_query: dict[str, str] | None = None, extra_query: dict[str, str] | None = None,
proxy: str | None = None,
): ):
super().__init__(api_key, api_base) super().__init__(api_key, api_base)
self.default_model = default_model self.default_model = default_model
@@ -366,6 +367,7 @@ class OpenAICompatProvider(LLMProvider):
self._extra_body = extra_body or {} self._extra_body = extra_body or {}
self._api_type = api_type if spec and spec.name == "openai" else "auto" self._api_type = api_type if spec and spec.name == "openai" else "auto"
self._extra_query = extra_query or {} self._extra_query = extra_query or {}
self._proxy = proxy or None
if api_key and spec and spec.env_key: if api_key and spec and spec.env_key:
self._setup_env(api_key, api_base) self._setup_env(api_key, api_base)
@@ -396,7 +398,14 @@ class OpenAICompatProvider(LLMProvider):
timeout_s = _openai_compat_timeout_s() timeout_s = _openai_compat_timeout_s()
http_client: httpx.AsyncClient | None = None http_client: httpx.AsyncClient | None = None
if self._is_local: if self._proxy:
http_client = httpx.AsyncClient(
timeout=timeout_s,
proxy=self._proxy,
trust_env=False,
follow_redirects=True,
)
elif self._is_local:
# Local model servers (Ollama, llama.cpp, vLLM) often close idle # Local model servers (Ollama, llama.cpp, vLLM) often close idle
# HTTP connections before the client-side keepalive expires. When # HTTP connections before the client-side keepalive expires. When
# two LLM calls happen seconds apart (e.g. heartbeat _decide then # two LLM calls happen seconds apart (e.g. heartbeat _decide then
@@ -1131,14 +1140,21 @@ class OpenAICompatProvider(LLMProvider):
if reasoning_content is None: if reasoning_content is None:
reasoning_content = m.get("reasoning_content") reasoning_content = m.get("reasoning_content")
# Deduplicate tool call IDs (same pattern as streaming path)
# Some providers reuse the same ID for parallel tool calls.
_seen_tc_ids: set[str] = set()
parsed_tool_calls = [] parsed_tool_calls = []
for tc in raw_tool_calls: for tc in raw_tool_calls:
tc_map = self._maybe_mapping(tc) or {} tc_map = self._maybe_mapping(tc) or {}
fn = self._maybe_mapping(tc_map.get("function")) or {} fn = self._maybe_mapping(tc_map.get("function")) or {}
args = parse_tool_arguments(fn.get("arguments", {})) args = parse_tool_arguments(fn.get("arguments", {}))
ec, prov, fn_prov = _extract_tc_extras(tc) ec, prov, fn_prov = _extract_tc_extras(tc)
raw_id = str(tc_map.get("id") or _short_tool_id())
if not raw_id or raw_id in _seen_tc_ids:
raw_id = _short_tool_id()
_seen_tc_ids.add(raw_id)
parsed_tool_calls.append(ToolCallRequest( parsed_tool_calls.append(ToolCallRequest(
id=str(tc_map.get("id") or _short_tool_id()), id=raw_id,
name=str(fn.get("name") or ""), name=str(fn.get("name") or ""),
arguments=args, arguments=args,
extra_content=ec, extra_content=ec,
+2
View File
@@ -32,6 +32,7 @@ class ProviderSpec:
keywords: tuple[str, ...] # model-name keywords for matching (lowercase) keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY" env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
display_name: str = "" # shown in `nanobot status` display_name: str = "" # shown in `nanobot status`
model_catalog: str = "auto" # WebUI model-list source
# which provider implementation to use # which provider implementation to use
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock" # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
@@ -221,6 +222,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("skywork", "skyclaw", "apifree"), keywords=("skywork", "skyclaw", "apifree"),
env_key="SKYWORK_API_KEY", env_key="SKYWORK_API_KEY",
display_name="Skywork", display_name="Skywork",
model_catalog="official",
backend="openai_compat", backend="openai_compat",
env_extras=(("APIFREE_API_KEY", "{api_key}"),), env_extras=(("APIFREE_API_KEY", "{api_key}"),),
is_gateway=True, is_gateway=True,
+111 -26
View File
@@ -1,5 +1,6 @@
"""Session management for conversation history.""" """Session management for conversation history."""
import base64
import json import json
import os import os
import re import re
@@ -26,6 +27,8 @@ from nanobot.utils.helpers import (
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
FILE_MAX_MESSAGES = 2000 FILE_MAX_MESSAGES = 2000
MIN_REPLAY_MAX_MESSAGES = 120
REPLAY_TOKENS_PER_MESSAGE = 100
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?") _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$") _LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$') _TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
@@ -42,6 +45,15 @@ _FORK_VOLATILE_METADATA_KEYS = {
} }
def replay_max_messages_for_context(context_window_tokens: int | None) -> int:
if not context_window_tokens or context_window_tokens <= 0:
return FILE_MAX_MESSAGES
return min(
FILE_MAX_MESSAGES,
max(MIN_REPLAY_MAX_MESSAGES, context_window_tokens // REPLAY_TOKENS_PER_MESSAGE),
)
def _sanitize_assistant_replay_text(content: str) -> str: def _sanitize_assistant_replay_text(content: str) -> str:
"""Remove internal replay artifacts that the model may have copied before. """Remove internal replay artifacts that the model may have copied before.
@@ -98,6 +110,12 @@ def _metadata_title(metadata: Any) -> str:
return strip_think(title) return strip_think(title)
@dataclass
class RetentionResult:
dropped: list[dict]
already_consolidated_count: int
@dataclass @dataclass
class Session: class Session:
"""A conversation session.""" """A conversation session."""
@@ -131,7 +149,7 @@ class Session:
def get_history( def get_history(
self, self,
max_messages: int = 120, max_messages: int = FILE_MAX_MESSAGES,
*, *,
max_tokens: int = 0, max_tokens: int = 0,
extend_to_user: bool = False, extend_to_user: bool = False,
@@ -142,7 +160,7 @@ class Session:
token budget from the tail (``max_tokens``) when provided. token budget from the tail (``max_tokens``) when provided.
""" """
unconsolidated = self.messages[self.last_consolidated:] unconsolidated = self.messages[self.last_consolidated:]
max_messages = max_messages if max_messages > 0 else 120 max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
start_idx = recent_message_start_index( start_idx = recent_message_start_index(
unconsolidated, unconsolidated,
max_messages, max_messages,
@@ -277,22 +295,26 @@ class Session:
max_messages: int, max_messages: int,
*, *,
extend_to_user: bool = False, extend_to_user: bool = False,
) -> tuple[list[dict], int]: ) -> RetentionResult:
"""Keep a legal recent suffix, optionally extending it back to a user turn. """Keep a legal recent suffix, optionally extending it back to a user turn.
Returns ``(dropped, already_consolidated_count)`` where *dropped* is Returns a RetentionResult with dropped messages and how many of those
the list of removed messages (in original order) and were in the already-consolidated prefix. This method mutates
*already_consolidated_count* is how many of those were inside the self.messages and self.last_consolidated in place.
pre-existing ``last_consolidated`` prefix and therefore do not need
raw archiving.
""" """
if max_messages <= 0: if max_messages <= 0:
dropped = list(self.messages) dropped = list(self.messages)
lc = self.last_consolidated lc = self.last_consolidated
self.clear() self.clear()
return dropped, min(lc, len(dropped)) return RetentionResult(
dropped=dropped,
already_consolidated_count=min(lc, len(dropped)),
)
if len(self.messages) <= max_messages: if len(self.messages) <= max_messages:
return [], 0 return RetentionResult(
dropped=[],
already_consolidated_count=0,
)
original = list(self.messages) original = list(self.messages)
before_lc = self.last_consolidated before_lc = self.last_consolidated
@@ -358,7 +380,10 @@ class Session:
self.messages = retained self.messages = retained
self.last_consolidated = new_lc self.last_consolidated = new_lc
self.updated_at = datetime.now() self.updated_at = datetime.now()
return dropped, already_consolidated return RetentionResult(
dropped=dropped,
already_consolidated_count=already_consolidated,
)
def enforce_file_cap( def enforce_file_cap(
self, self,
@@ -369,17 +394,17 @@ class Session:
if limit <= 0 or len(self.messages) <= limit: if limit <= 0 or len(self.messages) <= limit:
return return
dropped, already_consolidated = self.retain_recent_legal_suffix(limit) result = self.retain_recent_legal_suffix(limit)
if not dropped: if not result.dropped:
return return
archive_chunk = dropped[already_consolidated:] archive_chunk = result.dropped[result.already_consolidated_count:]
if archive_chunk and on_archive: if archive_chunk and on_archive:
on_archive(archive_chunk) on_archive(archive_chunk)
logger.info( logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}", "Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key, self.key,
len(dropped), len(result.dropped),
len(archive_chunk), len(archive_chunk),
len(self.messages), len(self.messages),
) )
@@ -403,14 +428,53 @@ class SessionManager:
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem.""" """Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
return safe_filename(key.replace(":", "_")) return safe_filename(key.replace(":", "_"))
@staticmethod
def _storage_key(key: str) -> str:
"""Collision-resistant encoding for internal session storage filenames."""
return base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
@staticmethod
def _decode_storage_key(stem: str) -> str | None:
"""Reverse _storage_key(): decode a base64url (no-padding) stem back to the original key."""
try:
# Restore padding stripped by rstrip("=")
padding = 4 - len(stem) % 4
if padding != 4:
stem += "=" * padding
return base64.urlsafe_b64decode(stem).decode("utf-8")
except Exception:
return None
def _get_session_path(self, key: str) -> Path: def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session.""" """Get the collision-resistant workspace path for a session."""
return self.sessions_dir / f"{self.safe_key(key)}.jsonl" return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
def _get_legacy_lossy_path(self, key: str) -> Path:
"""Previous workspace session path using lossy ':' to '_' replacement."""
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path: def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/).""" """Legacy global session path (~/.nanobot/sessions/)."""
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl" return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
@staticmethod
def _stored_key_for_path(path: Path) -> str | None:
"""Read the stored session key from a JSONL metadata row, if present."""
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
if data.get("_type") == "metadata":
stored_key = data.get("key")
return stored_key if isinstance(stored_key, str) else None
return None
except Exception:
return None
return None
def get_or_create(self, key: str) -> Session: def get_or_create(self, key: str) -> Session:
""" """
Get an existing session or create a new one. Get an existing session or create a new one.
@@ -435,13 +499,28 @@ class SessionManager:
"""Load a session from disk.""" """Load a session from disk."""
path = self._get_session_path(key) path = self._get_session_path(key)
if not path.exists(): if not path.exists():
legacy_path = self._get_legacy_session_path(key) fallback_paths = [
if legacy_path.exists(): (self._get_legacy_lossy_path(key), "legacy lossy path"),
(self._get_legacy_session_path(key), "legacy path"),
]
for fallback_path, description in fallback_paths:
if not fallback_path.exists():
continue
stored_key = self._stored_key_for_path(fallback_path)
if stored_key and stored_key != key:
logger.info(
"Skipping migration for {} from {} because it belongs to {}",
key,
description,
stored_key,
)
continue
try: try:
shutil.move(str(legacy_path), str(path)) shutil.move(str(fallback_path), str(path))
logger.info("Migrated session {} from legacy path", key) logger.info("Migrated session {} from {}", key, description)
except Exception: except Exception:
logger.exception("Failed to migrate session {}", key) logger.exception("Failed to migrate session {}", key)
break
if not path.exists(): if not path.exists():
return None return None
@@ -484,8 +563,9 @@ class SessionManager:
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages)) logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
return repaired return repaired
def _repair(self, key: str) -> Session | None: def _repair(self, key: str, *, path: Path | None = None) -> Session | None:
"""Attempt to recover a session from a corrupt JSONL file.""" """Attempt to recover a session from a corrupt JSONL file."""
if path is None:
path = self._get_session_path(key) path = self._get_session_path(key)
if not path.exists(): if not path.exists():
return None return None
@@ -623,7 +703,11 @@ class SessionManager:
Returns True if at least one JSONL file was found and unlinked. Returns True if at least one JSONL file was found and unlinked.
""" """
paths = [self._get_session_path(key), self._get_legacy_session_path(key)] paths = [
self._get_session_path(key),
self._get_legacy_lossy_path(key),
self._get_legacy_session_path(key),
]
self.invalidate(key) self.invalidate(key)
deleted = False deleted = False
for path in paths: for path in paths:
@@ -784,7 +868,8 @@ class SessionManager:
sessions = [] sessions = []
for path in self.sessions_dir.glob("*.jsonl"): for path in self.sessions_dir.glob("*.jsonl"):
fallback_key = path.stem.replace("_", ":", 1) decoded = self._decode_storage_key(path.stem)
fallback_key = decoded or path.stem.replace("_", ":", 1)
try: try:
# Read the metadata line and a small preview for session lists. # Read the metadata line and a small preview for session lists.
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
@@ -792,7 +877,7 @@ class SessionManager:
if first_line: if first_line:
data = json.loads(first_line) data = json.loads(first_line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1) key = data.get("key") or fallback_key
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
title = _metadata_title(metadata) title = _metadata_title(metadata)
preview = "" preview = ""
@@ -833,7 +918,7 @@ class SessionManager:
} }
) )
except Exception: except Exception:
repaired = self._repair(fallback_key) repaired = self._repair(fallback_key, path=path)
if repaired is not None: if repaired is not None:
sessions.append( sessions.append(
{ {
-4
View File
@@ -29,10 +29,6 @@ _GOAL_CONTINUATION_SENDER = "system:continuation"
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds" _GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12 _MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = { _STRIPPED_INBOUND_META_KEYS = {
"_stream_id",
"_stream_delta",
"_stream_end",
"_resuming",
INTERNAL_CONTINUATION_PENDING_META, INTERNAL_CONTINUATION_PENDING_META,
} }
+55 -47
View File
@@ -11,7 +11,15 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.bus import progress as bus_progress from nanobot.bus import progress as bus_progress
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import ( from nanobot.bus.runtime_events import (
GoalStateChanged, GoalStateChanged,
@@ -206,26 +214,22 @@ async def publish_turn_run_status(
if msg.channel != "websocket": if msg.channel != "websocket":
return return
cid = str(msg.chat_id) cid = str(msg.chat_id)
meta: dict[str, Any] = { started_at_event: float | None = None
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
if status == "running": if status == "running":
if isinstance(started_at, int | float) and started_at > 0: if isinstance(started_at, int | float) and started_at > 0:
t0 = float(started_at) t0 = float(started_at)
else: else:
t0 = time.time() t0 = time.time()
meta["started_at"] = t0 started_at_event = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0 _WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else: else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None) _WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound( await bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=cid, chat_id=cid,
content="", event=GoalStatusEvent(status=status, started_at=started_at_event),
metadata=meta, metadata=msg.metadata,
), ),
) )
@@ -318,28 +322,25 @@ class WebuiTurnCoordinator:
if not cid: if not cid:
return return
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel=event.context.channel, channel=event.context.channel,
chat_id=cid, chat_id=cid,
content="", event=GoalStateSyncEvent(
metadata={ goal_state=goal_state_ws_blob(event.session_metadata),
"_goal_state_sync": True, ),
"goal_state": goal_state_ws_blob(event.session_metadata), metadata=event.context.metadata,
},
), ),
) )
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None: async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( outbound_message_for_event(
channel="websocket", channel="websocket",
chat_id="*", chat_id="*",
content="", event=RuntimeModelUpdatedEvent(
metadata={ model=event.model,
"_runtime_model_updated": True, model_preset=event.model_preset,
"model": event.model, ),
"model_preset": event.model_preset,
},
) )
) )
@@ -374,17 +375,18 @@ class WebuiTurnCoordinator:
if msg.channel != "websocket": if msg.channel != "websocket":
return 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) session = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata) await self.bus.publish_outbound(
await self.bus.publish_outbound(OutboundMessage( outbound_message_for_event(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content="", event=TurnEndEvent(
metadata=turn_metadata, latency_ms=latency_ms,
)) goal_state=goal_state_ws_blob(session.metadata),
),
metadata=msg.metadata,
)
)
self._schedule_title_update(msg, session_key=session_key) self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None: def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
@@ -404,16 +406,11 @@ class WebuiTurnCoordinator:
model=title_llm.model, model=title_llm.model,
) )
if generated: if generated:
await self.bus.publish_outbound(OutboundMessage( await self._publish_session_metadata_updated(
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content="", metadata=msg.metadata,
metadata={ )
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify()) self.schedule_background(_generate_title_and_notify())
@@ -438,15 +435,26 @@ class WebuiTurnCoordinator:
model=title_llm.model, model=title_llm.model,
) )
if generated: if generated:
await self.bus.publish_outbound(OutboundMessage( await self._publish_session_metadata_updated(
channel=event.context.channel, channel=event.context.channel,
chat_id=event.context.chat_id, chat_id=event.context.chat_id,
content="", metadata=event.context.metadata,
metadata={ )
**event.context.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify()) 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,
)
)
+3
View File
@@ -529,6 +529,9 @@ class StreamingFileEditTracker:
"""Keep final start/end events keyed to any earlier streamed placeholder.""" """Keep final start/end events keyed to any earlier streamed placeholder."""
used_canonicals: set[str] = set() used_canonicals: set[str] = set()
for tool_call in final_tool_calls: for tool_call in final_tool_calls:
name = getattr(tool_call, "name", None)
if not is_file_edit_tool(name):
continue
canonical = self.canonical_call_id_for(tool_call) canonical = self.canonical_call_id_for(tool_call)
if canonical and canonical not in used_canonicals: if canonical and canonical not in used_canonicals:
try: try:
+7 -2
View File
@@ -35,10 +35,15 @@ def format_tool_hints(tool_calls: list, max_length: int = 40) -> str:
formatted = [] formatted = []
for tc in tool_calls: for tc in tool_calls:
fmt = _TOOL_FORMATS.get(tc.name) name = getattr(tc, "name", None)
if not isinstance(name, str) or not name:
# Degenerate/malformed tool call (e.g. a model emits name=None);
# skip it instead of raising AttributeError on the whole turn.
continue
fmt = _TOOL_FORMATS.get(name)
if fmt: if fmt:
formatted.append(_fmt_known(tc, fmt, max_length)) formatted.append(_fmt_known(tc, fmt, max_length))
elif tc.name.startswith("mcp_"): elif name.startswith("mcp_"):
formatted.append(_fmt_mcp(tc, max_length)) formatted.append(_fmt_mcp(tc, max_length))
else: else:
formatted.append(_fmt_fallback(tc, max_length)) formatted.append(_fmt_fallback(tc, max_length))
+51 -8
View File
@@ -26,10 +26,11 @@ from nanobot.session.manager import (
_metadata_title, _metadata_title,
) )
_INDEX_VERSION = 1 _INDEX_VERSION = 2
_INDEX_FILENAME = ".webui_session_index.json" _INDEX_FILENAME = ".webui_session_index.json"
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns" _WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
_WEBUI_ACTIVITY_SIZE = "webui_activity_size" _WEBUI_ACTIVITY_SIZE = "webui_activity_size"
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]: def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
@@ -214,14 +215,45 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
return stored return stored
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
if item.get(CRON_HISTORY_META) is True:
return None
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
return None
timestamp = item.get("timestamp")
return timestamp if isinstance(timestamp, str) else None
def _last_visible_message_at(messages: list[dict[str, Any]]) -> str | None:
latest: str | None = None
for item in messages:
timestamp = _visible_message_timestamp(item)
if timestamp is not None:
latest = _latest_updated_at(latest, timestamp)
return latest
def _visible_activity_updated_at(
stored: str | None,
visible_message_at: str | None,
webui_activity: str | None,
) -> str | None:
return _latest_updated_at(visible_message_at, webui_activity) or stored
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]: def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
signature = _file_signature(path) signature = _file_signature(path)
activity_signature = _webui_activity_signature(session.key) activity_signature = _webui_activity_signature(session.key)
activity_updated_at = _webui_activity_updated_at(activity_signature) activity_updated_at = _webui_activity_updated_at(activity_signature)
visible_message_at = _last_visible_message_at(session.messages)
return { return {
"key": session.key, "key": session.key,
"created_at": session.created_at.isoformat(), "created_at": session.created_at.isoformat(),
"updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at), "updated_at": _visible_activity_updated_at(
session.updated_at.isoformat(),
visible_message_at,
activity_updated_at,
),
"title": _metadata_title(session.metadata), "title": _metadata_title(session.metadata),
"preview": _preview_from_messages(session.messages), "preview": _preview_from_messages(session.messages),
"file": path.name, "file": path.name,
@@ -232,7 +264,8 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None: def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
fallback_key = path.stem.replace("_", ":", 1) storage_key = SessionManager._decode_storage_key(path.stem)
fallback_key = storage_key or path.stem.replace("_", ":", 1)
try: try:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
first_line = f.readline().strip() first_line = f.readline().strip()
@@ -243,20 +276,25 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
return None return None
preview = "" preview = ""
fallback_preview = "" fallback_preview = ""
visible_message_at = None
preview_done = False
scanned_records = 0 scanned_records = 0
scanned_chars = 0 scanned_chars = 0
for line in f: for line in f:
if not line.strip(): if not line.strip():
continue continue
item = json.loads(line)
timestamp = _visible_message_timestamp(item)
if timestamp is not None:
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
if not preview_done:
scanned_records += 1 scanned_records += 1
scanned_chars += len(line) scanned_chars += len(line)
if ( if (
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
): ):
break preview_done = True
item = json.loads(line)
if item.get("_type") == "metadata":
continue continue
if item.get(CRON_HISTORY_META) is True: if item.get(CRON_HISTORY_META) is True:
continue continue
@@ -265,7 +303,8 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
continue continue
if item.get("role") == "user": if item.get("role") == "user":
preview = text preview = text
break preview_done = True
continue
if not fallback_preview and item.get("role") == "assistant": if not fallback_preview and item.get("role") == "assistant":
fallback_preview = text fallback_preview = text
signature = _file_signature(path) signature = _file_signature(path)
@@ -281,7 +320,11 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
return { return {
"key": key, "key": key,
"created_at": created_at_s, "created_at": created_at_s,
"updated_at": _latest_updated_at(updated_at_s, activity_updated_at), "updated_at": _visible_activity_updated_at(
updated_at_s,
visible_message_at,
activity_updated_at,
),
"title": _metadata_title(data.get("metadata", {})), "title": _metadata_title(data.get("metadata", {})),
"preview": preview or fallback_preview, "preview": preview or fallback_preview,
"file": path.name, "file": path.name,
+17 -57
View File
@@ -22,7 +22,7 @@ from nanobot.audio.transcription_registry import (
resolve_transcription_provider, resolve_transcription_provider,
transcription_provider_names, transcription_provider_names,
) )
from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config
from nanobot.config.schema import ModelPresetConfig, ProviderConfig from nanobot.config.schema import ModelPresetConfig, ProviderConfig
from nanobot.providers.image_generation import ( from nanobot.providers.image_generation import (
get_image_gen_provider, get_image_gen_provider,
@@ -99,47 +99,6 @@ _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") _MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-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): class WebUISettingsError(ValueError):
"""User-facing settings validation failure.""" """User-facing settings validation failure."""
@@ -394,10 +353,13 @@ def _provider_settings_row(
def _model_catalog_kind(spec: Any) -> str: def _model_catalog_kind(spec: Any) -> str:
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS: catalog = getattr(spec, "model_catalog", "auto")
return "catalog" if catalog != "auto":
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS: return catalog
return "official" 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.is_local: if spec.is_local:
return "local" return "local"
if spec.is_direct: if spec.is_direct:
@@ -490,27 +452,20 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
raise WebUISettingsError("unknown provider") raise WebUISettingsError("unknown provider")
spec, provider_key, provider_config = resolved_provider spec, provider_key, provider_config = resolved_provider
catalog_kind = _model_catalog_kind(spec)
base_payload: dict[str, Any] = { base_payload: dict[str, Any] = {
"provider": provider_key, "provider": provider_key,
"label": spec.label, "label": spec.label,
"catalog_kind": _model_catalog_kind(spec), "catalog_kind": catalog_kind,
"models": [], "models": [],
"model_count": 0, "model_count": 0,
"message": None, "message": None,
"fetched_at": time.time(), "fetched_at": time.time(),
} }
if ( if catalog_kind == "unsupported":
spec.is_transcription_only
or (
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
and spec.name != "minimax_anthropic"
)
or spec.is_oauth
):
return { return {
**base_payload, **base_payload,
"status": "unsupported", "status": "unsupported",
"catalog_kind": "unsupported",
"message": "Model list is not available for this provider. Type a model ID manually.", "message": "Model list is not available for this provider. Type a model ID manually.",
} }
@@ -1166,14 +1121,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
except ImportError: except ImportError:
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
try:
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
except ValueError as e:
raise WebUISettingsError(str(e), status=400) from e
token = None token = None
with suppress(Exception): with suppress(Exception):
token = get_token() token = get_token(proxy=proxy)
if not (token and token.access): if not (token and token.access):
messages: list[str] = [] messages: list[str] = []
token = login_oauth_interactive( token = login_oauth_interactive(
print_fn=lambda message: messages.append(str(message)), print_fn=lambda message: messages.append(str(message)),
prompt_fn=lambda _prompt: "", prompt_fn=lambda _prompt: "",
proxy=proxy,
) )
if not (token and token.access): if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401) raise WebUISettingsError("OAuth login failed", status=401)
+1 -1
View File
@@ -31,7 +31,7 @@ dependencies = [
"websocket-client>=1.9.0,<2.0.0", "websocket-client>=1.9.0,<2.0.0",
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
"ddgs>=9.5.5,<10.0.0", "ddgs>=9.5.5,<10.0.0",
"oauth-cli-kit>=0.1.3,<1.0.0", "oauth-cli-kit>=0.1.6,<1.0.0",
"loguru>=0.7.3,<1.0.0", "loguru>=0.7.3,<1.0.0",
"readability-lxml>=0.8.4,<1.0.0", "readability-lxml>=0.8.4,<1.0.0",
"lxml-html-clean>=0.4.0,<1.0.0", "lxml-html-clean>=0.4.0,<1.0.0",
+10 -2
View File
@@ -269,7 +269,15 @@ if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
exit 0 exit 0
fi fi
info "Starting setup wizard..." if [ -t 0 ]; then
run_nanobot onboard --wizard info "Starting setup wizard..."
run_nanobot onboard --wizard
elif : 2>/dev/null < /dev/tty; then
info "Starting setup wizard..."
run_nanobot onboard --wizard < /dev/tty
else
info "Skipping setup wizard because no interactive terminal is available."
info "Run this later: $(nanobot_try_command) onboard --wizard"
fi
info "Done. Try: $(nanobot_try_command) agent -m \"Hello!\"" info "Done. Try: $(nanobot_try_command) agent -m \"Hello!\""
+2 -4
View File
@@ -38,7 +38,6 @@ def make_loop(
model: str = "test-model", model: str = "test-model",
context_window_tokens: int = 128_000, context_window_tokens: int = 128_000,
session_ttl_minutes: int = 0, session_ttl_minutes: int = 0,
max_messages: int = 120,
unified_session: bool = False, unified_session: bool = False,
mcp_servers: dict | None = None, mcp_servers: dict | None = None,
tools_config=None, tools_config=None,
@@ -64,7 +63,6 @@ def make_loop(
model=model, model=model,
context_window_tokens=context_window_tokens, context_window_tokens=context_window_tokens,
session_ttl_minutes=session_ttl_minutes, session_ttl_minutes=session_ttl_minutes,
max_messages=max_messages,
unified_session=unified_session, unified_session=unified_session,
) )
if mcp_servers is not None: if mcp_servers is not None:
@@ -79,8 +77,8 @@ def make_loop(
if patch_deps: if patch_deps:
with patch("nanobot.agent.loop.ContextBuilder"), \ with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \ patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
return AgentLoop(**kwargs) return AgentLoop(**kwargs)
return AgentLoop(**kwargs) return AgentLoop(**kwargs)
+9 -11
View File
@@ -91,7 +91,6 @@ def _make_fake_compact(
tail = list(session.messages[session.last_consolidated:]) tail = list(session.messages[session.last_consolidated:])
if not tail: if not tail:
session.updated_at = datetime.now()
loop.sessions.save(session) loop.sessions.save(session)
return "" return ""
@@ -103,15 +102,14 @@ def _make_fake_compact(
metadata={}, metadata={},
last_consolidated=0, last_consolidated=0,
) )
dropped, already_consolidated = probe.retain_recent_legal_suffix( result = probe.retain_recent_legal_suffix(
max_suffix, max_suffix,
extend_to_user=True, extend_to_user=True,
) )
kept = probe.messages kept = probe.messages
archive_msgs = dropped[already_consolidated:] archive_msgs = result.dropped[result.already_consolidated_count:]
if not archive_msgs and not kept: if not archive_msgs and not kept:
session.updated_at = datetime.now()
loop.sessions.save(session) loop.sessions.save(session)
return "" return ""
@@ -132,7 +130,6 @@ def _make_fake_compact(
session.messages = kept session.messages = kept
session.last_consolidated = 0 session.last_consolidated = 0
session.updated_at = datetime.now()
loop.sessions.save(session) loop.sessions.save(session)
return s return s
@@ -1021,27 +1018,28 @@ class TestProactiveAutoCompact:
await self._run_check_expired(loop) await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1 assert _fake_compact.state["count"] == 1
# Second tick: should NOT re-schedule (updated_at is fresh after clear) # Second tick: should NOT re-schedule because the session has no removable tail.
await self._run_check_expired(loop) await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_skip_refreshes_updated_at_prevents_reschedule(self, tmp_path): async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
"""Empty session skip refreshes updated_at, preventing immediate re-scheduling.""" """Empty expired sessions have no removable tail and should not schedule."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
loop.consolidator.compact_idle_session = _make_fake_compact(loop) _fake_compact = _make_fake_compact(loop)
loop.consolidator.compact_idle_session = _fake_compact
# First tick: skips (no messages), refreshes updated_at
await self._run_check_expired(loop) await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 0
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
# Second tick: should NOT re-schedule because updated_at is fresh
await self._run_check_expired(loop) await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 0
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.close_mcp()
+23 -2
View File
@@ -200,8 +200,11 @@ class TestCheckExpired:
"""Expired session should trigger schedule_background.""" """Expired session should trigger schedule_background."""
ac = _make_autocompact(ttl=15) ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager) mock_sm = MagicMock(spec=SessionManager)
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat() old_dt = datetime.now() - timedelta(minutes=20)
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}] session = _make_session("cli:old", updated_at=old_dt)
_add_turns(session, 5)
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_dt.isoformat()}]
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm ac.sessions = mock_sm
scheduled = [] scheduled = []
@@ -273,6 +276,24 @@ class TestCheckExpired:
scheduler.assert_not_called() scheduler.assert_not_called()
assert "dream:20260602-155256" not in ac._archiving assert "dream:20260602-155256" not in ac._archiving
def test_already_trimmed_session_skips(self):
"""Expired session with no removable tail should not be re-scheduled."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
last_active = datetime(2026, 1, 1, 10, 0, 0)
session = _make_session("cli:done", updated_at=last_active)
_add_turns(session, 2)
mock_sm.list_sessions.return_value = [
{"key": "cli:done", "updated_at": last_active.isoformat()},
]
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
scheduler = MagicMock()
ac.check_expired(scheduler)
scheduler.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _archive # _archive
+9 -3
View File
@@ -430,9 +430,11 @@ class TestCompactIdleSession:
) )
sessions = real_consolidator.sessions sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:test") session = sessions.get_or_create("cli:test")
old_ts = session.updated_at
for i in range(20): for i in range(20):
session.add_message("user", f"user msg {i}") session.add_message("user", f"user msg {i}")
session.add_message("assistant", f"assistant msg {i}") session.add_message("assistant", f"assistant msg {i}")
session.updated_at = old_ts
sessions.save(session) sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8) result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
@@ -445,6 +447,7 @@ class TestCompactIdleSession:
assert meta is not None assert meta is not None
assert meta["text"] == "Summary of old conversation." assert meta["text"] == "Summary of old conversation."
assert "last_active" in meta assert "last_active" in meta
assert reloaded.updated_at == old_ts
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_summarizes_retained_suffix_not_just_dropped_prefix( async def test_summarizes_retained_suffix_not_just_dropped_prefix(
@@ -518,8 +521,10 @@ class TestCompactIdleSession:
assert entries[0]["session_key"] == "cli:test" assert entries[0]["session_key"] == "cli:test"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_session_refreshes_timestamp(self, real_consolidator): async def test_empty_session_does_not_refresh_timestamp(
"""Empty session with old updated_at → refreshed after call, returns ''.""" self, real_consolidator
):
"""Empty session with old updated_at does not look active after compaction."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
sessions = real_consolidator.sessions sessions = real_consolidator.sessions
@@ -532,7 +537,8 @@ class TestCompactIdleSession:
assert result == "" assert result == ""
reloaded = sessions.get_or_create("cli:empty") reloaded = sessions.get_or_create("cli:empty")
assert reloaded.updated_at > old_ts assert reloaded.updated_at == old_ts
assert reloaded.metadata == {}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider): async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
+6
View File
@@ -24,9 +24,14 @@ class TestDreamSessionKey:
class TestPruneDreamSessions: class TestPruneDreamSessions:
def test_keeps_n_most_recent(self, tmp_path): def test_keeps_n_most_recent(self, tmp_path):
import os
import time
sessions_dir = tmp_path / "sessions" sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir() sessions_dir.mkdir()
base_time = time.time() - 100
for i in range(15): for i in range(15):
key = f"dream:20260528-{100000 + i:06d}" key = f"dream:20260528-{100000 + i:06d}"
safe_key = key.replace(":", "_") safe_key = key.replace(":", "_")
@@ -37,6 +42,7 @@ class TestPruneDreamSessions:
f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n', f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n',
encoding="utf-8", encoding="utf-8",
) )
os.utime(path, (base_time + i, base_time + i))
normal_path = sessions_dir / "telegram_123.jsonl" normal_path = sessions_dir / "telegram_123.jsonl"
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8") normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
+41 -1
View File
@@ -11,7 +11,6 @@ from unittest.mock import patch
from nanobot.providers.base import ToolCallRequest from nanobot.providers.base import ToolCallRequest
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}} GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
@@ -125,6 +124,47 @@ def test_parse_dict_preserves_extra_content() -> None:
assert payload["extra_content"] == GEMINI_EXTRA assert payload["extra_content"] == GEMINI_EXTRA
def test_parse_dict_deduplicates_duplicate_tool_call_ids() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response_dict = {
"choices": [
{
"message": {
"content": None,
"tool_calls": [{
"id": "call_same",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
}],
},
"finish_reason": "tool_calls",
},
{
"message": {
"content": None,
"tool_calls": [{
"id": "call_same",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
}],
},
"finish_reason": "tool_calls",
},
],
}
result = provider._parse(response_dict)
ids = [tc.id for tc in result.tool_calls]
assert len(ids) == 2
assert ids[0] == "call_same"
assert ids[1] != "call_same"
assert len(set(ids)) == 2
assert [tc.arguments for tc in result.tool_calls] == [{"path": "a.txt"}, {"path": "b.txt"}]
# ── _parse_chunks: streaming round-trip ─────────────────────────────── # ── _parse_chunks: streaming round-trip ───────────────────────────────
def test_parse_chunks_sdk_preserves_extra_content() -> None: def test_parse_chunks_sdk_preserves_extra_content() -> None:
@@ -5,6 +5,7 @@ import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.webui_turns import WebuiTurnCoordinator from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -54,13 +55,13 @@ async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
events.append(await loop.bus.consume_outbound()) events.append(await loop.bus.consume_outbound())
statuses = [ statuses = [
event.metadata event.event
for event in events for event in events
if event.metadata.get("_goal_status") is True if isinstance(event.event, GoalStatusEvent)
] ]
assert [status["goal_status"] for status in statuses] == ["running", "idle"] assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].get("started_at"), float) assert isinstance(statuses[0].started_at, float)
assert "started_at" not in statuses[1] assert statuses[1].started_at is None
@pytest.mark.asyncio @pytest.mark.asyncio
+77 -43
View File
@@ -9,6 +9,15 @@ import pytest
import nanobot.agent.runner as runner_module import nanobot.agent.runner as runner_module
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage 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.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.session.webui_turns import WebuiTurnCoordinator from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -260,25 +269,45 @@ class TestToolEventProgress:
) )
await loop._dispatch(msg) 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 = [] outbound = []
while bus.outbound_size > 0: while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
tool_event_msgs = [m for m in outbound if m.metadata and m.metadata.get("_tool_events")] tool_event_msgs = [
assert tool_event_msgs, "expected at least one outbound message with _tool_events" 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"
start_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] == "start"] start_msgs = [
finish_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] in ("end", "error")] 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")
]
assert start_msgs, "expected a start-phase tool event" assert start_msgs, "expected a start-phase tool event"
assert finish_msgs, "expected a finish-phase tool event" assert finish_msgs, "expected a finish-phase tool event"
start = start_msgs[0].metadata["_tool_events"][0] 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]
assert start["name"] == "exec" assert start["name"] == "exec"
assert start["call_id"] == "tc1" assert start["call_id"] == "tc1"
assert start["result"] is None assert start["result"] is None
finish = finish_msgs[0].metadata["_tool_events"][0] 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]
assert finish["phase"] == "end" assert finish["phase"] == "end"
assert finish["result"] == "file.txt" assert finish["result"] == "file.txt"
@@ -309,7 +338,8 @@ class TestToolEventProgress:
await invoke_file_edit_progress(progress, edit_events) await invoke_file_edit_progress(progress, edit_events)
outbound = await bus.consume_outbound() outbound = await bus.consume_outbound()
assert outbound.channel == "telegram" assert outbound.channel == "telegram"
assert outbound.metadata["_file_edit_events"] == edit_events assert isinstance(outbound.event, ProgressEvent)
assert outbound.event.file_edit_events == edit_events
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None: async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
@@ -389,7 +419,8 @@ class TestToolEventProgress:
edit_events = [ edit_events = [
event event
for msg in outbound for msg in outbound
for event in msg.metadata.get("_file_edit_events", []) if isinstance(msg.event, ProgressEvent)
for event in msg.event.file_edit_events or []
] ]
assert any( assert any(
event["status"] == "editing" event["status"] == "editing"
@@ -433,8 +464,8 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
assert [m.content for m in outbound] == ["Hello"] assert [m.content for m in outbound] == ["Hello"]
assert not any(m.metadata.get("_progress") for m in outbound) assert not any(isinstance(m.event, ProgressEvent) for m in outbound)
assert not any(m.metadata.get("_streamed") for m in outbound) assert not any(isinstance(m.event, StreamedResponseEvent) for m in outbound)
provider.chat_stream_with_retry.assert_not_awaited() provider.chat_stream_with_retry.assert_not_awaited()
provider.chat_with_retry.assert_awaited_once() provider.chat_with_retry.assert_awaited_once()
@@ -443,7 +474,7 @@ class TestToolEventProgress:
self, self,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Streaming channels still receive provider deltas through _stream_delta messages.""" """Streaming channels still receive provider deltas through stream events."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
@@ -473,21 +504,19 @@ class TestToolEventProgress:
while bus.outbound_size > 0: while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")] deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")] stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [ final = [
m for m in outbound m for m in outbound
if not m.metadata.get("_stream_delta") if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not m.metadata.get("_stream_end") and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
] ]
assert [m.content for m in deltas] == ["Hel", "lo"] assert [m.content for m in deltas] == ["Hel", "lo"]
assert len(stream_end) == 1 assert len(stream_end) == 1
assert final[-1].content == "Hello" assert final[-1].content == "Hello"
assert final[-1].metadata.get("_streamed") is True assert isinstance(final[-1].event, StreamedResponseEvent)
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")] turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end_msgs) == 1 assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == "" assert turn_end_msgs[0].content == ""
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@@ -528,23 +557,28 @@ class TestToolEventProgress:
while bus.outbound_size > 0: while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")] deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")] stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [ final = [
m for m in outbound m for m in outbound
if not m.metadata.get("_stream_delta") if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not m.metadata.get("_stream_end") and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
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.content for m in deltas] == ["partial", "full retry response"]
assert [m.metadata.get("_resuming") for m in stream_end] == [True, False] assert [m.event.resuming for m in stream_end if isinstance(m.event, StreamEndEvent)] == [
assert deltas[0].metadata.get("_stream_id") == stream_end[0].metadata.get("_stream_id") True,
assert deltas[1].metadata.get("_stream_id") == stream_end[1].metadata.get("_stream_id") False,
assert deltas[0].metadata.get("_stream_id") != deltas[1].metadata.get("_stream_id") ]
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 final[-1].content == "full retry response" assert final[-1].content == "full retry response"
assert final[-1].metadata.get("_streamed") is True assert isinstance(final[-1].event, StreamedResponseEvent)
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -623,9 +657,9 @@ class TestToolEventProgress:
done_msgs = [m for m in outbound if m.content == "Done"] done_msgs = [m for m in outbound if m.content == "Done"]
assert len(done_msgs) == 1 assert len(done_msgs) == 1
assert not done_msgs[0].metadata.get("_turn_end") assert not isinstance(done_msgs[0].event, TurnEndEvent)
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")] turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end_msgs) == 1 assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == "" assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1" assert turn_end_msgs[0].chat_id == "chat1"
@@ -659,14 +693,14 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."] error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."]
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")] turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
statuses = [m for m in outbound if m.metadata.get("_goal_status")] statuses = [m for m in outbound if isinstance(m.event, GoalStatusEvent)]
assert len(error_msgs) == 1 assert len(error_msgs) == 1
assert len(turn_end_msgs) == 1 assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == "" assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1" assert turn_end_msgs[0].chat_id == "chat1"
assert [m.metadata["goal_status"] for m in statuses] == ["idle"] assert [m.event.status for m in statuses if isinstance(m.event, GoalStatusEvent)] == ["idle"]
assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0]) assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0])
assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1]) assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1])
@@ -705,27 +739,27 @@ class TestToolEventProgress:
outbound: list = [] outbound: list = []
for _ in range(12): for _ in range(12):
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)) outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
if outbound[-1].metadata.get("_turn_end"): if isinstance(outbound[-1].event, TurnEndEvent):
break break
else: else:
raise AssertionError("_turn_end message not found") raise AssertionError("turn-end event not found")
done_with_body = [m for m in outbound if m.content == "Done"] done_with_body = [m for m in outbound if m.content == "Done"]
assert len(done_with_body) == 1 assert len(done_with_body) == 1
assert outbound[-1].metadata.get("_turn_end") is True assert isinstance(outbound[-1].event, TurnEndEvent)
await asyncio.wait_for(title_started.wait(), timeout=0.5) await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set() release_title.set()
session_updated = None session_updated = None
for _ in range(10): for _ in range(10):
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5) candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
if (candidate.metadata or {}).get("_session_updated"): if isinstance(candidate.event, SessionUpdatedEvent):
session_updated = candidate session_updated = candidate
break break
assert session_updated is not None assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True assert isinstance(session_updated.event, SessionUpdatedEvent)
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata" assert session_updated.event.scope == "metadata"
assert provider.chat_with_retry.await_count == 2 assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -837,4 +871,4 @@ class TestToolEventProgress:
assert len(outbound) == 1 assert len(outbound) == 1
assert outbound[0].content == "Done" assert outbound[0].content == "Done"
assert (outbound[0].metadata or {}).get("_turn_end") is not True assert not isinstance(outbound[0].event, TurnEndEvent)
+7 -5
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from nanobot.bus.outbound_events import StreamedResponseEvent
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -23,8 +24,8 @@ def _make_loop(tmp_path):
with patch("nanobot.agent.loop.ContextBuilder"), \ with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \ patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path) loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
return loop return loop
@@ -193,8 +194,9 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
assert result is not None assert result is not None
assert "503" in result.content assert "503" in result.content
assert not result.metadata.get("_streamed"), \ assert not isinstance(result.event, StreamedResponseEvent), (
"_streamed must not be set when stop_reason is error" "streamed response event must not be set when stop_reason is error"
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -239,7 +241,7 @@ async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
assert result is not None assert result is not None
assert result.content == "I cannot access private URLs. Please share the local file." assert result.content == "I cannot access private URLs. Please share the local file."
assert result.metadata.get("_streamed") is True assert isinstance(result.event, StreamedResponseEvent)
@pytest.mark.asyncio @pytest.mark.asyncio
+23 -16
View File
@@ -8,6 +8,13 @@ import pytest
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage 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.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse
@@ -765,7 +772,6 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
"_wants_stream": True, "_wants_stream": True,
"message_id": "om_001", "message_id": "om_001",
"origin_message_id": "root_001", "origin_message_id": "root_001",
"_stream_id": "old-stream",
}, },
)) ))
@@ -775,23 +781,23 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
assert queued.metadata["_wants_stream"] is True assert queued.metadata["_wants_stream"] is True
assert queued.metadata["message_id"] == "om_001" assert queued.metadata["message_id"] == "om_001"
assert queued.metadata["origin_message_id"] == "root_001" assert queued.metadata["origin_message_id"] == "root_001"
assert "_stream_id" not in queued.metadata
await loop._dispatch(queued) await loop._dispatch(queued)
outbound = [] outbound = []
while loop.bus.outbound_size: while loop.bus.outbound_size:
outbound.append(await loop.bus.consume_outbound()) outbound.append(await loop.bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")] deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
ends = [m for m in outbound if m.metadata.get("_stream_end")] ends = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
streamed_markers = [m for m in outbound if m.metadata.get("_streamed")] streamed_markers = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)]
assert [m.content for m in deltas] == ["done"] assert [m.content for m in deltas] == ["done"]
assert len(ends) == 1 assert len(ends) == 1
assert ends[0].metadata["_resuming"] is False assert isinstance(ends[0].event, StreamEndEvent)
assert ends[0].event.resuming is False
assert ends[0].metadata["message_id"] == "om_001" assert ends[0].metadata["message_id"] == "om_001"
assert ends[0].metadata["origin_message_id"] == "root_001" assert ends[0].metadata["origin_message_id"] == "root_001"
assert isinstance(ends[0].metadata.get("_stream_id"), str) assert isinstance(ends[0].event.stream_id, str)
assert streamed_markers and streamed_markers[-1].content == "done" assert streamed_markers and streamed_markers[-1].content == "done"
@@ -842,10 +848,10 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
first_outbound = [] first_outbound = []
while loop.bus.outbound_size: while loop.bus.outbound_size:
first_outbound.append(await loop.bus.consume_outbound()) first_outbound.append(await loop.bus.consume_outbound())
first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")] first_statuses = [m.event for m in first_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m["goal_status"] for m in first_statuses] == ["running"] assert [m.status for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if m.metadata.get("_turn_end")] assert not [m for m in first_outbound if isinstance(m.event, TurnEndEvent)]
started_at = first_statuses[0]["started_at"] started_at = first_statuses[0].started_at
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5) queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued.metadata[INTERNAL_CONTINUATION_META] is True assert queued.metadata[INTERNAL_CONTINUATION_META] is True
@@ -856,12 +862,13 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
second_outbound = [] second_outbound = []
while loop.bus.outbound_size: while loop.bus.outbound_size:
second_outbound.append(await loop.bus.consume_outbound()) second_outbound.append(await loop.bus.consume_outbound())
second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")] second_statuses = [m.event for m in second_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m["goal_status"] for m in second_statuses] == ["running", "idle"] assert [m.status for m in second_statuses] == ["running", "idle"]
assert second_statuses[0]["started_at"] == started_at assert second_statuses[0].started_at == started_at
turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")] turn_end = [m for m in second_outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end) == 1 assert len(turn_end) == 1
assert isinstance(turn_end[0].metadata.get("latency_ms"), int) assert isinstance(turn_end[0].event, TurnEndEvent)
assert isinstance(turn_end[0].event.latency_ms, int)
@pytest.mark.asyncio @pytest.mark.asyncio
+60 -56
View File
@@ -1,4 +1,4 @@
"""Tests for max_messages config wiring into session history replay.""" """Tests for the internal max_messages replay cap."""
from __future__ import annotations from __future__ import annotations
@@ -11,20 +11,27 @@ from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse
from nanobot.session.manager import Session from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.manager import (
DEFAULT_MAX_MESSAGES = 120 FILE_MAX_MESSAGES,
Session,
replay_max_messages_for_context,
)
def _make_loop(tmp_path: Path, max_messages: int = DEFAULT_MAX_MESSAGES) -> AgentLoop: def _make_loop(
tmp_path: Path,
context_window_tokens: int = 200_000,
) -> AgentLoop:
provider = MagicMock() provider = MagicMock()
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
provider.generation.max_tokens = 4096
return AgentLoop( return AgentLoop(
bus=MessageBus(), bus=MessageBus(),
provider=provider, provider=provider,
workspace=tmp_path, workspace=tmp_path,
model="test-model", model="test-model",
max_messages=max_messages, context_window_tokens=context_window_tokens,
) )
@@ -51,24 +58,44 @@ def _tool_round(call_id: str) -> list[dict]:
class TestMaxMessagesInit: class TestMaxMessagesInit:
"""Verify AgentLoop stores the config value correctly.""" """Verify AgentLoop derives the internal replay cap correctly."""
def test_default_is_builtin_limit(self, tmp_path: Path) -> None: def test_context_formula(self) -> None:
assert replay_max_messages_for_context(8_000) == 120
assert replay_max_messages_for_context(32_768) == 327
assert replay_max_messages_for_context(200_000) == FILE_MAX_MESSAGES
def test_default_for_200k_context_reaches_file_cap(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path) loop = _make_loop(tmp_path)
assert loop._max_messages == DEFAULT_MAX_MESSAGES assert loop._max_messages == FILE_MAX_MESSAGES
def test_positive_value_stored(self, tmp_path: Path) -> None: def test_default_scales_with_context_window(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path, max_messages=25) loop = _make_loop(tmp_path, context_window_tokens=32_768)
assert loop._max_messages == 25 assert loop._max_messages == 327
def test_zero_uses_builtin_limit(self, tmp_path: Path) -> None: def test_provider_refresh_resyncs_context_derived_limit(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path, max_messages=0) old_provider = MagicMock()
assert loop._max_messages == DEFAULT_MAX_MESSAGES old_provider.get_default_model.return_value = "old-model"
old_provider.generation.max_tokens = 4096
new_provider = MagicMock()
new_provider.generation.max_tokens = 4096
loop = AgentLoop(
bus=MessageBus(),
provider=old_provider,
workspace=tmp_path,
model="old-model",
context_window_tokens=32_768,
provider_snapshot_loader=lambda: ProviderSnapshot(
provider=new_provider,
model="new-model",
context_window_tokens=200_000,
signature=("new-model",),
),
)
def test_negative_treated_as_builtin_limit(self, tmp_path: Path) -> None: assert loop._max_messages == 327
"""Negative values should not produce negative slicing.""" loop._refresh_provider_snapshot()
loop = _make_loop(tmp_path, max_messages=-5) assert loop._max_messages == FILE_MAX_MESSAGES
assert loop._max_messages == DEFAULT_MAX_MESSAGES
class TestGetHistoryWithMaxMessages: class TestGetHistoryWithMaxMessages:
@@ -77,7 +104,7 @@ class TestGetHistoryWithMaxMessages:
def test_default_uses_builtin_limit(self) -> None: def test_default_uses_builtin_limit(self) -> None:
session = _populated_session(80) session = _populated_session(80)
history = session.get_history() history = session.get_history()
assert len(history) <= DEFAULT_MAX_MESSAGES assert len(history) <= FILE_MAX_MESSAGES
def test_explicit_max_messages_limits_output(self) -> None: def test_explicit_max_messages_limits_output(self) -> None:
session = _populated_session(40) # 80 messages total session = _populated_session(40) # 80 messages total
@@ -93,7 +120,7 @@ class TestGetHistoryWithMaxMessages:
def test_max_messages_zero_uses_builtin_limit(self) -> None: def test_max_messages_zero_uses_builtin_limit(self) -> None:
session = _populated_session(80) # 160 messages total session = _populated_session(80) # 160 messages total
history = session.get_history(max_messages=0) history = session.get_history(max_messages=0)
assert len(history) <= DEFAULT_MAX_MESSAGES assert len(history) <= FILE_MAX_MESSAGES
def test_small_session_unaffected(self) -> None: def test_small_session_unaffected(self) -> None:
"""When session has fewer messages than max_messages, all are returned.""" """When session has fewer messages than max_messages, all are returned."""
@@ -103,12 +130,13 @@ class TestGetHistoryWithMaxMessages:
class TestMaxMessagesIntegration: class TestMaxMessagesIntegration:
"""Verify the config flows from AgentLoop into get_history calls.""" """Verify AgentLoop passes the replay cap into get_history calls."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_message_passes_config_to_history_call(self, tmp_path: Path) -> None: async def test_process_message_passes_limit_to_history_call(self, tmp_path: Path) -> None:
"""The real message path should pass max_messages into session history replay.""" """The real message path should pass max_messages into session history replay."""
loop = _make_loop(tmp_path, max_messages=25) loop = _make_loop(tmp_path)
loop._max_messages = 25
loop.provider.chat_with_retry = AsyncMock( loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="ok", tool_calls=[], usage={}) return_value=LLMResponse(content="ok", tool_calls=[], usage={})
) )
@@ -127,8 +155,11 @@ class TestMaxMessagesIntegration:
assert mock_hist.call_args.kwargs["extend_to_user"] is False assert mock_hist.call_args.kwargs["extend_to_user"] is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_zero_config_passes_builtin_limit_to_history_call(self, tmp_path: Path) -> None: async def test_default_limit_passes_context_derived_limit_to_history_call(
loop = _make_loop(tmp_path, max_messages=0) self,
tmp_path: Path,
) -> None:
loop = _make_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock( loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="ok", tool_calls=[], usage={}) return_value=LLMResponse(content="ok", tool_calls=[], usage={})
) )
@@ -142,7 +173,7 @@ class TestMaxMessagesIntegration:
) )
assert result is not None assert result is not None
assert mock_hist.call_args.kwargs["max_messages"] == DEFAULT_MAX_MESSAGES assert mock_hist.call_args.kwargs["max_messages"] == FILE_MAX_MESSAGES
assert mock_hist.call_args.kwargs["extend_to_user"] is False assert mock_hist.call_args.kwargs["extend_to_user"] is False
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -151,7 +182,8 @@ class TestMaxMessagesIntegration:
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""A live user turn should not extend history to an older long tool turn.""" """A live user turn should not extend history to an older long tool turn."""
loop = _make_loop(tmp_path, max_messages=6) loop = _make_loop(tmp_path)
loop._max_messages = 6
loop.provider.chat_with_retry = AsyncMock( loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="ok", tool_calls=[], usage={}) return_value=LLMResponse(content="ok", tool_calls=[], usage={})
) )
@@ -182,31 +214,3 @@ class TestMaxMessagesIntegration:
sent_text = "\n".join(str(message.get("content")) for message in sent_messages) sent_text = "\n".join(str(message.get("content")) for message in sent_messages)
assert "new question" in sent_text assert "new question" in sent_text
assert "long older turn" not in sent_text assert "long older turn" not in sent_text
class TestSchemaConfig:
"""Verify the config schema accepts max_messages."""
def test_schema_default(self) -> None:
from nanobot.config.schema import AgentDefaults
defaults = AgentDefaults()
assert defaults.max_messages == DEFAULT_MAX_MESSAGES
def test_schema_accepts_zero_as_builtin_limit(self) -> None:
from nanobot.config.schema import AgentDefaults
defaults = AgentDefaults(max_messages=0)
assert defaults.max_messages == 0
def test_schema_accepts_positive(self) -> None:
from nanobot.config.schema import AgentDefaults
defaults = AgentDefaults(max_messages=25)
assert defaults.max_messages == 25
def test_schema_rejects_negative(self) -> None:
from nanobot.config.schema import AgentDefaults
with pytest.raises(Exception): # Pydantic validation error
AgentDefaults(max_messages=-1)
+2 -1
View File
@@ -853,10 +853,11 @@ class TestApiServerRegistration:
config = Config() config = Config()
from nanobot.config.schema import ApiConfig from nanobot.config.schema import ApiConfig
new_api = ApiConfig(host="0.0.0.0", port=9999) new_api = ApiConfig(host="0.0.0.0", port=9999, api_key="secret")
_SETTINGS_SETTER["API Server"](config, new_api) _SETTINGS_SETTER["API Server"](config, new_api)
assert config.api.host == "0.0.0.0" assert config.api.host == "0.0.0.0"
assert config.api.port == 9999 assert config.api.port == 9999
assert config.api.api_key == "secret"
class TestMainMenuUpdate: class TestMainMenuUpdate:
+40
View File
@@ -135,6 +135,46 @@ async def test_runner_tool_error_sets_final_content():
assert result.stop_reason == "tool_error" 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 @pytest.mark.asyncio
async def test_runner_tool_error_preserves_tool_results_in_messages(): async def test_runner_tool_error_preserves_tool_results_in_messages():
"""When a tool raises a fatal error, its results must still be appended """When a tool raises a fatal error, its results must still be appended
+160 -1
View File
@@ -15,7 +15,7 @@ from nanobot.agent.context_governance import (
) )
from nanobot.agent.runner import AgentRunSpec from nanobot.agent.runner import AgentRunSpec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse, ToolCallRequest
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -877,3 +877,162 @@ def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
assert non_system[0]["role"] in ("user", "tool"), ( assert non_system[0]["role"] in ("user", "tool"), (
f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}" f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}"
) )
# ---------------------------------------------------------------------------
# Malformed tool_call name guard (missing/non-string name wedges the session
# upstream: messages.content.N.tool_use.name: Input should be a valid string)
# ---------------------------------------------------------------------------
def test_drop_malformed_tool_calls_trims_response():
"""LLM response tool_calls with a missing/empty name are dropped in place."""
from nanobot.agent.runner import AgentRunner
response = LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(id="1", name=None, arguments={}),
ToolCallRequest(id="2", name="", arguments={}),
ToolCallRequest(id="3", name="read_file", arguments={}),
],
finish_reason="tool_calls",
)
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
assert [tc.name for tc in response.tool_calls] == ["read_file"]
assert response.finish_reason == "tool_calls"
assert response.should_execute_tools is True
assert dropped == 2
assert all_dropped is False
assert orig == "tool_calls"
def test_drop_malformed_tool_calls_all_bad_disables_execution():
"""If every tool call is malformed, execution is disabled (no empty exec)."""
from nanobot.agent.runner import AgentRunner
response = LLMResponse(
content="some text",
tool_calls=[ToolCallRequest(id="1", name=None, arguments={})],
finish_reason="tool_calls",
)
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
assert response.tool_calls == []
assert response.finish_reason == "stop"
assert response.should_execute_tools is False
assert dropped == 1
assert all_dropped is True
assert orig == "tool_calls"
def test_drop_malformed_returns_tuple_no_calls():
"""No tool calls returns (0, False, current_finish_reason)."""
from nanobot.agent.runner import AgentRunner
response = LLMResponse(content="hi", finish_reason="stop")
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
assert dropped == 0
assert all_dropped is False
assert orig == "stop"
def test_strip_malformed_tool_calls_keeps_valid_calls_in_history():
"""A mixed assistant turn keeps only its valid tool_calls."""
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "bad", "type": "function", "function": {"name": None, "arguments": "{}"}},
{"id": "ok", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "ok", "name": "exec", "content": "done"},
]
result = ContextGovernor.strip_malformed_tool_calls(messages)
assert result is not messages # copied, original untouched
assert len(messages[1]["tool_calls"]) == 2 # original preserved
kept = result[1]["tool_calls"]
assert [tc["function"]["name"] for tc in kept] == ["exec"]
def test_strip_malformed_tool_calls_drops_empty_assistant_turn():
"""An assistant turn that is only a malformed call is removed entirely;
the existing orphan-result cleanup then drops its dangling tool result,
so a polluted session self-heals."""
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "bad", "type": "function", "function": {"name": None, "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "bad", "name": "", "content": "r"},
]
stripped = ContextGovernor.strip_malformed_tool_calls(messages)
assert [m["role"] for m in stripped] == ["user", "tool"]
healed = ContextGovernor.drop_orphan_tool_results(stripped)
assert [m["role"] for m in healed] == ["user"]
def test_strip_malformed_tool_calls_noop_when_clean():
"""Clean history is returned unchanged (same object)."""
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "ok", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "ok", "name": "exec", "content": "done"},
]
assert ContextGovernor.strip_malformed_tool_calls(messages) is messages
def test_strip_placeholder_assistant_messages_removes_omitted():
"""Placeholder assistant messages are removed; real messages kept."""
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "real response"},
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "[Previous assistant message omitted.]"},
{"role": "user", "content": "?"},
{"role": "assistant", "content": "[Previous assistant message omitted.]"},
{"role": "user", "content": "hello"},
]
result = ContextGovernor.strip_placeholder_assistant_messages(messages)
assert [m["role"] for m in result] == [
"user", "assistant", "user", "user", "user",
]
assert result[1]["content"] == "real response"
def test_strip_placeholder_noop_when_clean():
"""Clean history is returned unchanged (same object)."""
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello back"},
]
assert ContextGovernor.strip_placeholder_assistant_messages(messages) is messages
def test_strip_placeholder_keeps_assistant_with_tool_calls():
"""A placeholder assistant that also carries tool_calls is kept."""
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "[Previous assistant message omitted.]",
"tool_calls": [
{"id": "1", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "1", "name": "exec", "content": "done"},
]
result = ContextGovernor.strip_placeholder_assistant_messages(messages)
assert result is messages
+9 -13
View File
@@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools import ToolResult
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -20,8 +22,6 @@ 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 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. rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
""" """
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[ provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse( LLMResponse(
@@ -64,8 +64,6 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
def test_is_ssrf_violation_recognizes_private_url_blocks(): def test_is_ssrf_violation_recognizes_private_url_blocks():
"""SSRF rejections are classified separately from workspace boundaries.""" """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)" 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(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation( assert AgentRunner._is_ssrf_violation(
@@ -88,8 +86,6 @@ def test_is_ssrf_violation_recognizes_private_url_blocks():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_returns_non_retryable_hint_on_ssrf_violation(): 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.""" """SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[ provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse( LLMResponse(
@@ -107,7 +103,7 @@ async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
]) ])
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=( tools.execute = AsyncMock(return_value=ToolResult.error(
"Error: Command blocked by safety guard (internal/private URL detected)" "Error: Command blocked by safety guard (internal/private URL detected)"
)) ))
@@ -141,8 +137,6 @@ 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 turn (silent hang on Telegram per #3605); now the LLM gets the soft
error back and can finalize on the next iteration. error back and can finalize on the next iteration.
""" """
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
captured_second_call: list[dict] = [] captured_second_call: list[dict] = []
@@ -163,7 +157,9 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock( tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)" return_value=ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
)
) )
runner = AgentRunner(provider) runner = AgentRunner(provider)
@@ -195,8 +191,6 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
the runner replaces the tool result with a hard "stop trying" message 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. so the model finally gives up and surfaces the boundary to the user.
""" """
from nanobot.agent.runner import AgentRunSpec, AgentRunner
bypass_attempts = [ bypass_attempts = [
ToolCallRequest( ToolCallRequest(
id=f"a{i}", name="exec", id=f"a{i}", name="exec",
@@ -215,7 +209,9 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock( tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)" return_value=ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
)
) )
runner = AgentRunner(provider) runner = AgentRunner(provider)
+108 -1
View File
@@ -8,7 +8,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.base import Tool 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.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -61,6 +63,40 @@ class _DelayTool(Tool):
return self._name 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): async def _run_optional_tool_response(response: LLMResponse):
provider = MagicMock() provider = MagicMock()
calls = {"n": 0} calls = {"n": 0}
@@ -91,6 +127,20 @@ async def _run_optional_tool_response(response: LLMResponse):
return result, shared_events 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: def _tool_message(result, tool_call_id: str) -> dict:
return [ return [
msg for msg in result.messages msg for msg in result.messages
@@ -320,6 +370,63 @@ async def test_runner_rejects_openai_responses_array_arguments_without_executing
assert "parameters must be a JSON object" in tool_message["content"] 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 @pytest.mark.asyncio
async def test_runner_blocks_repeated_external_fetches(): async def test_runner_blocks_repeated_external_fetches():
provider = MagicMock() provider = MagicMock()
+170
View File
@@ -0,0 +1,170 @@
"""Regression tests for collision-resistant session filenames."""
import json
from datetime import datetime
from pathlib import Path
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import safe_filename
def _manager(tmp_path: Path, monkeypatch) -> SessionManager:
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: tmp_path / "legacy_sessions",
)
return SessionManager(tmp_path / "workspace")
def _write_session_file(path: Path, key: str, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
metadata = {
"_type": "metadata",
"key": key,
"created_at": datetime(2025, 1, 1).isoformat(),
"updated_at": datetime(2025, 1, 1).isoformat(),
"metadata": {"source": "test"},
"last_consolidated": 0,
}
message = {"role": "user", "content": content}
path.write_text(
json.dumps(metadata) + "\n" + json.dumps(message) + "\n",
encoding="utf-8",
)
def test_distinct_keys_have_distinct_filenames(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
first = sm._get_session_path("telegram:a_b")
second = sm._get_session_path("telegram:a:b")
assert first.name != second.name
assert sm.safe_key("telegram:a_b") == sm.safe_key("telegram:a:b")
assert sm._storage_key("telegram:a_b") != sm._storage_key("telegram:a:b")
def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:a:b"
session = Session(key=key)
session.add_message("user", "first")
sm.save(session)
new_path = sm._get_session_path(key)
lossy_path = sm._get_legacy_lossy_path(key)
_write_session_file(lossy_path, key, "stale lossy content")
stale_lossy = lossy_path.read_text(encoding="utf-8")
session.add_message("assistant", "latest content")
sm.save(session)
assert new_path.exists()
assert lossy_path.exists()
assert "latest content" in new_path.read_text(encoding="utf-8")
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:legacy:lossy"
lossy_path = sm._get_legacy_lossy_path(key)
_write_session_file(lossy_path, key, "loaded from lossy")
session = sm._load(key)
assert session is not None
assert session.metadata == {"source": "test"}
assert session.messages[0]["content"] == "loaded from lossy"
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:migrate:lossy"
new_path = sm._get_session_path(key)
lossy_path = sm._get_legacy_lossy_path(key)
_write_session_file(lossy_path, key, "migrate me")
session = sm._load(key)
assert session is not None
assert session.messages[0]["content"] == "migrate me"
assert new_path.exists()
assert not lossy_path.exists()
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
tmp_path: Path,
monkeypatch,
) -> None:
sm = _manager(tmp_path, monkeypatch)
first_key = "telegram:a_b"
second_key = "telegram:a:b"
lossy_path = sm._get_legacy_lossy_path(first_key)
assert lossy_path == sm._get_legacy_lossy_path(second_key)
_write_session_file(lossy_path, first_key, "belongs to first")
loaded_second = sm._load(second_key)
assert loaded_second is None
assert lossy_path.exists()
assert not sm._get_session_path(second_key).exists()
loaded_first = sm._load(first_key)
assert loaded_first is not None
assert loaded_first.messages[0]["content"] == "belongs to first"
assert sm._get_session_path(first_key).exists()
assert not lossy_path.exists()
def test_safe_key_is_lossy() -> None:
assert SessionManager.safe_key("telegram:a_b") == SessionManager.safe_key("telegram:a:b")
def test_storage_key_is_collision_resistant() -> None:
encoded = {
SessionManager._storage_key("a:b"),
SessionManager._storage_key("a_b"),
SessionManager._storage_key("a:b:c"),
}
assert len(encoded) == 3
assert SessionManager._storage_key("telegram:a_b") != SessionManager._storage_key("telegram:a:b")
def test_lossy_path_helper_returns_expected_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:a:b"
expected = sm.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
assert sm._get_legacy_lossy_path(key) == expected
def test_storage_paths_are_distinct_when_keys_collide_under_safe_key(
tmp_path: Path,
monkeypatch,
) -> None:
sm = _manager(tmp_path, monkeypatch)
first = Session(key="telegram:a_b")
first.add_message("user", "underscore history")
second = Session(key="telegram:a:b")
second.add_message("user", "colon history")
sm.save(first)
sm.save(second)
assert sm.safe_key(first.key) == sm.safe_key(second.key)
assert sm._get_session_path(first.key).exists()
assert sm._get_session_path(second.key).exists()
assert sm._get_session_path(first.key) != sm._get_session_path(second.key)
sm.invalidate(first.key)
sm.invalidate(second.key)
loaded_first = sm._load(first.key)
loaded_second = sm._load(second.key)
assert loaded_first is not None
assert loaded_second is not None
assert loaded_first.messages[0]["content"] == "underscore history"
assert loaded_second.messages[0]["content"] == "colon history"
+2 -2
View File
@@ -58,11 +58,11 @@ def test_read_session_file_missing(tmp_path: Path) -> None:
assert sm.read_session_file("nope:none") is None assert sm.read_session_file("nope:none") is None
def test_safe_key_matches_internal_path(tmp_path: Path) -> None: def test_storage_key_matches_internal_path(tmp_path: Path) -> None:
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
key = "telegram:abc/def" key = "telegram:abc/def"
expected = sm._get_session_path(key).name expected = sm._get_session_path(key).name
assert SessionManager.safe_key(key) + ".jsonl" == expected assert SessionManager._storage_key(key) + ".jsonl" == expected
def _write_legacy_session(legacy_dir: Path, key: str, roles: list[str]) -> Path: def _write_legacy_session(legacy_dir: Path, key: str, roles: list[str]) -> Path:
+12 -12
View File
@@ -685,12 +685,12 @@ def test_retain_recent_legal_suffix_returns_dropped_messages():
for i in range(10): for i in range(10):
session.messages.append({"role": "user", "content": f"msg{i}"}) session.messages.append({"role": "user", "content": f"msg{i}"})
dropped, already_cons = session.retain_recent_legal_suffix(4) result = session.retain_recent_legal_suffix(4)
assert len(dropped) == 6 assert len(result.dropped) == 6
assert [m["content"] for m in dropped] == [f"msg{i}" for i in range(6)] assert [m["content"] for m in result.dropped] == [f"msg{i}" for i in range(6)]
assert len(session.messages) == 4 assert len(session.messages) == 4
assert already_cons == 0 assert result.already_consolidated_count == 0
def test_retain_recent_legal_suffix_returns_empty_when_no_drop(): def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
@@ -699,10 +699,10 @@ def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
for i in range(3): for i in range(3):
session.messages.append({"role": "user", "content": f"msg{i}"}) session.messages.append({"role": "user", "content": f"msg{i}"})
dropped, already_cons = session.retain_recent_legal_suffix(4) result = session.retain_recent_legal_suffix(4)
assert dropped == [] assert result.dropped == []
assert already_cons == 0 assert result.already_consolidated_count == 0
assert len(session.messages) == 3 assert len(session.messages) == 3
@@ -713,10 +713,10 @@ def test_retain_recent_legal_suffix_returns_all_on_zero():
session.messages.append({"role": "user", "content": f"msg{i}"}) session.messages.append({"role": "user", "content": f"msg{i}"})
session.last_consolidated = 3 session.last_consolidated = 3
dropped, already_cons = session.retain_recent_legal_suffix(0) result = session.retain_recent_legal_suffix(0)
assert len(dropped) == 5 assert len(result.dropped) == 5
assert already_cons == 3 assert result.already_consolidated_count == 3
assert session.messages == [] assert session.messages == []
@@ -820,11 +820,11 @@ def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
session.messages.append({"role": "assistant", "content": f"a{i}"}) session.messages.append({"role": "assistant", "content": f"a{i}"})
session.last_consolidated = 12 # u0..u9, a0, a1 consolidated session.last_consolidated = 12 # u0..u9, a0, a1 consolidated
dropped, already_cons = session.retain_recent_legal_suffix(4) result = session.retain_recent_legal_suffix(4)
# Retained messages start from latest user (u9) + max_messages forward # Retained messages start from latest user (u9) + max_messages forward
# so retained = [u9, a0..a9][:4] → but these are from original indices 9..12 # so retained = [u9, a0..a9][:4] → but these are from original indices 9..12
# Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3 # Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3
assert session.last_consolidated == 3 assert session.last_consolidated == 3
# already_cons should count dropped messages with original index < 12 # already_cons should count dropped messages with original index < 12
assert already_cons == 9 assert result.already_consolidated_count == 9
+3 -2
View File
@@ -127,6 +127,7 @@ class TestDispatch:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_streaming_preserves_message_metadata(self): async def test_dispatch_streaming_preserves_message_metadata(self):
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import StreamDeltaEvent, StreamEndEvent
loop, bus = _make_loop() loop, bus = _make_loop()
msg = InboundMessage( msg = InboundMessage(
@@ -156,10 +157,10 @@ class TestDispatch:
assert first.metadata["thread_root_event_id"] == "$root1" assert first.metadata["thread_root_event_id"] == "$root1"
assert first.metadata["thread_reply_to_event_id"] == "$reply1" assert first.metadata["thread_reply_to_event_id"] == "$reply1"
assert first.metadata["_stream_delta"] is True assert isinstance(first.event, StreamDeltaEvent)
assert second.metadata["thread_root_event_id"] == "$root1" assert second.metadata["thread_root_event_id"] == "$root1"
assert second.metadata["thread_reply_to_event_id"] == "$reply1" assert second.metadata["thread_reply_to_event_id"] == "$reply1"
assert second.metadata["_stream_end"] is True assert isinstance(second.event, StreamEndEvent)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_processing_lock_serializes(self): async def test_processing_lock_serializes(self):
+20 -1
View File
@@ -1,7 +1,7 @@
"""Tests for tool hint formatting (nanobot.utils.tool_hints).""" """Tests for tool hint formatting (nanobot.utils.tool_hints)."""
from nanobot.utils.tool_hints import format_tool_hints
from nanobot.providers.base import ToolCallRequest from nanobot.providers.base import ToolCallRequest
from nanobot.utils.tool_hints import format_tool_hints
def _tc(name: str, args) -> ToolCallRequest: def _tc(name: str, args) -> ToolCallRequest:
@@ -306,3 +306,22 @@ class TestToolHintMaxLength:
short = _hint([_tc("list_dir", {"path": long_path})], max_length=40) short = _hint([_tc("list_dir", {"path": long_path})], max_length=40)
long = _hint([_tc("list_dir", {"path": long_path})], max_length=120) long = _hint([_tc("list_dir", {"path": long_path})], max_length=120)
assert len(long) > len(short) assert len(long) > len(short)
class TestToolHintMalformedCalls:
"""Malformed tool calls must not crash hint formatting (see HKUDS/nanobot)."""
def test_none_name_is_skipped(self):
"""A tool call with name=None should be skipped, not raise AttributeError."""
result = _hint([_tc(None, None)])
assert result == ""
def test_empty_name_is_skipped(self):
"""A tool call with an empty name should be skipped."""
result = _hint([_tc("", {"path": "foo.txt"})])
assert result == ""
def test_none_name_mixed_with_valid_call(self):
"""A degenerate call must not suppress hints for the valid calls beside it."""
result = _hint([_tc(None, None), _tc("read_file", {"path": "foo.txt"})])
assert result == "read foo.txt"
@@ -1,7 +1,11 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from nanobot.agent.tools.base import Tool 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.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
def test_loader_discovers_entry_point_tools(): def test_loader_discovers_entry_point_tools():
@@ -74,3 +78,67 @@ def test_loader_skips_abstract_entry_point_tools():
discovered = loader._discover_plugins() discovered = loader._discover_plugins()
assert "abstract_plugin" not in discovered 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"
+5 -3
View File
@@ -13,6 +13,7 @@ from nanobot.agent.tools.long_task import (
CompleteGoalTool, CompleteGoalTool,
LongTaskTool, LongTaskTool,
) )
from nanobot.bus.outbound_events import GoalStateSyncEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.goal_state import GOAL_STATE_KEY
@@ -144,8 +145,8 @@ async def test_long_task_publishes_goal_state_ws_after_save(tmp_path):
call = bus.publish_outbound.await_args.args[0] call = bus.publish_outbound.await_args.args[0]
assert call.channel == "websocket" assert call.channel == "websocket"
assert call.chat_id == "chat-99" assert call.chat_id == "chat-99"
assert call.metadata.get("_goal_state_sync") is True assert isinstance(call.event, GoalStateSyncEvent)
assert call.metadata["goal_state"] == { assert call.event.goal_state == {
"active": True, "active": True,
"ui_summary": "alpha", "ui_summary": "alpha",
"objective": "Objective alpha", "objective": "Objective alpha",
@@ -180,7 +181,8 @@ async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path):
bus.publish_outbound.assert_awaited_once() bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0] call = bus.publish_outbound.await_args.args[0]
assert call.metadata["goal_state"] == {"active": False} assert isinstance(call.event, GoalStateSyncEvent)
assert call.event.goal_state == {"active": False}
@pytest.mark.asyncio @pytest.mark.asyncio
+4 -2
View File
@@ -236,10 +236,12 @@ class TestModifyRestricted:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_modify_context_window_valid(self): async def test_modify_context_window_valid(self):
tool = _make_tool() loop = _make_mock_loop(_sync_replay_max_messages=MagicMock())
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="set", key="context_window_tokens", value=131072) result = await tool.execute(action="set", key="context_window_tokens", value=131072)
assert "Set context_window_tokens" in result assert "Set context_window_tokens" in result
assert tool._runtime_state.context_window_tokens == 131072 assert loop.context_window_tokens == 131072
loop._sync_replay_max_messages.assert_called_once_with()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_modify_none_value_for_restricted_int(self): async def test_modify_none_value_for_restricted_int(self):
+244
View File
@@ -0,0 +1,244 @@
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,10 +1,19 @@
"""Tests for ChannelManager delta coalescing to reduce streaming latency.""" """Tests for ChannelManager delta coalescing to reduce streaming latency."""
import asyncio import asyncio
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
@@ -29,196 +38,187 @@ class MockChannel(BaseChannel):
pass pass
async def send(self, msg): async def send(self, msg):
"""Implement abstract method."""
return await self._send_mock(msg) return await self._send_mock(msg)
async def send_delta(self, chat_id, delta, metadata=None): async def send_delta(
"""Override send_delta for testing.""" self,
return await self._send_delta_mock(chat_id, delta, metadata) 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,
)
@pytest.fixture @pytest.fixture
def config(): def config():
"""Create a minimal config for testing."""
return Config() return Config()
@pytest.fixture @pytest.fixture
def bus(): def bus():
"""Create a message bus for testing."""
return MessageBus() return MessageBus()
@pytest.fixture @pytest.fixture
def manager(config, bus): def manager(config, bus):
"""Create a channel manager with a mock channel."""
manager = ChannelManager(config, bus) manager = ChannelManager(config, bus)
manager.channels["mock"] = MockChannel({}, bus) manager.channels["mock"] = MockChannel({}, bus)
return manager 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: class TestDeltaCoalescing:
"""Tests for _stream_delta message coalescing.""" """Tests for stream delta message coalescing."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_single_delta_not_coalesced(self, manager, bus): async def test_single_delta_not_coalesced(self, manager, bus):
"""A single delta should be sent as-is.""" msg = _delta("Hello")
msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="Hello",
metadata={"_stream_delta": True},
)
await bus.publish_outbound(msg) await bus.publish_outbound(msg)
# Process one message
async def process_one(): async def process_one():
try: try:
m = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1) m = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1)
if m.metadata.get("_stream_delta"): event = outbound_event_from_message(m)
if isinstance(event, StreamDeltaEvent):
m, pending = manager._coalesce_stream_deltas(m) m, pending = manager._coalesce_stream_deltas(m)
# Put pending back (none expected)
for p in pending: for p in pending:
await bus.publish_outbound(p) await bus.publish_outbound(p)
channel = manager.channels.get(m.channel) channel = manager.channels.get(m.channel)
if channel: event = outbound_event_from_message(m)
await channel.send_delta(m.chat_id, m.content, m.metadata) if channel and isinstance(event, StreamDeltaEvent):
await channel.send_delta(
m.chat_id,
m.content,
m.metadata,
stream_id=event.stream_id,
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
await process_one() await process_one()
manager.channels["mock"]._send_delta_mock.assert_called_once_with( manager.channels["mock"]._send_delta_mock.assert_called_once_with(
"chat1", "Hello", {"_stream_delta": True} "chat1",
"Hello",
{},
stream_id=None,
stream_end=False,
resuming=False,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multiple_deltas_coalesced(self, manager, bus): 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", "!"]: for text in ["Hello", " ", "world", "!"]:
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(_delta(text))
channel="mock",
chat_id="chat1",
content=text,
metadata={"_stream_delta": True},
))
# Process using coalescing logic
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged all deltas
assert merged.content == "Hello world!" assert merged.content == "Hello world!"
assert merged.metadata.get("_stream_delta") is True assert isinstance(merged.event, StreamDeltaEvent)
# No pending messages (all were coalesced)
assert len(pending) == 0 assert len(pending) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deltas_different_chats_not_coalesced(self, manager, bus): async def test_deltas_different_chats_not_coalesced(self, manager, bus):
"""Deltas for different chats should not be merged.""" await bus.publish_outbound(_delta("Hello", chat_id="chat1"))
# Put deltas for different chats await bus.publish_outbound(_delta("World", chat_id="chat2"))
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() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
# First chat should not include second chat's content
assert merged.content == "Hello" assert merged.content == "Hello"
assert merged.chat_id == "chat1" assert merged.chat_id == "chat1"
# Second chat should be in pending
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].chat_id == "chat2" assert pending[0].chat_id == "chat2"
assert pending[0].content == "World" assert pending[0].content == "World"
@pytest.mark.asyncio @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"))
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 len(pending) == 1
assert pending[0].content == "B1"
assert isinstance(pending[0].event, StreamDeltaEvent)
assert pending[0].event.stream_id == "stream-b"
@pytest.mark.asyncio
async def test_stream_end_terminates_coalescing(self, manager, bus): async def test_stream_end_terminates_coalescing(self, manager, bus):
"""_stream_end should stop coalescing and be included in final message.""" await bus.publish_outbound(_delta("Hello"))
# Put deltas with stream_end at the end await bus.publish_outbound(_end(" world"))
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() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
# Should have merged content
assert merged.content == "Hello world" assert merged.content == "Hello world"
# Should have stream_end flag assert isinstance(merged.event, StreamEndEvent)
assert merged.metadata.get("_stream_end") is True
# No pending
assert len(pending) == 0 assert len(pending) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_coalescing_stops_at_first_non_matching_boundary(self, manager, bus): async def test_coalescing_stops_at_first_non_matching_boundary(self, manager, bus):
"""Only consecutive deltas should be merged; later deltas stay queued.""" await bus.publish_outbound(_delta("Hello", stream_id="seg-1"))
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(_end(stream_id="seg-1"))
channel="mock", await bus.publish_outbound(_delta("world", stream_id="seg-2"))
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() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "Hello" assert merged.content == "Hello"
assert merged.metadata.get("_stream_end") is None assert isinstance(merged.event, StreamDeltaEvent)
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].metadata.get("_stream_end") is True assert isinstance(pending[0].event, StreamEndEvent)
assert pending[0].metadata.get("_stream_id") == "seg-1" assert pending[0].event.stream_id == "seg-1"
# The next stream segment must remain in queue order for later dispatch.
remaining = await bus.consume_outbound() remaining = await bus.consume_outbound()
assert remaining.content == "world" assert remaining.content == "world"
assert remaining.metadata.get("_stream_id") == "seg-2" assert isinstance(remaining.event, StreamDeltaEvent)
assert remaining.event.stream_id == "seg-2"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_non_delta_message_preserved(self, manager, bus): async def test_non_delta_message_preserved(self, manager, bus):
"""Non-delta messages should be preserved in pending list.""" await bus.publish_outbound(_delta("Delta"))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Delta",
metadata={"_stream_delta": True},
))
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="Final message", content="Final message",
metadata={}, # Not a delta
)) ))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
@@ -227,17 +227,11 @@ class TestDeltaCoalescing:
assert merged.content == "Delta" assert merged.content == "Delta"
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].content == "Final message" assert pending[0].content == "Final message"
assert pending[0].metadata.get("_stream_delta") is None assert pending[0].event is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_queue_stops_coalescing(self, manager, bus): async def test_empty_queue_stops_coalescing(self, manager, bus):
"""Coalescing should stop when queue is empty.""" await bus.publish_outbound(_delta("Only message"))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="Only message",
metadata={"_stream_delta": True},
))
first_msg = await bus.consume_outbound() first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg) merged, pending = manager._coalesce_stream_deltas(first_msg)
@@ -251,49 +245,35 @@ class TestDispatchOutboundWithCoalescing:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_coalesces_and_processes_pending(self, manager, bus): async def test_dispatch_coalesces_and_processes_pending(self, manager, bus):
"""_dispatch_outbound should coalesce deltas and process pending messages.""" await bus.publish_outbound(_delta("A"))
# Put multiple deltas followed by a regular message await bus.publish_outbound(_delta("B"))
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( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="Final", content="Final",
metadata={}, # Regular message
)) ))
# Run one iteration of dispatch logic manually
pending = [] pending = []
processed = [] processed = []
# First iteration: should coalesce A+B msg = pending.pop(0) if pending else await bus.consume_outbound()
if pending: event = outbound_event_from_message(msg)
msg = pending.pop(0) if isinstance(event, StreamDeltaEvent):
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) msg, extra_pending = manager._coalesce_stream_deltas(msg)
pending.extend(extra_pending) pending.extend(extra_pending)
channel = manager.channels.get(msg.channel) channel = manager.channels.get(msg.channel)
if channel: event = outbound_event_from_message(msg)
await channel.send_delta(msg.chat_id, msg.content, msg.metadata) if channel and isinstance(event, StreamDeltaEvent):
await channel.send_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=event.stream_id,
)
processed.append(("delta", msg.content)) processed.append(("delta", msg.content))
# Should have sent coalesced delta
assert processed == [("delta", "AB")] assert processed == [("delta", "AB")]
# Should have pending regular message
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].content == "Final" assert pending[0].content == "Final"
@@ -329,23 +309,20 @@ class TestProgressFiltering:
assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False
assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True 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 assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_channel_override_can_drop_progress_message(self, manager, bus): async def test_channel_override_can_drop_progress_message(self, manager, bus):
manager.channels["mock"].send_progress = False manager.channels["mock"].send_progress = False
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="thinking", event=ProgressEvent(content="thinking"),
metadata={"_progress": True},
)) ))
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="final answer", content="final answer",
metadata={},
)) ))
task = asyncio.create_task(manager._dispatch_outbound()) task = asyncio.create_task(manager._dispatch_outbound())
@@ -366,13 +343,37 @@ class TestProgressFiltering:
assert send_mock.await_args_list[0].args[0].content == "final answer" assert send_mock.await_args_list[0].args[0].content == "final answer"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_channel_override_can_enable_tool_hints(self, manager, bus): async def test_legacy_progress_flag_uses_runtime_progress_filter(self, manager, bus):
manager.channels["mock"].send_tool_hints = True manager.channels["mock"].send_progress = False
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="read_file(foo.py)", content="legacy progress-shaped message",
metadata={"_progress": True, "_tool_hint": True}, 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),
)) ))
task = asyncio.create_task(manager._dispatch_outbound()) task = asyncio.create_task(manager._dispatch_outbound())
@@ -398,24 +399,15 @@ class TestRetryWaitFiltering:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_retry_wait_message_dropped(self, manager, bus): async def test_retry_wait_message_dropped(self, manager, bus):
"""A ``_retry_wait`` message must be filtered before channel dispatch. retry_msg = outbound_message_for_event(
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", channel="mock",
chat_id="chat1", chat_id="chat1",
content="Model request failed, retry in 1s (attempt 1).", event=RetryWaitEvent(content="Model request failed, retry in 1s (attempt 1)."),
metadata={"_retry_wait": True},
) )
real_msg = OutboundMessage( real_msg = OutboundMessage(
channel="mock", channel="mock",
chat_id="chat1", chat_id="chat1",
content="final answer", content="final answer",
metadata={},
) )
await bus.publish_outbound(retry_msg) await bus.publish_outbound(retry_msg)
await bus.publish_outbound(real_msg) await bus.publish_outbound(real_msg)
@@ -437,4 +429,4 @@ class TestRetryWaitFiltering:
assert send_mock.await_count == 1 assert send_mock.await_count == 1
sent = send_mock.await_args_list[0].args[0] sent = send_mock.await_args_list[0].args[0]
assert sent.content == "final answer" assert sent.content == "final answer"
assert not sent.metadata.get("_retry_wait") assert sent.event is None
@@ -8,10 +8,9 @@ channels that opt in via ``channel.show_reasoning``; plugins without a
low-emphasis UI primitive keep the base no-op and the content silently low-emphasis UI primitive keep the base no-op and the content silently
drops at dispatch. drops at dispatch.
One-shot ``_reasoning`` frames are accepted for back-compat with hooks One-shot reasoning frames are represented as typed progress events and
that haven't migrated yet — ``BaseChannel.send_reasoning`` expands them ``BaseChannel.send_reasoning`` expands them to a single delta + end pair so
to a single delta + end pair so plugins only implement the streaming plugins only implement the streaming primitives.
primitives.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,6 +21,7 @@ from unittest.mock import AsyncMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
@@ -48,11 +48,11 @@ class _MockChannel(BaseChannel):
async def send(self, msg): async def send(self, msg):
return await self._send_mock(msg) return await self._send_mock(msg)
async def send_reasoning_delta(self, chat_id, delta, metadata=None): async def send_reasoning_delta(self, chat_id, delta, metadata=None, *, stream_id=None):
return await self._delta_mock(chat_id, delta, metadata) return await self._delta_mock(chat_id, delta, metadata, stream_id=stream_id)
async def send_reasoning_end(self, chat_id, metadata=None): async def send_reasoning_end(self, chat_id, metadata=None, *, stream_id=None):
return await self._end_mock(chat_id, metadata) return await self._end_mock(chat_id, metadata, stream_id=stream_id)
async def send_file_edit_events(self, chat_id, edits, metadata=None): async def send_file_edit_events(self, chat_id, edits, metadata=None):
return await self._file_edit_mock(chat_id, edits, metadata) 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 @pytest.mark.asyncio
async def test_reasoning_delta_routes_to_send_reasoning_delta(manager): async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="step-by-step", event=ProgressEvent(content="step-by-step", reasoning_delta=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once() channel._delta_mock.assert_awaited_once()
args = channel._delta_mock.await_args.args args = channel._delta_mock.await_args.args
assert args[0] == "c1" assert args[0] == "c1"
assert args[1] == "step-by-step" assert args[1] == "step-by-step"
assert channel._delta_mock.await_args.kwargs["stream_id"] == "r1"
channel._send_mock.assert_not_awaited() channel._send_mock.assert_not_awaited()
channel._end_mock.assert_not_awaited() channel._end_mock.assert_not_awaited()
@@ -112,11 +112,10 @@ async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reasoning_end_routes_to_send_reasoning_end(manager): async def test_reasoning_end_routes_to_send_reasoning_end(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="", event=ProgressEvent(reasoning_end=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._end_mock.assert_awaited_once() channel._end_mock.assert_awaited_once()
@@ -124,16 +123,13 @@ async def test_reasoning_end_routes_to_send_reasoning_end(manager):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_legacy_one_shot_reasoning_expands_to_delta_plus_end(manager): async def test_one_shot_reasoning_expands_to_delta_plus_end(manager):
"""`_reasoning` (no delta/end pair) falls back through `send_reasoning` """One-shot reasoning expands to a single delta + end."""
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"] channel = manager.channels["mock"]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="one-shot reasoning", event=ProgressEvent(content="one-shot reasoning", reasoning=True),
metadata={"_progress": True, "_reasoning": True},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._delta_mock.assert_awaited_once() channel._delta_mock.assert_awaited_once()
@@ -144,11 +140,10 @@ async def test_legacy_one_shot_reasoning_expands_to_delta_plus_end(manager):
async def test_dispatch_drops_reasoning_when_channel_opts_out(manager): async def test_dispatch_drops_reasoning_when_channel_opts_out(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
channel.show_reasoning = False channel.show_reasoning = False
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="hidden thinking", event=ProgressEvent(content="hidden thinking", reasoning_delta=True),
metadata={"_progress": True, "_reasoning_delta": True},
) )
await manager.bus.publish_outbound(msg) await manager.bus.publish_outbound(msg)
@@ -164,17 +159,15 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
channel.show_reasoning = True channel.show_reasoning = True
for chunk in ("first ", "second"): for chunk in ("first ", "second"):
await manager.bus.publish_outbound(OutboundMessage( await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content=chunk, event=ProgressEvent(content=chunk, reasoning_delta=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
)) ))
await manager.bus.publish_outbound(OutboundMessage( await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="", event=ProgressEvent(reasoning_end=True, stream_id="r1"),
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
)) ))
await _pump_one(manager) await _pump_one(manager)
@@ -185,11 +178,10 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager): async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager):
msg = OutboundMessage( msg = outbound_message_for_event(
channel="ghost", channel="ghost",
chat_id="c1", chat_id="c1",
content="nobody home", event=ProgressEvent(content="nobody home", reasoning_delta=True),
metadata={"_progress": True, "_reasoning_delta": True},
) )
await manager.bus.publish_outbound(msg) await manager.bus.publish_outbound(msg)
@@ -229,17 +221,34 @@ async def test_base_channel_reasoning_primitives_are_noop_safe():
async def test_file_edit_events_route_to_channel_capability(manager): async def test_file_edit_events_route_to_channel_capability(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}] edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = OutboundMessage( msg = outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="", event=ProgressEvent(file_edit_events=edits),
metadata={"_progress": True, "_file_edit_events": edits},
) )
await manager._send_once(channel, msg) await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with( channel._file_edit_mock.assert_awaited_once_with(
"c1", edits, {"_progress": True, "_file_edit_events": edits} "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
) )
channel._send_mock.assert_not_awaited() channel._send_mock.assert_not_awaited()
@@ -270,11 +279,10 @@ async def test_reasoning_routing_does_not_consult_send_progress(manager):
channel = manager.channels["mock"] channel = manager.channels["mock"]
channel.send_progress = False channel.send_progress = False
channel.show_reasoning = True channel.show_reasoning = True
await manager.bus.publish_outbound(OutboundMessage( await manager.bus.publish_outbound(outbound_message_for_event(
channel="mock", channel="mock",
chat_id="c1", chat_id="c1",
content="still surfaces", event=ProgressEvent(content="still surfaces", reasoning_delta=True),
metadata={"_progress": True, "_reasoning_delta": True},
)) ))
await _pump_one(manager) await _pump_one(manager)
+166 -11
View File
@@ -9,6 +9,13 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from nanobot.bus.events import OutboundMessage 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
@@ -718,7 +725,7 @@ async def test_send_with_retry_no_retry_when_max_is_zero():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_with_retry_calls_send_delta(): async def test_send_with_retry_calls_send_delta():
"""_send_with_retry should call send_delta when metadata has _stream_delta.""" """_send_with_retry should call send_delta for stream delta events."""
send_delta_called = False send_delta_called = False
class _StreamingChannel(BaseChannel): class _StreamingChannel(BaseChannel):
@@ -734,7 +741,16 @@ async def test_send_with_retry_calls_send_delta():
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
pass # Should not be called pass # Should not be called
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None: 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:
nonlocal send_delta_called nonlocal send_delta_called
send_delta_called = True send_delta_called = True
@@ -749,18 +765,147 @@ async def test_send_with_retry_calls_send_delta():
mgr.channels = {"streaming": _StreamingChannel(fake_config, mgr.bus)} mgr.channels = {"streaming": _StreamingChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None mgr._dispatch_task = None
msg = OutboundMessage( msg = outbound_message_for_event(
channel="streaming", chat_id="123", content="test delta", channel="streaming",
metadata={"_stream_delta": True} chat_id="123",
event=StreamDeltaEvent(content="test delta"),
) )
await mgr._send_with_retry(mgr.channels["streaming"], msg) await mgr._send_with_retry(mgr.channels["streaming"], msg)
assert send_delta_called is True 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 @pytest.mark.asyncio
async def test_send_with_retry_skips_send_when_streamed(): async def test_send_with_retry_skips_send_when_streamed():
"""_send_with_retry should not call send when metadata has _streamed flag.""" """_send_with_retry should not call send for streamed response events."""
send_called = False send_called = False
send_delta_called = False send_delta_called = False
@@ -778,7 +923,16 @@ async def test_send_with_retry_skips_send_when_streamed():
nonlocal send_called nonlocal send_called
send_called = True send_called = True
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None: 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:
nonlocal send_delta_called nonlocal send_delta_called
send_delta_called = True send_delta_called = True
@@ -793,10 +947,11 @@ async def test_send_with_retry_skips_send_when_streamed():
mgr.channels = {"streamed": _StreamedChannel(fake_config, mgr.bus)} mgr.channels = {"streamed": _StreamedChannel(fake_config, mgr.bus)}
mgr._dispatch_task = None mgr._dispatch_task = None
# _streamed means message was already sent via send_delta, so skip send msg = outbound_message_for_event(
msg = OutboundMessage( channel="streamed",
channel="streamed", chat_id="123", content="test", chat_id="123",
metadata={"_streamed": True} event=StreamedResponseEvent(),
content="test",
) )
await mgr._send_with_retry(mgr.channels["streamed"], msg) await mgr._send_with_retry(mgr.channels["streamed"], msg)
+8 -7
View File
@@ -10,6 +10,7 @@ pytest.importorskip("discord")
import discord import discord
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.discord import ( from nanobot.channels.discord import (
MAX_MESSAGE_LEN, MAX_MESSAGE_LEN,
@@ -718,9 +719,9 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
times = iter([1.0, 3.0, 5.0]) times = iter([1.0, 3.0, 5.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0)) monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
await owner.send_delta("123", "hel", {"_stream_delta": True, "_stream_id": "s1"}) await owner.send_delta("123", "hel", stream_id="s1")
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"}) await owner.send_delta("123", "lo", stream_id="s1")
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"}) await owner.send_delta("123", "", stream_id="s1", stream_end=True)
assert target.sent_payloads[0] == {"content": "hel"} assert target.sent_payloads[0] == {"content": "hel"}
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}] assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
@@ -745,9 +746,9 @@ async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None
times = iter([1.0, 3.0]) times = iter([1.0, 3.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0)) monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
await owner.send_delta("123", prefix, {"_stream_delta": True, "_stream_id": "s1"}) await owner.send_delta("123", prefix, stream_id="s1")
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"}) await owner.send_delta("123", suffix, stream_id="s1")
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"}) await owner.send_delta("123", "", stream_id="s1", stream_end=True)
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}] assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}] assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
@@ -1073,7 +1074,7 @@ async def test_send_stops_typing_after_send() -> None:
channel="discord", channel="discord",
chat_id="123", chat_id="123",
content="progress", content="progress",
metadata={"_progress": True}, event=ProgressEvent(content="progress"),
) )
) )
+2 -4
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.email import EmailChannel, EmailConfig from nanobot.channels.email import EmailChannel, EmailConfig
@@ -868,10 +869,7 @@ async def test_send_skips_progress_messages_before_smtp(monkeypatch) -> None:
channel="email", channel="email",
chat_id="alice@example.com", chat_id="alice@example.com",
content="", content="",
metadata={ event=ProgressEvent(tool_events=[{"phase": "end", "name": "exec"}]),
"_progress": True,
"_tool_events": [{"phase": "end", "name": "exec"}],
},
) )
) )
+17 -9
View File
@@ -193,7 +193,8 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001"}, metadata={"message_id": "om_001"},
stream_end=True,
) )
ch._remove_reaction.assert_called_once_with("om_001", "rx_42") ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
@@ -210,7 +211,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True}, stream_end=True,
) )
ch._remove_reaction.assert_not_called() ch._remove_reaction.assert_not_called()
@@ -227,7 +228,8 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001"}, metadata={"message_id": "om_001"},
stream_end=True,
) )
ch._remove_reaction.assert_not_called() ch._remove_reaction.assert_not_called()
@@ -242,7 +244,7 @@ class TestStreamEndReactionCleanup:
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True)) ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock() ch._remove_reaction = AsyncMock()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) await ch.send_delta("oc_chat1", "", stream_end=True)
ch._remove_reaction.assert_not_called() ch._remove_reaction.assert_not_called()
@@ -260,7 +262,7 @@ class TestStreamEndReactionCleanup:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_removal_when_resuming(self): 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 = _make_channel()
ch.config.done_emoji = "DONE" ch.config.done_emoji = "DONE"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
@@ -274,7 +276,9 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"}, metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
) )
ch._remove_reaction.assert_not_called() ch._remove_reaction.assert_not_called()
@@ -299,19 +303,23 @@ class TestStreamEndReactionCleanup:
# Intermediate stream end (more tool calls coming). # Intermediate stream end (more tool calls coming).
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"}, metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
) )
ch._remove_reaction.assert_not_called() ch._remove_reaction.assert_not_called()
ch._add_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( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="t", card_id="card_1", sequence=5, last_edit=0.0, text="t", card_id="card_1", sequence=5, last_edit=0.0,
) )
# Final stream end (resuming=False): OnIt removed, done_emoji added. # Final stream end (resuming=False): OnIt removed, done_emoji added.
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "_resuming": False, "message_id": "om_001"}, metadata={"message_id": "om_001"},
stream_end=True,
resuming=False,
) )
ch._remove_reaction.assert_called_once_with("om_001", "rx_42") ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
ch._add_reaction.assert_called_once_with("om_001", "DONE") ch._add_reaction.assert_called_once_with("om_001", "DONE")
+3 -1
View File
@@ -18,6 +18,7 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig from nanobot.channels.feishu import FeishuChannel, FeishuConfig
@@ -332,7 +333,8 @@ async def test_send_skips_reply_for_progress_messages() -> None:
channel="feishu", channel="feishu",
chat_id="oc_abc", chat_id="oc_abc",
content="thinking...", content="thinking...",
metadata={"message_id": "om_001", "_progress": True}, event=ProgressEvent(content="thinking..."),
metadata={"message_id": "om_001"},
)) ))
channel._client.im.v1.message.create.assert_called_once() channel._client.im.v1.message.create.assert_called_once()
+26 -18
View File
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
@@ -272,7 +273,7 @@ class TestSendDelta:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response() ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_called_once() ch._client.cardkit.v1.card_element.content.assert_called_once()
@@ -289,7 +290,7 @@ class TestSendDelta:
) )
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb") ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_not_called() ch._client.cardkit.v1.card_element.content.assert_not_called()
@@ -306,7 +307,8 @@ class TestSendDelta:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "oc_chat1",
"", "",
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"}, metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
) )
ch._client.im.v1.message.create.assert_called_once() ch._client.im.v1.message.create.assert_called_once()
@@ -326,11 +328,11 @@ class TestSendDelta:
"oc_chat1", "oc_chat1",
"", "",
metadata={ metadata={
"_stream_end": True,
"message_id": "om_001", "message_id": "om_001",
"chat_type": "group", "chat_type": "group",
"thread_id": "ot_001", "thread_id": "ot_001",
}, },
stream_end=True,
) )
ch._client.im.v1.message.reply.assert_called_once() ch._client.im.v1.message.reply.assert_called_once()
@@ -351,7 +353,8 @@ class TestSendDelta:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "oc_chat1",
"", "",
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"}, metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
) )
ch._client.im.v1.message.reply.assert_called_once() ch._client.im.v1.message.reply.assert_called_once()
@@ -369,7 +372,7 @@ class TestSendDelta:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False) 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") ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card.settings.call_count == 2 assert ch._client.cardkit.v1.card.settings.call_count == 2
@@ -388,7 +391,7 @@ class TestSendDelta:
] ]
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True) ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card_element.content.call_count == 2 assert ch._client.cardkit.v1.card_element.content.call_count == 2
@@ -398,7 +401,7 @@ class TestSendDelta:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_end_without_buf_is_noop(self): async def test_stream_end_without_buf_is_noop(self):
ch = _make_channel() ch = _make_channel()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) await ch.send_delta("oc_chat1", "", stream_end=True)
ch._client.cardkit.v1.card_element.content.assert_not_called() ch._client.cardkit.v1.card_element.content.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -446,7 +449,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='web_fetch("https://example.com")', content='web_fetch("https://example.com")',
metadata={"_tool_hint": True}, event=ProgressEvent(content='web_fetch("https://example.com")', tool_hint=True),
) )
await ch.send(msg) await ch.send(msg)
@@ -482,7 +485,7 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', content='read_file("path")',
metadata={"_tool_hint": True}, event=ProgressEvent(content='read_file("path")', tool_hint=True),
) )
await ch.send(msg) await ch.send(msg)
@@ -497,7 +500,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', content='read_file("path")',
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"}, event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
) )
await ch.send(msg) await ch.send(msg)
@@ -514,8 +518,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={ metadata={
"_tool_hint": True,
"message_id": "om_001", "message_id": "om_001",
"chat_type": "group", "chat_type": "group",
"thread_id": "ot_001", "thread_id": "ot_001",
@@ -538,7 +542,8 @@ class TestToolHintInlineStreaming:
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='read_file("path")', content='read_file("path")',
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"}, event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
) )
await ch.send(msg) await ch.send(msg)
@@ -558,13 +563,15 @@ class TestToolHintInlineStreaming:
msg1 = OutboundMessage( msg1 = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='$ cd /project', metadata={"_tool_hint": True}, content='$ cd /project',
event=ProgressEvent(content='$ cd /project', tool_hint=True),
) )
await ch.send(msg1) await ch.send(msg1)
msg2 = OutboundMessage( msg2 = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content='$ git status', metadata={"_tool_hint": True}, content='$ git status',
event=ProgressEvent(content='$ git status', tool_hint=True),
) )
await ch.send(msg2) await ch.send(msg2)
@@ -577,7 +584,7 @@ class TestToolHintInlineStreaming:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_hint_preserved_on_final_stream_end(self): async def test_tool_hint_preserved_on_final_stream_end(self):
"""When final _stream_end closes the card, tool hint is kept in the final text.""" """When stream end closes the card, tool hint is kept in the final text."""
ch = _make_channel() ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content\n\n🔧 web_fetch(\"url\")\n\n", text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
@@ -586,7 +593,7 @@ class TestToolHintInlineStreaming:
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response() ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs assert "oc_chat1" not in ch._stream_bufs
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0] update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
@@ -603,7 +610,8 @@ class TestToolHintInlineStreaming:
for content in ("", " ", "\t\n"): for content in ("", " ", "\t\n"):
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1", channel="feishu", chat_id="oc_chat1",
content=content, metadata={"_tool_hint": True}, content=content,
event=ProgressEvent(content=content, tool_hint=True),
) )
await ch.send(msg) await ch.send(msg)
@@ -1,7 +1,6 @@
"""Tests for FeishuChannel tool hint formatting.""" """Tests for FeishuChannel tool hint formatting."""
import json import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -18,6 +17,7 @@ if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.feishu import FeishuChannel from nanobot.channels.feishu import FeishuChannel
@@ -51,7 +51,7 @@ async def test_tool_hint_sends_interactive_card(mock_feishu_channel):
channel="feishu", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='web_search("test query")', content='web_search("test query")',
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content=" ", # whitespace only content=" ", # whitespace only
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='web_search("query"), read_file("/path/to/file")', content='web_search("query"), read_file("/path/to/file")',
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='read src/main.py, grep "TODO"', content='read src/main.py, grep "TODO"',
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='grep "hello, world", $ echo test', content='grep "hello, world", $ echo test',
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='read path × 3, grep "pattern"', content='read path × 3, grep "pattern"',
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='4_5v::analyze_image("photo.jpg")', content='4_5v::analyze_image("photo.jpg")',
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: 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", channel="feishu",
chat_id="oc_123456", chat_id="oc_123456",
content='web_search("foo, bar"), read_file("/path/to/file")', content='web_search("foo, bar"), read_file("/path/to/file")',
metadata={"_tool_hint": True} event=ProgressEvent(tool_hint=True),
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
+7 -6
View File
@@ -11,6 +11,7 @@ from nio import RoomSendResponse, SyncError
import nanobot.channels.matrix as matrix_module import nanobot.channels.matrix as matrix_module
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.matrix import ( from nanobot.channels.matrix import (
MATRIX_HTML_FORMAT, MATRIX_HTML_FORMAT,
@@ -1522,7 +1523,7 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None:
channel="matrix", channel="matrix",
chat_id="!room:matrix.org", chat_id="!room:matrix.org",
content="working...", content="working...",
metadata={"_progress": True, "_progress_kind": "reasoning"}, event=ProgressEvent(content="working..."),
) )
) )
@@ -1544,7 +1545,7 @@ async def test_send_empty_content_does_not_call_room_send() -> None:
channel="matrix", channel="matrix",
chat_id="!room:matrix.org", chat_id="!room:matrix.org",
content="", content="",
metadata={"_progress": True}, event=ProgressEvent(),
) )
) )
@@ -1563,7 +1564,7 @@ async def test_send_whitespace_only_content_does_not_call_room_send() -> None:
channel="matrix", channel="matrix",
chat_id="!room:matrix.org", chat_id="!room:matrix.org",
content=" \n\n ", content=" \n\n ",
metadata={"_progress": True}, event=ProgressEvent(content=" \n\n "),
) )
) )
@@ -1883,7 +1884,7 @@ async def test_send_delta_stream_end_replaces_existing_message() -> None:
last_edit=100.0, 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 "!room:matrix.org" not in channel._stream_bufs
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
@@ -1933,7 +1934,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", "Hello", metadata)
await channel.send_delta("!room:matrix.org", " world", metadata) await channel.send_delta("!room:matrix.org", " world", metadata)
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True, **metadata}) await channel.send_delta("!room:matrix.org", "", metadata, stream_end=True)
edit_content = client.room_send_calls[1]["content"] edit_content = client.room_send_calls[1]["content"]
final_content = client.room_send_calls[2]["content"] final_content = client.room_send_calls[2]["content"]
@@ -1966,7 +1967,7 @@ async def test_send_delta_stream_end_noop_when_buffer_missing() -> None:
client = _FakeAsyncClient("", "", "", None) client = _FakeAsyncClient("", "", "", None)
channel.client = client 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.room_send_calls == []
assert client.typing_calls == [] assert client.typing_calls == []

Some files were not shown because too many files have changed in this diff Show More