Compare commits

..
Author SHA1 Message Date
chengyongru 0ccb7234d4 refactor(session): extract turn history recovery 2026-07-02 16:21:06 +08:00
chengyongruandXubin Ren 34535b4e7c fix(webui): hide subagent backfill payloads 2026-07-02 14:36:23 +08:00
Stellar鱼andXubin Ren 5abe06f808 test: cover runner blocked tool-call finish reasons 2026-07-02 14:36:18 +08:00
yu-xin-candXubin Ren 64c7ff5fdc test(cron): cover stale instance mutation consistency 2026-07-02 14:36:12 +08:00
chengyongruandXubin Ren 54bcdb5a62 fix(exec): return early when session command exits 2026-07-02 14:36:06 +08:00
Xubin Ren ffdf05a603 fix(trigger): cap local trigger audit records 2026-07-02 13:46:27 +08:00
Xubin Ren fd9e57703c fix(trigger): tolerate unsupported directory fsync 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren 661ab00656 feat(trigger): add local trigger run audit records 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren b941233138 fix(trigger): clean up deleted trigger deliveries 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren acb0e853ff refactor(trigger): share automation turn delivery 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren afef27dd6c fix(webui): show pending local triggers 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren f32007c83f fix(trigger): defer local triggers until session idle 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren 09bde468eb fix(webui): narrow local trigger source label
maintainer edit: avoid optional source access after extracting automation source kind for TypeScript build.
2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren 2ebf5c4972 refactor(trigger): name CLI trigger source as local
maintainer edit: cron is also a trigger source, so keep the new CLI-delivered source explicitly named as local trigger across backend, WebUI, docs, and tests.
2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren 1ed2c9a213 fix(trigger): recover interrupted deliveries 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren 55b550ee01 fix(trigger): hide external trigger inputs 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren b690a48336 fix(trigger): require names for trigger creation 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren 7178ea3f13 docs: explain local triggers 2026-07-02 13:32:46 +08:00
chengyongruandXubin Ren 2a0cd19a74 feat(trigger): add session-bound local triggers 2026-07-02 13:32:46 +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
189 changed files with 9318 additions and 3165 deletions
+1
View File
@@ -107,6 +107,7 @@ File operations have path traversal protection, but:
**API Calls:**
- All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests
- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp:**
+1 -1
View File
@@ -51,7 +51,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|---|---|---|
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
| Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls |
| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
+60 -42
View File
@@ -103,7 +103,8 @@ class WebhookChannel(BaseChannel):
msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — 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])
# 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()`. |
| `is_running` | Returns `self._running`. |
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
| `send_reasoning_delta(chat_id, delta, metadata?)` | 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_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional (streaming)
| Method | Description |
|--------|-------------|
| `async send_delta(chat_id, delta, metadata?)` | 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
@@ -257,10 +258,12 @@ class OutboundMessage:
chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
# "message_id" for reply threading
metadata: dict # channel routing context, e.g. "message_id" for 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
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:
```python
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
# Streaming finished — do final formatting, cleanup, etc.
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)
```
**Metadata flags:**
| Flag | Meaning |
|------|---------|
| `_stream_delta: True` | A content chunk (delta contains the new text) |
| `_stream_end: True` | Streaming finished (delta is empty) |
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.
### Example: Webhook with Streaming
@@ -310,18 +316,27 @@ class WebhookChannel(BaseChannel):
super().__init__(config, bus)
self._buffers: dict[str, str] = {}
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
text = self._buffers.pop(chat_id, "")
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> 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
await self._deliver(chat_id, text, final=True)
return
self._buffers.setdefault(chat_id, "")
self._buffers[chat_id] += delta
self._buffers.setdefault(buffer_key, "")
self._buffers[buffer_key] += delta
# 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:
# Non-streaming path — unchanged
@@ -350,7 +365,7 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
| 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`. |
## 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 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
async def send(self, msg: OutboundMessage) -> None:
meta = msg.metadata or {}
from nanobot.bus.outbound_events import ProgressEvent
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")
await self._send_trace(msg.chat_id, msg.content, kind="tool")
return
if meta.get("_progress"):
if isinstance(event, ProgressEvent):
# Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress")
return
@@ -412,32 +429,33 @@ class WebhookChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
buffer_key = stream_id or chat_id
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
text = self._reasoning_buffers.pop(stream_id, "")
buffer_key = stream_id or chat_id
text = self._reasoning_buffers.pop(buffer_key, "")
if text:
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. |
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
| `send_reasoning_end()` | The current reasoning block is complete. |
Reasoning visibility is controlled by `showReasoning` globally or per channel:
+61
View File
@@ -16,6 +16,8 @@ These commands work inside chat channels and interactive agent sessions:
| `/dream-restore` | List recent Dream memory versions |
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
| `/skill` | List enabled skills and their descriptions |
| `/trigger` | Show local trigger usage |
| `/trigger <name>` | Create a named local trigger for the current chat/session |
| `/pairing` | List pending pairing requests |
| `/pairing approve <code>` | Approve a pairing code |
| `/pairing deny <code>` | Deny a pending pairing request |
@@ -55,6 +57,65 @@ To switch presets for future turns:
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
## Local triggers
Use `/trigger <name>` when a local script or another service should be able to
send a message into the current chat/session later. A name is required; plain
`/trigger` only shows the usage hint.
Create the trigger from the chat where future messages should arrive:
```text
/trigger PR review
```
nanobot replies with a trigger ID and a command shaped like:
```bash
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
```
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
trigger is bound to the session where it was created, so the message goes back
to that same chat. Keep `nanobot gateway` running so trigger messages can be
delivered. The trigger message starts an automation turn recorded in that
session with the message you passed to the CLI; it is not treated as a normal
user message. If that session is already running a turn, the trigger waits
until the session is idle instead of being injected into the active turn.
Trigger deliveries are stored in the workspace until their linked agent turn
finishes successfully. If the gateway exits after claiming a delivery but before
the turn completes, the next gateway start requeues that delivery. This is an
at-least-once local queue: a delivery may run more than once if the process
exits at the wrong time, so external scripts should make repeated trigger
messages safe. If the delivery reaches the agent and the agent turn fails, the
delivery is marked failed in Automations instead of retrying forever.
For longer or generated content, omit the message argument and pipe stdin:
```bash
printf '%s\n' "Review the latest failed CI job" | nanobot trigger trg_8K4P2Q9X
```
If an external webhook should wake nanobot up, run your own small webhook
service and have it call the trigger command after it builds the final message:
```bash
nanobot trigger <trigger-id> "<message>"
```
If you run multiple nanobot instances, pass the same config or workspace
selector used by the gateway:
```bash
nanobot trigger --config ./bot-a/config.json trg_8K4P2Q9X "Nightly report"
nanobot trigger --workspace ./bot-a/workspace trg_8K4P2Q9X "Nightly report"
```
Manage triggers from the WebUI Automations view. You can search, pause/resume,
rename, delete, and copy the trigger command there. A session may have multiple
triggers, just like it may have multiple scheduled automations.
## Periodic Tasks
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
+47
View File
@@ -13,6 +13,7 @@ Use this page when you know what you want to run and need the command shape. For
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
@@ -122,6 +123,52 @@ http://127.0.0.1:18790/health
The bundled WebUI is served by the WebSocket channel, usually on port `8765`, not by the gateway health endpoint.
## Local Triggers
`nanobot trigger` delivers one local message to a trigger that was created from
a chat/session with `/trigger <name>`.
```bash
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
```
Keep `nanobot gateway` running so the message can be delivered to the linked
chat/session. The message is recorded as an automation turn in that session,
not as a normal chat message typed by the user.
The command writes to a workspace-local durable queue. If `nanobot gateway` is
not running yet, the message waits in that workspace. If the target session is
already running a turn, the trigger waits for that session to become idle. If the
gateway exits after claiming a delivery but before the linked turn completes,
the next gateway start requeues that delivery. The queue is at-least-once, not
exactly-once, so the same message can be delivered again after an interrupted
process. If the agent receives the delivery and the turn fails, the delivery is
marked failed instead of retried indefinitely. Each delivery also writes an
audit record under `<workspace>/triggers/runs`. Run one gateway consumer per
workspace; this local queue is not a distributed multi-consumer queue.
Use stdin when another local process generates the message:
```bash
generate-report | nanobot trigger trg_8K4P2Q9X
```
Options:
| Command | Description |
|---|---|
| `nanobot trigger <id> "message"` | Deliver one message through a trigger |
| `nanobot trigger <id>` | Read the message from stdin |
| `nanobot trigger --config <path> <id> "message"` | Use the workspace from a specific config |
| `nanobot trigger --workspace <path> <id> "message"` | Use a specific workspace |
Triggers are managed in the WebUI Automations view instead of through separate
`list`, `revoke`, or `delete` CLI subcommands. From there you can pause/resume,
rename, delete, search, and copy the command for each trigger.
For webhooks or other external systems, run your own small service and have it
call this CLI after it decides what message nanobot should receive.
## OpenAI-Compatible API
| Command | Description |
+18 -3
View File
@@ -123,7 +123,7 @@ Tools are discovered automatically from built-in modules and plugin entry points
- shell execution with configurable sandboxing;
- web search and web fetch with SSRF checks;
- MCP servers;
- cron reminders and heartbeat tasks;
- cron reminders, local triggers, and heartbeat tasks;
- image generation;
- subagents and runtime self-inspection.
@@ -131,14 +131,29 @@ Security-sensitive controls live in [`configuration.md#security`](./configuratio
## Background Jobs
When `nanobot gateway` starts, it creates workspace-scoped cron storage at `<workspace>/cron/jobs.json` and registers system jobs:
When `nanobot gateway` starts, it runs workspace-scoped automations and
registers system jobs:
- `dream`, when `agents.defaults.dream.enabled` is true;
- `heartbeat`, when `gateway.heartbeat.enabled` is true.
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
User-created reminders use the same cron service but are not the same as the protected heartbeat system job. They run as scheduled turns in their origin chat/session and normally deliver the result back to that channel.
User-created reminders use the same cron service but are not the same as the
protected heartbeat system job. They run as scheduled turns in their origin
chat/session and normally deliver the result back to that channel.
Local triggers are also session-bound, but they do not have their own
schedule. Create one from the target chat with `/trigger <name>`, then call
`nanobot trigger <id> "<message>"` when a local script or external service wants
nanobot to respond in that session. Webhook servers, third-party auth, and
event-to-message formatting stay outside nanobot. Trigger deliveries are stored
in the workspace until the linked agent turn finishes successfully. If the
target session is busy, the trigger waits until that session is idle instead of
being injected into the active turn. The message is recorded as an automation
turn in that session. Delivery is at-least-once, so external systems should
tolerate repeated trigger messages; a delivery that reaches the agent but fails
is marked failed rather than retried forever.
## Where to Go Next
+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 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"`.
> - **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 |
|----------|---------|-------------|
@@ -632,20 +633,37 @@ nanobot agent -m "Reply with one short sentence."
<details>
<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:**
```bash
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
{
"modelPresets": {
"codex": {
"provider": "openai_codex",
"model": "openai-codex/gpt-5.1-codex"
"model": "gpt-5.1-codex",
"reasoningEffort": "high"
}
},
"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
nanobot agent -m "Hello!"
@@ -675,7 +695,17 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
<details>
<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:**
```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.intervalS` | `1800` | Seconds between heartbeat checks. |
| `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
+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).
## Authentication
Local-only `127.0.0.1` usage does not require an API key. If you bind the API
server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires
`api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent
endpoint on the network.
```json
{
"api": {
"host": "0.0.0.0",
"port": 8900,
"apiKey": "${NANOBOT_API_KEY}"
}
}
```
When `api.apiKey` is set, send it as a Bearer token on API routes. The health
endpoint remains unauthenticated so local probes and load balancers can still
check process health.
```bash
curl http://127.0.0.1:8900/v1/models \
-H "Authorization: Bearer $NANOBOT_API_KEY"
```
## Behavior
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
+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. |
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
| `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`.
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
### 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.
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
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
+35 -8
View File
@@ -56,7 +56,7 @@ Enter `tokenIssueSecret` when the WebUI asks for a password.
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
| Skills | Inspect available built-in and workspace skills before relying on them |
| Automations | Review, search, run, pause, edit, and delete scheduled agent turns |
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
## Chat Workspace
@@ -116,10 +116,30 @@ to perform that task.
## Automations
Automations are scheduled agent turns. They should be created from the chat,
channel, or session where they are supposed to run so nanobot keeps the correct
target context. When an automation runs, it normally delivers the result back to
that linked chat.
Automations are agent turns that run later in a linked chat/session. They should
be created from the chat, channel, or session where they are supposed to run so
nanobot keeps the correct target context. When an automation runs, it normally
delivers the result back to that linked chat.
There are two user-facing automation types:
- Scheduled automations, created by the agent's cron tool, run at a time,
interval, or cron expression.
- Local triggers, created with `/trigger <name>`, run when you call a local
command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`.
If a GitHub webhook, CI system, or another service should wake nanobot up, keep
that webhook/service outside nanobot and have it call the trigger command with
the final message.
Trigger deliveries use the same workspace as the gateway. They survive gateway
restarts and are requeued if the process exits before the linked turn completes.
If the linked session is already running a turn, the local trigger waits until
that session is idle instead of being injected into the active turn. This is an
at-least-once local queue, so repeated delivery is possible after an interrupted
process. A delivered trigger is recorded as an automation turn in the linked
session; if the agent receives it but the turn fails, Automations marks the run
failed instead of retrying indefinitely.
For recurring background checks that should stay quiet unless there is something
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
@@ -128,18 +148,25 @@ instead of creating a chat automation.
Use the Automations view to:
- Filter by all, active, paused, needs-attention, or system jobs.
- Search by task name, message, linked chat, schedule, or status.
- Search by task name, message, trigger command, linked chat, schedule, or status.
- Sort by next run, last run, updated time, or name.
- Run now, pause or resume, edit, or delete user-created automations.
- Run scheduled automations now.
- Pause or resume, rename, or delete user-created automations.
- Copy the CLI command for local triggers.
- Inspect protected system automations without changing them.
Search accepts plain text and field filters such as `name:backup`,
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`.
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
`status:paused`.
An automation without a linked chat cannot be enabled or run from the WebUI,
because nanobot would not know where to deliver the scheduled turn. Recreate it
from the target chat or channel so the automation has complete context.
Local triggers do not have a WebUI "Run now" action because each run needs a
message. Use the copied `nanobot trigger ...` command and replace `"message"`
with the content that should be delivered.
## Settings
Settings is the control surface for the browser session and gateway-backed
+22 -1
View File
@@ -34,6 +34,26 @@ class AutoCompact:
ts = datetime.fromisoformat(ts)
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
def _format_summary(text: str, last_active: datetime) -> str:
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
@@ -52,7 +72,8 @@ class AutoCompact:
continue
if key in active_session_keys:
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)
schedule_background(self._archive(key))
+145
View File
@@ -0,0 +1,145 @@
"""Shared coordination for session-bound automation turns."""
from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage
class AutomationTurnError(RuntimeError):
"""Raised when an automation turn reaches the agent and finishes with an error."""
async def publish_next_deferred_turn(
*,
deferred_queues: dict[str, list[InboundMessage]],
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
session_key: str,
) -> bool:
"""Publish the next deferred automation turn for a session."""
queue = deferred_queues.get(session_key)
if not queue:
return False
msg = queue.pop(0)
if not queue:
deferred_queues.pop(session_key, None)
await publish_inbound(msg)
return True
class AutomationTurnCoordinator:
"""Manage automation turns without mixing them into live injections."""
def __init__(
self,
*,
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
turn_id: Callable[[InboundMessage], str | None],
pending_id: Callable[[InboundMessage], str | None],
should_defer_turn: Callable[[InboundMessage, str, Iterable[str]], bool],
missing_id_error: str,
duplicate_id_error: Callable[[str], str],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None:
self._publish_inbound = publish_inbound
self._dispatch = dispatch
self._is_running = is_running
self._turn_id = turn_id
self._pending_id = pending_id
self._should_defer_turn = should_defer_turn
self._missing_id_error = missing_id_error
self._duplicate_id_error = duplicate_id_error
self.deferred_queues = deferred_queues if deferred_queues is not None else {}
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
self._pending_messages_by_turn_id: dict[str, InboundMessage] = {}
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit an automation turn and wait for its session response."""
turn_id = self._turn_id(msg)
if not turn_id:
raise ValueError(self._missing_id_error)
if turn_id in self._waiters:
raise RuntimeError(self._duplicate_id_error(turn_id))
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[turn_id] = future
self._pending_messages_by_turn_id[turn_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
try:
return await future
except asyncio.CancelledError:
raise
except Exception as exc:
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
finally:
self._waiters.pop(turn_id, None)
self._pending_messages_by_turn_id.pop(turn_id, None)
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer an automation turn when its target session is already active."""
if not self._should_defer_turn(msg, session_key, active_session_keys):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.deferred_queues.setdefault(session_key, []).append(pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
turn_id = self._turn_id(msg)
if not turn_id:
return
future = self._waiters.get(turn_id)
if future is None or future.done():
return
if error is not None:
future.set_exception(error)
else:
future.set_result(response)
def pending_ids_for_session(self, session_key: str) -> set[str]:
"""Return automation IDs that are waiting for or running in *session_key*."""
pending_ids: set[str] = set()
for msg in self.deferred_queues.get(session_key, []):
pending_id = self._pending_id(msg)
if pending_id:
pending_ids.add(pending_id)
for msg in self._pending_messages_by_turn_id.values():
if msg.session_key != session_key:
continue
pending_id = self._pending_id(msg)
if pending_id:
pending_ids.add(pending_id)
return pending_ids
async def publish_next_deferred(self, session_key: str) -> bool:
return await publish_next_deferred_turn(
deferred_queues=self.deferred_queues,
publish_inbound=self._publish_inbound,
session_key=session_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.
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
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)
@@ -61,7 +78,9 @@ class ContextGovernor:
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> 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.apply_tool_result_budget(config, updated)
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 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
def drop_orphan_tool_results(
messages: list[dict[str, Any]],
+22 -107
View File
@@ -2,11 +2,10 @@
from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.agent.automation_turns import AutomationTurnCoordinator
from nanobot.bus.events import InboundMessage
from nanobot.cron.session_turns import (
cron_run_id,
cron_trigger,
@@ -14,7 +13,7 @@ from nanobot.cron.session_turns import (
)
class CronTurnCoordinator:
class CronTurnCoordinator(AutomationTurnCoordinator):
"""Manage scheduled cron turns without mixing them into live injections."""
def __init__(
@@ -23,115 +22,31 @@ class CronTurnCoordinator:
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None:
self._publish_inbound = publish_inbound
self._dispatch = dispatch
self._is_running = is_running
self.deferred_queues: dict[str, list[InboundMessage]] = {}
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
self._pending_messages_by_run_id: dict[str, InboundMessage] = {}
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit a scheduled cron turn and wait for its session response."""
run_id = cron_run_id(msg.metadata)
if not run_id:
raise ValueError("cron turn metadata must include a run_id")
if run_id in self._waiters:
raise RuntimeError(f"cron run {run_id!r} is already pending")
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[run_id] = future
self._pending_messages_by_run_id[run_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
return await future
finally:
self._waiters.pop(run_id, None)
self._pending_messages_by_run_id.pop(run_id, None)
def should_defer(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
return (
defer_cron_until_session_idle(msg.metadata)
and session_key in active_session_keys
super().__init__(
publish_inbound=publish_inbound,
dispatch=dispatch,
is_running=is_running,
turn_id=lambda msg: cron_run_id(msg.metadata),
pending_id=_cron_job_id,
should_defer_turn=_should_defer_cron_turn,
missing_id_error="cron turn metadata must include a run_id",
duplicate_id_error=lambda run_id: f"cron run {run_id!r} is already pending",
deferred_queues=deferred_queues,
)
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer a cron turn when its target session is already active."""
if not self.should_defer(
msg,
session_key=session_key,
active_session_keys=active_session_keys,
):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.defer(session_key, pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
run_id = cron_run_id(msg.metadata)
if not run_id:
return
future = self._waiters.get(run_id)
if future is None or future.done():
return
if error is not None:
future.set_exception(error)
else:
future.set_result(response)
def defer(self, session_key: str, msg: InboundMessage) -> None:
self.deferred_queues.setdefault(session_key, []).append(msg)
def pending_job_ids_for_session(self, session_key: str) -> set[str]:
"""Return cron jobs that are waiting for or running in *session_key*."""
job_ids: set[str] = set()
for msg in self.deferred_queues.get(session_key, []):
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
for msg in self._pending_messages_by_run_id.values():
if msg.session_key != session_key:
continue
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
return job_ids
return self.pending_ids_for_session(session_key)
async def publish_next_deferred(self, session_key: str) -> None:
queue = self.deferred_queues.get(session_key)
if not queue:
return
msg = queue.pop(0)
if not queue:
self.deferred_queues.pop(session_key, None)
await self._publish_inbound(msg)
def _should_defer_cron_turn(
msg: InboundMessage,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
return defer_cron_until_session_idle(msg.metadata) and session_key in active_session_keys
def _cron_job_id(msg: InboundMessage) -> str | None:
+144 -260
View File
@@ -18,6 +18,7 @@ from loguru import logger
from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.automation_turns import publish_next_deferred_turn
from nanobot.agent.context import ContextBuilder
from nanobot.agent.cron_turns import CronTurnCoordinator
from nanobot.agent.hook import AgentHook, CompositeHook
@@ -31,6 +32,13 @@ from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
RetryWaitEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_message_for_event,
)
from nanobot.bus.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import (
@@ -40,9 +48,6 @@ from nanobot.bus.runtime_events import (
)
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.cron.session_turns import (
cron_history_overrides,
)
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import (
@@ -50,17 +55,22 @@ from nanobot.security.workspace_access import (
bind_workspace_scope,
reset_workspace_scope,
)
from nanobot.session import turn_continuation
from nanobot.session import turn_continuation, turn_history
from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import (
goal_state_runtime_lines,
runner_wall_llm_timeout_s,
sustained_goal_active,
)
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
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.triggers.local_turns import LocalTriggerTurnCoordinator
from nanobot.utils.document import extract_documents, reference_non_image_attachments
from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.runtime import (
@@ -163,8 +173,8 @@ class AgentLoop:
self._refresh_provider_snapshot()
return LLMRuntime(self.provider, self.model)
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn"
_RUNTIME_CHECKPOINT_KEY = turn_history.RUNTIME_CHECKPOINT_KEY
_PENDING_USER_TURN_KEY = turn_history.PENDING_USER_TURN_KEY
# Event-driven state transition table.
# Handlers return an event string; the driver looks up the next state here.
@@ -201,7 +211,6 @@ class AgentLoop:
timezone: str | None = None,
session_ttl_minutes: int = 0,
consolidation_ratio: float = 0.5,
max_messages: int = 120,
hooks: list[AgentHook] | None = None,
unified_session: bool = False,
disabled_skills: list[str] | None = None,
@@ -215,6 +224,8 @@ class AgentLoop:
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
runtime_events: RuntimeEventBus | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
restart_mode: str = "auto",
local_trigger_store: Any | None = None,
):
from nanobot.config.schema import ToolsConfig
@@ -224,6 +235,7 @@ class AgentLoop:
self.runtime_events = runtime_events or RuntimeEventBus()
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
self.channels_config = channels_config
self.restart_mode = restart_mode
self.provider = provider
self._provider_snapshot_loader = provider_snapshot_loader
self._preset_snapshot_loader = preset_snapshot_loader
@@ -261,6 +273,7 @@ class AgentLoop:
):
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
self.cron_service = cron_service
self.local_trigger_store = local_trigger_store
self.restrict_to_workspace = restrict_to_workspace
self.workspace_scopes = WorkspaceScopeResolver(
default_workspace=workspace,
@@ -292,7 +305,7 @@ class AgentLoop:
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
)
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._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, AsyncExitStack] = {}
@@ -305,10 +318,22 @@ class AgentLoop:
# When a session has an active task, new messages for that session
# are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue] = {}
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
self._cron_turns = CronTurnCoordinator(
publish_inbound=self.bus.publish_inbound,
dispatch=self._dispatch,
is_running=lambda: self._running,
deferred_queues=self._deferred_automation_turns,
)
self._local_trigger_turns = LocalTriggerTurnCoordinator(
publish_inbound=self.bus.publish_inbound,
dispatch=self._dispatch,
is_running=lambda: self._running,
deferred_queues=self._deferred_automation_turns,
)
self._automation_turn_coordinators = (
("cron", self._cron_turns),
("local trigger", self._local_trigger_turns),
)
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
@@ -390,10 +415,10 @@ class AgentLoop:
disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes,
consolidation_ratio=defaults.consolidation_ratio,
max_messages=defaults.max_messages,
tools_config=config.tools,
model_presets=preset_helpers.configured_model_presets(config),
model_preset=defaults.model_preset,
restart_mode=config.gateway.restart_mode,
provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader,
**extra,
@@ -421,6 +446,7 @@ class AgentLoop:
self.runner.provider = provider
self.subagents.set_provider(provider, model)
self.consolidator.set_provider(provider, model, context_window_tokens)
self._sync_replay_max_messages()
self._provider_signature = snapshot.signature
if publish_update and self._runtime_model_publisher is not None:
self._runtime_model_publisher(
@@ -434,6 +460,9 @@ class AgentLoop:
)
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:
if self._provider_snapshot_loader is None:
return
@@ -558,14 +587,12 @@ class AgentLoop:
"""Build a retry-wait callback that publishes to the message bus."""
async def _on_retry_wait(content: str) -> None:
meta = dict(msg.metadata or {})
meta["_retry_wait"] = True
await self.bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
event=RetryWaitEvent(content=content),
metadata=msg.metadata,
)
)
@@ -577,9 +604,22 @@ class AgentLoop:
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
return await self._cron_turns.submit(msg)
async def submit_local_trigger_turn(self, msg: InboundMessage) -> OutboundMessage | None:
return await self._local_trigger_turns.submit(msg)
def pending_cron_job_ids_for_session(self, session_key: str) -> set[str]:
return self._cron_turns.pending_job_ids_for_session(session_key)
def pending_local_trigger_ids_for_session(self, session_key: str) -> set[str]:
return self._local_trigger_turns.pending_trigger_ids_for_session(session_key)
async def _publish_next_deferred_automation_turn(self, session_key: str) -> None:
await publish_next_deferred_turn(
deferred_queues=self._deferred_automation_turns,
publish_inbound=self.bus.publish_inbound,
session_key=session_key,
)
def _persist_user_message_early(
self,
msg: InboundMessage,
@@ -598,10 +638,10 @@ class AgentLoop:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
text_override, cron_extra = cron_history_overrides(msg.metadata)
text_override, automation_extra = automation_history_overrides(msg.metadata)
if text_override is not None:
text = text_override
extra.update(cron_extra)
extra.update(automation_extra)
session.add_message("user", text, **extra)
self._mark_pending_user_turn(session)
self.sessions.save(session)
@@ -754,7 +794,20 @@ class AgentLoop:
content, media = self._prepare_message_media(content, media)
media = media or None
user_content = self.context._build_user_content(content, media)
return {"role": "user", "content": user_content}
row: dict[str, Any] = {"role": "user", "content": user_content}
metadata = pending_msg.metadata if isinstance(pending_msg.metadata, dict) else {}
if (
pending_msg.sender_id == "subagent"
and metadata.get("injected_event") == "subagent_result"
):
marker: dict[str, Any] = {"kind": "subagent_result"}
task_id = metadata.get("subagent_task_id")
if isinstance(task_id, str) and task_id:
marker["subagent_task_id"] = task_id
row["subagent_task_id"] = task_id
row[HIDDEN_HISTORY_META] = marker
row["injected_event"] = "subagent_result"
return row
items: list[dict[str, Any]] = []
while len(items) < limit:
@@ -909,15 +962,21 @@ class AgentLoop:
self.commands.dispatch_priority,
)
continue
if self._cron_turns.defer_if_active(
msg,
session_key=effective_key,
active_session_keys=self._pending_queues.keys(),
):
logger.info(
"Deferred cron turn for active session {}",
effective_key,
)
deferred = False
for label, coordinator in self._automation_turn_coordinators:
if coordinator.defer_if_active(
msg,
session_key=effective_key,
active_session_keys=self._pending_queues.keys(),
):
logger.info(
"Deferred {} turn for active session {}",
label,
effective_key,
)
deferred = True
break
if deferred:
continue
# If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn
@@ -990,26 +1049,31 @@ class AgentLoop:
return f"{stream_base_id}:{stream_segment}"
async def on_stream(delta: str) -> None:
meta = dict(msg.metadata or {})
meta["_stream_delta"] = True
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content=delta,
metadata=meta,
))
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=StreamDeltaEvent(
content=delta,
stream_id=_current_stream_id(),
),
metadata=msg.metadata,
)
)
async def on_stream_end(*, resuming: bool = False) -> None:
nonlocal stream_segment
meta = dict(msg.metadata or {})
meta["_stream_end"] = True
meta["_resuming"] = resuming
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="",
metadata=meta,
))
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=StreamEndEvent(
stream_id=_current_stream_id(),
resuming=resuming,
),
metadata=msg.metadata,
)
)
stream_segment += 1
response = await self._process_message(
@@ -1035,12 +1099,11 @@ class AgentLoop:
session_key=session_key,
metadata=msg.metadata,
)
self._cron_turns.complete(msg, response=response)
for _, coordinator in self._automation_turn_coordinators:
coordinator.complete(msg, response=response)
except asyncio.CancelledError:
self._cron_turns.complete(
msg,
error=asyncio.CancelledError(),
)
for _, coordinator in self._automation_turn_coordinators:
coordinator.complete(msg, error=asyncio.CancelledError())
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant
@@ -1079,7 +1142,8 @@ class AgentLoop:
session_key=session_key,
metadata=msg.metadata,
)
self._cron_turns.complete(msg, error=exc)
for _, coordinator in self._automation_turn_coordinators:
coordinator.complete(msg, error=exc)
finally:
# Drain any messages still in the pending queue and re-publish
# them to the bus so they are processed as fresh inbound messages
@@ -1110,14 +1174,14 @@ class AgentLoop:
msg, session_key, "idle"
)
self._runtime_events().clear_turn(session_key)
await self._cron_turns.publish_next_deferred(session_key)
await self._publish_next_deferred_automation_turn(session_key)
finally:
if pending is None:
await self._runtime_events().run_status_changed(
msg, session_key, "idle"
)
self._runtime_events().clear_turn(session_key)
await self._cron_turns.publish_next_deferred(session_key)
await self._publish_next_deferred_automation_turn(session_key)
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
@@ -1362,9 +1426,10 @@ class AgentLoop:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
event = None
meta = dict(msg.metadata or {})
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
meta["_streamed"] = True
event = StreamedResponseEvent()
if turn_latency_ms is not None:
meta["latency_ms"] = int(turn_latency_ms)
@@ -1372,6 +1437,7 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
event=event,
metadata=meta,
)
@@ -1429,7 +1495,7 @@ class AgentLoop:
# message. Mark messages with _command so get_history can filter
# them out of LLM context. /new is excluded because it
# intentionally clears the session.
if raw.lower() != "/new":
if cmd_ctx.raw.lower() != "/new":
ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session, _command=True
)
@@ -1586,38 +1652,13 @@ class AgentLoop:
should_truncate_text: bool = False,
drop_runtime: bool = False,
) -> list[dict[str, Any]]:
"""Strip volatile multimodal payloads before writing session history."""
filtered: list[dict[str, Any]] = []
for block in content:
if not isinstance(block, dict):
filtered.append(block)
continue
if (
drop_runtime
and block.get("type") == "text"
and isinstance(block.get("text"), str)
and block["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG)
):
continue
if block.get("type") == "image_url" and block.get("image_url", {}).get(
"url", ""
).startswith("data:image/"):
path = (block.get("_meta") or {}).get("path", "")
filtered.append({"type": "text", "text": image_placeholder_text(path)})
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"]
if should_truncate_text and len(text) > self.max_tool_result_chars:
text = truncate_text_fn(text, self.max_tool_result_chars)
filtered.append({**block, "text": text})
continue
filtered.append(block)
return filtered
return turn_history.sanitize_persisted_blocks(
content,
max_tool_result_chars=self.max_tool_result_chars,
runtime_context_tag=ContextBuilder._RUNTIME_CONTEXT_TAG,
should_truncate_text=should_truncate_text,
drop_runtime=drop_runtime,
)
def _save_turn(
self,
@@ -1627,193 +1668,36 @@ class AgentLoop:
*,
turn_latency_ms: int | None = None,
) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
declared_tool_call_ids = {
str(tc["id"])
for m in session.messages
if m.get("role") == "assistant"
for tc in m.get("tool_calls") or []
if isinstance(tc, dict) and tc.get("id")
}
last_assistant_idx: int | None = None
for m in messages[skip:]:
entry = dict(m)
role, content = entry.get("role"), entry.get("content")
if role == "assistant" and not content and not entry.get("tool_calls"):
continue # skip empty assistant messages — they poison session context
if role == "tool":
tool_call_id = entry.get("tool_call_id")
if not tool_call_id or str(tool_call_id) not in declared_tool_call_ids:
# Undeclared tool results corrupt future provider requests.
logger.warning(
"Dropping orphaned tool result {} from session {} during persistence",
tool_call_id or "(missing id)",
session.key,
)
continue
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
elif isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content, should_truncate_text=True)
if not filtered:
# Preserve the tool_call/result pair after block filtering.
filtered = [
{"type": "text", "text": "[tool result omitted during persistence]"}
]
entry["content"] = filtered
elif role == "user":
if isinstance(content, str) and ContextBuilder._RUNTIME_CONTEXT_TAG in content:
# Strip the runtime-context block appended at the end.
tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG)
before = content[:tag_pos].rstrip("\n ")
if before:
entry["content"] = before
else:
continue
if isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True)
if not filtered:
continue
entry["content"] = filtered
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
if role == "assistant":
last_assistant_idx = len(session.messages) - 1
declared_tool_call_ids.update(
str(tc["id"])
for tc in entry.get("tool_calls") or []
if isinstance(tc, dict) and tc.get("id")
)
if turn_latency_ms is not None and last_assistant_idx is not None:
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
session.updated_at = datetime.now()
turn_history.save_turn(
session,
messages,
skip,
max_tool_result_chars=self.max_tool_result_chars,
runtime_context_tag=ContextBuilder._RUNTIME_CONTEXT_TAG,
turn_latency_ms=turn_latency_ms,
)
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
"""Persist subagent follow-ups before prompt assembly so history stays durable.
Returns True if a new entry was appended; False if the follow-up was
deduped (same ``subagent_task_id`` already in session) or carries no
content worth persisting.
"""
if not msg.content:
return False
task_id = msg.metadata.get("subagent_task_id") if isinstance(msg.metadata, dict) else None
if task_id and any(
m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id
for m in session.messages
):
return False
session.add_message(
"assistant",
msg.content,
sender_id=msg.sender_id,
injected_event="subagent_result",
subagent_task_id=task_id,
)
return True
return turn_history.persist_subagent_followup(session, msg)
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
"""Persist the latest in-flight turn state into session metadata."""
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
turn_history.set_runtime_checkpoint(session, payload)
self.sessions.save(session)
def _mark_pending_user_turn(self, session: Session) -> None:
session.metadata[self._PENDING_USER_TURN_KEY] = True
turn_history.mark_pending_user_turn(session)
def _clear_pending_user_turn(self, session: Session) -> None:
session.metadata.pop(self._PENDING_USER_TURN_KEY, None)
turn_history.clear_pending_user_turn(session)
def _clear_runtime_checkpoint(self, session: Session) -> None:
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
@staticmethod
def _checkpoint_message_key(message: dict[str, Any]) -> tuple[Any, ...]:
return (
message.get("role"),
message.get("content"),
message.get("tool_call_id"),
message.get("name"),
message.get("tool_calls"),
message.get("reasoning_content"),
message.get("thinking_blocks"),
)
turn_history.clear_runtime_checkpoint(session)
def _restore_runtime_checkpoint(self, session: Session) -> bool:
"""Materialize an unfinished turn into session history before a new request."""
from datetime import datetime
checkpoint = session.metadata.get(self._RUNTIME_CHECKPOINT_KEY)
if not isinstance(checkpoint, dict):
return False
assistant_message = checkpoint.get("assistant_message")
completed_tool_results = checkpoint.get("completed_tool_results") or []
pending_tool_calls = checkpoint.get("pending_tool_calls") or []
restored_messages: list[dict[str, Any]] = []
if isinstance(assistant_message, dict):
restored = dict(assistant_message)
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for message in completed_tool_results:
if isinstance(message, dict):
restored = dict(message)
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for tool_call in pending_tool_calls:
if not isinstance(tool_call, dict):
continue
tool_id = tool_call.get("id")
name = ((tool_call.get("function") or {}).get("name")) or "tool"
restored_messages.append(
{
"role": "tool",
"tool_call_id": tool_id,
"name": name,
"content": "Error: Task interrupted before this tool finished.",
"timestamp": datetime.now().isoformat(),
}
)
overlap = 0
max_overlap = min(len(session.messages), len(restored_messages))
for size in range(max_overlap, 0, -1):
existing = session.messages[-size:]
restored = restored_messages[:size]
if all(
self._checkpoint_message_key(left) == self._checkpoint_message_key(right)
for left, right in zip(existing, restored)
):
overlap = size
break
session.messages.extend(restored_messages[overlap:])
self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(session)
return True
return turn_history.restore_runtime_checkpoint(session)
def _restore_pending_user_turn(self, session: Session) -> bool:
"""Close a turn that only persisted the user message before crashing."""
from datetime import datetime
if not session.metadata.get(self._PENDING_USER_TURN_KEY):
return False
if session.messages and session.messages[-1].get("role") == "user":
session.messages.append(
{
"role": "assistant",
"content": "Error: Task interrupted before a response was generated.",
"timestamp": datetime.now().isoformat(),
}
)
session.updated_at = datetime.now()
self._clear_pending_user_turn(session)
return True
return turn_history.restore_pending_user_turn(session)
async def process_direct(
self,
+2 -6
View File
@@ -33,7 +33,6 @@ if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import SessionManager
# ---------------------------------------------------------------------------
# MemoryStore — pure file I/O layer
# ---------------------------------------------------------------------------
@@ -1006,7 +1005,6 @@ class Consolidator:
messages_to_summarize = list(session.messages[session.last_consolidated:])
if not messages_to_summarize:
session.updated_at = datetime.now()
self.sessions.save(session)
return ""
@@ -1018,12 +1016,11 @@ class Consolidator:
metadata={},
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_remove = dropped[already_consolidated:]
messages_to_remove = result.dropped[result.already_consolidated_count:]
if not messages_to_remove and not messages_to_keep:
session.updated_at = datetime.now()
self.sessions.save(session)
return ""
@@ -1046,7 +1043,6 @@ class Consolidator:
session.messages = messages_to_keep
session.last_consolidated = 0
session.updated_at = datetime.now()
self.sessions.save(session)
if messages_to_remove:
+102 -59
View File
@@ -18,8 +18,9 @@ from nanobot.agent.context_governance import (
ContextGovernor,
)
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.session.history_visibility import is_hidden_history_message
from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
build_file_edit_end_event,
@@ -50,7 +51,6 @@ from nanobot.utils.runtime import (
build_finalization_retry_message,
build_goal_continue_message,
build_length_recovery_message,
build_runtime_budget_notice_message,
is_blank_text,
repeated_external_lookup_error,
repeated_workspace_violation_error,
@@ -68,7 +68,6 @@ _MAX_EMPTY_RETRIES = 2
_MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5
_BUDGET_NOTICE_MIN_ITERATIONS = 20
# Backward-compatible module attribute for tests/extensions that monkeypatch
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
prepare_file_edit_tracker = _prepare_file_edit_tracker
@@ -157,6 +156,8 @@ class AgentRunner:
messages
and injection.get("role") == "user"
and messages[-1].get("role") == "user"
and not is_hidden_history_message(injection)
and not is_hidden_history_message(messages[-1])
):
merged = dict(messages[-1])
merged["content"] = cls._merge_message_content(
@@ -359,7 +360,6 @@ class AgentRunner:
length_recovery_count = 0
had_injections = False
injection_cycles = 0
budget_notice_level_sent = 0
compacted_tool_call_ids: set[str] = set()
governance_config = ContextGovernanceConfig(
provider=self.provider,
@@ -392,7 +392,15 @@ class AgentRunner:
spec.session_key or "default",
)
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
)
@@ -514,12 +522,6 @@ class AgentRunner:
)
if _drained:
had_injections = True
budget_notice_level_sent = self._append_runtime_budget_notice_if_needed(
spec,
messages,
completed_iterations=iteration + 1,
sent_level=budget_notice_level_sent,
)
await hook.after_iteration(context)
continue
@@ -734,6 +736,8 @@ class AgentRunner:
messages: list[dict[str, Any]],
hook: AgentHook,
context: AgentHookContext,
*,
malformed_retry: bool = False,
):
timeout_s: float | None = spec.llm_timeout_s
if timeout_s is None:
@@ -876,8 +880,94 @@ class AgentRunner:
)
if progress_state and progress_state.get("reasoning_open"):
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
@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(
self,
spec: AgentRunSpec,
@@ -949,53 +1039,6 @@ class AgentRunner:
retry_messages.append(build_budget_exhausted_finalization_message())
return retry_messages
@classmethod
def _append_runtime_budget_notice_if_needed(
cls,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
*,
completed_iterations: int,
sent_level: int,
) -> int:
level = cls._runtime_budget_notice_level(
max_iterations=spec.max_iterations,
completed_iterations=completed_iterations,
)
if level <= sent_level:
return sent_level
remaining_iterations = max(0, spec.max_iterations - completed_iterations)
messages.append(build_runtime_budget_notice_message(
level=level,
max_iterations=spec.max_iterations,
used_iterations=completed_iterations,
remaining_iterations=remaining_iterations,
))
return level
@staticmethod
def _runtime_budget_notice_level(
*,
max_iterations: int,
completed_iterations: int,
) -> int:
"""Return the convergence-warning level for a long tool loop."""
if max_iterations < _BUDGET_NOTICE_MIN_ITERATIONS:
return 0
remaining_iterations = max_iterations - completed_iterations
if remaining_iterations <= 0:
return 0
convergence_threshold = max(5, (max_iterations + 9) // 10)
final_threshold = max(3, (max_iterations + 32) // 33)
if remaining_iterations <= final_threshold:
return 2
if remaining_iterations <= convergence_threshold:
return 1
return 0
@staticmethod
def _max_iterations_fallback(spec: AgentRunSpec) -> str:
if spec.max_iterations_message:
@@ -1226,7 +1269,7 @@ class AgentRunner:
return payload, event, exc
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:
await invoke_file_edit_progress(
progress_callback,
+2 -1
View File
@@ -1,6 +1,6 @@
"""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.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
@@ -25,6 +25,7 @@ __all__ = [
"Tool",
"ToolContext",
"ToolLoader",
"ToolResult",
"ToolRegistry",
"tool_parameters",
"tool_parameters_schema",
+4 -4
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.base import ToolResult, tool_parameters
from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import (
ArraySchema,
@@ -289,8 +289,8 @@ class ApplyPatchTool(_FsTool):
_format_summary(summary) for summary in summaries
)
except PermissionError as exc:
return f"Error: {exc}"
return ToolResult.error(f"Error: {exc}")
except _PatchError as exc:
return f"Error applying patch: {exc}"
return ToolResult.error(f"Error applying patch: {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)
class ToolResult(str):
"""String-compatible tool output with structured status."""
is_error: bool
def __new__(cls, content: str, *, is_error: bool = False) -> ToolResult:
obj = str.__new__(cls, content)
obj.is_error = is_error
return obj
@classmethod
def error(cls, content: str) -> ToolResult:
return cls(content, is_error=True)
class Tool(ABC):
"""Agent capability: read files, run commands, etc."""
@@ -193,9 +208,13 @@ class Tool(ABC):
@abstractmethod
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]:
if not isinstance(obj, dict):
return obj
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
@@ -136,4 +136,4 @@ class CliAppsTool(Tool):
restrict_to_workspace=access.restrict_to_workspace,
)
except CliAppError as exc:
return 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 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.schema import (
IntegerSchema,
@@ -99,7 +99,7 @@ class CronTool(Tool, ContextAware):
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
return ToolResult.error(f"Error: unknown timezone '{tz}'")
return None
def _display_timezone(self, schedule: CronSchedule) -> str:
@@ -148,7 +148,7 @@ class CronTool(Tool, ContextAware):
) -> str:
if action == "add":
if self._in_cron_context.get():
return "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)
elif action == "list":
return self._list_jobs()
@@ -166,20 +166,20 @@ class CronTool(Tool, ContextAware):
at: str | None,
) -> str:
if not message:
return (
return ToolResult.error(
"Error: cron action='add' requires a non-empty 'message' parameter "
"describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"."
)
session_key = self._session_key.get()
if not session_key:
return "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_chat_id = self._origin_chat_id.get()
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:
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 err := self._validate_timezone(tz):
return err
@@ -199,7 +199,7 @@ class CronTool(Tool, ContextAware):
try:
dt = datetime.fromisoformat(at)
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 err := self._validate_timezone(self._default_timezone):
return err
@@ -208,7 +208,7 @@ class CronTool(Tool, ContextAware):
schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True
else:
return "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(
name=name or message[:30],
@@ -279,7 +279,7 @@ class CronTool(Tool, ContextAware):
def _remove_job(self, job_id: str | None) -> str:
if not job_id:
return "Error: job_id is required for remove"
return ToolResult.error("Error: job_id is required for remove")
result = self._cron.remove_job(job_id)
if result == "removed":
return f"Removed job {job_id}"
+25 -68
View File
@@ -9,7 +9,7 @@ from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, 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.schema import (
BooleanSchema,
@@ -17,13 +17,6 @@ from nanobot.agent.tools.schema import (
StringSchema,
tool_parameters_schema,
)
from nanobot.agent.verification_state import (
VerificationAnalysis,
analyze_verification_result,
append_verification_feedback,
record_verification_observation,
)
from nanobot.utils.helpers import build_structured_output_summary
DEFAULT_YIELD_MS = 1000
MAX_YIELD_MS = 30_000
@@ -44,7 +37,6 @@ class _SessionPoll:
terminated: bool = False
stdin_closed: bool = False
truncated_chars: int = 0
analysis: VerificationAnalysis | None = None
@dataclass(slots=True)
@@ -136,7 +128,15 @@ class _ExecSession:
) -> _SessionPoll:
self.last_access = time.monotonic()
if yield_time_ms > 0 and self.process.returncode is None:
await asyncio.sleep(min(yield_time_ms, MAX_YIELD_MS) / 1000)
wait_s = min(yield_time_ms, MAX_YIELD_MS) / 1000
remaining_s = self.deadline - time.monotonic()
if remaining_s <= 0:
wait_s = 0
else:
wait_s = min(wait_s, remaining_s)
if wait_s > 0:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.process.wait(), timeout=wait_s)
if self.process.returncode is None and time.monotonic() >= self.deadline:
self._timed_out = True
@@ -155,19 +155,7 @@ class _ExecSession:
output = "".join(self._chunks)
self._chunks.clear()
analysis = analyze_verification_result(
command=self.command,
output=output,
exit_code=self.process.returncode,
timed_out=self._timed_out,
)
output, truncated = _truncate_output(
output,
max_output_chars,
analysis=analysis,
exit_code=self.process.returncode,
elapsed_s=max(0.0, time.monotonic() - self.started_at),
)
output, truncated = _truncate_output(output, max_output_chars)
return _SessionPoll(
output=output,
done=self.process.returncode is not None,
@@ -177,7 +165,6 @@ class _ExecSession:
terminated=terminated,
stdin_closed=stdin_closed,
truncated_chars=truncated,
analysis=analysis,
)
async def kill(self) -> None:
@@ -341,33 +328,15 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
return min(max(value, minimum), maximum)
def _truncate_output(
output: str,
max_output_chars: int,
*,
analysis: VerificationAnalysis | None = None,
exit_code: int | None = None,
elapsed_s: float | None = None,
) -> tuple[str, int]:
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
if len(output) <= max_output_chars:
return output, 0
half = max_output_chars // 2
omitted = len(output) - max_output_chars
return (
build_structured_output_summary(
"[tool output truncated]",
output,
max_chars=max_output_chars,
metadata=[
("original_size_chars", len(output)),
("exit_code", exit_code if exit_code is not None else "running"),
("elapsed_s", f"{elapsed_s:.1f}" if elapsed_s is not None else "unknown"),
],
analysis=analysis,
guidance=(
"Use the structured summary first. Poll again for new output "
"or rerun a narrower command instead of reading broad logs."
),
),
output[:half]
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
+ output[-half:],
omitted,
)
@@ -390,20 +359,6 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
return "\n".join(parts) if parts else "(no output yet)"
def _format_poll_with_verification(session_id: str, poll: _SessionPoll) -> str:
result = format_session_poll(session_id, poll)
if not poll.done:
return result
analysis = poll.analysis or analyze_verification_result(
command="",
output=result,
exit_code=poll.exit_code,
timed_out=poll.timed_out,
)
record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
@tool_parameters(
tool_parameters_schema(
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
@@ -545,11 +500,12 @@ class WriteStdinTool(Tool):
max_output_chars=output_limit,
owner_session_key=current_request_session_key(),
)
return _format_poll_with_verification(session_id, poll)
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
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:
return f"Error writing to exec session: {exc}"
return ToolResult.error(f"Error writing to exec session: {exc}")
async def _wait_for_output(
self,
@@ -585,13 +541,14 @@ class WriteStdinTool(Tool):
joined = "".join(aggregate)
if wait_for in joined:
poll.output = joined
return _format_poll_with_verification(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:
poll.output = "".join(aggregate)
result = _format_poll_with_verification(session_id, poll)
result = format_session_poll(session_id, poll)
if wait_for not in poll.output:
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())
@@ -659,4 +616,4 @@ class ListExecSessionsTool(Tool):
)
return "\n".join(lines)
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 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.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import (
@@ -268,19 +268,19 @@ class ReadFileTool(_FsTool):
) -> Any:
try:
if not path:
return "Error reading file: Unknown path"
return ToolResult.error("Error reading file: Unknown path")
# Device path blacklist
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)
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():
return f"Error: File not found: {path}"
return ToolResult.error(f"Error: File not found: {path}")
if not fp.is_file():
return f"Error: Not a file: {path}"
return ToolResult.error(f"Error: Not a file: {path}")
# PDF support
if fp.suffix.lower() == ".pdf":
@@ -343,7 +343,7 @@ class ReadFileTool(_FsTool):
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if mime and mime.startswith("image/"):
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
return 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
# concern (git checkouts with autocrlf, editors saving CRLF) but
@@ -357,7 +357,7 @@ class ReadFileTool(_FsTool):
if offset < 1:
offset = 1
if offset > total:
return 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
end = min(start + (limit or self._DEFAULT_LIMIT), total)
@@ -381,20 +381,20 @@ class ReadFileTool(_FsTool):
self._file_states.record_read(fp, offset=offset, limit=limit)
return result
except PermissionError as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {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:
try:
import fitz # pymupdf
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:
doc = fitz.open(str(fp))
except Exception as e:
return f"Error reading PDF: {e}"
return ToolResult.error(f"Error reading PDF: {e}")
total_pages = len(doc)
if pages:
@@ -402,10 +402,10 @@ class ReadFileTool(_FsTool):
start, end = _parse_page_range(pages, total_pages)
except (ValueError, IndexError):
doc.close()
return 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:
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:
start = 0
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
@@ -437,10 +437,10 @@ class ReadFileTool(_FsTool):
result = extract_text(fp)
if result is None:
return f"Error: Unsupported file format: {fp.suffix}"
return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
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:
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
@@ -492,9 +492,9 @@ class WriteFileTool(_FsTool):
self._file_states.record_write(fp)
return f"Successfully wrote {len(content)} characters to {fp}"
except PermissionError as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {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:
raise ValueError("Unknown new_text")
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:
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:
return "Error: expected_replacements must be >= 1."
return ToolResult.error("Error: expected_replacements must be >= 1.")
fp = self._resolve_write(path)
@@ -853,14 +853,14 @@ class EditFileTool(_FsTool):
except OSError:
fsize = 0
if fsize > self._MAX_EDIT_FILE_SIZE:
return 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
if old_text == "":
raw = fp.read_bytes()
content = raw.decode("utf-8")
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")
self._file_states.record_write(fp)
return f"Successfully edited {fp}"
@@ -878,15 +878,15 @@ class EditFileTool(_FsTool):
return self._not_found_msg(old_text, content, path)
count = len(matches)
if replace_all and occurrence is not None:
return "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:
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:
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 occurrence is not None:
if occurrence > count:
return (
return ToolResult.error(
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} times."
)
@@ -894,7 +894,7 @@ class EditFileTool(_FsTool):
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
distance = abs(nearest.line - line_hint)
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
return (
return ToolResult.error(
f"Error: line_hint {line_hint} is ambiguous; "
f"old_text appears {count} times."
)
@@ -910,7 +910,7 @@ class EditFileTool(_FsTool):
"or set replace_all=true."
)
elif occurrence is not None and occurrence > count:
return (
return ToolResult.error(
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} time."
)
@@ -928,7 +928,7 @@ class EditFileTool(_FsTool):
else:
selected = [matches[occurrence - 1 if occurrence else 0]]
if expected_replacements is not None and len(selected) != expected_replacements:
return (
return ToolResult.error(
f"Error: expected {expected_replacements} replacements but "
f"would make {len(selected)}."
)
@@ -954,9 +954,9 @@ class EditFileTool(_FsTool):
msg = f"{warning}\n{msg}"
return msg
except PermissionError as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {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:
"""Build an error message with 'Did you mean ...?' suggestions."""
@@ -969,7 +969,7 @@ class EditFileTool(_FsTool):
parts = [f"Error: File not found: {path}"]
if suggestions:
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
return "\n".join(parts)
return ToolResult.error("\n".join(parts))
@staticmethod
def _not_found_msg(old_text: str, content: str, path: str) -> str:
@@ -985,18 +985,18 @@ class EditFileTool(_FsTool):
hint_text = ""
if hints:
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
return (
return ToolResult.error(
f"Error: old_text not found in {path}."
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
)
if hints:
return (
return ToolResult.error(
f"Error: old_text not found in {path}. "
f"Possible cause: {', '.join(hints)}. "
"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")
dp = self._resolve(path)
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():
return f"Error: Not a directory: {path}"
return ToolResult.error(f"Error: Not a directory: {path}")
cap = max_entries or self._DEFAULT_MAX
items: list[str] = []
@@ -1084,6 +1084,6 @@ class ListDirTool(_FsTool):
result += f"\n\n(truncated, showing first {cap} of {total} entries)"
return result
except PermissionError as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {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 nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import (
ArraySchema,
IntegerSchema,
@@ -172,11 +172,11 @@ class ImageGenerationTool(Tool):
) -> str:
client = self._provider_client()
if client is None:
return 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
if requested > self.config.max_images_per_turn:
return (
return ToolResult.error(
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
f"({self.config.max_images_per_turn})"
)
@@ -206,4 +206,4 @@ class ImageGenerationTool(Tool):
break
return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc:
return 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 nanobot.agent.tools.base import Tool
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.registry import ToolRegistry
_SKIP_MODULES = frozenset({
@@ -96,6 +96,8 @@ class ToolLoader:
if not tool_cls.enabled(ctx):
continue
tool = tool_cls.create(ctx)
if is_plugin_source:
tool = _LegacyErrorPrefixTool(tool)
if registry.has(tool.name):
if is_plugin_source and tool.name in builtin_names:
logger.warning(
@@ -114,3 +116,67 @@ class ToolLoader:
except Exception:
logger.exception("Failed to register tool: %s", cls_label)
return registered
class _LegacyErrorPrefixTool(Tool):
"""Compatibility wrapper for external tools using the old error-string contract."""
_plugin_discoverable = False
def __init__(self, wrapped: Tool) -> None:
self._wrapped = wrapped
@property
def name(self) -> str:
return self._wrapped.name
@property
def description(self) -> str:
return self._wrapped.description
@property
def parameters(self) -> dict[str, Any]:
return self._wrapped.parameters
@property
def read_only(self) -> bool:
return self._wrapped.read_only
@property
def exclusive(self) -> bool:
return self._wrapped.exclusive
@property
def concurrency_safe(self) -> bool:
return self._wrapped.concurrency_safe
@property
def config_key(self) -> str:
return getattr(self._wrapped, "config_key", "")
def set_context(self, ctx: Any) -> None:
set_context = getattr(self._wrapped, "set_context", None)
if callable(set_context):
set_context(ctx)
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
return self._wrapped.cast_params(params)
def validate_params(self, params: dict[str, Any]) -> list[str]:
return self._wrapped.validate_params(params)
def to_schema(self) -> dict[str, Any]:
return self._wrapped.to_schema()
async def execute(self, **kwargs: Any) -> Any:
result = await self._wrapped.execute(**kwargs)
if (
isinstance(result, str)
and not isinstance(result, ToolResult)
and result.startswith("Error:")
):
return ToolResult.error(result)
return result
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
+6 -71
View File
@@ -20,14 +20,9 @@ from contextvars import ContextVar
from datetime import datetime
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.schema import StringSchema, tool_parameters_schema
from nanobot.agent.verification_state import (
clear_verification_observation,
format_completion_gate_message,
latest_verification_observation,
)
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
@@ -155,12 +150,12 @@ class LongTaskTool(Tool, _GoalToolsMixin):
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return (
return ToolResult.error(
"Error: long_task requires an active chat session (missing routing context)."
)
prior = parse_goal_state(goal_state_raw(sess.metadata))
if isinstance(prior, dict) and prior.get("status") == "active":
return (
return ToolResult.error(
"Error: a sustained goal is already active. "
"Use complete_goal when finished, or ask the user before replacing it."
)
@@ -192,29 +187,6 @@ class LongTaskTool(Tool, _GoalToolsMixin):
max_length=8000,
nullable=True,
),
verification_summary=StringSchema(
"For coding or file-producing tasks, summarize how the work was verified. "
"Mention the most relevant test/check command and whether it passed. "
"If no verification was possible, say why.",
max_length=4000,
nullable=True,
),
commands_run=StringSchema(
"Optional concise list of verification/build commands run before completion.",
max_length=4000,
nullable=True,
),
artifacts_created=StringSchema(
"Optional concise list of files, outputs, or artifacts created.",
max_length=4000,
nullable=True,
),
remaining_failures=StringSchema(
"Known unresolved failures, if intentionally stopping before success. "
"Leave empty when verification passes.",
max_length=4000,
nullable=True,
),
required=[],
)
)
@@ -250,67 +222,30 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
return (
"End bookkeeping for the active sustained goal. "
"Use when the objective is fully achieved and verified—recap what was delivered. "
"For coding/file-producing tasks, run the smallest reliable verification first and include "
"verification_summary / commands_run / artifacts_created. "
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
"what actually happened (not necessarily success). "
"If recent verification failed and no later verification passed, this tool will ask you to "
"continue fixing unless remaining_failures describes an intentional incomplete stop. "
"If no goal is active, the tool reports that and leaves metadata unchanged."
)
async def execute(
self,
recap: str | None = None,
verification_summary: str | None = None,
commands_run: str | None = None,
artifacts_created: str | None = None,
remaining_failures: str | None = None,
**kwargs: Any,
) -> str:
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return "Error: complete_goal requires an active chat session."
session_key = self._request_ctx.get().session_key if self._request_ctx.get() else None
observation = latest_verification_observation(session_key)
if (
observation is not None
and observation.analysis.status == "failed"
and not _has_meaningful_remaining_failures(remaining_failures)
):
return format_completion_gate_message(observation)
return ToolResult.error("Error: complete_goal requires an active chat session.")
prior = parse_goal_state(goal_state_raw(sess.metadata))
if not isinstance(prior, dict) or prior.get("status") != "active":
return "No active goal to complete."
ended = _iso_now()
completed = {
sess.metadata[GOAL_STATE_KEY] = {
**prior,
"status": "completed",
"completed_at": ended,
"recap": (recap or "").strip(),
}
if verification_summary:
completed["verification_summary"] = verification_summary.strip()
if commands_run:
completed["commands_run"] = commands_run.strip()
if artifacts_created:
completed["artifacts_created"] = artifacts_created.strip()
if remaining_failures:
completed["remaining_failures"] = remaining_failures.strip()
sess.metadata[GOAL_STATE_KEY] = completed
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
clear_verification_observation(session_key)
await self._publish_goal_state_changed(sess.metadata)
tail = (recap or "").strip()
if tail:
return f"Goal marked complete ({ended}). Recap:\n{tail}"
return f"Goal marked complete ({ended})."
def _has_meaningful_remaining_failures(value: str | None) -> bool:
text = (value or "").strip().lower()
return bool(text and text not in {"none", "no", "n/a", "na", "no remaining failures"})
+128 -15
View File
@@ -1,6 +1,7 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio
import json
import os
import re
import shutil
@@ -13,7 +14,7 @@ from weakref import WeakKeyDictionary
import httpx
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.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
@@ -165,12 +166,31 @@ async def _probe_http_url(url: str, timeout: float = 3.0) -> bool:
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:
"""Validate each outgoing MCP HTTP request, including redirect targets."""
ok, error = validate_url_target(str(request.url))
if not ok:
raise httpx.RequestError(
f"Blocked unsafe MCP URL {request.url} ({error})",
f"Blocked unsafe MCP URL {_redact_url(str(request.url))} ({error})",
request=request,
)
@@ -313,6 +333,52 @@ class _MCPWrapperBase(Tool):
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):
"""Wraps a single MCP server tool as a nanobot Tool."""
@@ -340,8 +406,6 @@ class MCPToolWrapper(_MCPWrapperBase):
return self._parameters
async def execute(self, **kwargs: Any) -> str:
from mcp import types
retried_transient = False
refreshed_session = False
while True:
@@ -396,17 +460,66 @@ class MCPToolWrapper(_MCPWrapperBase):
)
return f"(MCP tool call failed: {type(exc).__name__})"
else:
# Success — extract result
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
# Success — extract text and persist any image content as artifacts.
rendered = self._render_call_result(result.content, kwargs)
if getattr(result, "isError", False):
return ToolResult.error(rendered)
return rendered
return "(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):
"""Wraps an MCP resource URI as a read-only nanobot Tool."""
@@ -683,7 +796,7 @@ async def connect_mcp_servers(
logger.warning(
"MCP server '{}': blocked unsafe URL {} ({})",
name,
cfg.url,
_redact_url(cfg.url),
error,
)
await server_stack.aclose()
@@ -704,7 +817,7 @@ async def connect_mcp_servers(
read, write = await server_stack.enter_async_context(stdio_client(params))
elif transport_type == "sse":
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()
return name, None
@@ -731,7 +844,7 @@ async def connect_mcp_servers(
)
elif transport_type == "streamableHttp":
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()
return name, None
+7 -7
View File
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
@@ -198,7 +198,7 @@ class MessageTool(Tool, ContextAware):
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons
):
return "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_chat_id = self._default_chat_id.get()
channel = channel or default_channel
@@ -210,7 +210,7 @@ class MessageTool(Tool, ContextAware):
and str(explicit_chat_id).strip() != ""
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
):
return (
return ToolResult.error(
"Error: chat_id does not match the active WebSocket conversation. "
"Omit chat_id (and usually channel) so delivery uses the current "
"conversation id from context — WebSocket client_id strings "
@@ -229,16 +229,16 @@ class MessageTool(Tool, ContextAware):
message_id = None
if not channel or not chat_id:
return "Error: No target channel/chat specified"
return ToolResult.error("Error: No target channel/chat specified")
if not self._send_callback:
return "Error: Message sending not configured"
return ToolResult.error("Error: Message sending not configured")
if media:
try:
media = self._resolve_media(media)
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 {}
if message_id:
@@ -270,4 +270,4 @@ class MessageTool(Tool, ContextAware):
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e:
return f"Error sending message: {str(e)}"
return ToolResult.error(f"Error sending message: {str(e)}")
+18 -10
View File
@@ -3,7 +3,11 @@
import json
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:
@@ -100,22 +104,26 @@ class ToolRegistry:
suggestion = self._suggest_name(str(name))
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
return None, params, (
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
ToolResult.error(
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
)
)
params = self._coerce_params(tool, params)
if not isinstance(params, dict):
return tool, params, (
f"Error: Tool '{name}' parameters must be a JSON object, got "
f"{type(params).__name__}. Use named parameters like "
'tool_name(param1="value1", param2="value2") matching the tool schema.'
ToolResult.error(
f"Error: Tool '{name}' parameters must be a JSON object, got "
f"{type(params).__name__}. Use named parameters like "
'tool_name(param1="value1", param2="value2") matching the tool schema.'
)
)
cast_params = tool.cast_params(params)
errors = tool.validate_params(cast_params)
if errors:
return tool, cast_params, (
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
@@ -159,16 +167,16 @@ class ToolRegistry:
hint = "\n\n[Analyze the error above and try a different approach.]"
tool, params, error = self.prepare_call(name, params)
if error:
return error + hint
return ToolResult.error(str(error) + hint)
try:
assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params)
if isinstance(result, str) and result.startswith("Error"):
return result + hint
if is_tool_error_result(name, result):
return ToolResult.error(str(result) + hint)
return result
except Exception as e:
return f"Error executing {name}: {str(e)}" + hint
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
@property
def tool_names(self) -> list[str]:
+11 -10
View File
@@ -9,6 +9,7 @@ from contextlib import suppress
from pathlib import Path, PurePosixPath
from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250
@@ -218,12 +219,12 @@ class FindFilesTool(_SearchTool):
try:
target = self._resolve(path or ".")
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()):
return f"Error: Unsupported path: {path}"
return ToolResult.error(f"Error: Unsupported path: {path}")
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 = (
_DEFAULT_FILE_HEAD_LIMIT
@@ -271,9 +272,9 @@ class FindFilesTool(_SearchTool):
result += "\n\n" + note
return result
except PermissionError as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {e}")
except Exception as e:
return f"Error finding files: {e}"
return ToolResult.error(f"Error finding files: {e}")
class GrepTool(_SearchTool):
@@ -425,16 +426,16 @@ class GrepTool(_SearchTool):
try:
target = self._resolve(path or ".")
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()):
return f"Error: Unsupported path: {path}"
return ToolResult.error(f"Error: Unsupported path: {path}")
flags = re.IGNORECASE if case_insensitive else 0
try:
needle = re.escape(pattern) if fixed_strings else pattern
regex = re.compile(needle, flags)
except re.error as e:
return f"Error: invalid regex pattern: {e}"
return ToolResult.error(f"Error: invalid regex pattern: {e}")
if head_limit is not None:
limit = None if head_limit == 0 else head_limit
@@ -579,6 +580,6 @@ class GrepTool(_SearchTool):
result += "\n\n" + "\n".join(notes)
return result
except PermissionError as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {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 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.runtime_state import RuntimeState
from nanobot.config_base import Base
@@ -216,7 +216,7 @@ class MyTool(Tool, ContextAware):
@staticmethod
def _validate_key(key: str | None, label: str = "key") -> str | None:
if not key or not key.strip():
return f"Error: '{label}' cannot be empty or whitespace"
return ToolResult.error(f"Error: '{label}' cannot be empty or whitespace")
return None
# ------------------------------------------------------------------
@@ -321,7 +321,7 @@ class MyTool(Tool, ContextAware):
if action in ("inspect", "check"):
return self._inspect(key)
if not self._modify_allowed:
return "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"):
return self._modify(key, value)
return f"Unknown action: {action}"
@@ -333,7 +333,7 @@ class MyTool(Tool, ContextAware):
return self._inspect_all()
top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"):
return f"Error: '{top}' is not accessible"
return ToolResult.error(f"Error: '{top}' is not accessible")
obj, err = self._resolve_path(key)
if err:
# "scratchpad" alias for _runtime_vars
@@ -343,12 +343,12 @@ class MyTool(Tool, ContextAware):
# Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: {err}"
return ToolResult.error(f"Error: {err}")
# Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: '{key}' not found"
return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(obj, key)
def _inspect_all(self) -> str:
@@ -379,21 +379,21 @@ class MyTool(Tool, ContextAware):
top = key.split(".")[0]
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED {key}")
return 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:
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:
parent_path, leaf = key.rsplit(".", 1)
if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
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:
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)
if err:
return f"Error: {err}"
return ToolResult.error(f"Error: {err}")
if isinstance(parent, dict):
parent[leaf] = value
else:
@@ -408,11 +408,11 @@ class MyTool(Tool, ContextAware):
def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return "Error: 'model_preset' must be a non-empty string"
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
name = value.strip()
result = self._modify_free("model_preset", name)
if result.startswith("Error:"):
return result if result.endswith((".", "!", "?")) else f"{result}."
if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
return (
f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
@@ -422,22 +422,25 @@ class MyTool(Tool, ContextAware):
spec = self.RESTRICTED[key]
expected = spec["type"]
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):
try:
value = expected(value)
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)
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"]:
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"]:
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)
if key == "model":
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"):
self._runtime_state._sync_subagent_runtime_limits()
self._audit("modify", f"{key}: {old!r} -> {value!r}")
@@ -455,25 +458,25 @@ class MyTool(Tool, ContextAware):
"modify",
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:
setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}")
return f"Error: {message}"
return ToolResult.error(f"Error: {message}")
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
if callable(value):
self._audit("modify", f"REJECTED callable {key}")
return "Error: cannot store callable values"
return ToolResult.error("Error: cannot store callable values")
err = self._validate_json_safe(value)
if 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:
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)
self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
+38 -170
View File
@@ -6,19 +6,16 @@ import asyncio
import os
import re
import shutil
import subprocess
import sys
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loguru import logger
from pydantic import AliasChoices, 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.context import current_request_session_key
from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER,
@@ -36,19 +33,12 @@ from nanobot.agent.tools.schema import (
StringSchema,
tool_parameters_schema,
)
from nanobot.agent.verification_state import (
analyze_verification_result,
append_verification_feedback,
record_verification_observation,
)
from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
from nanobot.security.workspace_policy import is_path_within
from nanobot.utils.helpers import build_structured_output_summary
_IS_WINDOWS = sys.platform == "win32"
_DETACHED_EXIT_GRACE_S = 1.0 if _IS_WINDOWS else 0.2
# Policy note appended to recoverable workspace-boundary guard errors.
@@ -65,13 +55,6 @@ class ExecToolConfig(Base):
"""Shell exec tool configuration."""
enable: bool = True
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
allow_local_service_access: bool = Field(
default=False,
validation_alias=AliasChoices(
"allowLocalServiceAccess",
"allow_local_service_access",
),
) # allow shell commands to reach literal localhost/loopback services
path_prepend: str = ""
path_append: str = ""
sandbox: str = ""
@@ -143,16 +126,6 @@ class _PreparedCommand:
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
detach=BooleanSchema(
description=(
"Run the command as a detached background process that can "
"survive after the agent finishes. Use for local servers, "
"dev servers, mock APIs, or other services that must remain "
"available for later commands or external verification."
),
default=False,
nullable=True,
),
)
)
class ExecTool(Tool):
@@ -176,7 +149,6 @@ class ExecTool(Tool):
working_dir=ctx.workspace,
timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace,
allow_local_service_access=cfg.allow_local_service_access,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox,
path_prepend=cfg.path_prepend,
@@ -193,7 +165,6 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
allow_local_service_access: bool = False,
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "",
@@ -226,7 +197,6 @@ class ExecTool(Tool):
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
self.allow_local_service_access = allow_local_service_access
if allow_local_preview_access is not None:
webui_allow_local_service_access = allow_local_preview_access
self.webui_allow_local_service_access = webui_allow_local_service_access
@@ -266,11 +236,8 @@ class ExecTool(Tool):
"Use -y or --yes flags to avoid interactive prompts. "
"For long-running or interactive commands, pass yield_time_ms; "
"if the command keeps running, exec returns a session_id that can "
"be polled or written to with write_stdin. For services that "
"must remain available after you finish, pass detach=true instead "
"of yield_time_ms; detached output is written to a log file and "
"the tool returns a pid. Output is truncated at 10 000 chars; "
"timeout defaults to 60s."
"be polled or written to with write_stdin. Output is truncated at "
"10 000 chars; timeout defaults to 60s."
)
@property
@@ -284,13 +251,12 @@ class ExecTool(Tool):
login: bool | None = None, yield_time_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
detach: bool | None = False,
**kwargs: Any,
) -> str:
command = command or cmd
working_dir = working_dir or workdir
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:
max_output_chars = max_output_tokens
@@ -298,14 +264,10 @@ class ExecTool(Tool):
if isinstance(prepared, str):
return prepared
if detach:
return await self._execute_detached(prepared)
if yield_time_ms is not None:
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
try:
started_at = time.monotonic()
process = await self._spawn(
prepared.command,
prepared.cwd,
@@ -321,15 +283,7 @@ class ExecTool(Tool):
)
except asyncio.TimeoutError:
await self._kill_process(process)
result = f"Error: Command timed out after {prepared.timeout} seconds"
analysis = analyze_verification_result(
command=prepared.command,
output=result,
exit_code=None,
timed_out=True,
)
record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
except asyncio.CancelledError:
await self._kill_process(process)
raise
@@ -347,38 +301,20 @@ class ExecTool(Tool):
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
elapsed_s = max(0.0, time.monotonic() - started_at)
analysis = analyze_verification_result(
command=prepared.command,
output=result,
exit_code=process.returncode,
)
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
if len(result) > max_len:
result = build_structured_output_summary(
"[tool output truncated]",
result,
max_chars=max_len,
metadata=[
("original_size_chars", len(result)),
("exit_code", process.returncode),
("duration_s", f"{elapsed_s:.1f}"),
],
analysis=analysis,
guidance=(
"Use the structured summary first. Rerun a narrower "
"command, grep a specific failure, or inspect the "
"named artifact instead of rerunning broad noisy logs."
),
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
return result
except Exception as e:
return f"Error executing command: {str(e)}"
return ToolResult.error(f"Error executing command: {str(e)}")
async def _execute_session(
self,
@@ -404,69 +340,9 @@ class ExecTool(Tool):
),
)
result = format_session_poll(session_id, poll)
if poll.done:
analysis = analyze_verification_result(
command=prepared.command,
output=result,
exit_code=poll.exit_code,
timed_out=poll.timed_out,
)
record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
return result
return ToolResult.error(result) if poll.timed_out else result
except Exception as exc:
return f"Error executing command: {exc}"
async def _execute_detached(self, prepared: _PreparedCommand) -> str:
log_dir = Path(prepared.cwd) / ".nanobot" / "exec-logs"
try:
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"detached-{uuid.uuid4().hex[:12]}.log"
except Exception as exc:
return f"Error preparing detached command log directory: {exc}"
log_handle = None
try:
log_handle = open(log_path, "ab", buffering=0)
process = await self._spawn(
prepared.command,
prepared.cwd,
prepared.env,
prepared.shell_program,
prepared.login,
stdout=log_handle,
stderr=log_handle,
start_new_session=not _IS_WINDOWS,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if _IS_WINDOWS else 0,
)
except Exception as exc:
return f"Error starting detached command: {exc}"
finally:
if log_handle is not None:
with suppress(Exception):
log_handle.close()
try:
exit_code = await asyncio.wait_for(process.wait(), timeout=_DETACHED_EXIT_GRACE_S)
except asyncio.TimeoutError:
return (
"Detached process started.\n"
f"pid: {process.pid}\n"
f"cwd: {prepared.cwd}\n"
f"log: {log_path}\n"
"Poll the log or run a health check to verify the service is ready."
)
log_text = ""
with suppress(Exception):
log_text = log_path.read_text(encoding="utf-8", errors="replace")
if len(log_text) > 4000:
log_text = log_text[-4000:]
return (
f"Detached process exited immediately with code {exit_code}.\n"
f"log: {log_path}\n"
f"{log_text}"
)
return ToolResult.error(f"Error executing command: {exc}")
def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit).
@@ -508,12 +384,12 @@ class ExecTool(Tool):
requested = Path(cwd).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve()
except Exception:
return (
return ToolResult.error(
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if not is_path_within(requested, resolved_root):
return (
return ToolResult.error(
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
@@ -589,10 +465,6 @@ class ExecTool(Tool):
login: bool = False,
*,
stdin: int = asyncio.subprocess.DEVNULL,
stdout: Any = asyncio.subprocess.PIPE,
stderr: Any = asyncio.subprocess.PIPE,
start_new_session: bool = False,
creationflags: int = 0,
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
@@ -600,20 +472,18 @@ class ExecTool(Tool):
return await asyncio.create_subprocess_exec(
"powershell", "-NoProfile", "-Command", command,
stdin=stdin,
stdout=stdout,
stderr=stderr,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
creationflags=creationflags,
)
return await asyncio.create_subprocess_shell(
command,
stdin=stdin,
stdout=stdout,
stderr=stderr,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
creationflags=creationflags,
)
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
@@ -624,11 +494,10 @@ class ExecTool(Tool):
return await asyncio.create_subprocess_exec(
*args,
stdin=stdin,
stdout=stdout,
stderr=stderr,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
start_new_session=start_new_session,
)
@staticmethod
@@ -636,24 +505,24 @@ class ExecTool(Tool):
if not shell:
return None, None
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:
return None, "Error: shell contains invalid characters"
return None, ToolResult.error("Error: shell contains invalid characters")
allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser()
if path.is_absolute():
if path.name not in allowed:
return None, 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):
return None, f"Error: shell is not executable: {shell}"
return None, ToolResult.error(f"Error: shell is not executable: {shell}")
return str(path), None
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:
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)
if not resolved:
return None, f"Error: shell not found: {shell}"
return None, ToolResult.error(f"Error: shell not found: {shell}")
return resolved, None
@staticmethod
@@ -740,26 +609,25 @@ class ExecTool(Tool):
if not explicitly_allowed:
for pattern in self.deny_patterns:
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:
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
allow_loopback = self.allow_local_service_access or current_scope_allows_loopback(
enabled=self.webui_allow_local_service_access,
)
if contains_internal_url(
cmd,
allow_loopback=allow_loopback,
allow_loopback=current_scope_allows_loopback(
enabled=self.webui_allow_local_service_access,
),
):
# 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
if should_restrict:
if "..\\" in cmd or "../" in cmd:
return (
return ToolResult.error(
"Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE
)
@@ -794,7 +662,7 @@ class ExecTool(Tool):
if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace)
if p.is_absolute() and not allowed:
return (
return ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
+28 -28
View File
@@ -14,7 +14,7 @@ import httpx
from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@@ -395,13 +395,13 @@ class WebSearchTool(Tool):
elif provider == "keenable":
return await self._search_keenable(query, n)
else:
return 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:
try:
from olostep import AsyncOlostep, Olostep_BaseError
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", "")
if not api_key:
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
@@ -445,9 +445,9 @@ class WebSearchTool(Tool):
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
return _format_results(query, items, n)
except Olostep_BaseError as e:
return f"Olostep search error: {type(e).__name__}: {e}"
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {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:
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
@@ -481,13 +481,13 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return (
return ToolResult.error(
"Error: Brave search rate limited after retry. "
"Retry later or reduce consecutive web_search calls."
)
return f"Error: {e}"
return ToolResult.error(f"Error: {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:
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
@@ -505,7 +505,7 @@ class WebSearchTool(Tool):
r.raise_for_status()
return _format_results(query, r.json().get("results", []), n)
except Exception as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {e}")
async def _search_keenable(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "")
@@ -540,10 +540,10 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return "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("Error: Keenable search rate limited. Try again later or reduce search frequency.")
return ToolResult.error(f"Error: Keenable search failed ({e.response.status_code}): {e}")
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:
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
@@ -553,7 +553,7 @@ class WebSearchTool(Tool):
endpoint = f"{base_url.rstrip('/')}/search"
is_valid, error_msg = _validate_url(endpoint)
if not is_valid:
return f"Error: invalid SearXNG URL: {error_msg}"
return ToolResult.error(f"Error: invalid SearXNG URL: {error_msg}")
try:
async with httpx.AsyncClient(proxy=self.proxy) as client:
r = await client.get(
@@ -565,7 +565,7 @@ class WebSearchTool(Tool):
r.raise_for_status()
return _format_results(query, r.json().get("results", []), n)
except Exception as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {e}")
async def _search_jina(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "")
@@ -616,7 +616,7 @@ class WebSearchTool(Tool):
]
return _format_results(query, items, n)
except Exception as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {e}")
async def _search_exa(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
@@ -663,10 +663,10 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return "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("Error: Exa search rate limited. Try again later or reduce search frequency.")
return ToolResult.error(f"Error: Exa search failed ({e.response.status_code}): {e}")
except Exception as e:
return f"Error: Exa search failed: {e}"
return ToolResult.error(f"Error: Exa search failed: {e}")
async def _search_volcengine(
self,
@@ -690,7 +690,7 @@ class WebSearchTool(Tool):
normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None
normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None
except ValueError as e:
return f"Error: {e}"
return ToolResult.error(f"Error: {e}")
body: dict[str, Any] = {
"Query": query,
@@ -723,18 +723,18 @@ class WebSearchTool(Tool):
data = r.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return "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("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
return ToolResult.error(f"Error: Volcengine search failed ({e.response.status_code}): {e}")
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")
if error:
if isinstance(error, dict):
code = error.get("Code") or error.get("code") or "unknown"
message = error.get("Message") or error.get("message") or error
return f"Error: Volcengine search error {code}: {message}"
return f"Error: Volcengine search error: {error}"
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
return ToolResult.error(f"Error: Volcengine search error: {error}")
result = data.get("Result") or data
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
@@ -791,7 +791,7 @@ class WebSearchTool(Tool):
return _format_results(query, items, n)
except Exception as e:
logger.warning("DuckDuckGo search failed: {}", e)
return 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:
api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "")
@@ -819,7 +819,7 @@ class WebSearchTool(Tool):
timeout=self.config.timeout,
)
if r.status_code == 429:
return "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()
data = r.json()
wrapped_data = data.get("data") if isinstance(data, dict) else None
@@ -839,9 +839,9 @@ class WebSearchTool(Tool):
]
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
return 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:
return f"Error: {e}"
return ToolResult.error(f"Error: {e}")
@tool_parameters(
-292
View File
@@ -1,292 +0,0 @@
"""Lightweight verification-result detection for coding workflows."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Literal
VerificationStatus = Literal["passed", "failed"]
@dataclass(frozen=True, slots=True)
class VerificationAnalysis:
"""Structured summary of a command that appears to be verification."""
status: VerificationStatus
command: str
exit_code: int | None
failed_tests: tuple[str, ...] = ()
primary_errors: tuple[str, ...] = ()
missing_artifacts: tuple[str, ...] = ()
timed_out: bool = False
@dataclass(frozen=True, slots=True)
class VerificationObservation:
"""Latest verification signal observed for a session."""
analysis: VerificationAnalysis
sequence: int
_OBSERVATIONS: dict[str, VerificationObservation] = {}
_SEQUENCE = 0
_TEST_COMMAND_RE = re.compile(
r"(?ix)"
r"("
r"\bpytest\b|\bpy\.test\b|\bunittest\b|\bnosetests\b|"
r"\btest_outputs\.py\b|\brun_tests?(?:\.sh|\.py)?\b|"
r"\bnpm\s+(?:run\s+)?test\b|\byarn\s+test\b|\bpnpm\s+test\b|"
r"\bcargo\s+test\b|\bgo\s+test\b|\bctest\b|"
r"\bmake\s+(?:[^;&|]*\s+)?test\b"
r")"
)
_ARTIFACT_CHECK_COMMAND_RE = re.compile(
r"(?ix)"
r"("
r"\bcmp\b|"
r"\bdiff\b|"
r"\bsha(?:1|224|256|384|512)?sum\b|"
r"\bmd5sum\b|"
r"\bgcc\b.*(?:&&|;).*\./|"
r"\bclang\b.*(?:&&|;).*\./|"
r"\bpython3?\b.*<<['\"]?PY\b.*\bassert\b"
r")"
)
_COMPARISON_COMMAND_RE = re.compile(r"(?i)\b(?:cmp|diff)\b")
_FAILURE_RE = re.compile(
r"(?im)"
r"("
r"^FAILED\s+|"
r"\b\d+\s+failed\b|"
r"\bAssertionError\b|"
r"\bFileNotFoundError\b|"
r"\bTimeoutError\b|"
r"\bcommand not found\b|"
r"\bError:\s+Command timed out\b|"
r"\bFAILURES?\b|"
r"\bTEST FAILED\b"
r")"
)
_SUCCESS_RE = re.compile(
r"(?im)"
r"("
r"\b\d+\s+passed\b|"
r"\bOK\b|"
r"\bTEST PASSED\b|"
r"\bExit code:\s*0\b"
r")"
)
_ARTIFACT_SUCCESS_RE = re.compile(
r"(?im)"
r"("
r"\b(?:cmp|diff|test|verify)_exit:\s*0\b|"
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*0\s*$"
r")"
)
_ARTIFACT_FAILURE_RE = re.compile(
r"(?im)"
r"("
r"\b(?:cmp|diff|test|verify)_exit:\s*[1-9]\d*\b|"
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*[1-9]\d*\s*$"
r")"
)
_FAILED_TEST_RE = re.compile(r"(?m)^FAILED\s+([^\s]+)")
_PYTEST_SHORT_RE = re.compile(r"(?m)^_{3,}\s+([A-Za-z0-9_./:-]+)\s+_{3,}$")
_ERROR_LINE_RE = re.compile(
r"(?m)"
r"^\s*(?:E\s+)?("
r"(?:AssertionError|FileNotFoundError|TimeoutError|ValueError|TypeError|RuntimeError)"
r"(?::[^\n]*)?|"
r"assert\s+[^\n]+|"
r"[^:\n]+:\s+line\s+\d+:\s+[^:\n]+:\s+command not found|"
r"Error:\s+[^\n]+|"
r"TEST FAILED[^\n]*"
r")"
)
_MISSING_PATH_RE = re.compile(
r"(?i)"
r"(?:No such file or directory:\s*['\"]([^'\"]+)['\"]|"
r"(?:file|path)\s+([^\s'\"]+)\s+does not exist|"
r"cannot open file\s+['\"]([^'\"]+)['\"])"
)
def analyze_verification_result(
*,
command: str,
output: str,
exit_code: int | None,
timed_out: bool = False,
) -> VerificationAnalysis | None:
"""Return a verification summary when a command/output looks like a test."""
command = " ".join((command or "").split())
looks_like_test_command = bool(_TEST_COMMAND_RE.search(command))
looks_like_artifact_check = bool(_ARTIFACT_CHECK_COMMAND_RE.search(command))
looks_like_comparison_command = bool(_COMPARISON_COMMAND_RE.search(command))
looks_like_verification = looks_like_test_command or looks_like_artifact_check
failure_seen = bool(_FAILURE_RE.search(output))
success_seen = bool(_SUCCESS_RE.search(output))
artifact_success_seen = bool(_ARTIFACT_SUCCESS_RE.search(output)) and (
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*0\b", output, flags=re.I))
)
artifact_failure_seen = bool(_ARTIFACT_FAILURE_RE.search(output)) and (
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*[1-9]\d*\b", output, flags=re.I))
)
if not looks_like_test_command and not failure_seen:
if not (looks_like_artifact_check and artifact_success_seen and exit_code == 0):
return None
if (
(timed_out and looks_like_verification)
or (exit_code not in (None, 0) and (looks_like_verification or failure_seen))
or failure_seen
or artifact_failure_seen
):
return VerificationAnalysis(
status="failed",
command=command,
exit_code=exit_code,
failed_tests=_unique(_FAILED_TEST_RE.findall(output), limit=8),
primary_errors=_extract_primary_errors(output),
missing_artifacts=_extract_missing_artifacts(output),
timed_out=timed_out,
)
if looks_like_test_command and exit_code == 0 and success_seen:
return VerificationAnalysis(
status="passed",
command=command,
exit_code=exit_code,
)
if looks_like_artifact_check and exit_code == 0 and artifact_success_seen:
return VerificationAnalysis(
status="passed",
command=command,
exit_code=exit_code,
)
return None
def append_verification_feedback(output: str, analysis: VerificationAnalysis | None) -> str:
"""Append model-facing feedback for failed verification results."""
if analysis is None or analysis.status != "failed":
return output
lines = [
"",
"[Verification Feedback]",
"Verification status: failed.",
"Do not call complete_goal or present the task as finished until this is fixed and a verification passes.",
]
if analysis.command:
lines.append(f"Command: {analysis.command[:240]}")
if analysis.exit_code is not None:
lines.append(f"Exit code: {analysis.exit_code}")
if analysis.timed_out:
lines.append("Failure type: command timeout")
if analysis.failed_tests:
lines.append("Failed tests:")
lines.extend(f"- {item}" for item in analysis.failed_tests)
if analysis.primary_errors:
lines.append("Primary errors:")
lines.extend(f"- {item}" for item in analysis.primary_errors)
if analysis.missing_artifacts:
lines.append("Missing artifacts:")
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
lines.append("Next action: inspect the failing assertion, fix the implementation or artifact, then rerun the most specific verification command.")
lines.append("[/Verification Feedback]")
return output.rstrip() + "\n" + "\n".join(lines)
def record_verification_observation(session_key: str | None, analysis: VerificationAnalysis | None) -> None:
"""Remember the latest verification signal for a session."""
if not session_key or analysis is None:
return
global _SEQUENCE
_SEQUENCE += 1
_OBSERVATIONS[session_key] = VerificationObservation(
analysis=analysis,
sequence=_SEQUENCE,
)
def latest_verification_observation(session_key: str | None) -> VerificationObservation | None:
if not session_key:
return None
return _OBSERVATIONS.get(session_key)
def clear_verification_observation(session_key: str | None) -> None:
if session_key:
_OBSERVATIONS.pop(session_key, None)
def format_completion_gate_message(observation: VerificationObservation) -> str:
"""Build the complete_goal soft-gate message for unresolved failures."""
analysis = observation.analysis
lines = [
"Recent verification appears to have failed, so the goal is not marked complete yet.",
"Continue fixing the task and rerun verification before completing.",
]
if analysis.command:
lines.append(f"Last failed verification command: {analysis.command[:240]}")
if analysis.failed_tests:
lines.append("Failed tests: " + ", ".join(analysis.failed_tests[:5]))
if analysis.primary_errors:
lines.append("Primary error: " + analysis.primary_errors[0])
if analysis.missing_artifacts:
lines.append("Missing artifact: " + analysis.missing_artifacts[0])
lines.append(
"If you are intentionally stopping with known failures, call complete_goal again with remaining_failures describing them honestly."
)
return "\n".join(lines)
def _extract_primary_errors(output: str) -> tuple[str, ...]:
candidates: list[str] = []
for match in _ERROR_LINE_RE.findall(output):
text = " ".join(match.split())
if text and text not in candidates:
candidates.append(text[:240])
if len(candidates) >= 8:
break
if not candidates:
for match in _PYTEST_SHORT_RE.findall(output):
text = " ".join(match.split())
if text and text not in candidates:
candidates.append(text[:240])
if len(candidates) >= 4:
break
return tuple(candidates)
def _extract_missing_artifacts(output: str) -> tuple[str, ...]:
paths: list[str] = []
for groups in _MISSING_PATH_RE.findall(output):
path = next((item for item in groups if item), "")
if path and path not in paths:
paths.append(path[:240])
if len(paths) >= 8:
break
return tuple(paths)
def _unique(items: list[str], *, limit: int) -> tuple[str, ...]:
out: list[str] = []
for item in items:
text = " ".join(item.split())
if text and text not in out:
out.append(text[:240])
if len(out) >= limit:
break
return tuple(out)
+22 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import contextlib
import hmac
import json as _json
import time
import uuid
@@ -392,7 +393,10 @@ async def handle_health(request: web.Request) -> web.Response:
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:
"""Create the aiohttp application.
@@ -400,6 +404,7 @@ def create_app(
agent_loop: An initialized AgentLoop instance.
model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds.
api_key: Optional API key for Bearer-token authentication.
"""
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
app["agent_loop"] = agent_loop
@@ -407,6 +412,22 @@ def create_app(
app["request_timeout"] = request_timeout
app["session_locks"] = {} # per-user locks, keyed by session_key
@web.middleware
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
if not api_key:
return await handler(request)
# Allow unauthenticated health checks.
if request.path == "/health":
return await handler(request)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")
if not hmac.compare_digest(auth[len("Bearer "):], api_key):
return _error_json(401, "Invalid API key")
return await handler(request)
app.middlewares.append(auth_middleware)
app.router.add_post("/v1/chat/completions", handle_chat_completions)
app.router.add_get("/v1/models", handle_models)
app.router.add_get("/health", handle_health)
+8 -4
View File
@@ -2,7 +2,10 @@
from dataclasses import dataclass, field
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
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may
@@ -39,9 +42,9 @@ class InboundMessage:
class OutboundMessage:
"""Message to send to a chat channel.
``metadata`` can carry routing (``message_id``, ), trace flags (``_progress``),
and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI
channels may ignore unknown keys.
``event`` carries internal runtime/UI semantics. ``metadata`` is reserved
for channel routing context (``message_id``, thread ids, etc.) and optional
``OUTBOUND_META_AGENT_UI`` blobs for rich clients.
"""
channel: str
@@ -51,3 +54,4 @@ class OutboundMessage:
media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list)
event: "OutboundEvent | None" = None
+226
View File
@@ -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
+12 -15
View File
@@ -10,7 +10,8 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
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
@@ -29,23 +30,19 @@ def build_bus_progress_callback(
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
if file_edit_events:
meta["_file_edit_events"] = file_edit_events
await bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
event=ProgressEvent(
content=content,
tool_hint=tool_hint,
reasoning_delta=reasoning,
reasoning_end=reasoning_end,
tool_events=tool_events,
file_edit_events=file_edit_events,
),
metadata=msg.metadata,
)
)
+37 -17
View File
@@ -101,20 +101,33 @@ class BaseChannel(ABC):
"""
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.
Override in subclasses to enable streaming. Implementations should
raise on delivery failure so the channel manager can retry.
Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends
the current segment, and stateful implementations must key buffers by
``_stream_id`` rather than only by ``chat_id``.
Stateful implementations should key buffers by ``stream_id`` rather
than only by ``chat_id`` when it is provided.
"""
pass
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:
"""Stream a chunk of model reasoning/thinking content.
@@ -123,15 +136,17 @@ class BaseChannel(ABC):
subtext, WebUI italic bubble, ...) override to render reasoning
as a subordinate trace that updates in place as the model thinks.
Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta``
is a chunk, ``_reasoning_end`` ends the current reasoning segment,
and stateful implementations should key buffers by ``_stream_id``
rather than only by ``chat_id``.
Streaming contract mirrors :meth:`send_delta`: stateful implementations
should key buffers by ``stream_id`` rather than only by ``chat_id``.
"""
return
async def send_reasoning_end(
self, chat_id: str, metadata: dict[str, Any] | None = None
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Mark the end of a reasoning stream segment.
@@ -165,13 +180,18 @@ class BaseChannel(ABC):
"""
if not msg.content:
return
meta = dict(msg.metadata or {})
meta.setdefault("_reasoning_delta", True)
await self.send_reasoning_delta(msg.chat_id, msg.content, meta)
end_meta = dict(meta)
end_meta.pop("_reasoning_delta", None)
end_meta["_reasoning_end"] = True
await self.send_reasoning_end(msg.chat_id, end_meta)
stream_id = getattr(msg.event, "stream_id", None)
await self.send_reasoning_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=stream_id,
)
await self.send_reasoning_end(
msg.chat_id,
msg.metadata,
stream_id=stream_id,
)
@property
def supports_streaming(self) -> bool:
+21 -6
View File
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text
@@ -217,6 +218,16 @@ if DISCORD_AVAILABLE:
command_text = f"/model {preset}" if preset else "/model"
await self._forward_slash_command(interaction, command_text)
@self.tree.command(name="trigger", description="Create a named local trigger for this chat")
@app_commands.describe(name="Trigger name")
async def trigger_command(
interaction: discord.Interaction,
name: str,
) -> None:
name = name.strip()
command_text = f"/trigger {name}" if name else "/trigger"
await self._forward_slash_command(interaction, command_text)
@self.tree.command(name="help", description="Show available commands")
async def help_command(interaction: discord.Interaction) -> None:
sender_id = str(interaction.user.id)
@@ -458,7 +469,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping outbound message")
return
is_progress = bool((msg.metadata or {}).get("_progress"))
is_progress = isinstance(msg.event, ProgressEvent)
try:
await client.send_outbound(msg)
@@ -471,7 +482,14 @@ class DiscordChannel(BaseChannel):
await self._clear_reactions(msg.chat_id)
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
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 Discord delivery: send once, then edit until the stream ends."""
client = self._client
@@ -479,10 +497,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping stream delta")
return
meta = metadata or {}
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"):
if stream_end:
buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text:
return
+2 -1
View File
@@ -23,6 +23,7 @@ from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -218,7 +219,7 @@ class EmailChannel(BaseChannel):
return
# 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)
return
+18 -9
View File
@@ -22,6 +22,7 @@ from rich.panel import Panel
from rich.text import Text
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -1797,14 +1798,19 @@ class FeishuChannel(BaseChannel):
return self._stream_update_text_sync(card_id, content, sequence), sequence
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
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 streaming via CardKit: create card on first delta, stream-update on subsequent.
Supported metadata keys:
_stream_end: Finalize the streaming card.
_tool_hint: Delta is a formatted tool hint (for display only).
message_id: Original message id (used with _stream_end for reaction cleanup).
message_id: Original message id (used with stream end for reaction cleanup).
chat_type: "group" or "p2p" controls reply-in-thread for streaming cards.
"""
if not self._client:
@@ -1815,14 +1821,14 @@ class FeishuChannel(BaseChannel):
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback ---
if meta.get("_stream_end"):
if stream_end:
message_id = meta.get("message_id")
# Only finalize the OnIt -> DONE reaction transition on the truly
# final stream end. _resuming=True means the agent will keep
# final stream end. resuming=True means the agent will keep
# working (more tool-call rounds), so leave the reaction state
# in place — otherwise the OnIt indicator disappears prematurely
# and the DONE reaction fires after every tool call.
if message_id and not meta.get("_resuming"):
if message_id and not resuming:
reaction_id = self._reaction_ids.pop(message_id, None)
if 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
# this chat, inline the hint into the card instead of sending a
# 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()
if not hint:
return
@@ -1976,6 +1984,7 @@ class FeishuChannel(BaseChannel):
await self.send_delta(
msg.chat_id,
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
metadata=msg.metadata,
)
return
# No active streaming card — send as a regular interactive card
@@ -2009,7 +2018,7 @@ class FeishuChannel(BaseChannel):
reply_message_id: str | None = None
_msg_id = msg.metadata.get("message_id")
has_thread_id = msg.metadata.get("thread_id")
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
if self.config.reply_to_message and progress_event is None:
reply_message_id = _msg_id
# For topic group messages, always reply to keep context in thread
elif has_thread_id:
+159 -50
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
@@ -12,6 +13,16 @@ from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
RetryWaitEvent,
RuntimeModelUpdatedEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
replace_outbound_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
@@ -57,8 +68,10 @@ class ChannelManager:
*,
session_manager: "SessionManager | None" = None,
cron_service: Any | None = None,
local_trigger_store: Any | None = None,
webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
@@ -67,8 +80,10 @@ class ChannelManager:
self.bus = bus
self._session_manager = session_manager
self._cron_service = cron_service
self._local_trigger_store = local_trigger_store
self._webui_runtime_model_name = webui_runtime_model_name
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
@@ -128,7 +143,9 @@ class ChannelManager:
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
logger=logger,
)
kwargs["gateway"] = gateway
@@ -266,7 +283,7 @@ class ChannelManager:
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
metadata = msg.metadata or {}
if metadata.get("_progress"):
if isinstance(outbound_event_from_message(msg), ProgressEvent):
return False
fingerprint = self._fingerprint_content(msg.content)
if not fingerprint:
@@ -305,57 +322,59 @@ class ChannelManager:
timeout=1.0
)
if (
msg.metadata.get("_reasoning_delta")
or msg.metadata.get("_reasoning_end")
or msg.metadata.get("_reasoning")
event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
if progress_event and (
progress_event.reasoning_delta
or progress_event.reasoning_end
or progress_event.reasoning
):
# Reasoning rides its own plugin channel: only delivered
# when the destination channel opts in via ``show_reasoning``
# and overrides the streaming primitives. Channels without
# a low-emphasis UI affordance keep the base no-op and the
# content silently drops here. ``_reasoning`` (one-shot)
# is accepted for backward compatibility with hooks that
# haven't migrated to delta/end yet.
# content silently drops here.
channel = self.channels.get(msg.channel)
if channel is not None and channel.show_reasoning:
await self._send_with_retry(channel, msg)
continue
if msg.metadata.get("_progress"):
if msg.metadata.get("_tool_hint") and not self._should_send_progress(
if progress_event:
if progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=True,
):
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,
):
continue
if msg.metadata.get("_retry_wait"):
if isinstance(event, RetryWaitEvent):
continue
if (
msg.metadata.get("_runtime_model_updated")
isinstance(event, RuntimeModelUpdatedEvent)
and msg.channel == "websocket"
and "websocket" not in self.channels
):
continue
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
# Coalesce consecutive stream delta messages for the same (channel, chat_id)
# to reduce API calls and improve streaming latency
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
if isinstance(event, StreamDeltaEvent):
msg, extra_pending = self._coalesce_stream_deltas(msg)
pending.extend(extra_pending)
event = outbound_event_from_message(msg)
channel = self.channels.get(msg.channel)
if channel:
# Duplicate suppression is scoped to a known source message
# so repeated content from separate turns is still delivered.
if (
not msg.metadata.get("_stream_delta")
and not msg.metadata.get("_stream_end")
and not msg.metadata.get("_streamed")
not isinstance(
event,
StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent,
)
):
if self._should_suppress_outbound(msg):
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
@@ -369,34 +388,116 @@ class ChannelManager:
except asyncio.CancelledError:
break
@staticmethod
def _accepts_keyword(callable_obj: Callable[..., Any], name: str) -> bool:
try:
signature = inspect.signature(callable_obj)
except (TypeError, ValueError):
return True
return any(
parameter.kind is inspect.Parameter.VAR_KEYWORD or parameter.name == name
for parameter in signature.parameters.values()
)
@classmethod
async def _send_reasoning_delta(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_reasoning_delta, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
metadata["_reasoning_delta"] = True
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
await channel.send_reasoning_delta(
msg.chat_id,
msg.content,
metadata,
**kwargs,
)
@classmethod
async def _send_reasoning_end(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_reasoning_end, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
metadata["_reasoning_end"] = True
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
await channel.send_reasoning_end(
msg.chat_id,
metadata,
**kwargs,
)
@classmethod
async def _send_stream_event(
cls,
channel: BaseChannel,
msg: OutboundMessage,
event: StreamDeltaEvent | StreamEndEvent,
) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_delta, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
if isinstance(event, StreamEndEvent):
if cls._accepts_keyword(channel.send_delta, "stream_end"):
kwargs["stream_end"] = True
else:
metadata = dict(metadata or {})
metadata["_stream_end"] = True
if cls._accepts_keyword(channel.send_delta, "resuming"):
kwargs["resuming"] = event.resuming
elif not kwargs:
metadata = dict(metadata or {})
metadata["_stream_delta"] = True
await channel.send_delta(
msg.chat_id,
msg.content,
metadata,
**kwargs,
)
@staticmethod
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
"""Send one outbound message without retry policy."""
if msg.metadata.get("_reasoning_end"):
await channel.send_reasoning_end(msg.chat_id, msg.metadata)
elif msg.metadata.get("_reasoning_delta"):
await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata)
elif msg.metadata.get("_reasoning"):
# Back-compat: one-shot reasoning. BaseChannel translates this
# to a single delta + end pair so plugins only implement the
# streaming primitives.
event = outbound_event_from_message(msg)
if isinstance(event, ProgressEvent) and event.reasoning_end:
await ChannelManager._send_reasoning_end(channel, msg, event)
elif isinstance(event, ProgressEvent) and event.reasoning_delta:
await ChannelManager._send_reasoning_delta(channel, msg, event)
elif isinstance(event, ProgressEvent) and event.reasoning:
# BaseChannel translates one-shot reasoning to a single delta +
# end pair so plugins only implement the streaming primitives.
await channel.send_reasoning(msg)
elif msg.metadata.get("_file_edit_events"):
edits = msg.metadata.get("_file_edit_events")
elif isinstance(event, ProgressEvent) and event.file_edit_events:
await channel.send_file_edit_events(
msg.chat_id,
edits if isinstance(edits, list) else [],
event.file_edit_events,
msg.metadata,
)
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"):
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
elif not msg.metadata.get("_streamed"):
elif isinstance(event, StreamDeltaEvent):
await ChannelManager._send_stream_event(channel, msg, event)
elif isinstance(event, StreamEndEvent):
await ChannelManager._send_stream_event(channel, msg, event)
elif not isinstance(event, StreamedResponseEvent):
await channel.send(msg)
def _coalesce_stream_deltas(
self, first_msg: OutboundMessage
) -> tuple[OutboundMessage, list[OutboundMessage]]:
"""Merge consecutive _stream_delta messages for the same (channel, chat_id, _stream_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
deltas, which happens when LLM generates faster than the channel can process.
@@ -404,10 +505,15 @@ class ChannelManager:
Returns:
tuple of (merged_message, list_of_non_matching_messages)
"""
first_metadata = first_msg.metadata or {}
target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_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
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] = []
# Only merge consecutive deltas. As soon as we hit any other message,
@@ -419,21 +525,29 @@ class ChannelManager:
break
# Check if this message belongs to the same stream
next_metadata = next_msg.metadata or {}
next_event = outbound_event_from_message(next_msg)
next_stream_id = (
next_event.stream_id
if isinstance(next_event, StreamDeltaEvent | StreamEndEvent)
else None
)
same_target = (
next_msg.channel,
next_msg.chat_id,
next_metadata.get("_stream_id"),
next_stream_id,
) == target_key
is_delta = next_metadata.get("_stream_delta")
is_end = next_metadata.get("_stream_end")
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
combined_content += next_msg.content
# If we see _stream_end, remember it and stop coalescing this stream
if is_end:
final_metadata["_stream_end"] = True
# If we see stream_end, remember it and stop coalescing this stream
if isinstance(next_event, StreamEndEvent):
final_event = StreamEndEvent(
stream_id=next_stream_id,
resuming=next_event.resuming,
)
# Stream ended - stop coalescing this stream
break
else:
@@ -441,12 +555,7 @@ class ChannelManager:
non_matching.append(next_msg)
break
merged = OutboundMessage(
channel=first_msg.channel,
chat_id=first_msg.chat_id,
content=combined_content,
metadata=final_metadata,
)
merged = replace_outbound_event(first_msg, final_event, content=combined_content)
return merged, non_matching
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 nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir
@@ -504,7 +505,7 @@ class MatrixChannel(BaseChannel):
text = msg.content or ""
candidates = self._collect_outbound_media_candidates(msg.media)
relates_to = self._build_thread_relates_to(msg.metadata)
is_progress = bool((msg.metadata or {}).get("_progress"))
is_progress = isinstance(msg.event, ProgressEvent)
try:
failures: list[str] = []
if candidates:
@@ -528,11 +529,19 @@ class MatrixChannel(BaseChannel):
if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
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:
relates_to = self._build_thread_relates_to(metadata)
if meta.get("_stream_end"):
if stream_end:
buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.event_id or not buf.text:
return
+2 -1
View File
@@ -18,6 +18,7 @@ import httpx
from pydantic import Field, computed_field, field_validator
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -539,7 +540,7 @@ class SignalChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Signal."""
is_progress_message = bool(msg.metadata.get("_progress"))
is_progress_message = isinstance(msg.event, ProgressEvent)
try:
plain_text, text_styles = _markdown_to_signal(msg.content)
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 nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -164,7 +165,7 @@ class SlackChannel(BaseChannel):
# only makes sense within the originating conversation.
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
is_progress = (msg.metadata or {}).get("_progress", False)
is_progress = isinstance(msg.event, ProgressEvent)
if is_progress and not msg.content:
pass # skip empty progress messages (e.g. tool-event-only updates)
elif msg.content or not (msg.media or []):
@@ -190,7 +191,7 @@ class SlackChannel(BaseChannel):
self.logger.exception("Failed to upload file {}", media_path)
# Update reaction emoji when the final (non-progress) response is sent
if not (msg.metadata or {}).get("_progress"):
if not is_progress:
event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts"))
+19 -7
View File
@@ -26,6 +26,7 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag
from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text
@@ -36,7 +37,7 @@ from nanobot.utils.helpers import split_message
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
# safety margin for mid-stream edits (plain text). 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
# boundary so the final rendered message never overflows.
TELEGRAM_HTML_MAX_LEN = 4096
@@ -410,6 +411,7 @@ class TelegramChannel(BaseChannel):
BotCommand("status", "Show bot status"),
BotCommand("history", "Show recent conversation messages"),
BotCommand("goal", "Start a sustained objective (long-running task)"),
BotCommand("trigger", "Create a named local trigger"),
BotCommand("pairing", "Manage DM pairing (approve/deny/list)"),
BotCommand("model", "Switch runtime model preset"),
BotCommand("skill", "List enabled skills"),
@@ -422,7 +424,7 @@ class TelegramChannel(BaseChannel):
# Regex for slash commands routed to AgentLoop via ``_forward_command``.
# Hyphenated ``dream-*`` commands stay on a separate handler (below).
TELEGRAM_BUS_SLASH_COMMAND_RE = re.compile(
r"^/(?:new|stop|restart|status|dream|history|goal|pairing|model|skill)(?:@\w+)?(?:\s+.*)?$"
r"^/(?:new|stop|restart|status|dream|history|goal|trigger|pairing|model|skill)(?:@\w+)?(?:\s+.*)?$"
)
@classmethod
@@ -706,8 +708,10 @@ class TelegramChannel(BaseChannel):
self.logger.warning("bot not running")
return
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
# Only stop typing indicator and remove reaction for final responses
if not msg.metadata.get("_progress", False):
if progress_event is None:
self._stop_typing(msg.chat_id)
if reply_to_message_id := msg.metadata.get("message_id"):
with suppress(ValueError):
@@ -792,7 +796,7 @@ class TelegramChannel(BaseChannel):
# Send text content
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 []
reply_markup = self._build_keyboard(buttons) if buttons else None
text = msg.content
@@ -887,15 +891,23 @@ class TelegramChannel(BaseChannel):
def _is_not_modified_error(exc: Exception) -> bool:
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower()
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> 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."""
if not self._app:
return
meta = metadata or {}
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)
if not buf or not buf.message_id or not buf.text:
return
+58 -52
View File
@@ -19,6 +19,16 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -148,16 +158,13 @@ def publish_runtime_model_update(
model_preset: str | None,
) -> None:
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(OutboundMessage(
channel="websocket",
chat_id="*",
content="",
metadata={
"_runtime_model_updated": True,
"model": model,
"model_preset": model_preset,
},
))
bus.outbound.put_nowait(
outbound_message_for_event(
channel="websocket",
chat_id="*",
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
)
)
def _parse_inbound_payload(raw: str) -> str | None:
@@ -851,70 +858,63 @@ class WebSocketChannel(BaseChannel):
raise
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(
model_name=msg.metadata.get("model"),
model_preset=msg.metadata.get("model_preset"),
model_name=event.model,
model_preset=event.model_preset,
)
return
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ()))
if not conns:
if (
msg.metadata.get("_progress")
or msg.metadata.get("_file_edit_events")
or msg.metadata.get("_turn_end")
or msg.metadata.get("_session_updated")
or msg.metadata.get("_goal_status")
or msg.metadata.get("_goal_state_sync")
if isinstance(
event,
ProgressEvent
| TurnEndEvent
| SessionUpdatedEvent
| GoalStatusEvent
| GoalStateSyncEvent,
):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
if msg.metadata.get("_goal_state_sync"):
if isinstance(event, GoalStateSyncEvent):
if conns:
blob = msg.metadata.get("goal_state")
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
return
if msg.metadata.get("_goal_status"):
if isinstance(event, GoalStatusEvent):
if conns:
status = msg.metadata.get("goal_status")
if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
if event.status in ("running", "idle"):
await self.send_goal_status(
msg.chat_id,
status,
started_at=float(started_raw) if isinstance(started_raw, int | float) else None,
event.status,
started_at=event.started_at,
)
return
# Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"):
lat = msg.metadata.get("latency_ms")
lat_i = int(lat) if isinstance(lat, (int, float)) else None
gs = msg.metadata.get("goal_state")
gs_blob = gs if isinstance(gs, dict) else None
if isinstance(event, TurnEndEvent):
await self.send_turn_end(
msg.chat_id,
latency_ms=lat_i,
goal_state=gs_blob,
latency_ms=event.latency_ms,
goal_state=event.goal_state,
metadata=msg.metadata,
)
await self.send_session_updated(msg.chat_id, scope="thread")
return
if msg.metadata.get("_session_updated"):
if isinstance(event, SessionUpdatedEvent):
if conns:
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated(
msg.chat_id,
scope=scope if isinstance(scope, str) else None,
scope=event.scope,
)
return
if msg.metadata.get("_file_edit_events"):
edits = msg.metadata.get("_file_edit_events")
if progress_event and progress_event.file_edit_events:
await self.send_file_edit_events(
msg.chat_id,
edits if isinstance(edits, list) else [],
progress_event.file_edit_events,
msg.metadata,
)
return
@@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel):
lat = msg.metadata.get("latency_ms")
if isinstance(lat, (int, float)):
payload["latency_ms"] = int(lat)
if msg.metadata.get("_tool_events"):
payload["tool_events"] = msg.metadata["_tool_events"]
if progress_event and progress_event.tool_events:
payload["tool_events"] = progress_event.tool_events
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
if agent_ui is not None:
payload["agent_ui"] = agent_ui
# Mark intermediate agent breadcrumbs (tool-call hints, generic
# progress strings) so WS clients can render them as subordinate
# trace rows rather than conversational replies.
if msg.metadata.get("_tool_hint"):
if progress_event and progress_event.tool_hint:
payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"):
elif progress_event:
payload["kind"] = "progress"
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
self._transcripts.prepare_and_append(
@@ -971,6 +971,8 @@ class WebSocketChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
clients receive a stream that opens, updates in place, and closes
@@ -986,7 +988,6 @@ class WebSocketChannel(BaseChannel):
"chat_id": chat_id,
"text": delta,
}
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
@@ -1005,6 +1006,8 @@ class WebSocketChannel(BaseChannel):
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Close the current reasoning stream segment for in-place renderers."""
conns = list(self._subs.get(chat_id, ()))
@@ -1013,7 +1016,6 @@ class WebSocketChannel(BaseChannel):
"event": "reasoning_end",
"chat_id": chat_id,
}
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
@@ -1057,11 +1059,15 @@ class WebSocketChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
conns = list(self._subs.get(chat_id, ()))
meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
if meta.get("_stream_end"):
stream_key = (chat_id, str(stream_id or ""))
if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = self._stream_text_buffers.pop(stream_key, [])
if delta:
@@ -1077,8 +1083,8 @@ class WebSocketChannel(BaseChannel):
"text": delta,
}
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
chat_id,
body,
+2 -1
View File
@@ -13,6 +13,7 @@ from typing import Any
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -497,7 +498,7 @@ class WecomChannel(BaseChannel):
try:
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
frame = self._chat_frames.get(msg.chat_id)
+54 -10
View File
@@ -29,6 +29,7 @@ from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir
@@ -129,6 +130,13 @@ class WeixinConfig(Base):
token: str = "" # Manually set token, or obtained via QR login
state_dir: str = "" # Default: ~/.nanobot/weixin/
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):
@@ -167,6 +175,10 @@ class WeixinChannel(BaseChannel):
self._typing_tickets: dict[str, dict[str, Any]] = {}
self._context_token_at: dict[str, float] = {}
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
@@ -1090,11 +1102,13 @@ class WeixinChannel(BaseChannel):
raise RuntimeError("WeChat client not initialized or not authenticated")
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
# 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:
return
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
# 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(
"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)
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:
"""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
when the final answer carries the ``_streamed`` flag and bypasses
:meth:`send`.
WeChat iLink has no native incremental delivery, and the manager
bypasses :meth:`send` for the ``_streamed`` final answer. So we
accumulate content deltas and flush the full reply as a single message
at stream end. Reasoning deltas are invisible in WeChat and are dropped.
"""
if metadata and metadata.get("_stream_end"):
await self._flush_tool_hints(chat_id)
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)
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:
"""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(_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:
info = _safe_attr(event, "Info")
message = _safe_attr(event, "Message")
@@ -532,6 +556,9 @@ class WhatsAppChannel(BaseChannel):
while len(self._processed_message_ids) > 1000:
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"))
sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt"))
sender_candidates = [sender_alt_jid, participant_jid]
+158 -19
View File
@@ -50,6 +50,14 @@ from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.bus.outbound_events import ( # noqa: E402
ProgressEvent,
RetryWaitEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
@@ -461,25 +469,25 @@ async def _maybe_print_interactive_progress(
renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool:
metadata = msg.metadata or {}
if metadata.get("_retry_wait"):
event = outbound_event_from_message(msg)
if isinstance(event, RetryWaitEvent):
await _print_interactive_progress_line(msg.content, thinking, renderer)
return True
if not metadata.get("_progress"):
if not isinstance(event, ProgressEvent):
return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if metadata.get("_reasoning_end"):
if event.reasoning_end:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True
is_tool_hint = metadata.get("_tool_hint", False)
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
is_tool_hint = event.tool_hint
is_reasoning = event.reasoning or event.reasoning_delta
if is_reasoning:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
@@ -710,6 +718,21 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
return loaded
def _read_trigger_cli_message(message: str | None) -> str:
"""Read a trigger message from an argument or stdin."""
if message and message.strip():
return message
try:
if not sys.stdin.isatty():
content = sys.stdin.read()
if content.strip():
return content
except Exception:
pass
console.print("[red]Error: trigger message is required[/red]")
raise typer.Exit(1)
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
"""Hint users to remove obsolete keys from their config file."""
import json
@@ -741,6 +764,35 @@ def _migrate_cron_store(config: "Config") -> None:
shutil.move(str(legacy_path), str(new_path))
@app.command()
def trigger(
trigger_id: str = typer.Argument(..., help="Trigger ID returned by /trigger"),
message: str | None = typer.Argument(None, help="Message to deliver; stdin is used when omitted"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
):
"""Deliver a local trigger message to its bound chat session."""
from nanobot.triggers.local_store import (
LocalTriggerStore,
TriggerDisabledError,
TriggerNotFoundError,
TriggerStoreError,
)
runtime_config = _load_runtime_config(config, workspace)
content = _read_trigger_cli_message(message)
store = LocalTriggerStore(runtime_config.workspace_path)
try:
delivery = store.enqueue(trigger_id, content)
except (TriggerNotFoundError, TriggerDisabledError) as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
except (TriggerStoreError, ValueError) as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
console.print(f"[green]Queued[/green] {delivery.trigger_id} ({delivery.id})")
# ============================================================================
# OpenAI-Compatible API Server
# ============================================================================
@@ -798,14 +850,24 @@ def serve(
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
console.print(" [cyan]Session[/cyan] : api:default")
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
if host in {"0.0.0.0", "::"}:
if not api_key:
console.print(
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
"Set api.api_key in config to prevent unauthenticated access.[/red]"
)
raise typer.Exit(1)
console.print(
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
"[yellow]API is bound to all interfaces "
"(authentication required).[/yellow]"
)
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):
await agent_loop._connect_mcp()
@@ -847,6 +909,8 @@ def _run_gateway(
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
@@ -869,6 +933,7 @@ def _run_gateway(
# Create cron service with workspace-scoped store
cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path)
trigger_store = LocalTriggerStore(config.workspace_path)
# Create agent with cron service
agent = AgentLoop.from_config(
@@ -883,13 +948,13 @@ def _run_gateway(
runtime_events=runtime_events,
provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store,
)
WebuiTurnCoordinator(
bus=bus,
sessions=session_manager,
schedule_background=lambda coro: agent._schedule_background(coro),
).subscribe(runtime_events)
from nanobot.bus.events import OutboundMessage
from nanobot.session.keys import session_key_for_channel
@@ -1085,8 +1150,14 @@ def _run_gateway(
bus,
session_manager=session_manager,
cron_service=cron,
local_trigger_store=trigger_store,
webui_runtime_model_name=_webui_runtime_model_name,
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
webui_local_trigger_pending_ids=getattr(
agent,
"pending_local_trigger_ids_for_session",
None,
),
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
@@ -1227,6 +1298,13 @@ def _run_gateway(
tasks = [
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
asyncio.create_task(
run_local_trigger_queue(
store=trigger_store,
submit_turn=getattr(agent, "submit_local_trigger_turn", None),
),
name="nanobot-local-triggers",
),
]
if health_server_enabled:
tasks.append(asyncio.create_task(
@@ -1446,7 +1524,7 @@ def agent(
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[tuple[str, dict]] = []
turn_response: list[Any] = []
renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer()
@@ -1454,18 +1532,19 @@ def agent(
while True:
try:
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:
await renderer.on_delta(msg.content)
continue
if msg.metadata.get("_stream_end"):
if isinstance(event, StreamEndEvent):
if renderer:
await renderer.on_end(
resuming=msg.metadata.get("_resuming", False),
resuming=event.resuming,
)
continue
if msg.metadata.get("_streamed"):
if isinstance(event, StreamedResponseEvent):
turn_done.set()
continue
@@ -1480,7 +1559,7 @@ def agent(
if not turn_done.is_set():
if msg.content:
turn_response.append((msg.content, dict(msg.metadata or {})))
turn_response.append(msg)
turn_done.set()
elif msg.content:
await _print_interactive_response(
@@ -1533,8 +1612,10 @@ def agent(
await turn_done.wait()
if turn_response:
content, meta = turn_response[0]
if content and not meta.get("_streamed"):
response_msg = turn_response[0]
content = response_msg.content
meta = response_msg.metadata
if content and not isinstance(response_msg.event, StreamedResponseEvent):
if renderer:
await renderer.close()
print_kwargs: dict[str, Any] = {}
@@ -1744,6 +1825,11 @@ _PROVIDER_DISPLAY: dict[str, str] = {
"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):
"""Register an OAuth login handler."""
@@ -1775,9 +1861,51 @@ def _resolve_oauth_provider(provider: str):
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")
def provider_login(
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."""
spec = _resolve_oauth_provider(provider)
@@ -1789,6 +1917,8 @@ def provider_login(
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
handler()
if set_main or model:
_set_oauth_provider_as_main(spec.name, model=model, config_path=config)
@provider_app.command("logout")
@@ -1812,14 +1942,23 @@ def _login_openai_codex() -> None:
try:
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
with suppress(Exception):
token = get_token()
token = get_token(proxy=proxy)
if not (token and token.access):
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
token = login_oauth_interactive(
print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s),
proxy=proxy,
)
if not (token and token.access):
console.print("[red]✗ Authentication failed[/red]")
+90 -4
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import os
import subprocess
import sys
import time
from contextlib import suppress
@@ -50,7 +51,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
BuiltinCommandSpec(
"/restart",
"Restart nanobot",
"Restart the bot process in place.",
"Restart the bot process.",
"rotate-cw",
),
BuiltinCommandSpec(
@@ -80,6 +81,13 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"activity",
"<goal>",
),
BuiltinCommandSpec(
"/trigger",
"Create named local trigger",
"Create a named CLI trigger bound to this chat session.",
"zap",
"<name>",
),
BuiltinCommandSpec(
"/dream",
"Run Dream",
@@ -130,6 +138,15 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
loop = ctx.loop
msg = ctx.msg
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."
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
@@ -138,7 +155,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
"""Restart the process in-place via os.execv."""
"""Restart the process."""
msg = ctx.msg
set_restart_notice_to_env(
channel=msg.channel,
@@ -148,7 +165,19 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
async def _do_restart():
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())
return OutboundMessage(
@@ -626,7 +655,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap plus verification_summary / commands_run / artifacts_created when applicable. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
Goal:
{goal}
@@ -696,6 +725,61 @@ async def cmd_skill(ctx: CommandContext) -> OutboundMessage:
metadata=dict(ctx.msg.metadata or {}),
)
async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
"""Create a local trigger bound to the current session."""
name = ctx.args.strip()
if not name:
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=(
"Usage: /trigger <name>\n\n"
"Create a named local trigger bound to this chat session."
),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
from nanobot.triggers.local_store import LocalTriggerStore
loop = ctx.loop
workspace = getattr(loop, "workspace", None)
if workspace is None:
workspace = getattr(getattr(loop, "context", None), "workspace", None)
if workspace is None:
raise RuntimeError("workspace unavailable for trigger creation")
store = getattr(loop, "local_trigger_store", None)
if store is None:
store = LocalTriggerStore(workspace)
from nanobot.session.keys import UNIFIED_SESSION_KEY
session_key = (
ctx.msg.session_key
if ctx.key == UNIFIED_SESSION_KEY
else ctx.key
)
trigger = store.create(
name=name,
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
session_key=session_key,
sender_id="trigger",
origin_metadata=dict(ctx.msg.metadata or {}),
)
command = f'nanobot trigger {trigger.id} "message"'
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=(
f"Trigger created: {trigger.name}\n"
f"ID: {trigger.id}\n\n"
f"Command:\n{command}"
),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
async def cmd_help(ctx: CommandContext) -> OutboundMessage:
"""Return available slash commands."""
return OutboundMessage(
@@ -730,6 +814,8 @@ def register_builtin_commands(router: CommandRouter) -> None:
router.prefix("/history ", cmd_history)
router.exact("/goal", cmd_goal)
router.prefix("/goal ", cmd_goal)
router.exact("/trigger", cmd_trigger)
router.prefix("/trigger ", cmd_trigger)
router.exact("/dream", cmd_dream)
router.exact("/dream-log", cmd_dream_log)
router.prefix("/dream-log ", cmd_dream_log)
+25 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable
@@ -10,6 +11,26 @@ if TYPE_CHECKING:
from nanobot.session.manager import Session
Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
_BOT_SUFFIX_RE = re.compile(r"^[A-Za-z0-9_]+$")
def normalize_command_text(text: str) -> str:
"""Normalize slash-command transport variants before routing.
Telegram and Discord-style command dispatch can produce ``/cmd@bot args``.
The bot suffix belongs to the transport, not the command name, so strip it
once at the router boundary while preserving user arguments verbatim.
"""
stripped = text.strip()
if not stripped.startswith("/"):
return stripped
first, sep, rest = stripped.partition(" ")
if "@" not in first:
return stripped
command, suffix = first.rsplit("@", 1)
if command and suffix and _BOT_SUFFIX_RE.fullmatch(suffix):
return f"{command}{sep}{rest}" if sep else command
return stripped
@dataclass
@@ -50,7 +71,7 @@ class CommandRouter:
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
def is_priority(self, text: str) -> bool:
return text.strip().lower() in self._priority
return normalize_command_text(text).lower() in self._priority
def is_dispatchable_command(self, text: str) -> bool:
"""Check whether *text* matches any non-priority command tier (exact or prefix).
@@ -58,7 +79,7 @@ class CommandRouter:
Does NOT check priority tier.
If this returns True, ``dispatch()`` is guaranteed to match a handler.
"""
cmd = text.strip().lower()
cmd = normalize_command_text(text).lower()
if cmd in self._exact:
return True
for pfx, _ in self._prefix:
@@ -68,6 +89,7 @@ class CommandRouter:
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
"""Dispatch a priority command. Called from run() without the lock."""
ctx.raw = normalize_command_text(ctx.raw)
handler = self._priority.get(ctx.raw.lower())
if handler:
return await handler(ctx)
@@ -75,6 +97,7 @@ class CommandRouter:
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
"""Try exact, then prefix handlers. Returns None if unhandled."""
ctx.raw = normalize_command_text(ctx.raw)
cmd = ctx.raw.lower()
if handler := self._exact.get(cmd):
+22
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Any
import pydantic
from loguru import logger
from pydantic import BaseModel
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)
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:
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:
"""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
tools = data.get("tools", {})
exec_cfg = tools.get("exec", {})
+14 -4
View File
@@ -154,10 +154,6 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
serialization_alias="idleCompactAfterMinutes",
) # 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(
default=0.5,
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_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)
proxy: str | None = None # OpenAI-compatible/Codex HTTP proxy URL
thinking_style: str | None = None # Thinking/reasoning style for custom providers
# 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.
port: int = 8900
timeout: float = 120.0 # Per-request timeout in seconds.
api_key: str = Field(default="", repr=False)
@model_validator(mode="after")
def wildcard_host_requires_auth(self) -> "ApiConfig":
if self.host not in ("0.0.0.0", "::"):
return self
if self.api_key.strip():
return self
raise ValueError(
"host is 0.0.0.0 (all interfaces) but api_key is not set "
"- set api.api_key to prevent unauthenticated access"
)
class GatewayConfig(Base):
@@ -317,6 +326,7 @@ class GatewayConfig(Base):
host: str = "127.0.0.1" # Safer default: local-only bind.
port: int = 18790
restart_mode: Literal["auto", "exec", "spawn", "exit"] = "auto"
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
+43 -22
View File
@@ -1,6 +1,7 @@
"""Cron service for scheduling agent tasks."""
import asyncio
import errno
import json
import os
import time
@@ -23,6 +24,12 @@ from nanobot.cron.types import (
CronSchedule,
CronStore,
)
from nanobot.utils.run_records import (
safe_run_record_name,
)
from nanobot.utils.run_records import (
write_run_record as write_automation_run_record,
)
class CronJobSkippedError(Exception):
@@ -357,6 +364,25 @@ class CronService:
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:
"""Save jobs to disk."""
if not self._store:
@@ -437,11 +463,15 @@ class CronService:
os.replace(tmp_path, path)
# fsync the parent directory so the rename itself is durable.
# Skip on Windows where opening a directory raises PermissionError;
# NTFS journals metadata synchronously so this is a no-op there.
# some shared filesystems reject directory fsync with EINVAL.
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(fd)
try:
os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(fd)
except BaseException:
@@ -450,20 +480,11 @@ class CronService:
@staticmethod
def _safe_run_record_name(run_id: str) -> str:
return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id)
return safe_run_record_name(run_id)
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
"""Write an internal audit record for one cron execution."""
name = self._safe_run_record_name(run_id)
if not name:
name = str(uuid.uuid4())
path = self._run_records_dir / f"{name}.json"
payload = {
**record,
"run_id": run_id,
"updated_at_ms": _now_ms(),
}
self._atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False))
write_automation_run_record(self._run_records_dir, run_id, record)
async def start(self) -> None:
"""Start the cron service."""
@@ -622,7 +643,7 @@ class CronService:
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
"""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]
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
@@ -684,7 +705,7 @@ class CronService:
_normalize_agent_turn_job(job)
self._enforce_agent_binding(job)
if self._running:
store = self._load_store()
store = self._require_store()
store.jobs.append(job)
self._save_store()
self._arm_timer()
@@ -696,7 +717,7 @@ class CronService:
def register_system_job(self, job: CronJob) -> CronJob:
"""Register an internal system job (idempotent on restart)."""
store = self._load_store()
store = self._require_store()
now = _now_ms()
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
job.created_at_ms = now
@@ -710,7 +731,7 @@ class CronService:
def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]:
"""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)
if job is None:
return "not_found"
@@ -735,7 +756,7 @@ class CronService:
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
"""Enable or disable a job."""
store = self._load_store()
store = self._require_store()
for job in store.jobs:
if job.id == job_id:
job.enabled = enabled
@@ -770,7 +791,7 @@ class CronService:
For ``channel`` and ``to``, pass an explicit value (including ``None``)
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)
if job is None:
return "not_found"
@@ -815,7 +836,7 @@ class CronService:
was_running = self._running
self._running = True
try:
store = self._load_store()
store = self._require_store()
for job in store.jobs:
if job.id == job_id:
if self._is_unbound_agent_job(job):
@@ -835,12 +856,12 @@ class CronService:
def get_job(self, job_id: str) -> CronJob | None:
"""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)
def status(self) -> dict:
"""Get service status."""
store = self._load_store()
store = self._require_store()
return {
"enabled": self._running,
"jobs": len(store.jobs),
+30 -18
View File
@@ -5,16 +5,43 @@ from __future__ import annotations
from typing import Any, Mapping
from nanobot.cron.types import CronJob
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
CRON_TRIGGER_META = "_cron_trigger"
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
CRON_HISTORY_META = "_cron_turn"
def _cron_history_text(trigger: Mapping[str, Any]) -> str | None:
persist_content = trigger.get("persist_content")
return (
persist_content
if isinstance(persist_content, str) and persist_content.strip()
else None
)
CRON_AUTOMATION_SPEC = AutomationTurnSpec(
kind="cron",
trigger_meta_key=CRON_TRIGGER_META,
legacy_history_meta_key=CRON_HISTORY_META,
history_fields={
"cron_job_id": "job_id",
"cron_job_name": "job_name",
"cron_run_id": "run_id",
"cron_prompt_ref": "prompt_ref",
},
text_builder=_cron_history_text,
)
def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured cron trigger metadata when present."""
raw = (metadata or {}).get(CRON_TRIGGER_META)
return raw if isinstance(raw, dict) else None
return automation_trigger(metadata, CRON_AUTOMATION_SPEC)
def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool:
@@ -38,22 +65,7 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a cron turn."""
trigger = cron_trigger(metadata)
if not trigger:
return None, {}
persist_content = trigger.get("persist_content")
text = (
persist_content
if isinstance(persist_content, str) and persist_content.strip()
else None
)
return text, {
CRON_HISTORY_META: True,
"cron_job_id": trigger.get("job_id"),
"cron_job_name": trigger.get("job_name"),
"cron_run_id": trigger.get("run_id"),
"cron_prompt_ref": trigger.get("prompt_ref"),
}
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
def is_bound_cron_job(job: CronJob) -> bool:
+12
View File
@@ -54,6 +54,18 @@ class ToolCallRequest:
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]:
"""Serialize to an OpenAI-style tool_call payload."""
arguments = (
+12 -1
View File
@@ -58,6 +58,11 @@ def _make_provider_core(
if spec and spec.is_transcription_only:
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
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 not p or not p.api_base:
@@ -79,7 +84,10 @@ def _make_provider_core(
if backend == "openai_codex":
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":
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,
api_type=p.api_type if p and provider_name == "openai" else "auto",
extra_query=p.extra_query if p else None,
proxy=p.proxy if p else None,
)
provider.generation = resolved.to_generation_settings()
@@ -218,6 +227,7 @@ def provider_signature(
fallback.temperature,
fallback.reasoning_effort,
fallback.context_window_tokens,
getattr(fp, "proxy", None) if fp else None,
)
provider_name = config.get_provider_name(resolved.model, preset=resolved)
@@ -237,6 +247,7 @@ def provider_signature(
resolved.temperature,
resolved.reasoning_effort,
resolved.context_window_tokens,
getattr(p, "proxy", None) if p else None,
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
)
+19 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import time
import webbrowser
from collections.abc import Awaitable, Callable
@@ -29,6 +30,12 @@ _EXPIRY_SKEW_SECONDS = 60
_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:
return FileTokenStorage(
token_filename=TOKEN_FILENAME,
@@ -68,11 +75,16 @@ def login_github_copilot(
printer = print_fn or print
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:
response = client.post(
DEFAULT_GITHUB_DEVICE_CODE_URL,
device_code_url,
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()
payload = response.json()
@@ -96,10 +108,10 @@ def login_github_copilot(
token_expires_in = _LONG_LIVED_TOKEN_SECONDS
while time.time() < deadline:
poll = client.post(
DEFAULT_GITHUB_ACCESS_TOKEN_URL,
access_token_url,
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
data={
"client_id": GITHUB_COPILOT_CLIENT_ID,
"client_id": client_id,
"device_code": 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.")
user = client.get(
DEFAULT_GITHUB_USER_URL,
user_url,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
@@ -164,7 +176,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
self._copilot_expires_at: float = 0.0
super().__init__(
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,
extra_headers={
"Editor-Version": EDITOR_VERSION,
@@ -186,7 +198,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
timeout = httpx.Timeout(20.0, connect=20.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
response = await client.get(
DEFAULT_COPILOT_TOKEN_URL,
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers=_copilot_headers(github_token.access),
)
response.raise_for_status()
+21 -82
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import ast
import asyncio
import hashlib
import json
@@ -27,25 +26,6 @@ from nanobot.providers.openai_responses import (
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "nanobot"
_RESPONSE_FAILED_PREFIX = "Response failed:"
_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
"overloaded",
"overloaded_error",
"rate_limit_exceeded",
"request_limit_exceeded",
"requests_limit_exceeded",
"server_error",
"server_is_overloaded",
"service_unavailable",
"temporarily_unavailable",
"too_many_requests",
})
_NON_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
"content_filter",
"content_policy_violation",
"cyber_policy",
"safety_violation",
})
class OpenAICodexProvider(LLMProvider):
@@ -53,9 +33,14 @@ class OpenAICodexProvider(LLMProvider):
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)
self.default_model = default_model
self.proxy = proxy or None
async def _call_codex(
self,
@@ -72,9 +57,6 @@ class OpenAICodexProvider(LLMProvider):
model = model or self.default_model
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] = {
"model": _strip_model_prefix(model),
"store": False,
@@ -94,9 +76,13 @@ class OpenAICodexProvider(LLMProvider):
body["tools"] = convert_tools(tools)
try:
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
headers = _build_headers(token.account_id, token.access)
try:
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True,
proxy=self.proxy,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
@@ -107,6 +93,7 @@ class OpenAICodexProvider(LLMProvider):
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False,
proxy=self.proxy,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
@@ -219,12 +206,17 @@ async def _request_codex(
headers: dict[str, str],
body: dict[str, Any],
verify: bool,
proxy: str | None = None,
on_content_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,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
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:
if response.status_code != 200:
text = await response.aread()
@@ -266,8 +258,6 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
status_code = getattr(exc, "status_code", None)
error_kind: str | None = None
error_type = getattr(exc, "error_type", None)
error_code = getattr(exc, "error_code", None)
default_detail: str | None = None
should_retry: bool | None = getattr(exc, "should_retry", None)
@@ -287,20 +277,12 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
error_kind = "http"
default_detail = "HTTP request failed"
failed_type, failed_code = _extract_response_failed_error(detail)
if failed_type or failed_code:
error_kind = error_kind or "provider"
error_type = failed_type or error_type
error_code = failed_code or error_code
if should_retry is None:
should_retry = _should_retry_response_failed(error_type, error_code, detail)
if status_code is not None and should_retry is None:
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
should_retry = _should_retry_status(
int(status_code),
error_type,
error_code,
getattr(exc, "error_type", None),
getattr(exc, "error_code", None),
retry_content,
)
@@ -313,56 +295,13 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
retry_after=retry_after,
error_status_code=int(status_code) if status_code is not None else None,
error_kind=error_kind,
error_type=error_type,
error_code=error_code,
error_type=getattr(exc, "error_type", None),
error_code=getattr(exc, "error_code", None),
error_retry_after_s=retry_after,
error_should_retry=should_retry,
)
def _extract_response_failed_error(detail: str) -> tuple[str | None, str | None]:
"""Extract provider semantic error fields from Responses SSE failures."""
if _RESPONSE_FAILED_PREFIX not in detail:
return None, None
payload = detail.split(_RESPONSE_FAILED_PREFIX, 1)[1].strip()
if not payload:
return None, None
parsed: Any = None
try:
parsed = json.loads(payload)
except Exception:
try:
parsed = ast.literal_eval(payload)
except Exception:
parsed = None
error_type, error_code = LLMProvider._extract_error_type_code(parsed or payload)
return error_type, error_code
def _should_retry_response_failed(
error_type: str | None,
error_code: str | None,
detail: str,
) -> bool | None:
semantic_tokens = {
token for token in (
LLMProvider._normalize_error_token(error_type),
LLMProvider._normalize_error_token(error_code),
)
if token is not None
}
if any(token in _NON_RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
return False
if any(token in _RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
return True
if LLMProvider._is_transient_error(detail):
return True
return None
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
if response.error_status_code is not None:
+10 -1
View File
@@ -358,6 +358,7 @@ class OpenAICompatProvider(LLMProvider):
extra_body: dict[str, Any] | None = None,
api_type: str = "auto",
extra_query: dict[str, str] | None = None,
proxy: str | None = None,
):
super().__init__(api_key, api_base)
self.default_model = default_model
@@ -366,6 +367,7 @@ class OpenAICompatProvider(LLMProvider):
self._extra_body = extra_body or {}
self._api_type = api_type if spec and spec.name == "openai" else "auto"
self._extra_query = extra_query or {}
self._proxy = proxy or None
if api_key and spec and spec.env_key:
self._setup_env(api_key, api_base)
@@ -396,7 +398,14 @@ class OpenAICompatProvider(LLMProvider):
timeout_s = _openai_compat_timeout_s()
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
# HTTP connections before the client-side keepalive expires. When
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
+2
View File
@@ -32,6 +32,7 @@ class ProviderSpec:
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
display_name: str = "" # shown in `nanobot status`
model_catalog: str = "auto" # WebUI model-list source
# which provider implementation to use
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
@@ -221,6 +222,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("skywork", "skyclaw", "apifree"),
env_key="SKYWORK_API_KEY",
display_name="Skywork",
model_catalog="official",
backend="openai_compat",
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
is_gateway=True,
+92
View File
@@ -0,0 +1,92 @@
"""Shared handling for session-bound automation turns."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any
AUTOMATION_HISTORY_META = "_automation_turn"
@dataclass(frozen=True)
class AutomationTurnSpec:
"""Source-specific wiring for one session-bound automation turn type."""
kind: str
trigger_meta_key: str
legacy_history_meta_key: str | None = None
history_fields: Mapping[str, str] = field(default_factory=dict)
text_builder: Callable[[Mapping[str, Any]], str | None] | None = None
def automation_trigger(
metadata: Mapping[str, Any] | None,
spec: AutomationTurnSpec,
) -> dict[str, Any] | None:
"""Return source trigger metadata for *spec* when present."""
raw = (metadata or {}).get(spec.trigger_meta_key)
return raw if isinstance(raw, dict) else None
def automation_history_overrides_for_spec(
metadata: Mapping[str, Any] | None,
spec: AutomationTurnSpec,
) -> tuple[str | None, dict[str, Any]]:
"""Return hidden session-history text/metadata overrides for *spec*."""
trigger = automation_trigger(metadata, spec)
if not trigger:
return None, {}
details: dict[str, Any] = {"kind": spec.kind}
extra: dict[str, Any] = {AUTOMATION_HISTORY_META: details}
if spec.legacy_history_meta_key:
extra[spec.legacy_history_meta_key] = True
for history_key, trigger_key in spec.history_fields.items():
value = trigger.get(trigger_key)
extra[history_key] = value
details[history_key] = value
text = spec.text_builder(trigger) if spec.text_builder else None
return text, extra
@lru_cache(maxsize=1)
def _automation_specs() -> tuple[AutomationTurnSpec, ...]:
# Source modules import the generic helpers above, so keep spec loading lazy.
from nanobot.cron.session_turns import CRON_AUTOMATION_SPEC
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_AUTOMATION_SPEC
return (CRON_AUTOMATION_SPEC, LOCAL_TRIGGER_AUTOMATION_SPEC)
def automation_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for supported automation turns."""
for spec in _automation_specs():
text, extra = automation_history_overrides_for_spec(metadata, spec)
if extra:
return text, extra
return None, {}
def is_automation_history_message(message: Mapping[str, Any] | None) -> bool:
"""True for hidden automation trigger records in session history."""
if not message:
return False
marker = message.get(AUTOMATION_HISTORY_META)
if marker is True or isinstance(marker, Mapping):
return True
return any(
spec.legacy_history_meta_key
and message.get(spec.legacy_history_meta_key) is True
for spec in _automation_specs()
)
def is_automation_kind(value: Any) -> bool:
return isinstance(value, str) and (
value == "trigger" or any(spec.kind == value for spec in _automation_specs())
)
+22
View File
@@ -0,0 +1,22 @@
"""Visibility helpers for persisted session history messages."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from nanobot.session.automation_turns import is_automation_history_message
HIDDEN_HISTORY_META = "_hidden_history"
def _has_hidden_history_marker(message: Mapping[str, Any] | None) -> bool:
if not message:
return False
marker = message.get(HIDDEN_HISTORY_META)
return marker is True or isinstance(marker, Mapping)
def is_hidden_history_message(message: Mapping[str, Any] | None) -> bool:
"""True for persisted messages that should not be shown as chat turns."""
return _has_hidden_history_marker(message) or is_automation_history_message(message)
+46 -21
View File
@@ -15,6 +15,7 @@ from typing import Any
from loguru import logger
from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.session.turn_history import PENDING_USER_TURN_KEY, RUNTIME_CHECKPOINT_KEY
from nanobot.utils.helpers import (
ensure_dir,
estimate_message_tokens,
@@ -27,6 +28,8 @@ from nanobot.utils.helpers import (
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
FILE_MAX_MESSAGES = 2000
MIN_REPLAY_MAX_MESSAGES = 120
REPLAY_TOKENS_PER_MESSAGE = 100
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
@@ -35,14 +38,23 @@ _SESSION_LIST_PREVIEW_MAX_RECORDS = 200
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
_FORK_VOLATILE_METADATA_KEYS = {
"goal_state",
"pending_user_turn",
"runtime_checkpoint",
PENDING_USER_TURN_KEY,
RUNTIME_CHECKPOINT_KEY,
"thread_goal",
"title",
"title_user_edited",
}
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:
"""Remove internal replay artifacts that the model may have copied before.
@@ -99,6 +111,12 @@ def _metadata_title(metadata: Any) -> str:
return strip_think(title)
@dataclass
class RetentionResult:
dropped: list[dict]
already_consolidated_count: int
@dataclass
class Session:
"""A conversation session."""
@@ -132,7 +150,7 @@ class Session:
def get_history(
self,
max_messages: int = 120,
max_messages: int = FILE_MAX_MESSAGES,
*,
max_tokens: int = 0,
extend_to_user: bool = False,
@@ -143,7 +161,7 @@ class Session:
token budget from the tail (``max_tokens``) when provided.
"""
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(
unconsolidated,
max_messages,
@@ -278,22 +296,26 @@ class Session:
max_messages: int,
*,
extend_to_user: bool = False,
) -> tuple[list[dict], int]:
) -> RetentionResult:
"""Keep a legal recent suffix, optionally extending it back to a user turn.
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
the list of removed messages (in original order) and
*already_consolidated_count* is how many of those were inside the
pre-existing ``last_consolidated`` prefix and therefore do not need
raw archiving.
Returns a RetentionResult with dropped messages and how many of those
were in the already-consolidated prefix. This method mutates
self.messages and self.last_consolidated in place.
"""
if max_messages <= 0:
dropped = list(self.messages)
lc = self.last_consolidated
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:
return [], 0
return RetentionResult(
dropped=[],
already_consolidated_count=0,
)
original = list(self.messages)
before_lc = self.last_consolidated
@@ -359,7 +381,10 @@ class Session:
self.messages = retained
self.last_consolidated = new_lc
self.updated_at = datetime.now()
return dropped, already_consolidated
return RetentionResult(
dropped=dropped,
already_consolidated_count=already_consolidated,
)
def enforce_file_cap(
self,
@@ -370,17 +395,17 @@ class Session:
if limit <= 0 or len(self.messages) <= limit:
return
dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
if not dropped:
result = self.retain_recent_legal_suffix(limit)
if not result.dropped:
return
archive_chunk = dropped[already_consolidated:]
archive_chunk = result.dropped[result.already_consolidated_count:]
if archive_chunk and on_archive:
on_archive(archive_chunk)
logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key,
len(dropped),
len(result.dropped),
len(archive_chunk),
len(self.messages),
)
@@ -539,9 +564,10 @@ class SessionManager:
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
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."""
path = self._get_session_path(key)
if path is None:
path = self._get_session_path(key)
if not path.exists():
return None
@@ -615,7 +641,6 @@ class SessionManager:
the most recent writes.
"""
path = self._get_session_path(session.key)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(".jsonl.tmp")
try:
@@ -894,7 +919,7 @@ class SessionManager:
}
)
except Exception:
repaired = self._repair(fallback_key)
repaired = self._repair(fallback_key, path=path)
if repaired is not None:
sessions.append(
{
-4
View File
@@ -29,10 +29,6 @@ _GOAL_CONTINUATION_SENDER = "system:continuation"
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = {
"_stream_id",
"_stream_delta",
"_stream_end",
"_resuming",
INTERNAL_CONTINUATION_PENDING_META,
}
+266
View File
@@ -0,0 +1,266 @@
"""Turn history persistence and interrupted-turn recovery."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
if TYPE_CHECKING:
from nanobot.session.manager import Session
RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
PENDING_USER_TURN_KEY = "pending_user_turn"
def sanitize_persisted_blocks(
content: list[dict[str, Any]],
*,
max_tool_result_chars: int,
runtime_context_tag: str,
should_truncate_text: bool = False,
drop_runtime: bool = False,
) -> list[dict[str, Any]]:
"""Strip volatile multimodal payloads before writing session history."""
filtered: list[dict[str, Any]] = []
for block in content:
if not isinstance(block, dict):
filtered.append(block)
continue
if (
drop_runtime
and block.get("type") == "text"
and isinstance(block.get("text"), str)
and block["text"].startswith(runtime_context_tag)
):
continue
if block.get("type") == "image_url" and block.get("image_url", {}).get(
"url", ""
).startswith("data:image/"):
path = (block.get("_meta") or {}).get("path", "")
filtered.append({"type": "text", "text": image_placeholder_text(path)})
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"]
if should_truncate_text and len(text) > max_tool_result_chars:
text = truncate_text_fn(text, max_tool_result_chars)
filtered.append({**block, "text": text})
continue
filtered.append(block)
return filtered
def save_turn(
session: Session,
messages: list[dict],
skip: int,
*,
max_tool_result_chars: int,
runtime_context_tag: str,
turn_latency_ms: int | None = None,
) -> None:
"""Save new-turn messages into session, truncating large tool results."""
declared_tool_call_ids = {
str(tc["id"])
for m in session.messages
if m.get("role") == "assistant"
for tc in m.get("tool_calls") or []
if isinstance(tc, dict) and tc.get("id")
}
last_assistant_idx: int | None = None
for m in messages[skip:]:
entry = dict(m)
role, content = entry.get("role"), entry.get("content")
if role == "assistant" and not content and not entry.get("tool_calls"):
continue # skip empty assistant messages - they poison session context
if role == "tool":
tool_call_id = entry.get("tool_call_id")
if not tool_call_id or str(tool_call_id) not in declared_tool_call_ids:
# Undeclared tool results corrupt future provider requests.
logger.warning(
"Dropping orphaned tool result {} from session {} during persistence",
tool_call_id or "(missing id)",
session.key,
)
continue
if isinstance(content, str) and len(content) > max_tool_result_chars:
entry["content"] = truncate_text_fn(content, max_tool_result_chars)
elif isinstance(content, list):
filtered = sanitize_persisted_blocks(
content,
max_tool_result_chars=max_tool_result_chars,
runtime_context_tag=runtime_context_tag,
should_truncate_text=True,
)
if not filtered:
# Preserve the tool_call/result pair after block filtering.
filtered = [
{"type": "text", "text": "[tool result omitted during persistence]"}
]
entry["content"] = filtered
elif role == "user":
if isinstance(content, str) and runtime_context_tag in content:
# Strip the runtime-context block appended at the end.
tag_pos = content.find(runtime_context_tag)
before = content[:tag_pos].rstrip("\n ")
if before:
entry["content"] = before
else:
continue
if isinstance(content, list):
filtered = sanitize_persisted_blocks(
content,
max_tool_result_chars=max_tool_result_chars,
runtime_context_tag=runtime_context_tag,
drop_runtime=True,
)
if not filtered:
continue
entry["content"] = filtered
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
if role == "assistant":
last_assistant_idx = len(session.messages) - 1
declared_tool_call_ids.update(
str(tc["id"])
for tc in entry.get("tool_calls") or []
if isinstance(tc, dict) and tc.get("id")
)
if turn_latency_ms is not None and last_assistant_idx is not None:
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
session.updated_at = datetime.now()
def persist_subagent_followup(session: Session, msg: Any) -> bool:
"""Persist subagent follow-ups before prompt assembly so history stays durable.
Returns True if a new entry was appended; False if the follow-up was
deduped (same ``subagent_task_id`` already in session) or carries no
content worth persisting.
"""
if not msg.content:
return False
task_id = msg.metadata.get("subagent_task_id") if isinstance(msg.metadata, dict) else None
if task_id and any(
m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id
for m in session.messages
):
return False
session.add_message(
"assistant",
msg.content,
sender_id=msg.sender_id,
injected_event="subagent_result",
subagent_task_id=task_id,
)
return True
def set_runtime_checkpoint(session: Session, payload: dict[str, Any]) -> None:
"""Persist the latest in-flight turn state into session metadata."""
session.metadata[RUNTIME_CHECKPOINT_KEY] = payload
def mark_pending_user_turn(session: Session) -> None:
session.metadata[PENDING_USER_TURN_KEY] = True
def clear_pending_user_turn(session: Session) -> None:
session.metadata.pop(PENDING_USER_TURN_KEY, None)
def clear_runtime_checkpoint(session: Session) -> None:
session.metadata.pop(RUNTIME_CHECKPOINT_KEY, None)
def checkpoint_message_key(message: dict[str, Any]) -> tuple[Any, ...]:
return (
message.get("role"),
message.get("content"),
message.get("tool_call_id"),
message.get("name"),
message.get("tool_calls"),
message.get("reasoning_content"),
message.get("thinking_blocks"),
)
def restore_runtime_checkpoint(session: Session) -> bool:
"""Materialize an unfinished turn into session history before a new request."""
checkpoint = session.metadata.get(RUNTIME_CHECKPOINT_KEY)
if not isinstance(checkpoint, dict):
return False
assistant_message = checkpoint.get("assistant_message")
completed_tool_results = checkpoint.get("completed_tool_results") or []
pending_tool_calls = checkpoint.get("pending_tool_calls") or []
restored_messages: list[dict[str, Any]] = []
if isinstance(assistant_message, dict):
restored = dict(assistant_message)
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for message in completed_tool_results:
if isinstance(message, dict):
restored = dict(message)
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for tool_call in pending_tool_calls:
if not isinstance(tool_call, dict):
continue
tool_id = tool_call.get("id")
name = ((tool_call.get("function") or {}).get("name")) or "tool"
restored_messages.append(
{
"role": "tool",
"tool_call_id": tool_id,
"name": name,
"content": "Error: Task interrupted before this tool finished.",
"timestamp": datetime.now().isoformat(),
}
)
overlap = 0
max_overlap = min(len(session.messages), len(restored_messages))
for size in range(max_overlap, 0, -1):
existing = session.messages[-size:]
restored = restored_messages[:size]
if all(
checkpoint_message_key(left) == checkpoint_message_key(right)
for left, right in zip(existing, restored)
):
overlap = size
break
session.messages.extend(restored_messages[overlap:])
clear_pending_user_turn(session)
clear_runtime_checkpoint(session)
return True
def restore_pending_user_turn(session: Session) -> bool:
"""Close a turn that only persisted the user message before crashing."""
if not session.metadata.get(PENDING_USER_TURN_KEY):
return False
if session.messages and session.messages[-1].get("role") == "user":
session.messages.append(
{
"role": "assistant",
"content": "Error: Task interrupted before a response was generated.",
"timestamp": datetime.now().isoformat(),
}
)
session.updated_at = datetime.now()
clear_pending_user_turn(session)
return True
+59 -51
View File
@@ -11,7 +11,15 @@ from typing import Any
from loguru import logger
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.runtime_events import (
GoalStateChanged,
@@ -22,9 +30,9 @@ from nanobot.bus.runtime_events import (
TurnCompleted,
TurnRunStatusChanged,
)
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.providers.base import LLMProvider
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import strip_think, truncate_text
from nanobot.utils.llm_runtime import LLMRuntime
@@ -69,7 +77,7 @@ def _title_inputs(session: Session) -> tuple[str, str]:
for message in session.messages:
if message.get("_command") is True:
continue
if message.get(CRON_HISTORY_META) is True:
if is_hidden_history_message(message):
continue
role = message.get("role")
content = message.get("content")
@@ -206,26 +214,22 @@ async def publish_turn_run_status(
if msg.channel != "websocket":
return
cid = str(msg.chat_id)
meta: dict[str, Any] = {
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
started_at_event: float | None = None
if status == "running":
if isinstance(started_at, int | float) and started_at > 0:
t0 = float(started_at)
else:
t0 = time.time()
meta["started_at"] = t0
started_at_event = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=msg.channel,
chat_id=cid,
content="",
metadata=meta,
event=GoalStatusEvent(status=status, started_at=started_at_event),
metadata=msg.metadata,
),
)
@@ -318,28 +322,25 @@ class WebuiTurnCoordinator:
if not cid:
return
await self.bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=event.context.channel,
chat_id=cid,
content="",
metadata={
"_goal_state_sync": True,
"goal_state": goal_state_ws_blob(event.session_metadata),
},
event=GoalStateSyncEvent(
goal_state=goal_state_ws_blob(event.session_metadata),
),
metadata=event.context.metadata,
),
)
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
await self.bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel="websocket",
chat_id="*",
content="",
metadata={
"_runtime_model_updated": True,
"model": event.model,
"model_preset": event.model_preset,
},
event=RuntimeModelUpdatedEvent(
model=event.model,
model_preset=event.model_preset,
),
)
)
@@ -374,17 +375,18 @@ class WebuiTurnCoordinator:
if msg.channel != "websocket":
return
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if latency_ms is not None:
turn_metadata["latency_ms"] = int(latency_ms)
session = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata=turn_metadata,
))
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=TurnEndEvent(
latency_ms=latency_ms,
goal_state=goal_state_ws_blob(session.metadata),
),
metadata=msg.metadata,
)
)
self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
@@ -404,16 +406,11 @@ class WebuiTurnCoordinator:
model=title_llm.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
await self._publish_session_metadata_updated(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
metadata=msg.metadata,
)
self.schedule_background(_generate_title_and_notify())
@@ -438,15 +435,26 @@ class WebuiTurnCoordinator:
model=title_llm.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
await self._publish_session_metadata_updated(
channel=event.context.channel,
chat_id=event.context.chat_id,
content="",
metadata={
**event.context.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
metadata=event.context.metadata,
)
self.schedule_background(_generate_title_and_notify())
async def _publish_session_metadata_updated(
self,
*,
channel: str,
chat_id: str,
metadata: dict[str, Any],
) -> None:
await self.bus.publish_outbound(
outbound_message_for_event(
channel=channel,
chat_id=chat_id,
event=SessionUpdatedEvent(scope="metadata"),
metadata=metadata,
)
)
+2 -2
View File
@@ -26,7 +26,7 @@ Those belong to the execution phase after the marker is set.
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). For coding or file-producing tasks, include **`verification_summary`**, **`commands_run`**, and **`artifacts_created`** when possible; if stopping with known unresolved issues, fill **`remaining_failures`** honestly. Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
@@ -68,7 +68,7 @@ Use this when the goal is to **build or reshape a codebase** (app, service, tool
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter. Before `complete_goal`, run the smallest reliable verification you can and summarize it in `verification_summary`.
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter.
## Look things up instead of guessing
+19
View File
@@ -0,0 +1,19 @@
"""Local trigger support."""
from nanobot.triggers.local_store import (
LocalTriggerStore,
TriggerDisabledError,
TriggerNotFoundError,
TriggerStoreError,
)
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord
__all__ = [
"LocalTrigger",
"LocalTriggerStore",
"TriggerDelivery",
"TriggerDisabledError",
"TriggerNotFoundError",
"TriggerRunRecord",
"TriggerStoreError",
]
+209
View File
@@ -0,0 +1,209 @@
"""Gateway delivery loop for local triggers."""
from __future__ import annotations
import asyncio
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from loguru import logger
from nanobot.agent.automation_turns import AutomationTurnError
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
async def run_local_trigger_queue(
*,
store: LocalTriggerStore,
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None = None,
poll_interval_s: float = 0.5,
batch_size: int = 20,
) -> None:
"""Poll local trigger deliveries and submit them as session turns."""
if submit_turn is None:
raise ValueError("run_local_trigger_queue requires submit_turn")
logger.info("Local trigger queue started")
recovered = store.recover_processing_deliveries()
if recovered:
logger.warning(
"Trigger: recovered {} interrupted delivery file(s) from processing",
recovered,
)
while True:
deliveries = store.claim_deliveries(limit=batch_size)
if not deliveries:
await asyncio.sleep(poll_interval_s)
continue
for delivery in deliveries:
try:
await _deliver_delivery(
store,
delivery,
submit_turn=submit_turn,
)
store.complete_delivery(delivery)
except asyncio.CancelledError as exc:
store.retry_delivery(delivery, str(exc) or exc.__class__.__name__)
_write_delivery_run_record(
store,
delivery,
status="interrupted",
error=str(exc) or exc.__class__.__name__,
)
raise
except _TerminalDeliveryError as exc:
store.record_delivery(
delivery.trigger_id,
status="error",
error=str(exc),
run_at_ms=delivery.created_at_ms,
)
_write_delivery_run_record(
store,
delivery,
status="error",
error=str(exc),
)
store.complete_delivery(delivery)
logger.warning(
"Trigger: dropped delivery {} for {}: {}",
delivery.id,
delivery.trigger_id,
exc,
)
except AutomationTurnError as exc:
error = str(exc) or exc.__class__.__name__
store.record_delivery(
delivery.trigger_id,
status="error",
error=error,
run_at_ms=delivery.created_at_ms,
)
_write_delivery_run_record(
store,
delivery,
status="error",
error=error,
)
store.complete_delivery(delivery)
logger.warning(
"Trigger: delivery {} for {} reached the agent but failed: {}",
delivery.id,
delivery.trigger_id,
error,
)
except Exception as exc:
error = str(exc) or exc.__class__.__name__
retried = store.retry_delivery(delivery, error)
_write_delivery_run_record(
store,
delivery,
status="retrying" if retried else "error",
error=error,
)
store.record_delivery(
delivery.trigger_id,
status="error",
error=error,
run_at_ms=delivery.created_at_ms,
)
logger.exception(
"Trigger: failed delivery {} for {}{}",
delivery.id,
delivery.trigger_id,
"; queued retry" if retried else "; moved to failed queue",
)
class _TerminalDeliveryError(RuntimeError):
pass
async def _deliver_delivery(
store: LocalTriggerStore,
delivery: TriggerDelivery,
*,
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]],
) -> None:
trigger = store.get(delivery.trigger_id)
if trigger is None:
raise _TerminalDeliveryError("trigger not found")
if not trigger.enabled:
raise _TerminalDeliveryError("trigger is disabled")
store.write_delivery_run_record(delivery, trigger=trigger, status="processing")
msg = InboundMessage(
channel=trigger.channel,
sender_id=trigger.sender_id,
chat_id=trigger.chat_id,
content=delivery.content,
metadata=_delivery_metadata(trigger, delivery),
session_key_override=trigger.session_key,
)
response = await submit_turn(msg)
store.record_delivery(
trigger.id,
status="ok",
run_at_ms=delivery.created_at_ms,
)
_write_delivery_run_record(
store,
delivery,
trigger=trigger,
status="ok",
response=response.content if response else "",
)
def _write_delivery_run_record(
store: LocalTriggerStore,
delivery: TriggerDelivery,
*,
status: str,
trigger: LocalTrigger | None = None,
error: str | None = None,
response: str | None = None,
) -> None:
try:
store.write_delivery_run_record(
delivery,
trigger=trigger,
status=status,
error=error,
response=response,
)
except Exception:
logger.exception(
"Trigger: failed to write run record for delivery {}",
delivery.id,
)
def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict[str, Any]:
metadata = dict(trigger.origin_metadata or {})
metadata[LOCAL_TRIGGER_META] = {
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"delivery_id": delivery.id,
"created_at_ms": delivery.created_at_ms,
"persist_content": _history_content(trigger, delivery),
}
if trigger.channel == "websocket":
metadata.pop(WEBUI_TURN_METADATA_KEY, None)
metadata[WEBUI_TURN_METADATA_KEY] = f"trigger:{trigger.id}:{uuid.uuid4().hex}"
source: dict[str, str] = {"kind": "local_trigger"}
if trigger.name:
source["label"] = trigger.name
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
return metadata
def _history_content(trigger: LocalTrigger, delivery: TriggerDelivery) -> str:
label = trigger.name.strip() if trigger.name else trigger.id
return f"Local trigger received: {label}\n\n{delivery.content}"
+62
View File
@@ -0,0 +1,62 @@
"""Shared metadata helpers for local trigger session turns."""
from __future__ import annotations
from typing import Any, Mapping
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
LOCAL_TRIGGER_META = "_local_trigger"
def _local_trigger_history_text(trigger: Mapping[str, Any]) -> str:
persist_content = trigger.get("persist_content")
if isinstance(persist_content, str) and persist_content.strip():
return persist_content
name = trigger.get("trigger_name")
trigger_id = trigger.get("trigger_id")
label = name if isinstance(name, str) and name.strip() else trigger_id
return (
f"Local trigger received: {label}"
if isinstance(label, str) and label.strip()
else "Local trigger received"
)
LOCAL_TRIGGER_AUTOMATION_SPEC = AutomationTurnSpec(
kind="local_trigger",
trigger_meta_key=LOCAL_TRIGGER_META,
history_fields={
"trigger_id": "trigger_id",
"trigger_name": "trigger_name",
"trigger_delivery_id": "delivery_id",
},
text_builder=_local_trigger_history_text,
)
def local_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured local trigger metadata when present."""
return automation_trigger(metadata, LOCAL_TRIGGER_AUTOMATION_SPEC)
def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None:
trigger = local_trigger(metadata)
if not trigger:
return None
value = trigger.get("delivery_id")
return value if isinstance(value, str) and value else None
def local_trigger_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a local trigger turn."""
return automation_history_overrides_for_spec(
metadata,
LOCAL_TRIGGER_AUTOMATION_SPEC,
)
+474
View File
@@ -0,0 +1,474 @@
"""Workspace-scoped local trigger store and delivery queue."""
from __future__ import annotations
import errno
import json
import os
import secrets
import time
import uuid
from contextlib import suppress
from pathlib import Path
from typing import Any
from filelock import FileLock
from loguru import logger
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord
from nanobot.utils.helpers import truncate_text
from nanobot.utils.run_records import write_run_record as write_automation_run_record
_TRIGGER_ID_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_MAX_RUN_HISTORY = 20
_MAX_DELIVERY_ATTEMPTS = 10
_RUN_RECORD_TEXT_MAX_CHARS = 4000
_PROCESSING_RECOVERY_ERROR = "delivery was recovered from interrupted processing"
class TriggerStoreError(RuntimeError):
"""Base class for trigger store errors."""
class TriggerNotFoundError(TriggerStoreError):
"""Raised when a trigger ID does not exist."""
class TriggerDisabledError(TriggerStoreError):
"""Raised when a trigger is disabled."""
class LocalTriggerStore:
"""Persistent local triggers for one workspace."""
def __init__(self, workspace_path: Path):
self.workspace_path = Path(workspace_path)
self.root = self.workspace_path / "triggers"
self.store_path = self.root / "triggers.json"
self.inbox_dir = self.root / "inbox"
self.processing_dir = self.root / "processing"
self.failed_dir = self.root / "failed"
self.runs_dir = self.root / "runs"
self._lock = FileLock(str(self.root / ".lock"))
def create(
self,
*,
name: str,
channel: str,
chat_id: str,
session_key: str,
sender_id: str = "trigger",
origin_metadata: dict[str, Any] | None = None,
) -> LocalTrigger:
"""Create a new session-bound local trigger."""
clean_name = _clean_name(name)
channel = channel.strip()
chat_id = chat_id.strip()
session_key = session_key.strip()
if not channel or not chat_id or not session_key:
raise ValueError("channel, chat_id, and session_key are required")
now = _now_ms()
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
existing_ids = {trigger.id for trigger in triggers}
trigger_id = _new_trigger_id(existing_ids)
trigger = LocalTrigger(
id=trigger_id,
name=clean_name,
enabled=True,
channel=channel,
chat_id=chat_id,
session_key=session_key,
sender_id=sender_id.strip() or "trigger",
origin_metadata=dict(origin_metadata or {}),
created_at_ms=now,
updated_at_ms=now,
)
triggers.append(trigger)
self._save_triggers_unlocked(triggers)
return trigger
def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]:
"""List triggers in this workspace."""
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
if not include_disabled:
triggers = [trigger for trigger in triggers if trigger.enabled]
return sorted(triggers, key=lambda trigger: (trigger.updated_at_ms, trigger.id), reverse=True)
def list_for_session(
self,
session_key: str,
*,
include_disabled: bool = True,
) -> list[LocalTrigger]:
"""List triggers bound to one session key."""
return [
trigger
for trigger in self.list_triggers(include_disabled=include_disabled)
if trigger.session_key == session_key
]
def get(self, trigger_id: str) -> LocalTrigger | None:
"""Return one trigger by ID."""
self._ensure_dirs()
with self._lock:
return self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
def enable(self, trigger_id: str, *, enabled: bool) -> LocalTrigger | None:
"""Enable or disable a trigger."""
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
trigger = self._find_unlocked(triggers, trigger_id)
if trigger is None:
return None
trigger.enabled = enabled
trigger.updated_at_ms = _now_ms()
self._save_triggers_unlocked(triggers)
return trigger
def update(self, trigger_id: str, *, name: str | None = None) -> LocalTrigger | None:
"""Update mutable trigger fields."""
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
trigger = self._find_unlocked(triggers, trigger_id)
if trigger is None:
return None
if name is not None:
trigger.name = _clean_name(name)
trigger.updated_at_ms = _now_ms()
self._save_triggers_unlocked(triggers)
return trigger
def delete(self, trigger_id: str) -> bool:
"""Delete a trigger by ID."""
trigger_id = trigger_id.strip()
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
remaining = [trigger for trigger in triggers if trigger.id != trigger_id]
if len(remaining) == len(triggers):
return False
self._save_triggers_unlocked(remaining)
self._delete_delivery_files_for_trigger_unlocked(trigger_id)
return True
def enqueue(self, trigger_id: str, content: str) -> TriggerDelivery:
"""Queue a delivery for the gateway process to consume."""
trigger_id = trigger_id.strip()
if not content.strip():
raise ValueError("trigger message is required")
self._ensure_dirs()
with self._lock:
trigger = self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
if trigger is None:
raise TriggerNotFoundError(f"trigger not found: {trigger_id}")
if not trigger.enabled:
raise TriggerDisabledError(f"trigger is disabled: {trigger_id}")
delivery = TriggerDelivery(
id=f"tdl_{uuid.uuid4().hex[:12]}",
trigger_id=trigger_id,
content=content,
created_at_ms=_now_ms(),
)
path = self.inbox_dir / f"{delivery.created_at_ms}-{delivery.id}.json"
self._atomic_write(path, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path = path
try:
self.write_delivery_run_record(delivery, trigger=trigger, status="queued")
except BaseException:
path.unlink(missing_ok=True)
delivery.path = None
raise
return delivery
def claim_deliveries(self, *, limit: int = 20) -> list[TriggerDelivery]:
"""Move pending deliveries into processing and return them."""
self._ensure_dirs()
claimed: list[TriggerDelivery] = []
with self._lock:
for path in sorted(self.inbox_dir.glob("*.json"))[: max(0, limit)]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
delivery = TriggerDelivery.from_dict(
data.get("delivery", data),
path=self.processing_dir / path.name,
)
except Exception:
logger.exception("Trigger: failed to parse delivery {}", path)
self._move_bad_delivery_unlocked(path)
continue
os.replace(path, delivery.path)
claimed.append(delivery)
return claimed
def recover_processing_deliveries(self) -> int:
"""Requeue deliveries left in processing by an interrupted gateway."""
self._ensure_dirs()
recovered = 0
with self._lock:
for path in sorted(self.processing_dir.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
delivery = TriggerDelivery.from_dict(
data.get("delivery", data),
path=path,
)
except Exception:
logger.exception("Trigger: failed to parse processing delivery {}", path)
self._move_bad_delivery_unlocked(path)
continue
if self._retry_delivery_unlocked(delivery, _PROCESSING_RECOVERY_ERROR):
recovered += 1
return recovered
def complete_delivery(self, delivery: TriggerDelivery) -> None:
"""Delete a claimed delivery after it is handled."""
if delivery.path is None:
return
self._ensure_dirs()
with self._lock:
delivery.path.unlink(missing_ok=True)
def retry_delivery(self, delivery: TriggerDelivery, error: str) -> bool:
"""Retry a claimed delivery unless it exceeded the attempt limit."""
if delivery.path is None:
return False
self._ensure_dirs()
with self._lock:
return self._retry_delivery_unlocked(delivery, error)
def record_delivery(
self,
trigger_id: str,
*,
status: str,
error: str | None = None,
run_at_ms: int | None = None,
) -> None:
"""Record the latest delivery status on a trigger."""
self._ensure_dirs()
run_at_ms = run_at_ms or _now_ms()
with self._lock:
triggers = self._load_triggers_unlocked()
trigger = self._find_unlocked(triggers, trigger_id)
if trigger is None:
return
trigger.last_run_at_ms = run_at_ms
trigger.last_status = "ok" if status == "ok" else "error"
trigger.last_error = None if status == "ok" else (error or "delivery failed")
trigger.updated_at_ms = _now_ms()
trigger.run_history.append(
TriggerRunRecord(
run_at_ms=run_at_ms,
status=trigger.last_status,
error=trigger.last_error,
)
)
trigger.run_history = trigger.run_history[-_MAX_RUN_HISTORY:]
self._save_triggers_unlocked(triggers)
def write_run_record(self, run_id: str, record: dict[str, Any]) -> Path:
"""Write an internal audit record for one local trigger delivery."""
self._ensure_dirs()
return write_automation_run_record(self.runs_dir, run_id, record)
def write_delivery_run_record(
self,
delivery: TriggerDelivery,
*,
status: str,
trigger: LocalTrigger | None = None,
error: str | None = None,
response: str | None = None,
) -> Path:
"""Write the durable audit record for one local trigger delivery."""
if trigger is None:
trigger = self.get(delivery.trigger_id)
record = _delivery_run_record(delivery, trigger)
record["status"] = status
if error:
record["error"] = _run_record_text(error)
if response is not None:
record["response"] = _run_record_text(response)
return self.write_run_record(delivery.id, record)
def _ensure_dirs(self) -> None:
self.root.mkdir(parents=True, exist_ok=True)
self.inbox_dir.mkdir(parents=True, exist_ok=True)
self.processing_dir.mkdir(parents=True, exist_ok=True)
self.failed_dir.mkdir(parents=True, exist_ok=True)
self.runs_dir.mkdir(parents=True, exist_ok=True)
def _load_triggers_unlocked(self) -> list[LocalTrigger]:
if not self.store_path.exists():
return []
try:
data = json.loads(self.store_path.read_text(encoding="utf-8"))
return [
LocalTrigger.from_dict(raw)
for raw in data.get("triggers", [])
if isinstance(raw, dict)
]
except Exception as exc:
backup = self.store_path.with_suffix(
self.store_path.suffix + f".corrupt-{int(time.time())}"
)
with suppress(OSError):
os.replace(self.store_path, backup)
raise TriggerStoreError(
f"trigger store at {self.store_path} could not be loaded and was preserved "
"as a .corrupt-<ts> backup"
) from exc
def _save_triggers_unlocked(self, triggers: list[LocalTrigger]) -> None:
payload = {
"version": 1,
"triggers": [trigger.to_dict() for trigger in triggers],
}
self._atomic_write(self.store_path, json.dumps(payload, indent=2, ensure_ascii=False))
@staticmethod
def _find_unlocked(
triggers: list[LocalTrigger],
trigger_id: str,
) -> LocalTrigger | None:
return next((trigger for trigger in triggers if trigger.id == trigger_id), None)
def _move_bad_delivery_unlocked(self, path: Path) -> None:
target = self.failed_dir / f"{path.name}.bad"
with suppress(OSError):
os.replace(path, target)
def _retry_delivery_unlocked(self, delivery: TriggerDelivery, error: str) -> bool:
if delivery.path is None:
return False
if delivery.attempts + 1 >= _MAX_DELIVERY_ATTEMPTS:
delivery.attempts += 1
delivery.last_error = error
failed = self.failed_dir / delivery.path.name
self._atomic_write(failed, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return False
delivery.attempts += 1
delivery.last_error = error
target = self.inbox_dir / delivery.path.name
self._atomic_write(target, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return True
def _delete_delivery_files_for_trigger_unlocked(self, trigger_id: str) -> None:
for directory in (self.inbox_dir, self.processing_dir, self.failed_dir):
for path in directory.iterdir():
if not path.is_file():
continue
if self._delivery_file_trigger_id(path) != trigger_id:
continue
try:
path.unlink(missing_ok=True)
except OSError as exc:
logger.warning(
"Trigger: failed to delete delivery file {} for deleted trigger {}: {}",
path,
trigger_id,
exc,
)
@staticmethod
def _delivery_file_trigger_id(path: Path) -> str | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
raw = data.get("delivery", data) if isinstance(data, dict) else None
if not isinstance(raw, dict):
return None
trigger_id = raw.get("triggerId", raw.get("trigger_id", ""))
return str(trigger_id) if trigger_id else None
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
try:
os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(fd)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
def _new_trigger_id(existing_ids: set[str]) -> str:
for _ in range(100):
suffix = "".join(secrets.choice(_TRIGGER_ID_ALPHABET) for _ in range(8))
candidate = f"trg_{suffix}"
if candidate not in existing_ids:
return candidate
raise TriggerStoreError("could not allocate a unique trigger id")
def _clean_name(name: str) -> str:
stripped = " ".join(name.strip().split())
return (stripped or "Local trigger")[:120]
def _now_ms() -> int:
return int(time.time() * 1000)
def _delivery_payload(delivery: TriggerDelivery) -> dict[str, Any]:
return {
"version": 1,
"delivery": delivery.to_dict(),
}
def _delivery_run_record(
delivery: TriggerDelivery,
trigger: LocalTrigger | None,
) -> dict[str, Any]:
record: dict[str, Any] = {
"kind": "local_trigger",
"trigger_id": delivery.trigger_id,
"delivery_id": delivery.id,
"content": _run_record_text(delivery.content),
"created_at_ms": delivery.created_at_ms,
"attempts": delivery.attempts,
}
if delivery.last_error:
record["last_error"] = _run_record_text(delivery.last_error)
if trigger is not None:
record.update(
{
"trigger_name": trigger.name,
"session_key": trigger.session_key,
"channel": trigger.channel,
"chat_id": trigger.chat_id,
"sender_id": trigger.sender_id,
"origin_metadata": trigger.origin_metadata,
}
)
return record
def _run_record_text(value: str) -> str:
return truncate_text(value, _RUN_RECORD_TEXT_MAX_CHARS)
+55
View File
@@ -0,0 +1,55 @@
"""Coordination for local trigger turns."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable
from nanobot.agent.automation_turns import AutomationTurnCoordinator
from nanobot.bus.events import InboundMessage
from nanobot.triggers.local_session_turns import local_trigger, local_trigger_delivery_id
class LocalTriggerTurnCoordinator(AutomationTurnCoordinator):
"""Manage local trigger turns without mixing them into live injections."""
def __init__(
self,
*,
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None:
super().__init__(
publish_inbound=publish_inbound,
dispatch=dispatch,
is_running=is_running,
turn_id=lambda msg: local_trigger_delivery_id(msg.metadata),
pending_id=_local_trigger_id,
should_defer_turn=_should_defer_local_trigger_turn,
missing_id_error="local trigger turn metadata must include a delivery_id",
duplicate_id_error=lambda delivery_id: (
f"local trigger delivery {delivery_id!r} is already pending"
),
deferred_queues=deferred_queues,
)
def pending_trigger_ids_for_session(self, session_key: str) -> set[str]:
"""Return local triggers waiting for or running in *session_key*."""
return self.pending_ids_for_session(session_key)
def _should_defer_local_trigger_turn(
msg: InboundMessage,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
return local_trigger(msg.metadata) is not None and session_key in active_session_keys
def _local_trigger_id(msg: InboundMessage) -> str | None:
trigger = local_trigger(msg.metadata)
if not trigger:
return None
value = trigger.get("trigger_id")
return value if isinstance(value, str) and value else None
+141
View File
@@ -0,0 +1,141 @@
"""Persistent types for local triggers."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
TriggerStatus = Literal["ok", "error"]
def _get(data: dict[str, Any], camel: str, snake: str, default: Any = None) -> Any:
if camel in data:
return data[camel]
return data.get(snake, default)
@dataclass
class TriggerRunRecord:
"""A single local trigger delivery record."""
run_at_ms: int
status: TriggerStatus
error: str | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TriggerRunRecord":
return cls(
run_at_ms=int(_get(data, "runAtMs", "run_at_ms", 0)),
status=str(data.get("status") or "error"), # type: ignore[arg-type]
error=data.get("error"),
)
def to_dict(self) -> dict[str, Any]:
return {
"runAtMs": self.run_at_ms,
"status": self.status,
"error": self.error,
}
@dataclass
class LocalTrigger:
"""A session-bound local trigger."""
id: str
name: str
enabled: bool
channel: str
chat_id: str
session_key: str
sender_id: str = "trigger"
origin_metadata: dict[str, Any] = field(default_factory=dict)
created_at_ms: int = 0
updated_at_ms: int = 0
last_run_at_ms: int | None = None
last_status: TriggerStatus | None = None
last_error: str | None = None
run_history: list[TriggerRunRecord] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "LocalTrigger":
history = [
record if isinstance(record, TriggerRunRecord) else TriggerRunRecord.from_dict(record)
for record in data.get("runHistory", data.get("run_history", []))
if isinstance(record, (dict, TriggerRunRecord))
]
return cls(
id=str(data["id"]),
name=str(data.get("name") or data["id"]),
enabled=bool(data.get("enabled", True)),
channel=str(data.get("channel") or ""),
chat_id=str(_get(data, "chatId", "chat_id", "")),
session_key=str(_get(data, "sessionKey", "session_key", "")),
sender_id=str(_get(data, "senderId", "sender_id", "trigger") or "trigger"),
origin_metadata=dict(_get(data, "originMetadata", "origin_metadata", {}) or {}),
created_at_ms=int(_get(data, "createdAtMs", "created_at_ms", 0)),
updated_at_ms=int(_get(data, "updatedAtMs", "updated_at_ms", 0)),
last_run_at_ms=_get(data, "lastRunAtMs", "last_run_at_ms"),
last_status=_get(data, "lastStatus", "last_status"), # type: ignore[arg-type]
last_error=_get(data, "lastError", "last_error"),
run_history=history,
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"channel": self.channel,
"chatId": self.chat_id,
"sessionKey": self.session_key,
"senderId": self.sender_id,
"originMetadata": self.origin_metadata,
"createdAtMs": self.created_at_ms,
"updatedAtMs": self.updated_at_ms,
"lastRunAtMs": self.last_run_at_ms,
"lastStatus": self.last_status,
"lastError": self.last_error,
"runHistory": [record.to_dict() for record in self.run_history],
}
@dataclass
class TriggerDelivery:
"""One pending local trigger delivery written by the CLI."""
id: str
trigger_id: str
content: str
created_at_ms: int
attempts: int = 0
last_error: str | None = None
path: Path | None = field(default=None, compare=False, repr=False)
@classmethod
def from_dict(
cls,
data: dict[str, Any],
*,
path: Path | None = None,
) -> "TriggerDelivery":
return cls(
id=str(data["id"]),
trigger_id=str(_get(data, "triggerId", "trigger_id", "")),
content=str(data.get("content") or ""),
created_at_ms=int(_get(data, "createdAtMs", "created_at_ms", 0)),
attempts=int(data.get("attempts", 0)),
last_error=data.get("lastError") or data.get("last_error"),
path=path,
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"triggerId": self.trigger_id,
"content": self.content,
"createdAtMs": self.created_at_ms,
"attempts": self.attempts,
"lastError": self.last_error,
}
+3
View File
@@ -529,6 +529,9 @@ class StreamingFileEditTracker:
"""Keep final start/end events keyed to any earlier streamed placeholder."""
used_canonicals: set[str] = set()
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)
if canonical and canonical not in used_canonicals:
try:
+21 -100
View File
@@ -290,8 +290,7 @@ def current_time_str(timezone: str | None = None) -> str:
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS = 800
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS = 80
_TOOL_RESULT_PREVIEW_CHARS = 1200
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
_TOOL_RESULT_MAX_BUCKETS = 32
@@ -405,106 +404,22 @@ def stringify_text_blocks(content: list[dict[str, Any]]) -> str | None:
return "\n".join(parts)
def build_structured_output_summary(
title: str,
text: str,
def _render_tool_result_reference(
filepath: Path,
*,
max_chars: int,
metadata: list[tuple[str, Any]] | None = None,
analysis: Any | None = None,
guidance: str | None = None,
original_size: int,
preview: str,
truncated_preview: bool,
) -> str:
"""Return a compact, structured head/tail summary for oversized tool output."""
if max_chars <= 0:
return text
edge_chars = min(
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS,
max(_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS, max_chars // 3),
)
while True:
head = text[:edge_chars]
if len(text) > edge_chars * 2:
tail: str | None = text[-edge_chars:]
omitted_middle_chars = len(text) - len(head) - len(tail)
else:
tail = None
omitted_middle_chars = 0
result = _render_structured_output_summary(
title,
metadata=metadata or [],
guidance=guidance,
analysis=analysis,
head=head,
tail=tail,
omitted_middle_chars=omitted_middle_chars,
)
if len(result) <= max_chars or edge_chars <= _TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS:
return truncate_text(result, max_chars)
overflow = len(result) - max_chars
edge_chars = max(
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS,
edge_chars - max(overflow // 2 + 1, 16),
)
def _render_structured_output_summary(
title: str,
*,
metadata: list[tuple[str, Any]],
guidance: str | None,
analysis: Any | None,
head: str,
tail: str | None,
omitted_middle_chars: int,
) -> str:
lines = [title]
lines.extend(f"{key}: {value}" for key, value in metadata)
if omitted_middle_chars:
lines.append(f"truncation: {omitted_middle_chars:,} chars truncated from the middle")
if guidance:
lines.append(f"guidance: {guidance}")
lines.extend(_verification_summary_lines(analysis))
lines.extend(["head:", head])
if tail is not None:
lines.extend(["tail:", tail])
return "\n".join(lines)
def _verification_summary_lines(analysis: Any | None) -> list[str]:
if analysis is None or getattr(analysis, "status", None) != "failed":
return []
lines = ["verification_status: failed"]
if getattr(analysis, "timed_out", False):
lines.append("failure_type: command timeout")
if getattr(analysis, "failed_tests", ()):
lines.append("failed_tests:")
lines.extend(f"- {item}" for item in analysis.failed_tests)
if getattr(analysis, "primary_errors", ()):
lines.append("primary_errors:")
lines.extend(f"- {item}" for item in analysis.primary_errors)
if getattr(analysis, "missing_artifacts", ()):
lines.append("missing_artifacts:")
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
return lines
def _build_tool_result_reference(filepath: Path, text: str, *, max_chars: int) -> str:
return build_structured_output_summary(
"[tool output persisted]",
text,
max_chars=max_chars,
metadata=[
("tool_output_id", filepath.stem),
("original_size_chars", len(text)),
("storage", "internal audit artifact"),
],
guidance=(
"Use this head/tail summary first. Avoid reading persisted "
"tool-output files wholesale; rerun a narrower command when "
"more detail is needed."
),
result = (
f"[tool output persisted]\n"
f"Full output saved to: {filepath}\n"
f"Original size: {original_size} chars\n"
f"Preview:\n{preview}"
)
if truncated_preview:
result += "\n...\n(Read the saved file if you need the full output.)"
return result
def _bucket_mtime(path: Path) -> float:
@@ -579,7 +494,13 @@ def maybe_persist_tool_result(
else:
_write_text_atomic(path, text_payload)
return _build_tool_result_reference(path, text_payload, max_chars=max_chars)
preview = text_payload[:_TOOL_RESULT_PREVIEW_CHARS]
return _render_tool_result_reference(
path,
original_size=len(text_payload),
preview=preview,
truncated_preview=len(text_payload) > _TOOL_RESULT_PREVIEW_CHARS,
)
def split_message(content: str, max_len: int = 2000) -> list[str]:
+58
View File
@@ -0,0 +1,58 @@
"""Durable JSON run records for automation executions."""
from __future__ import annotations
import errno
import json
import os
import time
import uuid
from contextlib import suppress
from pathlib import Path
from typing import Any
def safe_run_record_name(run_id: str) -> str:
"""Return a filesystem-safe filename stem for a run ID."""
return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id)
def write_run_record(runs_dir: Path, run_id: str, record: dict[str, Any]) -> Path:
"""Write or replace one durable automation run audit record."""
name = safe_run_record_name(run_id) or str(uuid.uuid4())
path = runs_dir / f"{name}.json"
payload = {
**record,
"run_id": run_id,
"updated_at_ms": _now_ms(),
}
_atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False))
return path
def _now_ms() -> int:
return int(time.time() * 1000)
def _atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
try:
os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(fd)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
-40
View File
@@ -42,27 +42,6 @@ SUSTAINED_GOAL_CONTINUE_PROMPT = (
"objective using your tools, or call complete_goal if the work is truly finished."
)
RUNTIME_BUDGET_CONVERGENCE_PROMPT = """\
[Runtime Budget Notice]
You have used {used_iterations} of {max_iterations} model/tool iterations for this turn. \
{remaining_iterations} iteration(s) remain before NanoBot must finalize without more tools.
Switch to convergence mode: stop broad exploration, choose the smallest high-signal command or edit, \
verify the likely solution, and preserve enough budget for a final answer. For coding or \
file-producing tasks, do not mark the work complete until the smallest reliable verification passes, \
or clearly state remaining failures.
[/Runtime Budget Notice]"""
RUNTIME_BUDGET_FINAL_PROMPT = """\
[Runtime Budget Notice]
Only {remaining_iterations} of {max_iterations} model/tool iteration(s) remain before NanoBot must \
finalize without more tools.
Finalize the solution path now: avoid new broad searches or builds unless essential, make the \
smallest final fix or artifact, run one targeted verification if possible, then answer honestly with \
the evidence or remaining failures.
[/Runtime Budget Notice]"""
def empty_tool_result_message(tool_name: str) -> str:
"""Short prompt-safe marker for tools that completed without visible output."""
@@ -109,25 +88,6 @@ def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
def build_runtime_budget_notice_message(
*,
level: int,
max_iterations: int,
used_iterations: int,
remaining_iterations: int,
) -> dict[str, str]:
"""Prompt the model to converge as the generic tool-iteration budget runs low."""
template = RUNTIME_BUDGET_FINAL_PROMPT if level >= 2 else RUNTIME_BUDGET_CONVERGENCE_PROMPT
return {
"role": "user",
"content": template.format(
max_iterations=max_iterations,
used_iterations=used_iterations,
remaining_iterations=remaining_iterations,
),
}
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
"""Stable signature for repeated external lookups we want to throttle."""
if not isinstance(arguments, dict):
+7 -2
View File
@@ -35,10 +35,15 @@ def format_tool_hints(tool_calls: list, max_length: int = 40) -> str:
formatted = []
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:
formatted.append(_fmt_known(tc, fmt, max_length))
elif tc.name.startswith("mcp_"):
elif name.startswith("mcp_"):
formatted.append(_fmt_mcp(tc, max_length))
else:
formatted.append(_fmt_fallback(tc, max_length))
+8
View File
@@ -26,7 +26,9 @@ class GatewayServices:
workspaces: WebUIWorkspaceController
session_manager: Any | None
cron_service: Any | None
local_trigger_store: Any | None
cron_pending_job_ids: Callable[[str], set[str]] | None
local_trigger_pending_ids: Callable[[str], set[str]] | None
def build_gateway_services(
@@ -42,7 +44,9 @@ def build_gateway_services(
runtime_capabilities_overrides: dict[str, Any] | None,
disabled_skills: set[str] | None = None,
cron_service: Any | None = None,
local_trigger_store: Any | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
tokens = GatewayTokenStore()
@@ -70,7 +74,9 @@ def build_gateway_services(
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
cron_service=cron_service,
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
log=logger,
)
return GatewayServices(
@@ -81,5 +87,7 @@ def build_gateway_services(
workspaces=workspaces,
session_manager=session_manager,
cron_service=cron_service,
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
)
+158 -13
View File
@@ -5,9 +5,12 @@ from __future__ import annotations
from collections.abc import Collection
from typing import Any, Protocol
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.cron.types import CronJob
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import _message_preview_text
from nanobot.triggers.local_types import LocalTrigger
AutomationJob = CronJob | LocalTrigger
class _CronServiceLike(Protocol):
@@ -21,6 +24,17 @@ class _CronServiceLike(Protocol):
) -> list[CronJob]: ...
class _LocalTriggerStoreLike(Protocol):
def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]: ...
def list_for_session(
self,
session_key: str,
*,
include_disabled: bool = True,
) -> list[LocalTrigger]: ...
class _SessionManagerLike(Protocol):
def read_session_file(self, key: str) -> dict[str, Any] | None: ...
@@ -28,26 +42,43 @@ class _SessionManagerLike(Protocol):
def session_automation_jobs(
cron_service: _CronServiceLike | None,
session_key: str,
) -> list[CronJob]:
*,
local_trigger_store: _LocalTriggerStoreLike | None = None,
) -> list[AutomationJob]:
"""Return user automations attached to the WebUI session."""
if cron_service is None:
return []
return cron_service.list_bound_cron_jobs_for_session(
session_key,
include_disabled=True,
)
jobs: list[AutomationJob] = []
if cron_service is not None:
jobs.extend(
cron_service.list_bound_cron_jobs_for_session(
session_key,
include_disabled=True,
)
)
if local_trigger_store is not None:
jobs.extend(
local_trigger_store.list_for_session(
session_key,
include_disabled=True,
)
)
return jobs
def session_automations_payload(
cron_service: _CronServiceLike | None,
session_key: str,
*,
local_trigger_store: _LocalTriggerStoreLike | None = None,
pending_job_ids: Collection[str] | None = None,
) -> dict[str, Any]:
"""Return user-created automation jobs attached to a WebUI session."""
return {
"jobs": serialize_automation_jobs(
session_automation_jobs(cron_service, session_key),
session_automation_jobs(
cron_service,
session_key,
local_trigger_store=local_trigger_store,
),
pending_job_ids=pending_job_ids,
)
}
@@ -56,11 +87,16 @@ def session_automations_payload(
def all_automations_payload(
cron_service: _CronServiceLike | None,
*,
local_trigger_store: _LocalTriggerStoreLike | None = None,
session_manager: _SessionManagerLike | None = None,
pending_job_ids: Collection[str] | None = None,
) -> dict[str, Any]:
"""Return all cron jobs visible to the WebUI automation manager."""
jobs = cron_service.list_jobs(include_disabled=True) if cron_service is not None else []
jobs: list[AutomationJob] = []
if cron_service is not None:
jobs.extend(cron_service.list_jobs(include_disabled=True))
if local_trigger_store is not None:
jobs.extend(local_trigger_store.list_triggers(include_disabled=True))
return {
"jobs": serialize_automation_jobs(
jobs,
@@ -72,7 +108,7 @@ def all_automations_payload(
def serialize_automation_jobs(
jobs: list[CronJob],
jobs: list[AutomationJob],
*,
pending_job_ids: Collection[str] | None = None,
include_details: bool = False,
@@ -90,12 +126,20 @@ def serialize_automation_jobs(
def _serialize_job(
job: CronJob,
job: AutomationJob,
*,
pending: bool = False,
include_details: bool = False,
session_manager: _SessionManagerLike | None = None,
) -> dict[str, Any]:
if isinstance(job, LocalTrigger):
return _serialize_trigger(
job,
pending=pending,
include_details=include_details,
session_manager=session_manager,
)
payload = {
"id": job.id,
"name": job.name,
@@ -143,6 +187,67 @@ def _serialize_job(
return payload
def _serialize_trigger(
trigger: LocalTrigger,
*,
pending: bool = False,
include_details: bool = False,
session_manager: _SessionManagerLike | None = None,
) -> dict[str, Any]:
command = f'nanobot trigger {trigger.id} "message"'
payload = {
"id": trigger.id,
"name": trigger.name,
"enabled": trigger.enabled,
"kind": "local_trigger",
"schedule": {
"kind": "local",
"at_ms": None,
"every_ms": None,
"expr": None,
"tz": None,
},
"payload": {
"kind": "local_trigger",
"message": command,
"command": command,
},
"state": {
"next_run_at_ms": None,
"last_status": trigger.last_status,
"pending": pending,
},
}
if not include_details:
return payload
payload["protected"] = False
payload["delete_after_run"] = False
payload["created_at_ms"] = trigger.created_at_ms
payload["updated_at_ms"] = trigger.updated_at_ms
payload["state"].update(
{
"last_run_at_ms": trigger.last_run_at_ms,
"last_error": trigger.last_error,
"run_history": [
{
"run_at_ms": record.run_at_ms,
"status": record.status,
"duration_ms": 0,
"error": record.error,
}
for record in trigger.run_history[-5:]
],
}
)
payload["origin"] = _trigger_origin_payload(trigger, session_manager)
payload["trigger"] = {
"id": trigger.id,
"command": command,
}
return payload
def _origin_payload(
job: CronJob,
session_manager: _SessionManagerLike | None,
@@ -161,6 +266,46 @@ def _origin_payload(
}
session_key = f"{channel}:{chat_id}"
return _websocket_origin_payload(
session_key=session_key,
channel=channel,
chat_id=chat_id,
session_manager=session_manager,
)
def _trigger_origin_payload(
trigger: LocalTrigger,
session_manager: _SessionManagerLike | None,
) -> dict[str, Any] | None:
channel = trigger.channel
chat_id = trigger.chat_id
if not channel or not chat_id:
return None
if channel != "websocket":
return {
"channel": channel,
"title": "",
"preview": "",
}
return _websocket_origin_payload(
session_key=trigger.session_key or f"{channel}:{chat_id}",
channel=channel,
chat_id=chat_id,
session_manager=session_manager,
)
def _websocket_origin_payload(
*,
session_key: str,
channel: str,
chat_id: str,
session_manager: _SessionManagerLike | None,
) -> dict[str, Any]:
title = ""
preview = ""
if session_manager is not None:
data = session_manager.read_session_file(session_key)
if isinstance(data, dict):
@@ -183,7 +328,7 @@ def _session_preview(messages: Any) -> str:
for message in messages:
if not isinstance(message, dict):
continue
if message.get(CRON_HISTORY_META) is True:
if is_hidden_history_message(message):
continue
text = _message_preview_text(message)
if not text:
+68 -24
View File
@@ -16,7 +16,7 @@ from typing import Any
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import (
_SESSION_LIST_PREVIEW_MAX_CHARS,
_SESSION_LIST_PREVIEW_MAX_RECORDS,
@@ -26,10 +26,11 @@ from nanobot.session.manager import (
_metadata_title,
)
_INDEX_VERSION = 1
_INDEX_VERSION = 2
_INDEX_FILENAME = ".webui_session_index.json"
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
@@ -153,7 +154,7 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
if item.get(CRON_HISTORY_META) is True:
if is_hidden_history_message(item):
continue
text = _message_preview_text(item)
if not text:
@@ -214,14 +215,45 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
return stored
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
if is_hidden_history_message(item):
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]:
signature = _file_signature(path)
activity_signature = _webui_activity_signature(session.key)
activity_updated_at = _webui_activity_updated_at(activity_signature)
visible_message_at = _last_visible_message_at(session.messages)
return {
"key": session.key,
"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),
"preview": _preview_from_messages(session.messages),
"file": path.name,
@@ -244,31 +276,39 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
return None
preview = ""
fallback_preview = ""
visible_message_at = None
preview_done = False
scanned_records = 0
scanned_chars = 0
for line in f:
if not line.strip():
continue
scanned_records += 1
scanned_chars += len(line)
if (
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
item = json.loads(line)
if item.get("_type") == "metadata":
continue
if item.get(CRON_HISTORY_META) is True:
continue
text = _message_preview_text(item)
if not text:
continue
if item.get("role") == "user":
preview = text
break
if not fallback_preview and item.get("role") == "assistant":
fallback_preview = text
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_chars += len(line)
if (
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
):
preview_done = True
continue
if item.get("_type") == "metadata":
continue
if is_hidden_history_message(item):
continue
text = _message_preview_text(item)
if not text:
continue
if item.get("role") == "user":
preview = text
preview_done = True
continue
if not fallback_preview and item.get("role") == "assistant":
fallback_preview = text
signature = _file_signature(path)
created_at_s = data.get("created_at")
updated_at_s = data.get("updated_at")
@@ -282,7 +322,11 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
return {
"key": key,
"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", {})),
"preview": preview or fallback_preview,
"file": path.name,
+17 -57
View File
@@ -22,7 +22,7 @@ from nanobot.audio.transcription_registry import (
resolve_transcription_provider,
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.providers.image_generation import (
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_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
"anthropic",
"azure_openai",
"bedrock",
"github_copilot",
"openai_codex",
}
_MODEL_LIST_CATALOG_PROVIDERS = {
"aihubmix",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"novita",
"openrouter",
"siliconflow",
"volcengine",
"volcengine_coding_plan",
}
_MODEL_LIST_OFFICIAL_PROVIDERS = {
"ant_ling",
"dashscope",
"deepseek",
"gemini",
"groq",
"longcat",
"minimax",
"minimax_anthropic",
"mistral",
"moonshot",
"nvidia",
"openai",
"qianfan",
"skywork",
"stepfun",
"xiaomi_mimo",
"zhipu",
}
class WebUISettingsError(ValueError):
"""User-facing settings validation failure."""
@@ -394,10 +353,13 @@ def _provider_settings_row(
def _model_catalog_kind(spec: Any) -> str:
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
return "catalog"
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
return "official"
catalog = getattr(spec, "model_catalog", "auto")
if catalog != "auto":
return catalog
if spec.is_transcription_only or spec.is_oauth:
return "unsupported"
if spec.backend != "openai_compat" and spec.name != "minimax_anthropic":
return "unsupported"
if spec.is_local:
return "local"
if spec.is_direct:
@@ -490,27 +452,20 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
raise WebUISettingsError("unknown provider")
spec, provider_key, provider_config = resolved_provider
catalog_kind = _model_catalog_kind(spec)
base_payload: dict[str, Any] = {
"provider": provider_key,
"label": spec.label,
"catalog_kind": _model_catalog_kind(spec),
"catalog_kind": catalog_kind,
"models": [],
"model_count": 0,
"message": None,
"fetched_at": time.time(),
}
if (
spec.is_transcription_only
or (
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
and spec.name != "minimax_anthropic"
)
or spec.is_oauth
):
if catalog_kind == "unsupported":
return {
**base_payload,
"status": "unsupported",
"catalog_kind": "unsupported",
"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:
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
with suppress(Exception):
token = get_token()
token = get_token(proxy=proxy)
if not (token and token.access):
messages: list[str] = []
token = login_oauth_interactive(
print_fn=lambda message: messages.append(str(message)),
prompt_fn=lambda _prompt: "",
proxy=proxy,
)
if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401)
+30 -6
View File
@@ -17,7 +17,8 @@ from urllib.parse import unquote, urlparse
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.automation_turns import is_automation_kind
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import SessionManager
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
@@ -598,9 +599,12 @@ def normalize_webui_turn_id(value: Any) -> str:
def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY)
if not isinstance(raw, dict) or raw.get("kind") != "cron":
if not isinstance(raw, dict):
return None
source: dict[str, str] = {"kind": "cron"}
kind = raw.get("kind")
if not is_automation_kind(kind):
return None
source: dict[str, str] = {"kind": kind}
label = raw.get("label")
if isinstance(label, str) and label.strip():
source["label"] = label.strip()
@@ -779,6 +783,8 @@ def write_session_messages_as_transcript(
target_chat_id = _chat_id_from_session_key(target_key)
rows: list[dict[str, Any]] = []
for msg in messages:
if is_hidden_history_message(msg):
continue
role = msg.get("role")
content = msg.get("content")
text = content if isinstance(content, str) else ""
@@ -849,13 +855,28 @@ def build_user_transcript_event(
return event
def _is_legacy_raw_subagent_result(message: dict[str, Any]) -> bool:
content = message.get("content")
if not isinstance(content, str):
return False
text = content.replace("\r\n", "\n").strip()
return (
text.startswith("[Subagent '")
and "\n\nTask:" in text
and "\n\nResult:" in text
and "Summarize this naturally" in text
)
def _session_user_event(
session_key: str,
message: dict[str, Any],
) -> dict[str, Any] | None:
if message.get("role") != "user":
return None
if message.get(CRON_HISTORY_META) is True:
if is_hidden_history_message(message):
return None
if _is_legacy_raw_subagent_result(message):
return None
content = message.get("content")
text = content if isinstance(content, str) else ""
@@ -1271,9 +1292,12 @@ def replay_transcript_to_ui_messages(
def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
source = rec.get("source")
if not isinstance(source, dict) or source.get("kind") != "cron":
if not isinstance(source, dict):
return {}
out: dict[str, Any] = {"source": {"kind": "cron"}}
kind = source.get("kind")
if not is_automation_kind(kind):
return {}
out: dict[str, Any] = {"source": {"kind": kind}}
label = source.get("label")
if isinstance(label, str) and label.strip():
out["source"]["label"] = label.strip()
+101 -9
View File
@@ -26,6 +26,7 @@ from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob, CronSchedule
from nanobot.triggers.local_types import LocalTrigger
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
@@ -89,6 +90,7 @@ if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager
from nanobot.triggers.local_store import LocalTriggerStore
def _decode_api_key(raw_key: str) -> str | None:
@@ -153,7 +155,9 @@ class GatewayHTTPHandler:
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
cron_service: CronService | None = None,
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
log: Any = logger,
) -> None:
self.config = config
@@ -167,7 +171,9 @@ class GatewayHTTPHandler:
self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set()
self.cron_service = cron_service
self.local_trigger_store = local_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids
self.local_trigger_pending_ids = local_trigger_pending_ids
self._log = log
self._runtime_surface = runtime_surface
@@ -483,13 +489,12 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
pending_job_ids: set[str] = set()
if self.cron_pending_job_ids is not None:
pending_job_ids = self.cron_pending_job_ids(decoded_key)
pending_job_ids = self._pending_automation_ids_for_session(decoded_key)
return _http_json_response(
session_automations_payload(
self.cron_service,
decoded_key,
local_trigger_store=self.local_trigger_store,
pending_job_ids=pending_job_ids,
)
)
@@ -506,7 +511,11 @@ class GatewayHTTPHandler:
return _http_error(404, "session not found")
query = _parse_query(request.path)
delete_automations = (_query_first(query, "delete_automations") or "").lower()
automation_jobs = session_automation_jobs(self.cron_service, decoded_key)
automation_jobs = session_automation_jobs(
self.cron_service,
decoded_key,
local_trigger_store=self.local_trigger_store,
)
if automation_jobs and delete_automations not in {"1", "true", "yes"}:
return _http_json_response(
{
@@ -515,9 +524,13 @@ class GatewayHTTPHandler:
"automations": serialize_automation_jobs(automation_jobs),
}
)
if automation_jobs and self.cron_service is not None:
if automation_jobs:
for job in automation_jobs:
self.cron_service.remove_job(job.id)
if isinstance(job, LocalTrigger):
if self.local_trigger_store is not None:
self.local_trigger_store.delete(job.id)
elif self.cron_service is not None:
self.cron_service.remove_job(job.id)
deleted = self.session_manager.delete_session(decoded_key)
delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)})
@@ -548,14 +561,37 @@ class GatewayHTTPHandler:
pending.update(self.cron_pending_job_ids(session_key))
return pending
def _pending_local_trigger_ids_for_all(self) -> set[str]:
if self.local_trigger_store is None or self.local_trigger_pending_ids is None:
return set()
pending: set[str] = set()
for trigger in self.local_trigger_store.list_triggers(include_disabled=True):
session_key = trigger.session_key
if not session_key and trigger.channel and trigger.chat_id:
session_key = f"{trigger.channel}:{trigger.chat_id}"
if session_key:
pending.update(self.local_trigger_pending_ids(session_key))
return pending
def _pending_automation_ids_for_session(self, session_key: str) -> set[str]:
pending: set[str] = set()
if self.cron_pending_job_ids is not None:
pending.update(self.cron_pending_job_ids(session_key))
if self.local_trigger_pending_ids is not None:
pending.update(self.local_trigger_pending_ids(session_key))
return pending
def _handle_webui_automations(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
pending_job_ids = self._pending_cron_job_ids_for_all()
pending_job_ids.update(self._pending_local_trigger_ids_for_all())
return _http_json_response(
all_automations_payload(
self.cron_service,
local_trigger_store=self.local_trigger_store,
session_manager=self.session_manager,
pending_job_ids=self._pending_cron_job_ids_for_all(),
pending_job_ids=pending_job_ids,
)
)
@@ -566,13 +602,19 @@ class GatewayHTTPHandler:
) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if self.cron_service is None:
return _http_error(503, "cron service unavailable")
if self.cron_service is None and self.local_trigger_store is None:
return _http_error(503, "automation service unavailable")
query = _parse_query(request.path)
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
if not job_id:
return _http_error(400, "missing automation id")
trigger = self.local_trigger_store.get(job_id) if self.local_trigger_store else None
if trigger is not None:
return self._handle_local_trigger_action(request, action, trigger)
if self.cron_service is None:
return _http_error(404, "automation not found")
job = self.cron_service.get_job(job_id)
if job is None:
return _http_error(404, "automation not found")
@@ -618,6 +660,40 @@ class GatewayHTTPHandler:
return self._handle_webui_automations(request)
def _handle_local_trigger_action(
self,
request: WsRequest,
action: str,
trigger: LocalTrigger,
) -> Response:
if self.local_trigger_store is None:
return _http_error(503, "trigger service unavailable")
if action == "enable":
if self.local_trigger_store.enable(trigger.id, enabled=True) is None:
return _http_error(404, "automation not found")
elif action == "disable":
if self.local_trigger_store.enable(trigger.id, enabled=False) is None:
return _http_error(404, "automation not found")
elif action == "delete":
if not self.local_trigger_store.delete(trigger.id):
return _http_error(404, "automation not found")
elif action == "run":
return _http_error(409, "local trigger requires a CLI message")
elif action == "update":
values = _automation_values_from_request(request)
if values is None:
return _http_error(400, "invalid automation update payload")
parsed = _parse_local_trigger_update(values)
if isinstance(parsed, str):
return _http_error(400, parsed)
if parsed:
if self.local_trigger_store.update(trigger.id, **parsed) is None:
return _http_error(404, "automation not found")
else:
return _http_error(404, "unknown automation action")
return self._handle_webui_automations(request)
@staticmethod
def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
try:
@@ -830,6 +906,22 @@ def _parse_automation_update(
return update
def _parse_local_trigger_update(values: dict[str, Any]) -> dict[str, Any] | str:
update: dict[str, Any] = {}
if "name" in values:
raw_name = values.get("name")
if not isinstance(raw_name, str):
return "name must be a string"
name = raw_name.strip()
if not name:
return "name cannot be empty"
update["name"] = name
forbidden = [key for key in ("message", "schedule") if key in values]
if forbidden:
return "local trigger updates only support name"
return update
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
raw_kind = values.get("kind")
if not isinstance(raw_kind, str):
+1 -1
View File
@@ -31,7 +31,7 @@ dependencies = [
"websocket-client>=1.9.0,<2.0.0",
"httpx>=0.28.0,<1.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",
"readability-lxml>=0.8.4,<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
fi
info "Starting setup wizard..."
run_nanobot onboard --wizard
if [ -t 0 ]; then
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!\""
+2 -4
View File
@@ -38,7 +38,6 @@ def make_loop(
model: str = "test-model",
context_window_tokens: int = 128_000,
session_ttl_minutes: int = 0,
max_messages: int = 120,
unified_session: bool = False,
mcp_servers: dict | None = None,
tools_config=None,
@@ -64,7 +63,6 @@ def make_loop(
model=model,
context_window_tokens=context_window_tokens,
session_ttl_minutes=session_ttl_minutes,
max_messages=max_messages,
unified_session=unified_session,
)
if mcp_servers is not None:
@@ -79,8 +77,8 @@ def make_loop(
if patch_deps:
with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
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:])
if not tail:
session.updated_at = datetime.now()
loop.sessions.save(session)
return ""
@@ -103,15 +102,14 @@ def _make_fake_compact(
metadata={},
last_consolidated=0,
)
dropped, already_consolidated = probe.retain_recent_legal_suffix(
result = probe.retain_recent_legal_suffix(
max_suffix,
extend_to_user=True,
)
kept = probe.messages
archive_msgs = dropped[already_consolidated:]
archive_msgs = result.dropped[result.already_consolidated_count:]
if not archive_msgs and not kept:
session.updated_at = datetime.now()
loop.sessions.save(session)
return ""
@@ -132,7 +130,6 @@ def _make_fake_compact(
session.messages = kept
session.last_consolidated = 0
session.updated_at = datetime.now()
loop.sessions.save(session)
return s
@@ -1021,27 +1018,28 @@ class TestProactiveAutoCompact:
await self._run_check_expired(loop)
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)
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
await loop.close_mcp()
@pytest.mark.asyncio
async def test_empty_skip_refreshes_updated_at_prevents_reschedule(self, tmp_path):
"""Empty session skip refreshes updated_at, preventing immediate re-scheduling."""
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
"""Empty expired sessions have no removable tail and should not schedule."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test")
session.updated_at = datetime.now() - timedelta(minutes=20)
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)
assert _fake_compact.state["count"] == 0
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)
assert _fake_compact.state["count"] == 0
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
+23 -2
View File
@@ -200,8 +200,11 @@ class TestCheckExpired:
"""Expired session should trigger schedule_background."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
old_dt = datetime.now() - timedelta(minutes=20)
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
scheduled = []
@@ -273,6 +276,24 @@ class TestCheckExpired:
scheduler.assert_not_called()
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
+9 -3
View File
@@ -430,9 +430,11 @@ class TestCompactIdleSession:
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:test")
old_ts = session.updated_at
for i in range(20):
session.add_message("user", f"user msg {i}")
session.add_message("assistant", f"assistant msg {i}")
session.updated_at = old_ts
sessions.save(session)
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["text"] == "Summary of old conversation."
assert "last_active" in meta
assert reloaded.updated_at == old_ts
@pytest.mark.asyncio
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
@@ -518,8 +521,10 @@ class TestCompactIdleSession:
assert entries[0]["session_key"] == "cli:test"
@pytest.mark.asyncio
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
"""Empty session with old updated_at → refreshed after call, returns ''."""
async def test_empty_session_does_not_refresh_timestamp(
self, real_consolidator
):
"""Empty session with old updated_at does not look active after compaction."""
from datetime import datetime, timedelta
sessions = real_consolidator.sessions
@@ -532,7 +537,8 @@ class TestCompactIdleSession:
assert result == ""
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
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
@@ -5,6 +5,7 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -54,13 +55,13 @@ async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
events.append(await loop.bus.consume_outbound())
statuses = [
event.metadata
event.event
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 isinstance(statuses[0].get("started_at"), float)
assert "started_at" not in statuses[1]
assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].started_at, float)
assert statuses[1].started_at is None
@pytest.mark.asyncio
+77 -43
View File
@@ -9,6 +9,15 @@ import pytest
import nanobot.agent.runner as runner_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
ProgressEvent,
SessionUpdatedEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.session.webui_turns import WebuiTurnCoordinator
@@ -260,25 +269,45 @@ class TestToolEventProgress:
)
await loop._dispatch(msg)
# Drain all outbound messages and find the one carrying _tool_events
# Drain all outbound messages and find the one carrying tool events.
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
tool_event_msgs = [m for m in outbound if m.metadata and m.metadata.get("_tool_events")]
assert tool_event_msgs, "expected at least one outbound message with _tool_events"
tool_event_msgs = [
m
for m in outbound
if isinstance(m.event, ProgressEvent) and m.event.tool_events
]
assert tool_event_msgs, "expected at least one outbound message with tool events"
start_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] == "start"]
finish_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] in ("end", "error")]
start_msgs = [
m
for m in tool_event_msgs
if isinstance(m.event, ProgressEvent)
and m.event.tool_events
and m.event.tool_events[0]["phase"] == "start"
]
finish_msgs = [
m
for m in tool_event_msgs
if isinstance(m.event, ProgressEvent)
and m.event.tool_events
and m.event.tool_events[0]["phase"] in ("end", "error")
]
assert start_msgs, "expected a start-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["call_id"] == "tc1"
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["result"] == "file.txt"
@@ -309,7 +338,8 @@ class TestToolEventProgress:
await invoke_file_edit_progress(progress, edit_events)
outbound = await bus.consume_outbound()
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
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 = [
event
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(
event["status"] == "editing"
@@ -433,8 +464,8 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound())
assert [m.content for m in outbound] == ["Hello"]
assert not any(m.metadata.get("_progress") for m in outbound)
assert not any(m.metadata.get("_streamed") for m in outbound)
assert not any(isinstance(m.event, ProgressEvent) 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_with_retry.assert_awaited_once()
@@ -443,7 +474,7 @@ class TestToolEventProgress:
self,
tmp_path: Path,
) -> None:
"""Streaming channels still receive provider deltas through _stream_delta messages."""
"""Streaming channels still receive provider deltas through stream events."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
@@ -473,21 +504,19 @@ class TestToolEventProgress:
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [
m for m in outbound
if not m.metadata.get("_stream_delta")
and not m.metadata.get("_stream_end")
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
]
assert [m.content for m in deltas] == ["Hel", "lo"]
assert len(stream_end) == 1
assert final[-1].content == "Hello"
assert final[-1].metadata.get("_streamed") is True
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
assert isinstance(final[-1].event, StreamedResponseEvent)
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
provider.chat_with_retry.assert_not_awaited()
@@ -528,23 +557,28 @@ class TestToolEventProgress:
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
final = [
m for m in outbound
if not m.metadata.get("_stream_delta")
and not m.metadata.get("_stream_end")
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
]
assert [m.content for m in deltas] == ["partial", "full retry response"]
assert [m.metadata.get("_resuming") for m in stream_end] == [True, False]
assert deltas[0].metadata.get("_stream_id") == stream_end[0].metadata.get("_stream_id")
assert deltas[1].metadata.get("_stream_id") == stream_end[1].metadata.get("_stream_id")
assert deltas[0].metadata.get("_stream_id") != deltas[1].metadata.get("_stream_id")
assert [m.event.resuming for m in stream_end if isinstance(m.event, StreamEndEvent)] == [
True,
False,
]
assert isinstance(deltas[0].event, StreamDeltaEvent)
assert isinstance(deltas[1].event, StreamDeltaEvent)
assert isinstance(stream_end[0].event, StreamEndEvent)
assert isinstance(stream_end[1].event, StreamEndEvent)
assert deltas[0].event.stream_id == stream_end[0].event.stream_id
assert deltas[1].event.stream_id == stream_end[1].event.stream_id
assert deltas[0].event.stream_id != deltas[1].event.stream_id
assert 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()
@pytest.mark.asyncio
@@ -623,9 +657,9 @@ class TestToolEventProgress:
done_msgs = [m for m in outbound if m.content == "Done"]
assert len(done_msgs) == 1
assert not done_msgs[0].metadata.get("_turn_end")
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 turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1"
@@ -659,14 +693,14 @@ class TestToolEventProgress:
outbound.append(await bus.consume_outbound())
error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."]
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
statuses = [m for m in outbound if m.metadata.get("_goal_status")]
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
statuses = [m for m in outbound if isinstance(m.event, GoalStatusEvent)]
assert len(error_msgs) == 1
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1"
assert [m.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(turn_end_msgs[0]) < outbound.index(statuses[-1])
@@ -705,27 +739,27 @@ class TestToolEventProgress:
outbound: list = []
for _ in range(12):
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
if outbound[-1].metadata.get("_turn_end"):
if isinstance(outbound[-1].event, TurnEndEvent):
break
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"]
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)
release_title.set()
session_updated = None
for _ in range(10):
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
if (candidate.metadata or {}).get("_session_updated"):
if isinstance(candidate.event, SessionUpdatedEvent):
session_updated = candidate
break
assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
assert isinstance(session_updated.event, SessionUpdatedEvent)
assert session_updated.event.scope == "metadata"
assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio
@@ -837,4 +871,4 @@ class TestToolEventProgress:
assert len(outbound) == 1
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
from nanobot.bus.outbound_events import StreamedResponseEvent
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -23,8 +24,8 @@ def _make_loop(tmp_path):
with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
return loop
@@ -193,8 +194,9 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
assert result is not None
assert "503" in result.content
assert not result.metadata.get("_streamed"), \
"_streamed must not be set when stop_reason is error"
assert not isinstance(result.event, StreamedResponseEvent), (
"streamed response event must not be set when stop_reason is error"
)
@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.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
+89 -16
View File
@@ -8,9 +8,17 @@ import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.session.turn_continuation import (
@@ -26,6 +34,7 @@ from nanobot.session.webui_turns import (
clean_generated_title,
maybe_generate_webui_title,
)
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
from nanobot.utils.llm_runtime import LLMRuntime
@@ -94,6 +103,13 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert persisted is True
message = session.messages[-1]
assert message["content"] == "Scheduled cron job triggered: Daily check"
assert message[AUTOMATION_HISTORY_META] == {
"kind": "cron",
"cron_job_id": "job-1",
"cron_job_name": "Daily check",
"cron_run_id": "job-1:1",
"cron_prompt_ref": prompt_ref,
}
assert message[CRON_HISTORY_META] is True
assert CRON_TRIGGER_META not in message
assert message["cron_job_id"] == "job-1"
@@ -102,6 +118,63 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert message["cron_prompt_ref"] == prompt_ref
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:auto")
persisted = loop._persist_user_message_early(
InboundMessage(
channel="websocket",
sender_id="trigger",
chat_id="auto",
content="Review PR #4502",
metadata={
LOCAL_TRIGGER_META: {
"trigger_id": "trg_123",
"trigger_name": "PR review",
"delivery_id": "tdel_456",
"created_at_ms": 1_700_000_000_000,
"persist_content": "Local trigger received: PR review\n\nReview PR #4502",
}
},
),
session,
)
assert persisted is True
message = session.messages[-1]
assert message["content"] == "Local trigger received: PR review\n\nReview PR #4502"
assert message[AUTOMATION_HISTORY_META] == {
"kind": "local_trigger",
"trigger_id": "trg_123",
"trigger_name": "PR review",
"trigger_delivery_id": "tdel_456",
}
assert LOCAL_TRIGGER_META not in message
assert message["trigger_id"] == "trg_123"
assert message["trigger_name"] == "PR review"
assert message["trigger_delivery_id"] == "tdel_456"
@pytest.mark.asyncio
async def test_new_with_bot_suffix_does_not_persist_command(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
response = await loop._process_message(
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-1",
content="/new@nanobot_bot",
)
)
assert response is not None
assert response.content == "New session started."
session = loop.sessions.get_or_create("websocket:chat-1")
assert session.messages == []
def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == ""
@@ -765,7 +838,6 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
"_wants_stream": True,
"message_id": "om_001",
"origin_message_id": "root_001",
"_stream_id": "old-stream",
},
))
@@ -775,23 +847,23 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
assert queued.metadata["_wants_stream"] is True
assert queued.metadata["message_id"] == "om_001"
assert queued.metadata["origin_message_id"] == "root_001"
assert "_stream_id" not in queued.metadata
await loop._dispatch(queued)
outbound = []
while loop.bus.outbound_size:
outbound.append(await loop.bus.consume_outbound())
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
ends = [m for m in outbound if m.metadata.get("_stream_end")]
streamed_markers = [m for m in outbound if m.metadata.get("_streamed")]
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
ends = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
streamed_markers = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)]
assert [m.content for m in deltas] == ["done"]
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["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"
@@ -842,10 +914,10 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
first_outbound = []
while loop.bus.outbound_size:
first_outbound.append(await loop.bus.consume_outbound())
first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if m.metadata.get("_turn_end")]
started_at = first_statuses[0]["started_at"]
first_statuses = [m.event for m in first_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m.status for m in first_statuses] == ["running"]
assert not [m for m in first_outbound if isinstance(m.event, TurnEndEvent)]
started_at = first_statuses[0].started_at
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
@@ -856,12 +928,13 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
second_outbound = []
while loop.bus.outbound_size:
second_outbound.append(await loop.bus.consume_outbound())
second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")]
assert [m["goal_status"] for m in second_statuses] == ["running", "idle"]
assert second_statuses[0]["started_at"] == started_at
turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")]
second_statuses = [m.event for m in second_outbound if isinstance(m.event, GoalStatusEvent)]
assert [m.status for m in second_statuses] == ["running", "idle"]
assert second_statuses[0].started_at == started_at
turn_end = [m for m in second_outbound if isinstance(m.event, TurnEndEvent)]
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
+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
@@ -11,20 +11,27 @@ from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.manager import Session
DEFAULT_MAX_MESSAGES = 120
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.manager import (
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.get_default_model.return_value = "test-model"
provider.generation.max_tokens = 4096
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
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:
"""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)
assert loop._max_messages == DEFAULT_MAX_MESSAGES
assert loop._max_messages == FILE_MAX_MESSAGES
def test_positive_value_stored(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path, max_messages=25)
assert loop._max_messages == 25
def test_default_scales_with_context_window(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path, context_window_tokens=32_768)
assert loop._max_messages == 327
def test_zero_uses_builtin_limit(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path, max_messages=0)
assert loop._max_messages == DEFAULT_MAX_MESSAGES
def test_provider_refresh_resyncs_context_derived_limit(self, tmp_path: Path) -> None:
old_provider = MagicMock()
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:
"""Negative values should not produce negative slicing."""
loop = _make_loop(tmp_path, max_messages=-5)
assert loop._max_messages == DEFAULT_MAX_MESSAGES
assert loop._max_messages == 327
loop._refresh_provider_snapshot()
assert loop._max_messages == FILE_MAX_MESSAGES
class TestGetHistoryWithMaxMessages:
@@ -77,7 +104,7 @@ class TestGetHistoryWithMaxMessages:
def test_default_uses_builtin_limit(self) -> None:
session = _populated_session(80)
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:
session = _populated_session(40) # 80 messages total
@@ -93,7 +120,7 @@ class TestGetHistoryWithMaxMessages:
def test_max_messages_zero_uses_builtin_limit(self) -> None:
session = _populated_session(80) # 160 messages total
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:
"""When session has fewer messages than max_messages, all are returned."""
@@ -103,12 +130,13 @@ class TestGetHistoryWithMaxMessages:
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
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."""
loop = _make_loop(tmp_path, max_messages=25)
loop = _make_loop(tmp_path)
loop._max_messages = 25
loop.provider.chat_with_retry = AsyncMock(
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
@pytest.mark.asyncio
async def test_zero_config_passes_builtin_limit_to_history_call(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path, max_messages=0)
async def test_default_limit_passes_context_derived_limit_to_history_call(
self,
tmp_path: Path,
) -> None:
loop = _make_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
)
@@ -142,7 +173,7 @@ class TestMaxMessagesIntegration:
)
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
@pytest.mark.asyncio
@@ -151,7 +182,8 @@ class TestMaxMessagesIntegration:
tmp_path: Path,
) -> None:
"""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(
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)
assert "new question" 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)

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