Compare commits

...
Author SHA1 Message Date
Xubin Ren 950dddec49 chore: bump version to 0.1.5.post2 2026-04-21 17:25:08 +00:00
kandXubin Ren e5b288c6eb fix: map MiniMax reasoning_effort to reasoning_split 2026-04-22 00:52:56 +08:00
Xubin Ren 558aa98491 chore: temporary keep WebUI source-only 2026-04-21 14:33:44 +00:00
aiguozhi123456andXubin Ren 53ba410e49 feat(read_file): add DOCX, XLSX, PPTX support via document.extract_text()
Wire up the existing office document extractors in document.py to
ReadFileTool by adding an extension guard and _read_office_doc() method
that follows the established PDF pattern. Handles missing libraries,
corrupt files, empty documents, and 128K truncation consistently.
2026-04-21 22:12:19 +08:00
彭星杰andXubin Ren 46864b0911 fix: use try/finally in _extract_xlsx to prevent resource leak 2026-04-21 22:01:17 +08:00
彭星杰andXubin Ren a00beebd06 fix: use context manager in _extract_xlsx to prevent resource leak 2026-04-21 22:01:17 +08:00
chengyongruandXubin Ren e15705b471 fix(tests): add _cancel_active_tasks mock to cmd_new test fixtures
The existing test_unified_session tests construct a SimpleNamespace
loop mock that now needs _cancel_active_tasks since cmd_new calls it.
2026-04-21 21:50:37 +08:00
chengyongruandXubin Ren d4e34f8c67 fix(commands): intercept non-priority commands during active turn
Non-priority slash commands (e.g. /new, /help, /dream-log) arriving
while a session has an active LLM turn were silently queued into the
pending injection buffer and later injected as raw user messages into
the LLM conversation. This caused the model to respond to "/new" as
plain text instead of executing the command.

Root cause: the run() loop only checked priority commands (/stop,
/restart, /status) before routing messages to the pending queue. All
other command tiers (exact, prefix) bypassed command dispatch entirely.

Changes:
- Add CommandRouter.is_dispatchable_command() to match exact/prefix
  tiers, mirroring the existing is_priority() pattern.
- In run(), intercept dispatchable commands before pending queue
  insertion and dispatch them directly via _dispatch_command_inline().
- Extract _cancel_active_tasks() from cmd_stop for reuse; cmd_new now
  cancels active tasks before clearing the session to prevent shared
  mutable state corruption from concurrent asyncio coroutines.
- Update /new semantics: stops active task first, then clears session.
- Update documentation in help text, docs, and Discord command list.
2026-04-21 21:50:37 +08:00
hussein1362andXubin Ren f8a023218d fix(telegram): improve markdown rendering for modern LLM output
Problem:
Modern LLMs (GPT-5.4, Claude, Gemini) produce markdown-heavy responses with
numbered lists, headers, and nested formatting. The Telegram channel's
_markdown_to_telegram_html() converter has gaps that leave these poorly
formatted:

1. Numbered lists (1. 2. 3.) have zero handling — sent as raw text
2. Headers (# Title) are stripped to plain text, losing visual hierarchy
3. Mid-stream edits send raw markdown (users see **bold** and ### headers
   while the response generates, before the final HTML conversion)

Root Cause:
_markdown_to_telegram_html() handles bullets (- *) but skips numbered lists
entirely. Headers are stripped of # but not given any emphasis. The streaming
path in send_delta() sends buf.text as-is during mid-stream edits (plain
text, no parse_mode) — only the final _stream_end edit converts to HTML.

Fix:
1. Headers now render as <b>bold</b> in the final HTML (using placeholder
   markers that survive HTML escaping, restored after all other processing)
2. Numbered lists are normalized (extra whitespace after the dot is cleaned)
3. New _strip_md_block() function strips markdown syntax for readable
   plain-text preview during streaming mid-edits

The final _stream_end HTML conversion is unchanged — it still produces
full HTML with parse_mode=HTML. Only the intermediate edits are improved.

Tests:
Added 10 new tests covering:
- Headers converting to bold HTML
- Numbered list preservation and whitespace normalization
- Headers with HTML special characters
- Mixed formatting (headers + bullets + numbers + bold)
- _strip_md_block for inline formatting, headers, bullets, numbers, links
- Streaming mid-edit markdown stripping (initial send + edit)
2026-04-21 21:35:34 +08:00
chengyongruandXubin Ren 37ea8b8f5b fix(retry): recognize ZhiPu 1302 rate-limit error for retry
ZhiPu API returns code 1302 with Chinese text "速率限制" instead of
standard HTTP 429 + "rate limit", causing the retry engine to treat
it as non-transient and fail immediately.
2026-04-21 21:23:20 +08:00
Xubin Ren 1b692debdc docs(webui): revise README to clarify WebSocket channel setup and sequence of startup steps 2026-04-21 12:46:17 +00:00
Xubin RenandXubin Ren c1957e14ff refactor(memory): centralize cursor validation behind a single gate
Move the non-int cursor guard out of the two consumer sites and into a
shared ``_iter_valid_entries`` iterator so the invariant lives in one
place.  Closes three gaps left by the original fix:

* ``bool`` is now rejected — ``isinstance(True, int)`` is ``True`` in
  Python, so the previous guard silently treated ``{"cursor": true}`` as
  cursor ``1``.
* Recovery now returns ``max(valid cursors) + 1``.  Under adversarial
  corruption "first int scanning in reverse" is not the same thing, and
  only ``max`` keeps the recovered cursor strictly greater than every
  legitimate cursor still on disk.
* Non-int cursors are logged exactly once per ``MemoryStore``.  Silently
  dropping corrupted entries hides the root cause (an external writer
  to ``memory/history.jsonl``); rate-limiting keeps the log clean when
  the same poisoned file is read every turn.

All 7 tests from the original fix pass unchanged; 3 new tests pin the
invariants above.

Made-with: Cursor
2026-04-21 14:02:53 +08:00
Muata KamdibeandXubin Ren c0a11c7cf4 fix(memory): harden cursor recovery against non-integer corruption
_next_cursor now checks isinstance(cursor, int) before arithmetic,
falling back to a reverse scan of all entries when the last entry's
cursor is corrupted. read_unprocessed_history skips entries with
non-int cursors instead of crashing on comparison.

Root cause: external callers (cron jobs, plugins) occasionally wrote
string cursors to history.jsonl, which blocked all subsequent
append_history calls with TypeError/ValueError.

Includes 7 regression tests covering string, float, null, and list
cursor types.
2026-04-21 14:02:53 +08:00
chengyongruandXubin Ren 409afe1a3d test(tools): add basic regression tests for ContextVar routing context 2026-04-21 13:25:30 +08:00
jr_blue_551andXubin Ren ff8c28d5a8 agent: use ContextVar for tool routing context 2026-04-21 13:25:30 +08:00
Xubin RenandXubin Ren 82aa9efc02 test(mcp): pin CancelledError short-circuits the retry loop
The retry branch is only reachable via `except Exception`, and
`CancelledError` inherits from `BaseException`, so today it naturally
bypasses the retry path and /stop still works.  Add one focused
regression test so any future refactor that widens the retry catch to
`BaseException`, re-orders the handlers, or adds `CancelledError` to
`_TRANSIENT_EXC_NAMES` fails CI instead of silently swallowing /stop.

Made-with: Cursor
2026-04-21 13:24:40 +08:00
hussein1362andXubin Ren 368752e707 fix(mcp): retry once on transient connection errors
When an MCP server restarts or a network connection drops between
tool calls, the existing session throws ClosedResourceError,
BrokenPipeError, ConnectionResetError, etc. Currently these are
caught as generic exceptions and returned as permanent failures
to the LLM, which then tells the user 'my tools are broken.'

This change adds a single automatic retry with a 1-second backoff
for transient connection-class errors in MCPToolWrapper,
MCPResourceWrapper, and MCPPromptWrapper. Non-transient errors
(ValueError, RuntimeError, McpError, etc.) are not retried.

The retry is conservative:
- Only 1 retry (not configurable, to keep the change minimal)
- Only for a specific set of connection-class exceptions
- Matched by exception class name to avoid importing anyio/etc.
- 1s sleep between attempts to allow the server to recover
- Clear logging distinguishes retried vs permanent failures

In production this eliminates most 'MCP tool call failed:
ClosedResourceError' noise when MCP bridge processes restart
(e.g. after config changes or OOM kills).

Tests: 22 new tests covering retry, exhaustion, non-transient
bypass, timeout bypass, and all three wrapper types.
2026-04-21 13:24:40 +08:00
Xubin Ren 6c24f24e9e feat(models): add support for kimi-k2.6 with temperature override and update documentation 2026-04-20 18:18:06 +00:00
Xubin RenandXubin Ren 009cce78ad fix(anthropic): also enforce leading-user + empty-array recovery
Extend `_merge_consecutive` so the three invariants from
`LLMProvider._enforce_role_alternation` all hold for Anthropic:

1. collapse consecutive same-role turns (unchanged)
2. no trailing assistant — Anthropic rejects prefill (unchanged)
3. no leading assistant — Anthropic requires the first turn be user
4. non-empty messages array — recover the last stripped assistant as a
   user turn when every turn got stripped, so callers don't hit a
   secondary "messages array empty" 400

Anthropic-specific wrinkle: `tool_use` blocks live inside `content` (not
a separate `tool_calls` field) and are illegal inside user turns, so
both recovery paths skip any message carrying them rather than silently
producing a malformed request.

Adds 4 unit tests covering the new branches, including the tool_use
opt-outs, and updates the existing `test_single_assistant_stripped` to
reflect the new rerouting contract.

Made-with: Cursor
2026-04-21 01:32:32 +08:00
hussein1362andXubin Ren 2f02342083 fix(anthropic): strip trailing assistant messages to prevent prefill error
Anthropic does not support assistant-message prefill and returns a 400
error when the conversation ends with an assistant turn. This commonly
happens when heartbeat/system messages accumulate trailing assistant
replies in the session history.

The _merge_consecutive method already handles same-role merging but did
not strip trailing assistant messages. The base provider's
_enforce_role_alternation (used by OpenAI-compat) does strip them, but
AnthropicProvider uses its own _merge_consecutive instead.

Add a trailing-assistant stripping loop to _merge_consecutive, matching
the behavior already present in _enforce_role_alternation.

Includes 7 new tests covering merge + strip behavior.
2026-04-21 01:32:32 +08:00
Xubin RenandXubin Ren 00de55072d test(agent): exercise /stop cancellation through _dispatch
Add a regression test that actually runs the CancelledError branch of
AgentLoop._dispatch end-to-end and asserts the in-flight checkpoint is
materialized into session.messages before the cancellation unwinds.

The three existing tests call _restore_runtime_checkpoint directly, so
they pass even if the cancel-time restore is ever removed from
_dispatch. This new test is the one that actually locks the fix in
place.

Made-with: Cursor
2026-04-21 01:14:41 +08:00
hussein1362andXubin Ren 847c50b2de fix(loop): preserve partial context when /stop cancels a task
When a user sends /stop to interrupt an active agent turn, the task is
cancelled via CancelledError. Previously, the cancellation handler just
logged and re-raised, discarding any tool results and assistant messages
accumulated during the interrupted turn.

The runtime checkpoint mechanism already persists partial turn state
(assistant messages, completed tool results, pending tool calls) into
session metadata via _emit_checkpoint. However, this checkpoint was only
materialized into session history on the NEXT incoming message via
_restore_runtime_checkpoint — not at cancellation time.

Now the CancelledError handler in _dispatch calls
_restore_runtime_checkpoint immediately, so the partial context is
preserved in session history. This means the next message the user sends
will see all the work that was done before /stop, rather than starting
from scratch.

Fixes #2966

Includes 3 tests verifying checkpoint restoration on cancellation.
2026-04-21 01:14:41 +08:00
hlgandXubin Ren 899a9073ce fix(memory): do not fall back to raw entry when strip_think empties it
`append_history` previously used `strip_think(entry) or entry.rstrip()`
as a safety net, so if the entire entry was a template-token leak (e.g.
`<think>reasoning</think>` or `<channel|>` alone), the raw leaked text
was still persisted to history — later re-introducing the very content
`strip_think` was meant to scrub, via consolidation / replay.

Persist the cleaned content directly. When cleanup empties a non-empty
entry, log at debug and store an empty-content record (cursor continuity
preserved). Adds 3 regression tests in test_memory_store.py covering:

  - Well-formed thinking blocks are stripped before persistence.
  - Pure-leak entries persist as empty, not as raw text.
  - Malformed prefix leaks (`<channel|>`) also persist as empty.
2026-04-20 17:04:48 +08:00
hlgandXubin Ren 8e7d8bef6a fix(utils): handle malformed think tags and channel markers in strip_think
Some models / Ollama renderers occasionally emit tokenizer-level template
leaks that the existing regexes miss:

  1. Malformed opening tags with no closing `>`, running straight into
     user-facing content — e.g. `<think广场照明灯目前…` (observed with
     Gemma 4 via Ollama). The earlier `<think>[\s\S]*?</think>` and
     `^\s*<think>[\s\S]*$` patterns both require `>`, so these leak into
     rendered messages.
  2. Harmony-style channel markers like `<channel|>` / `<|channel|>` at
     the start of a response.
  3. Orphan `</think>` / `</thought>` closing tags left behind when only
     the opener was consumed upstream.

Handles each case conservatively:

  - Malformed `<think` / `<thought` only match when the next char is NOT
    a tag-name continuation (`[A-Za-z0-9_\-:>/]`). Explicit ASCII class
    instead of `\w` because Python's Unicode `\w` matches CJK and would
    defeat the primary fix.
  - Orphan closing tags and channel markers are stripped **only at the
    start or end of the text**. `strip_think` is also applied before
    persisting history (memory.py), so mid-text stripping would silently
    rewrite transcripts where the tokens themselves are discussed.

Preserves: `<thinker>`, `<think-foo>`, `<think_foo>`, `<think1>`,
`<think:foo>`, `<thought/>`, literal `` `</think>` `` / `` `<channel|>` ``
inside prose or code blocks.

Adds 16 new regression tests covering both the leak cases and the
preserved-prose cases.
2026-04-20 17:04:48 +08:00
chengyongruandXubin Ren f900c5bb8e fix(telegram): address code review issues from cherry-pick merge
- Fix critical plain-text fallback that was sending raw HTML tags to
  users: keep raw markdown available for the fallback path
- Extract TELEGRAM_HTML_MAX_LEN (4096) constant to replace hardcoded
  magic number and document the difference from TELEGRAM_MAX_MESSAGE_LEN
- Add fallback to _send_text for extra HTML chunks when HTML parse fails
- Add missing @pytest.mark.asyncio decorator on
  test_send_delta_stream_end_html_expansion_does_not_overflow
2026-04-20 16:58:46 +08:00
2eea82f5ee fix(telegram): split oversized stream buffer mid-flight
Cherry-picked from #3311 (stutiredboy). Streaming edits called
edit_message_text(text=buf.text) without chunking, so once accumulated
deltas crossed Telegram's 4096-char limit an ongoing stream would fail
with BadRequest.

Extracts _flush_stream_overflow helper that edits the first chunk in
place, sends any middle chunks, and re-anchors the buffer to a new
message for the tail so subsequent deltas keep streaming.

Co-Authored-By: stutiredboy <stutiredboy@users.noreply.github.com>
2026-04-20 16:58:46 +08:00
himax12andXubin Ren fd8f08cc83 fix(telegram): convert markdown to HTML before splitting to avoid message length overflow
Cherry-picked from #3316 (himax12). When streaming completes in send_delta(),
the code was splitting raw markdown text by 4000, then converting to HTML.
The markdown-to-HTML conversion adds 10-33% characters, which could push
the result over Telegram's 4096 character limit.

The fix converts markdown to HTML first, then splits by 4096 (actual Telegram
limit), ensuring the edited message always fits.

Fixes #3315
2026-04-20 16:58:46 +08:00
jhkim43andXubin Ren 297b852f6e feat(telegram): change to mid-stream split per review feedback(#2967 PR) 2026-04-20 16:58:46 +08:00
chengyongruandXubin Ren ecfbb0ed4f refactor(email): use _remember_processed_uid in SPF/DKIM reject paths
Replaces inline dedup logic with the existing helper to match the
style of _is_self_address and other reject branches, and to keep the
_processed_uids eviction logic in one place.
2026-04-20 16:46:49 +08:00
flobo3andXubin Ren ffac8d3b0a fix: deduplicate SPF/DKIM-rejected emails to stop log spam 2026-04-20 16:46:49 +08:00
Xubin Ren 26fd2c099a build: ship THIRD_PARTY_NOTICES and fix webui packaging in wheel 2026-04-20 08:22:10 +00:00
chengyongruandXubin Ren 68466b1c2a fix(agent): propagate effective session key through subagent pipeline
The previous fix hardcoded session_key_override as channel:chat_id which
broke unified session mode where pending queues use "unified:default".
Propagate the effective key from _set_tool_context through SpawnTool
into the origin dict so _announce_result routes to the correct pending
queue in both normal and unified session modes.
2026-04-20 14:47:14 +08:00
chengyongruandXubin Ren 2193a64c80 fix(agent): align subagent result session key with main agent for mid-turn injection
When mid-turn message injection (PR #2985) was introduced, the pending
queue routing uses the effective session key to match incoming messages
against active sessions. Subagent results, however, use channel="system"
which produces a session key of "system:feishu:ou_..." instead of the
main agent's "feishu:ou_...", causing the result to bypass the pending
queue and be dispatched as a competing independent task.

Fix: set session_key_override to the original channel:chat_id so
_effective_session_key returns the correct key and the subagent result
gets routed into the main agent's pending queue.
2026-04-20 14:47:14 +08:00
chengyongruandXubin Ren 79821a571f fix: suppress intermediate progress output in cron jobs
Cron jobs now pass on_progress=_silent to process_direct, matching
the heartbeat pattern. Previously, tool hints and streaming deltas
were published to the user channel via bus during execution, but the
final response could be rejected by evaluate_response — leaving users
with confusing partial output and no conclusion.

Closes #3319
2026-04-20 11:43:54 +08:00
chengyongruandXubin Ren 8eddacf2f8 fix(webui): sync code block theme with dark mode toggle instantly
- Replace one-time DOM read with MutationObserver on <html> class
- Remove hardcoded #0a0a0a background, let oneDark/oneLight own it
- Add light-mode header/copy-button colors (bg-zinc-100 for light)
- Bump font size from 13px to 14px, line-height from 1.55 to 1.6
- Add subtle border to distinguish code block edges
2026-04-20 00:21:07 +08:00
chengyongruandXubin Ren a3adec08a9 style(webui): improve typography with Apple-inspired font stack and CJK support
- Add explicit CJK fonts (PingFang SC, Noto Sans SC, Microsoft YaHei) and
  programmer fonts (JetBrains Mono, Fira Code, Cascadia Code) to Tailwind config
- Bump prose base size from prose-sm (14px) to prose-lg (18px) for sharper CJK rendering
- Unify user/assistant message font size at 18px with CJK-aware line-height (1.8)
- Replace pure black/white foreground with Apple-style warm grays (#1d1d1f / #f5f5f7)
- Override Tailwind Typography colors to use design tokens for consistency
- Add negative letter-spacing on headings for tighter, more polished look
2026-04-20 00:21:07 +08:00
Xubin RenandXubin Ren 56a779c128 fix(session): repair read-only corrupt session paths 2026-04-20 00:17:50 +08:00
aiguozhi123456andXubin Ren efb04a1712 fix(session): use atomic writes and add corrupt-file repair
SessionManager.save() previously used bare open("w") which could
truncate the JSONL file if the process crashed mid-write. Now writes
to a .tmp file and atomically replaces via os.replace(), matching the
pattern already used in qq.py.

_load() now attempts _repair() before returning None, recovering
valid lines from partially-written files. 12 new tests cover atomic
save correctness, temp-file cleanup on failure, and repair of
truncated/corrupt JSONL.

cowork-with:opencode(glm-5.1)
2026-04-20 00:17:50 +08:00
Alfredo ArenasandXubin Ren 5d976d79ff test(discord): update tests for bot-to-bot fix (#3217)
The old test `test_on_message_ignores_bot_messages` asserted the
previous (incorrect) contract that ALL bot-authored messages are
dropped. With #3217 only self-loops are dropped, so this test was
replaced with three more precise tests:

- test_on_message_ignores_self_messages: verifies self-loop guard
  (author_id == _bot_user_id is dropped)
- test_on_message_accepts_messages_from_other_bots: new test for
  the fix itself — other bots' messages flow through
- test_on_message_stops_typing_on_handle_exception: preserves the
  typing cleanup assertion from the original test

Net result: +1 behavior tested, same behaviors retained.

Co-authored with Claude Opus 4.7
2026-04-19 23:32:40 +08:00
Alfredo ArenasandXubin Ren 3fd24c72fd fix(discord): allow bot-to-bot messaging, only drop self-loops (#3217)
Previously the Discord channel dropped every message from any bot
account via `if message.author.bot`, which prevented legitimate
multi-agent setups (one bot asking another for help, bot-to-bot
@mentions, etc.) from working.

Narrow the guard to only drop messages from this bot's own account
by comparing against self._bot_user_id (already populated in on_ready).
Self-loop protection is preserved — each bot instance still ignores
its own outbound messages.

Co-authored with Claude Opus 4.7
2026-04-19 23:32:40 +08:00
coldxiangyuXubin Renfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
7527961b19 fix(cron): drop top-level oneOf so OpenAI Codex/Responses accept tool schema
PR #3125 added a top-level `oneOf` branch to `_CRON_PARAMETERS` to
advertise per-action required fields. OpenAI Codex/Responses rejects
`oneOf`/`anyOf`/`allOf`/`enum`/`not` at the root of function
parameters, so any agent that registers the cron tool now fails to
start with:

    HTTP 400: Invalid schema for function 'cron': schema must have
    type 'object' and not have 'oneOf'/'anyOf'/'allOf'/'enum'/'not'
    at the top level.

Remove the top-level `oneOf`. The original intent of #3125 (stop LLMs
from looping on the #3113 contract mismatch) is preserved by:

  - `validate_params` — runtime-enforces `message` for `action='add'`
    and `job_id` for `action='remove'`
  - field descriptions — each schema field already flags
    "REQUIRED when action='...'" so the LLM sees the contract

The regression test is updated to lock the invariant in the other
direction: the top-level schema must not contain
`oneOf`/`anyOf`/`allOf`/`not`, and the REQUIRED hints must stay on
`message` and `job_id`.

Verified:
  - tests/cron/              70 passed
  - tests/agent/test_loop_cron_timezone.py + tests/providers/  232 passed

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-04-19 21:54:38 +08:00
Xubin Ren 97ae9cb318 docs: refine README for WebUI development workflow clarity 2026-04-19 13:42:02 +00:00
Xubin RenandGitHub d920f07715 Merge PR #3310: feat(webui): add initial browser UI with websocket chat and i18n
feat(webui): add initial browser UI with websocket chat and i18n
2026-04-19 21:41:07 +08:00
Xubin Ren b3049f7323 fix(webui): stabilize empty session history state 2026-04-19 13:38:47 +00:00
Xubin Ren f9e1d92abd docs: update README and webui documentation for WebUI development workflow 2026-04-19 13:10:36 +00:00
Xubin Ren c4b3837c5f Merge remote-tracking branch 'origin/main' into nanobot-webui 2026-04-19 12:36:52 +00:00
Xubin Ren 46e11a68a7 test: speed up cron and restart timing tests
Replace fixed sleep-based waits with condition polling in cron tests and mock the restart delay in CLI restart tests to reduce suite runtime without changing behavior.
2026-04-19 12:35:57 +00:00
Xubin RenandXubin Ren b6d63fb1ec fix: normalize responses circuit breaker keys
Made-with: Cursor
2026-04-19 20:16:25 +08:00
Mohamed ElkholyandXubin Ren 3036b16140 style: fix import sorting (ruff I001) 2026-04-19 20:16:25 +08:00
Mohamed ElkholyandXubin Ren 4aad6b737d style: move loguru import to module top level
Addresses reviewer suggestion to keep imports conventional.
2026-04-19 20:16:25 +08:00
Mohamed ElkholyandXubin Ren baba3b2160 fix(providers): add circuit breaker for Responses API fallback
When the Responses API fails repeatedly (3 consecutive compatibility
errors), skip it and fall back directly to Chat Completions.  Unlike a
permanent disable, the circuit re-probes after 5 minutes so recovery
is automatic when the API comes back.  Success resets the counter.

Keyed per (model, reasoning_effort) so a failure with one model does
not affect others.
2026-04-19 20:16:25 +08:00
Xubin RenandXubin Ren ccd6c05f71 fix: include pending summaries in consolidation estimates
Made-with: Cursor
2026-04-19 20:06:11 +08:00
Xubin RenandXubin Ren 54b659929e test: cover summary persistence after token consolidation
Made-with: Cursor
2026-04-19 20:06:11 +08:00
Jiajun XieandXubin Ren d95bc9c9c4 fix: unify summary injection strategy between consolidation paths
- Track last_summary in maybe_consolidate_by_tokens() to persist the summary
- Change return to break in the consolidation loop to allow summary persistence
- Save summary to session.metadata['_last_summary'] for consistency with AutoCompact._archive()
- Ensures compressed content remains visible to the model via prepare_session() injection

Fixes #3274
2026-04-19 20:06:11 +08:00
Xubin RenandXubin Ren 107eae14d7 docs: add badges for commit activity and closed issues in README 2026-04-19 19:25:05 +08:00
Xubin RenandXubin Ren 508e247c82 docs: remove feature showcase and update memory and Python SDK documentation for clarity and completeness 2026-04-19 19:25:05 +08:00
Xubin RenandXubin Ren ed150a4228 docs: enhance README installation instructions for better readability 2026-04-19 19:25:05 +08:00
Xubin RenandXubin Ren 622c467839 docs: refine README description for clarity 2026-04-19 19:25:05 +08:00
Xubin RenandXubin Ren 53fb3c199a docs: update README and docs for clarity and consistency 2026-04-19 19:25:05 +08:00
Xubin RenandXubin Ren 8ff7b56cb2 docs: refactor README into a docs-first landing page 2026-04-19 19:25:05 +08:00
Xubin Ren 4650b23d75 feat(webui): add i18n support and locale switcher 2026-04-19 06:39:06 +00:00
Xubin Ren be10ba1f0d Merge remote-tracking branch 'origin/main' into nanobot-webui 2026-04-19 05:15:27 +00:00
Alfredo ArenasandXubin Ren 2d0442976e test(cli): update _make_console tests for isatty-based fix (#3265)
The old test `test_make_console_uses_force_terminal` hardcoded
`force_terminal is True`, which contradicts the fix: we now defer
to sys.stdout.isatty() so piped / non-TTY output gets plain text
instead of ANSI escape codes.

Split into two tests covering both branches:

- test_make_console_force_terminal_when_stdout_is_tty: TTY path
  (force_terminal=True, rich output)
- test_make_console_force_terminal_false_when_stdout_is_not_tty:
  non-TTY path (force_terminal=False, plain text) — regression
  guard for the bug reported in #3265

Co-authored with Claude Opus 4.7
2026-04-19 04:19:59 +08:00
Alfredo ArenasandXubin Ren 261b843839 fix(cli): respect sys.stdout.isatty() in stream renderer (#3265) 2026-04-19 04:19:59 +08:00
Xubin RenandGitHub 9773d4b8ab Merge PR #3112: fix(config): return provider default api base in config resolution
fix(config): return provider default api base in config resolution
2026-04-19 04:14:46 +08:00
Xubin Ren 384bad17b4 Merge origin/main into fix/config-default-api-base
Made-with: Cursor
2026-04-18 20:08:21 +00:00
Xubin RenandGitHub 3218307f80 Merge PR #3125: fix: harden cron tool contract
fix: harden cron tool contract
2026-04-19 04:01:27 +08:00
Xubin Ren 9c0dc8b276 fix: drop generic repeated tool-call guard
The global guard changed baseline agent and subagent behavior without
proving a real no-progress loop. Keep this PR focused on the cron
contract hardening and validation fixes.

Made-with: Cursor
2026-04-18 19:59:58 +00:00
Xubin Ren adc1e843b4 Merge origin/main into fix/cron-contract-repeat-guard
Made-with: Cursor
2026-04-18 19:42:48 +00:00
Xubin RenandXubin Ren e08507f3ce fix: handle git worktrees in GitStore nested repo protection
Treat `.git` files the same as `.git` directories so GitStore refuses to initialize inside git worktrees, and add a focused regression test for that checkout shape.

Made-with: Cursor
2026-04-19 03:38:22 +08:00
Lê Bảo LongandXubin Ren ff5b97dc34 Remove .oss from .gitignore 2026-04-19 03:38:22 +08:00
longle325andXubin Ren fb28678b64 fix: prevent GitStore from creating nested repos and overwriting .gitignore (#2980)
GitStore.init() now checks if the workspace is already inside a git
repository before calling porcelain.init(). If so, it refuses to create
a nested repo. Additionally, existing .gitignore files are preserved
by appending only missing Dream-specific entries rather than overwriting.

Closes #2980
2026-04-19 03:38:22 +08:00
Xubin Ren 1b211c7d3a Merge branch 'main' into nanobot-webui
Made-with: Cursor
2026-04-18 19:17:16 +00:00
Xubin Ren 8f8e41fe06 chore: ignore tsbuildinfo cache files 2026-04-18 18:55:05 +00:00
Xubin Ren 9ed3031a42 feat(webui): add initial webui with websocket chat flow 2026-04-18 18:51:53 +00:00
chengyongruandXubin Ren 48692afa38 chore: remove PR template, keep only issue templates 2026-04-19 01:46:14 +08:00
chengyongruandXubin Ren 8f383655b5 feat: add issue and PR templates
Add structured issue templates for bug reports and feature requests,
with dropdown menus for channel, LLM provider, Python version, and OS.
Redirect questions to Discussions. Add PR template with checklist.

Ref: https://github.com/HKUDS/nanobot/discussions/3284
2026-04-19 01:46:14 +08:00
chengyongruandXubin Ren 5818569e8f feat(wizard): auto-detect Literal fields as select menus
Literal["standard", "persistent"] fields are now rendered as select
dropdowns instead of free-text input. This makes provider_retry_mode
and any future Literal fields self-documenting in the wizard.
2026-04-18 21:56:10 +08:00
chengyongruandXubin Ren ebb5179cab feat(wizard): add Channel Common, API Server menus and field constraint validation
- Add [H] Channel Common menu to configure send_progress, send_tool_hints,
  send_max_retries, and transcription_provider
- Add [I] API Server menu to configure host, port, timeout
- Add real-time Pydantic field constraint validation (ge/gt/le/lt/min_length/max_length)
  with constraint hints shown in field display (e.g. "Send Max Retries (0-10)")
- Add _pause() to View Configuration Summary to prevent immediate screen clear
- Fix _format_value dict branch to handle BaseModel instances without crashing
2026-04-18 21:56:10 +08:00
chengyongruandXubin Ren 58110afb88 fix(templates): keep Search & Discovery heading in identity.md
No reason to rename it to "Tools" — the section still covers the
same grep/glob search tips as before.
2026-04-18 21:55:56 +08:00
chengyongruandXubin Ren 34e8f97b1f refactor(templates): separate identity and SOUL responsibilities
Move all behavioral instructions out of identity.md into SOUL.md so that
each file has a single clear purpose:

- identity.md: capability facts only (runtime, workspace, format hints,
  tool guidance, untrusted content warning)
- SOUL.md: behavioral rules (name, personality, execution rules)

The "Act, don't narrate" rule is refined into layered behavior: act
immediately on single-step tasks, plan first for multi-step tasks. This
eliminates the contradiction where identity said "never end with a plan"
but user SOUL.md said "always plan first".
2026-04-18 21:55:56 +08:00
Xubin RenandXubin Ren 6bfb75ed03 feat(websocket): multiplex multiple chat_ids over a single connection 2026-04-18 16:49:12 +08:00
Xubin RenandXubin Ren 70a1279b86 test: pin retry-wait callback routing so internal heartbeats stay off channels
Add two focused regression tests for the retry-wait leak this PR fixes:

- tests/agent/test_runner.py::test_runner_binds_on_retry_wait_to_retry_callback_not_progress
  locks in that `AgentRunSpec.retry_wait_callback` (not `progress_callback`) is
  what `_build_request_kwargs` forwards to the provider as `on_retry_wait`.

- tests/channels/test_channel_manager_delta_coalescing.py::TestRetryWaitFiltering
  runs `_dispatch_outbound` end-to-end and asserts that `_retry_wait: True`
  messages never reach channel send.

Both tests fail on origin/main and pass with this PR's fix applied.

Made-with: Cursor
2026-04-18 13:50:05 +08:00
chengjun.zhuandXubin Ren 9c19de67bf fix: 错误消息流转路径:1. 当 LLM 服务出现临时性错误(如网络波动、超时、429限流等)时, base.py 中的 _run_with_retry 方法会启动重试机制。2. 在重试等待期间, _sleep_with_heartbeat 方法会周期性调用 on_retry_wait 回调函数,发送类似 'Model request failed, retry in 1s (attempt 1)' 的心跳消息。3. 之前 on_retry_wait 参数被错误地绑定到 _bus_progress ,导致这些内部诊断消息被当作普通进度消息发送到飞书客户端。4. manager.py 的消息分发器没有过滤这类重试心跳消息。 修复方案:1. loop.py - 新增重试等待回调- 新增独立的 _on_retry_wait 回调函数,为重试消息添加 _retry_wait: True 元数据标识- 在 AgentRunSpec 中传入 retry_wait_callback 参数。2. runner.py - 支持重试回调参数- 在 AgentRunSpec 数据类中新增 retry_wait_callback 字段- 在 _build_request_kwargs 中将 on_retry_wait 参数从 progress_callback 改为 retry_wait_callback。3. manager.py - 过滤重试心跳消息- 在 _dispatch_outbound 方法中新增过滤逻辑,丢弃所有带 _retry_wait 标识的消息,确保重试心跳不会发送到任何客户端。 2026-04-18 13:50:05 +08:00
Xubin RenandXubin Ren c8d834a504 fix(loop): document subagent-followup persistence and guard empty content
- Add inline rationale for persisting before ContextBuilder and for
  passing current_message="" on subagent follow-ups (avoids
  double-projection after merge).
- Skip persistence for empty subagent content (no-op messages should
  not pollute history).
- Add regression test covering the empty-content guard.

Made-with: Cursor
2026-04-18 13:30:22 +08:00
xzq.xuandXubin Ren 1c939e8a5f fix(loop): persist subagent follow-up events in history 2026-04-18 13:30:22 +08:00
04cbandXubin Ren c27b4d07c4 fix(utils): recurse into PPTX groups and tables when extracting text (#3250) 2026-04-18 12:30:42 +08:00
JunghwanNAandXubin Ren 34fccb2ee9 Prevent self-inspection from leaking configured secrets
MyTool blocks direct access to sensitive nested paths, but its formatter
still printed scalar fields for small config objects. That let
`my(action="check", key="web_config.search")` expose `api_key` in plain
text even though the docs promise sensitive sub-fields are protected.

This keeps the change narrow: sensitive nested config fields are omitted
from MyTool's formatted output, and regression coverage locks the
behavior in.

Constraint: Must preserve existing read-only inspection behavior for non-sensitive fields
Constraint: Keep scope limited to MyTool rather than introducing broader redaction plumbing
Rejected: Rework global context/tool redaction around MyTool | broader than needed for the leak path
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If more nested config rendering is added later, filter sensitive field names at the formatter boundary as well as the path resolver
Tested: PYTHONPATH=$PWD pytest -q tests/agent/tools/test_self_tool.py /Users/jh0927/Workspace/nanobot-validation-artifacts-2026-04-18/test_my_tool_secret_leak_regression.py
Not-tested: Full repository test suite
Related: #3259
2026-04-18 00:59:08 +08:00
JunghwanNAandXubin Ren c196b5b0c2 Prevent failed SSE requests from masquerading as successful completions
The streaming API currently logs backend exceptions but still emits the
same `finish_reason: "stop"` + `[DONE]` terminator used for successful
responses. That makes a failed streamed request look successful to
OpenAI-compatible clients.

This keeps the fix narrow: track whether the stream backend failed and
suppress the success terminator in that case. A regression test locks in
the expected behavior.

Constraint: Keep the non-streaming response path untouched
Constraint: Follow up on the known limitation called out during PR #3222 review without redesigning the SSE protocol
Rejected: Introduce a custom SSE error event shape in the same patch | expands API surface and review scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If explicit streamed error events are added later, keep them distinct from the success stop+[DONE] terminator to preserve client retry semantics
Tested: PYTHONPATH=$PWD pytest -q tests/test_api_stream.py /Users/jh0927/Workspace/nanobot-validation-artifacts-2026-04-18/test_api_stream_error_regression.py
Not-tested: Full repository test suite
Related: #3260
Related: #3222
2026-04-18 00:44:44 +08:00
SteveandXubin Ren 39dd59f2ba fix(cron): state per-action requirements in descriptions, keep list/remove callable
The previous patch promoted `message` into top-level `required`, which solved
the `add` loop but broke `list` and `remove`: `ToolRegistry.prepare_call`
enforces `required` via `validate_params`, so `cron(action="list")` and
`cron(action="remove", job_id=...)` — both documented in `SKILL.md` — started
failing schema validation with the same "missing required message" shape that
#3113 describes for `add`.

Instead:
- Keep `required=["action"]` so `list`/`remove` stay callable.
- Prefix `message`'s description with `REQUIRED when action='add'.` and
  `job_id`'s with `REQUIRED when action='remove'.` so LLMs see the real
  per-action contract up front.
- Keep the improved runtime error message from the previous commit for the
  case an LLM still omits `message` on `add`.

Also add `tests/cron/test_cron_tool_schema_contract.py` to lock in:
  - `list` and `remove` pass schema validation with no `message`
  - `add` with `message` passes
  - `add` without `message` surfaces the actionable runtime error
  - field descriptions carry the REQUIRED hints
  - top-level `required` stays `["action"]`

Existing `tests/cron/test_cron_tool_list.py` cases bypass schema validation by
calling `_list_jobs()` / `_remove_job()` directly, which is why CI didn't catch
the regression; the new test goes through `ToolRegistry.prepare_call`.
2026-04-17 22:52:48 +08:00
19dada927a fix: make cron tool schema require message for add action
Previously the JSON schema only required "action" but the runtime
rejected empty messages, causing LLM retry loops. Making "message"
required in the schema prevents the mismatch, and the improved error
message guides the LLM to retry with the correct parameters.

Fixes #3113

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:52:48 +08:00
Xubin RenandXubin Ren 14ee7cb121 style: revert unrelated Black-style formatting churn (#3220)
The earlier commits picked up a large amount of Black-style reformatting
(multi-line frozenset / keyword-arg wrapping / docstring blanks / removed
parens) on top of the actual guard fix. @chengyongru flagged it; the
first pass reverted some but not all.

This restores nanobot/providers/base.py, runner.py, heartbeat/service.py,
and utils/evaluator.py to origin/main and reapplies only the guard logic:

  - base.py: add should_execute_tools property
  - runner.py / heartbeat/service.py / utils/evaluator.py: route through it
    + log a warning when has_tool_calls but finish_reason is anomalous

Net diff vs main is now +87/-4 (was +211/-102) — roughly 30 lines of real
logic, which is what the PR is actually about.

Behavior unchanged from previous HEAD; full suite still 2014 passed.

Made-with: Cursor
2026-04-17 20:39:46 +08:00
Xubin RenandXubin Ren 9a569fdc6a style: collapse should_execute_tools docstring to one line
Made-with: Cursor
2026-04-17 20:39:46 +08:00
Xubin RenandXubin Ren b8d327dc41 test + docs: lock should_execute_tools guard semantics (#3220)
Two small follow-ups to the guard:

1. Fix the should_execute_tools docstring so it matches the actual code.
   The previous version said "Only execute when finish_reason explicitly
   signals tool intent" but the code also accepts finish_reason == "stop".
   Explain why (some compliant providers emit "stop" with legitimate tool
   calls — openai_compat_provider.py already mirrors this at lines ~633 /
   ~678 where ("tool_calls", "stop") are both treated as the terminal
   tool-call state). Without this, a strict "tool_calls"-only guard would
   regress 15 existing runner tests that construct LLMResponse with
   tool_calls but no explicit finish_reason (default = "stop").

2. Add tests/providers/test_llm_response.py. This locks the three cases:
   - no tool calls                  -> never executes
   - tool calls + "tool_calls"/stop -> executes
   - tool calls + refusal / content_filter / error / length / ... -> blocked

   These are exactly the boundary cases the #3220 fix is about; without a
   test here a future refactor could silently revert the guard.

Body + tests only, no behavior change beyond the existing PR's intent.

Made-with: Cursor
2026-04-17 20:39:46 +08:00
SubalandXubin Ren b7de21131f fixed the CI issue and reverted the formating changes 2026-04-17 20:39:46 +08:00
SubalandXubin Ren 322da6ca06 fix: guard tool execution against non-compliant API gateway injection 2026-04-17 20:39:46 +08:00
Cheng YongruandXubin Ren aabc3d5017 fix(memory): fall back to raw_archive on LLM error response
When chat_with_retry returns an error response (finish_reason='error')
instead of raising an exception, archive() previously treated the error
message as a valid summary and wrote it to history.jsonl, while the
original session data was already cleared by /new — causing irreversible
data loss.

Fix: check finish_reason after the LLM call and raise RuntimeError on
error responses, which naturally falls through to the existing raw_archive
fallback. This preserves the original messages in history.jsonl instead
of losing them.

Fixes #3244
2026-04-17 20:15:07 +08:00
Xubin RenandXubin Ren ebbed1cbe2 fix(docs): depend on nanobot-ai, not the unrelated nanobot package
The PyPI package `nanobot` is a different project ("Minimalist robot
navigation framework"), not this one. This project publishes as
`nanobot-ai` (see pyproject.toml). Following the guide as-written would
pull down the wrong package — flagged by vansatchen in #3188.

Same toml block as the build-backend fix, one-word change.

Made-with: Cursor
2026-04-17 17:08:34 +08:00
Jiajun XieandXubin Ren 19c1facf7f fix(docs): update channel plugin build backend to hatchling
The previous setuptools.backends._legacy:_Backend has been removed in
Python 3.14 and newer setuptools, causing 'Cannot import setuptools.backends.legacy' error.

Using hatchling (same as main project) ensures compatibility across Python versions.

Closes #3188
2026-04-17 17:08:34 +08:00
Mariano CampoandXubin Ren d0e65ebf70 fix(exec): pass allowed_env_keys to exec tool calls in subagents 2026-04-17 16:32:25 +08:00
Xubin RenandXubin Ren 3ae4333cef test(email): cover smtp_username / imap_username / case-insensitive self-address match
The original regression only exercised a from_address match with all three
identity fields set to the same value, so it couldn't distinguish whether
_self_addresses actually picks up smtp_username and imap_username or just
collapses on from_address. Add a parametrized test covering:

- smtp_username-only match (from_address empty, imap_username different) —
  simulates SMTP relays that rewrite outbound From to the login identity.
- imap_username-only match — simulates mailbox-identity setups.
- Case-insensitive match — inbound From arriving upper-cased must still hit.

No production code changes.

Made-with: Cursor
2026-04-17 16:25:16 +08:00
yorkhellenandXubin Ren 1011ea5ac8 fix(email): ignore self-sent mailbox messages
Skip inbound emails that come from the bot's own configured addresses so a mailbox wired to the same SMTP/IMAP account does not trigger infinite reply loops.
2026-04-17 16:25:16 +08:00
chengyongruandXubin Ren 8c0c4e5b31 refactor(agent): tighten comments, extract constant, strengthen edge case test
- Extract synthetic user message string to module-level constant
- Tighten comments in _snip_history recovery branch
- Strengthen no-user edge case test to verify safety net interaction
2026-04-17 16:20:53 +08:00
44b526c4ee fix(agent): preserve user message in _snip_history to prevent GLM error 1214
When _snip_history truncates the message history and the only user message
ends up outside the kept window, providers like GLM reject the resulting
system→assistant sequence with error 1214 ("messages 参数非法").

Two-layer fix:
1. _snip_history now walks backwards through non_system messages to recover
   the nearest user message when none exists in the kept window.
2. _enforce_role_alternation inserts a synthetic user message
   "(conversation continued)" when the first non-system message is a bare
   assistant (no tool_calls), serving as a safety net for any edge cases
   that slip through.

Co-authored-by: darlingbud <darlingbud@users.noreply.github.com>
2026-04-17 16:20:53 +08:00
Xubin RenandXubin Ren e9d727c3a5 docs(readme): flag Matrix channel as unsupported on Windows
#3194 adds `; sys_platform != 'win32'` markers to `matrix-nio[e2e]` so
`pip install nanobot-ai[matrix]` no longer fails on Windows — but it also
no longer installs matrix-nio there. Without this note, Windows users get
a silent half-install and discover the limitation only when the channel
crashes at startup.

Made-with: Cursor
2026-04-17 16:11:37 +08:00
Xubin RenandXubin Ren 5badb75f6c review: tighten scope and add regression tests
Follow-ups from review of #3194:

- ci.yml: drop unconditional --ignore=tests/channels/test_matrix_channel.py.
  That test file already calls pytest.importorskip("nio") at module top, so
  it self-skips on Windows (where nio isn't installed) without also hiding
  62 tests from Linux CI.

- filesystem.py: hoist `import os` to the module top and drop the duplicate
  inline import in ReadFileTool.execute. Document the CRLF->LF normalization
  as intentional (primarily a Windows UX fix so downstream StrReplace/Grep
  match consistently regardless of where the file was written).

- test_read_enhancements.py: lock down two new behaviors
  * TestFileStateHashFallback: check_read warns when content changes but
    mtime is unchanged (coarse-mtime filesystems on Windows).
  * TestReadFileLineEndingNormalization: ReadFileTool strips CRLF and
    preserves LF-only files untouched.

- test_tool_validation.py: restore list2cmdline/shlex.quote in
  test_exec_head_tail_truncation. The temp_path-based form was correct,
  but dropping the quoting broke on any Windows path containing spaces
  (e.g. C:\Users\John Doe\...). CI runners happen not to have spaces so
  this slipped through.

Tests: 1993 passed locally.
Made-with: Cursor
2026-04-17 16:11:37 +08:00
Jiajun XieandXubin Ren 3db2eb66e4 ci: add Windows and Python 3.14 support 2026-04-17 16:11:37 +08:00
yeyitech 655f3d2cc5 fix: harden cron tool contract and repeat guard 2026-04-14 12:40:23 +08:00
moranfong 0750d1f182 fix(config): return provider default api base in config resolution 2026-04-13 23:42:58 +08:00
chengyongruandchengyongru b3288fbc87 fix(log): only log auto-compact when messages are actually archived 2026-04-13 16:52:47 +08:00
chengyongruandchengyongru b311759e87 fix(log): remove noisy no-op logs from auto-compact
Remove two debug log lines that fire on every idle channel check:
- "scheduling archival" (logged before knowing if there's work)
- "skipping, no un-consolidated messages" (the common no-op path)

The meaningful "archived" info log (only on real work) is preserved.
2026-04-13 16:09:42 +08:00
chengyongruandchengyongru 89ea2375fd fix(provider): recover trailing assistant message as user to prevent empty request
When a subagent result is injected with current_role="assistant",
_enforce_role_alternation drops the trailing assistant message, leaving
only the system prompt. Providers like Zhipu/GLM reject such requests
with error 1214 ("messages parameter invalid"). Now the last popped
assistant message is recovered as a user message when no user/tool
messages remain.
2026-04-13 12:01:45 +08:00
chengyongruandchengyongru 62bd54ac4a fix(agent): skip auto-compact for sessions with active agent tasks
Prevent proactive compaction from archiving sessions that have an
in-flight agent task, avoiding mid-turn context truncation when a
task runs longer than the idle TTL.
2026-04-13 12:01:29 +08:00
191 changed files with 22460 additions and 2733 deletions
+135
View File
@@ -0,0 +1,135 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug! Please fill out the sections below to help us diagnose the issue.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear description of what went wrong.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: How can we reproduce this behavior?
placeholder: |
1. Configure nanobot with ...
2. Send message ...
3. See error ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: |
Paste any relevant log output. You can run nanobot with `--log-level DEBUG` for more verbose logs.
**Remember to redact any sensitive information (tokens, API keys, passwords, etc.)**
render: shell
- type: input
id: version
attributes:
label: nanobot Version
description: Run `nanobot --version` or `pip show nanobot-ai`
placeholder: e.g., 0.1.5
validations:
required: true
- type: dropdown
id: python_version
attributes:
label: Python Version
description: What Python version are you using?
options:
- "3.11"
- "3.12"
- "3.13"
- Other (specify below)
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Windows
- macOS
- Linux
- Docker
- Other (specify below)
validations:
required: true
- type: dropdown
id: channel
attributes:
label: Channel / Platform
description: Which messaging platform are you using?
options:
- Weixin (Personal WeChat)
- WeCom (Enterprise WeChat)
- Feishu (Lark)
- DingTalk
- Telegram
- Discord
- Slack
- QQ
- WhatsApp
- Email
- MS Teams
- Matrix
- WebSocket
- API Server
- Other (specify below)
validations:
required: true
- type: dropdown
id: llm_provider
attributes:
label: LLM Provider
description: Which LLM provider are you using?
options:
- OpenAI
- Anthropic (Claude)
- DeepSeek
- Google (Gemini)
- Ollama (Local)
- OpenRouter
- Azure OpenAI
- Other (specify below)
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration (Optional)
description: |
Relevant parts of your nanobot configuration. **Remember to redact any sensitive information.**
render: yaml
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context, screenshots, or information that might help.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Question / Support
url: https://github.com/HKUDS/nanobot/discussions
about: Ask questions and get help from the community in Discussions.
@@ -0,0 +1,55 @@
name: Feature Request
description: Suggest a new feature or enhancement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please describe your idea clearly.
- type: textarea
id: problem
attributes:
label: Problem / Motivation
description: What problem does this feature solve? What are you trying to accomplish?
placeholder: I'm always frustrated when ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: How would you like this to work?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: What other approaches have you considered?
- type: dropdown
id: component
attributes:
label: Related Component
description: Which part of nanobot does this relate to?
options:
- Channel (WeChat, Feishu, Telegram, etc.)
- LLM Provider
- Agent / Prompts
- Skills / Plugins
- Configuration
- CLI
- API Server
- Documentation
- Other
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context, examples from other projects, screenshots, etc.
+6 -4
View File
@@ -8,10 +8,11 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
python-version: ["3.11", "3.12", "3.13"] os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -24,10 +25,11 @@ jobs:
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v4 uses: astral-sh/setup-uv@v4
- name: Install system dependencies - name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
- name: Install all dependencies - name: Install dependencies
run: uv sync --all-extras run: uv sync --all-extras
- name: Lint with ruff - name: Lint with ruff
+7
View File
@@ -6,6 +6,13 @@
.web .web
.orion .orion
# webui (monorepo frontend)
webui/node_modules/
webui/dist/
webui/coverage/
webui/.vite/
*.tsbuildinfo
# Python bytecode & caches # Python bytecode & caches
*.pyc *.pyc
*.pyo *.pyo
+140 -2098
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
# Third-Party Notices
The following third-party components are redistributed as part of the packaged
nanobot Python distribution (`pip install nanobot-ai`).
---
## KaTeX — math rendering (MIT)
- **Source**: https://github.com/KaTeX/KaTeX
- **Bundled**: `nanobot/web/dist/assets/index-*.{js,css}`
```
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
## KaTeX Fonts — math typography (SIL OFL 1.1)
- **Source**: https://github.com/KaTeX/KaTeX/tree/main/src/fonts
- **Bundled**: `nanobot/web/dist/assets/KaTeX_*.{woff2,woff,ttf}`
The fonts are redistributed unmodified.
```
Copyright (c) 2009-2010, Design Science, Inc. (<www.mathjax.org>)
Copyright (c) 2014-2018 Khan Academy (<www.khanacademy.org>),
with Reserved Font Names KaTeX_AMS, KaTeX_Caligraphic, KaTeX_Fraktur,
KaTeX_Main, KaTeX_Math, KaTeX_SansSerif, KaTeX_Script, KaTeX_Size1,
KaTeX_Size2, KaTeX_Size3, KaTeX_Size4, KaTeX_Typewriter.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
```
-138
View File
@@ -1,138 +0,0 @@
# Python SDK
> **Note:** This interface is currently an experiment in the latest source code version and is planned to officially ship in `v0.1.5`.
Use nanobot programmatically — load config, run the agent, get results.
## Quick Start
```python
import asyncio
from nanobot import Nanobot
async def main():
bot = Nanobot.from_config()
result = await bot.run("What time is it in Tokyo?")
print(result.content)
asyncio.run(main())
```
## API
### `Nanobot.from_config(config_path?, *, workspace?)`
Create a `Nanobot` from a config file.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.nanobot/config.json`. |
| `workspace` | `str \| Path \| None` | `None` | Override workspace directory from config. |
Raises `FileNotFoundError` if an explicit path doesn't exist.
### `await bot.run(message, *, session_key?, hooks?)`
Run the agent once. Returns a `RunResult`.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `message` | `str` | *(required)* | The user message to process. |
| `session_key` | `str` | `"sdk:default"` | Session identifier for conversation isolation. Different keys get independent history. |
| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. |
```python
# Isolated sessions — each user gets independent conversation history
await bot.run("hi", session_key="user-alice")
await bot.run("hi", session_key="user-bob")
```
### `RunResult`
| Field | Type | Description |
|-------|------|-------------|
| `content` | `str` | The agent's final text response. |
| `tools_used` | `list[str]` | Tool names invoked during the run. |
| `messages` | `list[dict]` | Raw message history (for debugging). |
## Hooks
Hooks let you observe or modify the agent loop without touching internals.
Subclass `AgentHook` and override any method:
| Method | When |
|--------|------|
| `before_iteration(ctx)` | Before each LLM call |
| `on_stream(ctx, delta)` | On each streamed token |
| `on_stream_end(ctx)` | When streaming finishes |
| `before_execute_tools(ctx)` | Before tool execution (inspect `ctx.tool_calls`) |
| `after_iteration(ctx, response)` | After each LLM response |
| `finalize_content(ctx, content)` | Transform final output text |
### Example: Audit Hook
```python
from nanobot.agent import AgentHook, AgentHookContext
class AuditHook(AgentHook):
def __init__(self):
self.calls = []
async def before_execute_tools(self, ctx: AgentHookContext) -> None:
for tc in ctx.tool_calls:
self.calls.append(tc.name)
print(f"[audit] {tc.name}({tc.arguments})")
hook = AuditHook()
result = await bot.run("List files in /tmp", hooks=[hook])
print(f"Tools used: {hook.calls}")
```
### Composing Hooks
Pass multiple hooks — they run in order, errors in one don't block others:
```python
result = await bot.run("hi", hooks=[AuditHook(), MetricsHook()])
```
Under the hood this uses `CompositeHook` for fan-out with error isolation.
### `finalize_content` Pipeline
Unlike the async methods (fan-out), `finalize_content` is a pipeline — each hook's output feeds the next:
```python
class Censor(AgentHook):
def finalize_content(self, ctx, content):
return content.replace("secret", "***") if content else content
```
## Full Example
```python
import asyncio
from nanobot import Nanobot
from nanobot.agent import AgentHook, AgentHookContext
class TimingHook(AgentHook):
async def before_iteration(self, ctx: AgentHookContext) -> None:
import time
ctx.metadata["_t0"] = time.time()
async def after_iteration(self, ctx, response) -> None:
import time
elapsed = time.time() - ctx.metadata.get("_t0", 0)
print(f"[timing] iteration took {elapsed:.2f}s")
async def main():
bot = Nanobot.from_config(workspace="/my/project")
result = await bot.run(
"Explain the main function",
hooks=[TimingHook()],
)
print(result.content)
asyncio.run(main())
```
+34
View File
@@ -0,0 +1,34 @@
# nanobot Docs
For the latest documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview).
The pages in this directory track the current repository and may move faster than the published website.
## Core Docs
Start here for setup, everyday usage, and deployment.
| Topic | Repo docs | What it covers |
|---|---|---|
| Install and quick start | [`quick-start.md`](./quick-start.md) | Installation, onboarding, and first-run setup |
| Chat apps | [`chat-apps.md`](./chat-apps.md) | Connect nanobot to Telegram, Discord, WeChat, and more |
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | Local API endpoints, request format, and file uploads |
| Deployment | [`deployment.md`](./deployment.md) | Docker and Linux service setup |
## Advanced Docs
Use these when you want deeper customization, integration, or extension details.
| Topic | Repo docs | What it covers |
|---|---|---|
| Memory | [`memory.md`](./memory.md) | How nanobot stores, consolidates, and restores memory |
| Python SDK | [`python-sdk.md`](./python-sdk.md) | Use nanobot programmatically from Python |
| Channel plugin guide | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | Build and test custom chat channel plugins |
| WebSocket channel | [`websocket.md`](./websocket.md) | Real-time WebSocket access and protocol details |
| Custom tools | [`my-tool.md`](./my-tool.md) | Inspect and tune runtime state with the `my` tool |
+10
View File
@@ -0,0 +1,10 @@
# Agent Social Network
🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
| Platform | How to Join (send this message to your bot) |
|----------|-------------|
| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest.
@@ -19,7 +19,7 @@ We'll build a minimal webhook channel that receives messages via HTTP POST and s
### Project Structure ### Project Structure
``` ```text
nanobot-channel-webhook/ nanobot-channel-webhook/
├── nanobot_channel_webhook/ ├── nanobot_channel_webhook/
│ ├── __init__.py # re-export WebhookChannel │ ├── __init__.py # re-export WebhookChannel
@@ -135,14 +135,17 @@ class WebhookChannel(BaseChannel):
[project] [project]
name = "nanobot-channel-webhook" name = "nanobot-channel-webhook"
version = "0.1.0" version = "0.1.0"
dependencies = ["nanobot", "aiohttp"] dependencies = ["nanobot-ai", "aiohttp"]
[project.entry-points."nanobot.channels"] [project.entry-points."nanobot.channels"]
webhook = "nanobot_channel_webhook:WebhookChannel" webhook = "nanobot_channel_webhook:WebhookChannel"
[build-system] [build-system]
requires = ["setuptools"] requires = ["hatchling"]
build-backend = "setuptools.backends._legacy:_Backend" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["nanobot_channel_webhook"]
``` ```
The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass. The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
+661
View File
@@ -0,0 +1,661 @@
# Chat Apps
Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./channel-plugin-guide.md).
| Channel | What you need |
|---------|---------------|
| **Telegram** | Bot token from @BotFather |
| **Discord** | Bot token + Message Content intent |
| **WhatsApp** | QR code scan (`nanobot channels login whatsapp`) |
| **WeChat (Weixin)** | QR code scan (`nanobot channels login weixin`) |
| **Feishu** | App ID + App Secret |
| **DingTalk** | App Key + App Secret |
| **Slack** | Bot token + App-Level token |
| **Matrix** | Homeserver URL + Access token |
| **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret |
| **Wecom** | Bot ID + Bot Secret |
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
| **Mochat** | Claw token (auto-setup available) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
**1. Create a bot**
- Open Telegram, search `@BotFather`
- Send `/newbot`, follow prompts
- Copy the token
**2. Configure**
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"]
}
}
}
```
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`.
> Copy this value **without the `@` symbol** and paste it into the config file.
**3. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>Mochat (Claw IM)</b></summary>
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
**1. Ask nanobot to set up Mochat for you**
Simply send this message to nanobot (replace `xxx@xxx` with your real email):
```
Read https://raw.githubusercontent.com/HKUDS/MoChat/refs/heads/main/skills/nanobot/skill.md and register on MoChat. My Email account is xxx@xxx Bind me as your owner and DM me on MoChat.
```
nanobot will automatically register, configure `~/.nanobot/config.json`, and connect to Mochat.
**2. Restart gateway**
```bash
nanobot gateway
```
That's it — nanobot handles the rest!
<br>
<details>
<summary>Manual configuration (advanced)</summary>
If you prefer to configure manually, add the following to `~/.nanobot/config.json`:
> Keep `claw_token` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint.
```json
{
"channels": {
"mochat": {
"enabled": true,
"base_url": "https://mochat.io",
"socket_url": "https://mochat.io",
"socket_path": "/socket.io",
"claw_token": "claw_xxx",
"agent_user_id": "6982abcdef",
"sessions": ["*"],
"panels": ["*"],
"reply_delay_mode": "non-mention",
"reply_delay_ms": 120000
}
}
}
```
</details>
</details>
<details>
<summary><b>Discord</b></summary>
**1. Create a bot**
- Go to https://discord.com/developers/applications
- Create an application → Bot → Add Bot
- Copy the bot token
**2. Enable intents**
- In the Bot settings, enable **MESSAGE CONTENT INTENT**
- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
- Discord Settings → Advanced → enable **Developer Mode**
- Right-click your avatar → **Copy User ID**
**4. Configure**
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"],
"allowChannels": [],
"groupPolicy": "mention",
"streaming": true
}
}
}
```
> `groupPolicy` controls how the bot responds in group channels:
> - `"mention"` (default) — Only respond when @mentioned
> - `"open"` — Respond to all messages
> DMs always respond when the sender is in `allowFrom`.
> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.
> `allowChannels` restricts the bot to specific Discord channel IDs. Empty (default) means respond in every channel the bot can see. Example: `["1234567890", "0987654321"]`. The filter applies after `allowFrom`, so both must pass.
> `streaming` defaults to `true`. Disable it only if you explicitly want non-streaming replies.
**5. Invite the bot**
- OAuth2 → URL Generator
- Scopes: `bot`
- Bot Permissions: `Send Messages`, `Read Message History`
- Open the generated invite URL and add the bot to your server
**6. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>Matrix (Element)</b></summary>
Install Matrix dependencies first:
```bash
pip install nanobot-ai[matrix]
```
> [!NOTE]
> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on
> `python-olm`, which has no pre-built Windows wheel and is skipped by the
> `matrix` extra on `sys_platform == 'win32'`. The command above will still
> succeed on Windows but without `matrix-nio` installed, so enabling the
> Matrix channel will fail at startup. Use macOS, Linux, or WSL2.
**1. Create/choose a Matrix account**
- Create or reuse a Matrix account on your homeserver (for example `matrix.org`).
- Confirm you can log in with Element.
**2. Get credentials**
- You need:
- `userId` (example: `@nanobot:matrix.org`)
- `password`
(Note: `accessToken` and `deviceId` are still supported for legacy reasons, but
for reliable encryption, password login is recommended instead. If the
`password` is provided, `accessToken` and `deviceId` will be ignored.)
**3. Configure**
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"userId": "@nanobot:matrix.org",
"password": "mypasswordhere",
"e2eeEnabled": true,
"allowFrom": ["@your_user:matrix.org"],
"groupPolicy": "open",
"groupAllowFrom": [],
"allowRoomMentions": false,
"maxMediaBytes": 20971520
}
}
}
```
> Keep a persistent `matrix-store` — encrypted session state is lost if these change across restarts.
| Option | Description |
|--------|-------------|
| `allowFrom` | User IDs allowed to interact. Empty denies all; use `["*"]` to allow everyone. |
| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
**4. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>WhatsApp</b></summary>
Requires **Node.js ≥18**.
**1. Link device**
```bash
nanobot channels login whatsapp
# Scan QR with WhatsApp → Settings → Linked Devices
```
**2. Configure**
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"allowFrom": ["+1234567890"]
}
}
}
```
**3. Run** (two terminals)
```bash
# Terminal 1
nanobot channels login whatsapp
# Terminal 2
nanobot gateway
```
> WhatsApp bridge updates are not applied automatically for existing installations.
> After upgrading nanobot, rebuild the local bridge with:
> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
</details>
<details>
<summary><b>Feishu</b></summary>
Uses **WebSocket** long connection — no public IP required.
**1. Create a Feishu bot**
- Visit [Feishu Open Platform](https://open.feishu.cn/app)
- Create a new app → Enable **Bot** capability
- **Permissions**:
- `im:message` (send messages) and `im:message.p2p_msg:readonly` (receive messages)
- **Streaming replies** (default in nanobot): add **`cardkit:card:write`** (often labeled **Create and update cards** in the Feishu developer console). Required for CardKit entities and streamed assistant text. Older apps may not have it yet — open **Permission management**, enable the scope, then **publish** a new app version if the console requires it.
- If you **cannot** add `cardkit:card:write`, set `"streaming": false` under `channels.feishu` (see below). The bot still works; replies use normal interactive cards without token-by-token streaming.
- **Events**: Add `im.message.receive_v1` (receive messages)
- Select **Long Connection** mode (requires running nanobot first to establish connection)
- Get **App ID** and **App Secret** from "Credentials & Basic Info"
- Publish the app
**2. Configure**
```json
{
"channels": {
"feishu": {
"enabled": true,
"appId": "cli_xxx",
"appSecret": "xxx",
"encryptKey": "",
"verificationToken": "",
"allowFrom": ["ou_YOUR_OPEN_ID"],
"groupPolicy": "mention",
"reactEmoji": "OnIt",
"doneEmoji": "DONE",
"toolHintPrefix": "🔧",
"streaming": true,
"domain": "feishu"
}
}
}
```
> `streaming` defaults to `true`. Use `false` if your app does not have **`cardkit:card:write`** (see permissions above).
> `encryptKey` and `verificationToken` are optional for Long Connection mode.
> `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users.
> `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond.
> `reactEmoji`: Emoji for "processing" status (default: `OnIt`). See [available emojis](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce).
> `doneEmoji`: Optional emoji for "completed" status (e.g., `DONE`, `OK`, `HEART`). When set, bot adds this reaction after removing `reactEmoji`.
> `toolHintPrefix`: Prefix for inline tool hints in streaming cards (default: `🔧`).
> `domain`: `"feishu"` (default) for China (open.feishu.cn), `"lark"` for international Lark (open.larksuite.com).
**3. Run**
```bash
nanobot gateway
```
> [!TIP]
> Feishu uses WebSocket to receive messages — no webhook or public IP needed!
</details>
<details>
<summary><b>QQ (QQ单聊)</b></summary>
Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
**1. Register & create bot**
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
- Create a new bot application
- Go to **开发设置 (Developer Settings)** → copy **AppID** and **AppSecret**
**2. Set up sandbox for testing**
- In the bot management console, find **沙箱配置 (Sandbox Config)**
- Under **在消息列表配置**, click **添加成员** and add your own QQ number
- Once added, scan the bot's QR code with mobile QQ → open the bot profile → tap "发消息" to start chatting
**3. Configure**
> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access.
> - `msgFormat`: Optional. Use `"plain"` (default) for maximum compatibility with legacy QQ clients, or `"markdown"` for richer formatting on newer clients.
> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
```json
{
"channels": {
"qq": {
"enabled": true,
"appId": "YOUR_APP_ID",
"secret": "YOUR_APP_SECRET",
"allowFrom": ["YOUR_OPENID"],
"msgFormat": "plain"
}
}
}
```
**4. Run**
```bash
nanobot gateway
```
Now send a message to the bot from QQ — it should respond!
</details>
<details>
<summary><b>DingTalk (钉钉)</b></summary>
Uses **Stream Mode** — no public IP required.
**1. Create a DingTalk bot**
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
- Create a new app -> Add **Robot** capability
- **Configuration**:
- Toggle **Stream Mode** ON
- **Permissions**: Add necessary permissions for sending messages
- Get **AppKey** (Client ID) and **AppSecret** (Client Secret) from "Credentials"
- Publish the app
**2. Configure**
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"clientId": "YOUR_APP_KEY",
"clientSecret": "YOUR_APP_SECRET",
"allowFrom": ["YOUR_STAFF_ID"]
}
}
}
```
> `allowFrom`: Add your staff ID. Use `["*"]` to allow all users.
**3. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>Slack</b></summary>
Uses **Socket Mode** — no public URL required.
**1. Create a Slack app**
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
- Pick a name and select your workspace
**2. Configure the app**
- **Socket Mode**: Toggle ON → Generate an **App-Level Token** with `connections:write` scope → copy it (`xapp-...`)
- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
- **Event Subscriptions**: Toggle ON → Subscribe to bot events: `message.im`, `message.channels`, `app_mention` → Save Changes
- **App Home**: Scroll to **Show Tabs** → Enable **Messages Tab** → Check **"Allow users to send Slash commands and messages from the messages tab"**
- **Install App**: Click **Install to Workspace** → Authorize → copy the **Bot Token** (`xoxb-...`)
**3. Configure nanobot**
```json
{
"channels": {
"slack": {
"enabled": true,
"botToken": "xoxb-...",
"appToken": "xapp-...",
"allowFrom": ["YOUR_SLACK_USER_ID"],
"groupPolicy": "mention"
}
}
}
```
**4. Run**
```bash
nanobot gateway
```
DM the bot directly or @mention it in a channel — it should respond!
> [!TIP]
> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels).
> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
</details>
<details>
<summary><b>Email</b></summary>
Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** — like a personal email assistant.
**1. Get credentials (Gmail example)**
- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`)
- Enable 2-Step Verification → Create an [App Password](https://myaccount.google.com/apppasswords)
- Use this app password for both IMAP and SMTP
**2. Configure**
> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate — set `false` to fully disable.
> - `allowFrom`: Add your email address. Use `["*"]` to accept emails from anyone.
> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly.
> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
> - `allowedAttachmentTypes`: Save inbound attachments matching these MIME types — `["*"]` for all, e.g. `["application/pdf", "image/*"]` (default `[]` = disabled).
> - `maxAttachmentSize`: Max size per attachment in bytes (default `2000000` / 2MB).
> - `maxAttachmentsPerEmail`: Max attachments to save per email (default `5`).
```json
{
"channels": {
"email": {
"enabled": true,
"consentGranted": true,
"imapHost": "imap.gmail.com",
"imapPort": 993,
"imapUsername": "my-nanobot@gmail.com",
"imapPassword": "your-app-password",
"smtpHost": "smtp.gmail.com",
"smtpPort": 587,
"smtpUsername": "my-nanobot@gmail.com",
"smtpPassword": "your-app-password",
"fromAddress": "my-nanobot@gmail.com",
"allowFrom": ["your-real-email@gmail.com"],
"allowedAttachmentTypes": ["application/pdf", "image/*"]
}
}
}
```
**3. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>WeChat (微信 / Weixin)</b></summary>
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
**1. Install with WeChat support**
```bash
pip install "nanobot-ai[weixin]"
```
**2. Configure**
```json
{
"channels": {
"weixin": {
"enabled": true,
"allowFrom": ["YOUR_WECHAT_USER_ID"]
}
}
}
```
> - `allowFrom`: Add the sender ID you see in nanobot logs for your WeChat account. Use `["*"]` to allow all users.
> - `token`: Optional. If omitted, log in interactively and nanobot will save the token for you.
> - `routeTag`: Optional. When your upstream Weixin deployment requires request routing, nanobot will send it as the `SKRouteTag` header.
> - `stateDir`: Optional. Defaults to nanobot's runtime directory for Weixin state.
> - `pollTimeout`: Optional long-poll timeout in seconds.
**3. Login**
```bash
nanobot channels login weixin
```
Use `--force` to re-authenticate and ignore any saved token:
```bash
nanobot channels login weixin --force
```
**4. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>Wecom (企业微信)</b></summary>
> Here we use [wecom-aibot-sdk-python](https://github.com/chengyongru/wecom_aibot_sdk) (community Python version of the official [@wecom/aibot-node-sdk](https://www.npmjs.com/package/@wecom/aibot-node-sdk)).
>
> Uses **WebSocket** long connection — no public IP required.
**1. Install the optional dependency**
```bash
pip install nanobot-ai[wecom]
```
**2. Create a WeCom AI Bot**
Go to the WeCom admin console → Intelligent Robot → Create Robot → select **API mode** with **long connection**. Copy the Bot ID and Secret.
**3. Configure**
```json
{
"channels": {
"wecom": {
"enabled": true,
"botId": "your_bot_id",
"secret": "your_bot_secret",
"allowFrom": ["your_id"]
}
}
}
```
**4. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>Microsoft Teams</b> (MVP — DM only)</summary>
> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
> Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy.
**1. Install the optional dependency**
```bash
pip install nanobot-ai[msteams]
```
**2. Create a Teams / Azure bot app registration**
Create or reuse a Microsoft Teams / Azure bot app registration. Set the bot messaging endpoint to a public HTTPS URL ending in `/api/messages`.
**3. Configure**
```json
{
"channels": {
"msteams": {
"enabled": true,
"appId": "YOUR_APP_ID",
"appPassword": "YOUR_APP_SECRET",
"tenantId": "YOUR_TENANT_ID",
"host": "0.0.0.0",
"port": 3978,
"path": "/api/messages",
"allowFrom": ["*"],
"replyInThread": true,
"mentionOnlyResponse": "Hi — what can I help with?",
"validateInboundAuth": true
}
}
}
```
> - `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available.
> - `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention (`<at>Nanobot</at>`). Set to `""` to ignore mention-only messages.
> - `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation (signature, issuer, audience, lifetime, `serviceUrl`). This is the safe default for public deployments. Only set it to `false` for local development or tightly controlled testing.
**4. Run**
```bash
nanobot gateway
```
</details>
+33
View File
@@ -0,0 +1,33 @@
# In-Chat Commands
These commands work inside chat channels and interactive agent sessions:
| Command | Description |
|---------|-------------|
| `/new` | Stop current task and start a new conversation |
| `/stop` | Stop the current task |
| `/restart` | Restart the bot |
| `/status` | Show bot status |
| `/dream` | Run Dream memory consolidation now |
| `/dream-log` | Show the latest Dream memory change |
| `/dream-log <sha>` | Show a specific Dream memory change |
| `/dream-restore` | List recent Dream memory versions |
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
| `/help` | Show available in-chat commands |
## Periodic Tasks
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown
## Periodic Tasks
- [ ] Check weather forecast and send a summary
- [ ] Scan inbox for urgent emails
```
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
+21
View File
@@ -0,0 +1,21 @@
# CLI Reference
| Command | Description |
|---------|-------------|
| `nanobot onboard` | Initialize config & workspace at `~/.nanobot/` |
| `nanobot onboard --wizard` | Launch the interactive onboarding wizard |
| `nanobot onboard -c <config> -w <workspace>` | Initialize or refresh a specific instance config and workspace |
| `nanobot agent -m "..."` | Chat with the agent |
| `nanobot agent -w <workspace>` | Chat against a specific workspace |
| `nanobot agent -w <workspace> -c <config>` | Chat against a specific workspace/config |
| `nanobot agent` | Interactive chat mode |
| `nanobot agent --no-markdown` | Show plain-text replies |
| `nanobot agent --logs` | Show runtime logs during chat |
| `nanobot serve` | Start the OpenAI-compatible API |
| `nanobot gateway` | Start the gateway |
| `nanobot status` | Show status |
| `nanobot provider login openai-codex` | OAuth login for providers |
| `nanobot channels login <channel>` | Authenticate a channel interactively |
| `nanobot channels status` | Show channel status |
Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
+809
View File
@@ -0,0 +1,809 @@
# Configuration
Config file: `~/.nanobot/config.json`
> [!NOTE]
> If your config file is older than the current schema, you can refresh it without overwriting your existing values:
> run `nanobot onboard`, then answer `N` when asked whether to overwrite the config.
> nanobot will merge in missing default fields and keep your current settings.
## Environment Variables for Secrets
Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}` references that are resolved from environment variables at startup:
```json
{
"channels": {
"telegram": { "token": "${TELEGRAM_TOKEN}" },
"email": {
"imapPassword": "${IMAP_PASSWORD}",
"smtpPassword": "${SMTP_PASSWORD}"
}
},
"providers": {
"groq": { "apiKey": "${GROQ_API_KEY}" }
}
}
```
For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
```ini
# /etc/systemd/system/nanobot.service (excerpt)
[Service]
EnvironmentFile=/home/youruser/nanobot_secrets.env
User=nanobot
ExecStart=...
```
```bash
# /home/youruser/nanobot_secrets.env (mode 600, owned by youruser)
TELEGRAM_TOKEN=your-token-here
IMAP_PASSWORD=your-password-here
```
## Providers
> [!TIP]
> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead — the API key is picked from the matching provider config.
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
| `custom` | Any OpenAI-compatible endpoint | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) |
| `openai` | LLM + Voice transcription (Whisper) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) |
| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) |
| `minimax_anthropic` | LLM (MiniMax Anthropic-compatible endpoint, thinking mode) | [platform.minimaxi.com](https://platform.minimaxi.com) |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
| `ollama` | LLM (local, Ollama) | — |
| `lm_studio` | LLM (local, LM Studio) | — |
| `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) |
| `stepfun` | LLM (Step Fun/阶跃星辰) | [platform.stepfun.com](https://platform.stepfun.com) |
| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
<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.
**1. Login:**
```bash
nanobot provider login openai-codex
```
**2. Set model** (merge into `~/.nanobot/config.json`):
```json
{
"agents": {
"defaults": {
"model": "openai-codex/gpt-5.1-codex"
}
}
}
```
**3. Chat:**
```bash
nanobot agent -m "Hello!"
# Target a specific workspace/config locally
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
# One-off workspace override on top of that config
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
</details>
<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.
**1. Login:**
```bash
nanobot provider login github-copilot
```
**2. Set model** (merge into `~/.nanobot/config.json`):
```json
{
"agents": {
"defaults": {
"model": "github-copilot/gpt-4.1"
}
}
}
```
**3. Chat:**
```bash
nanobot agent -m "Hello!"
# Target a specific workspace/config locally
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
# One-off workspace override on top of that config
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
</details>
<details>
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
Connects directly to any OpenAI-compatible endpoint — llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Model name is passed as-is.
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.your-provider.com/v1"
}
},
"agents": {
"defaults": {
"model": "your-model-name"
}
}
}
```
> For local servers that don't require authentication, set `apiKey` to `null`.
>
> `custom` is the right choice for providers that expose an OpenAI-compatible **chat completions** API. It does **not** force third-party endpoints onto the OpenAI/Azure **Responses API**.
>
> If your proxy or gateway is specifically Responses-API-compatible, use the `azure_openai` provider shape instead and point `apiBase` at that endpoint:
>
> ```json
> {
> "providers": {
> "azure_openai": {
> "apiKey": "your-api-key",
> "apiBase": "https://api.your-provider.com",
> "defaultModel": "your-model-name"
> }
> },
> "agents": {
> "defaults": {
> "provider": "azure_openai",
> "model": "your-model-name"
> }
> }
> }
> ```
>
> In short: **chat-completions-compatible endpoint → `custom`**; **Responses-compatible endpoint → `azure_openai`**.
</details>
<details>
<summary><b>Ollama (local)</b></summary>
Run a local model with Ollama, then add to config:
**1. Start Ollama** (example):
```bash
ollama run llama3.2
```
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434"
}
},
"agents": {
"defaults": {
"provider": "ollama",
"model": "llama3.2"
}
}
}
```
> `provider: "auto"` also works when `providers.ollama.apiBase` is configured, but setting `"provider": "ollama"` is the clearest option.
</details>
<details>
<summary><b>LM Studio (local)</b></summary>
[LM Studio](https://lmstudio.ai/) provides a local OpenAI-compatible server for running LLMs. Download models through the LM Studio UI, then start the local server.
**1. Start LM Studio server:**
- Launch LM Studio
- Go to the "Local Server" tab
- Load a model (e.g., Llama, Mistral, Qwen)
- Click "Start Server" (default port: 1234)
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
```json
{
"providers": {
"lm_studio": {
"apiKey": null,
"apiBase": "http://localhost:1234/v1"
}
},
"agents": {
"defaults": {
"provider": "lm_studio",
"model": "local-model"
}
}
}
```
> **Note:** Set `apiKey` to `null` for LM Studio since it runs locally and doesn't require authentication. The model name should match what's shown in the LM Studio UI.
> `provider: "auto"` also works when `providers.lm_studio.apiBase` is configured, but setting `"provider": "lm_studio"` is the clearest option.
</details>
<details>
<summary><b>OpenVINO Model Server (local / OpenAI-compatible)</b></summary>
Run LLMs locally on Intel GPUs using [OpenVINO Model Server](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html). OVMS exposes an OpenAI-compatible API at `/v3`.
> Requires Docker and an Intel GPU with driver access (`/dev/dri`).
**1. Pull the model** (example):
```bash
mkdir -p ov/models && cd ov
docker run -d \
--rm \
--user $(id -u):$(id -g) \
-v $(pwd)/models:/models \
openvino/model_server:latest-gpu \
--pull \
--model_name openai/gpt-oss-20b \
--model_repository_path /models \
--source_model OpenVINO/gpt-oss-20b-int4-ov \
--task text_generation \
--tool_parser gptoss \
--reasoning_parser gptoss \
--enable_prefix_caching true \
--target_device GPU
```
> This downloads the model weights. Wait for the container to finish before proceeding.
**2. Start the server** (example):
```bash
docker run -d \
--rm \
--name ovms \
--user $(id -u):$(id -g) \
-p 8000:8000 \
-v $(pwd)/models:/models \
--device /dev/dri \
--group-add=$(stat -c "%g" /dev/dri/render* | head -n 1) \
openvino/model_server:latest-gpu \
--rest_port 8000 \
--model_name openai/gpt-oss-20b \
--model_repository_path /models \
--source_model OpenVINO/gpt-oss-20b-int4-ov \
--task text_generation \
--tool_parser gptoss \
--reasoning_parser gptoss \
--enable_prefix_caching true \
--target_device GPU
```
**3. Add to config** (partial — merge into `~/.nanobot/config.json`):
```json
{
"providers": {
"ovms": {
"apiBase": "http://localhost:8000/v3"
}
},
"agents": {
"defaults": {
"provider": "ovms",
"model": "openai/gpt-oss-20b"
}
}
}
```
> OVMS is a local server — no API key required. Supports tool calling (`--tool_parser gptoss`), reasoning (`--reasoning_parser gptoss`), and streaming.
> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
</details>
<details>
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
Run your own model with vLLM or any OpenAI-compatible server, then add to config:
**1. Start the server** (example):
```bash
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
```
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
*Provider (set API key to null for local servers):*
```json
{
"providers": {
"vllm": {
"apiKey": null,
"apiBase": "http://localhost:8000/v1"
}
}
}
```
*Model:*
```json
{
"agents": {
"defaults": {
"model": "meta-llama/Llama-3.1-8B-Instruct"
}
}
}
```
</details>
<details>
<summary><b>Adding a New Provider (Developer Guide)</b></summary>
nanobot uses a **Provider Registry** (`nanobot/providers/registry.py`) as the single source of truth.
Adding a new provider only takes **2 steps** — no if-elif chains to touch.
**Step 1.** Add a `ProviderSpec` entry to `PROVIDERS` in `nanobot/providers/registry.py`:
```python
ProviderSpec(
name="myprovider", # config field name
keywords=("myprovider", "mymodel"), # model-name keywords for auto-matching
env_key="MYPROVIDER_API_KEY", # env var name
display_name="My Provider", # shown in `nanobot status`
default_api_base="https://api.myprovider.com/v1", # OpenAI-compatible endpoint
)
```
**Step 2.** Add a field to `ProvidersConfig` in `nanobot/config/schema.py`:
```python
class ProvidersConfig(BaseModel):
...
myprovider: ProviderConfig = ProviderConfig()
```
That's it! Environment variables, model routing, config matching, and `nanobot status` display will all work automatically.
**Common `ProviderSpec` options:**
| Field | Description | Example |
|-------|-------------|---------|
| `default_api_base` | OpenAI-compatible base URL | `"https://api.deepseek.com"` |
| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` |
| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}), ("kimi-k2.6", {"temperature": 1.0}),)` |
| `is_gateway` | Can route any model (like OpenRouter) | `True` |
| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` |
| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` |
| `strip_model_prefix` | Strip provider prefix before sending to gateway | `True` (for AiHubMix) |
| `supports_max_completion_tokens` | Use `max_completion_tokens` instead of `max_tokens`; required for providers that reject both being set simultaneously (e.g. VolcEngine) | `True` |
</details>
## Channel Settings
Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
```json
{
"channels": {
"sendProgress": true,
"sendToolHints": false,
"sendMaxRetries": 3,
"transcriptionProvider": "groq",
"telegram": { ... }
}
}
```
| Setting | Default | Description |
|---------|---------|-------------|
| `sendProgress` | `true` | Stream agent's text progress to the channel |
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
### Retry Behavior
Retry is intentionally simple.
When a channel `send()` raises, nanobot retries at the channel-manager layer. By default, `channels.sendMaxRetries` is `3`, and that count includes the initial send.
- **Attempt 1**: Send immediately
- **Attempt 2**: Retry after `1s`
- **Attempt 3**: Retry after `2s`
- **Higher retry budgets**: Backoff continues as `1s`, `2s`, `4s`, then stays capped at `4s`
- **Transient failures**: Network hiccups and temporary API limits often recover on the next attempt
- **Permanent failures**: Invalid tokens, revoked access, or banned channels will exhaust the retry budget and fail cleanly
> [!NOTE]
> This design is deliberate: channel implementations should raise on delivery failure, and the channel manager owns the shared retry policy.
>
> Some channels may still apply small API-specific retries internally. For example, Telegram separately retries timeout and flood-control errors before surfacing a final failure to the manager.
>
> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
## Web Search
> [!TIP]
> Use `proxy` in `tools.web` to route all web requests (search + fetch) through a proxy:
> ```json
> { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } }
> ```
nanobot supports multiple web search providers. Configure in `~/.nanobot/config.json` under `tools.web.search`.
By default, web tools are enabled and web search uses `duckduckgo`, so search works out of the box without an API key.
If you want to disable all built-in web tools entirely, set `tools.web.enable` to `false`. This removes both `web_search` and `web_fetch` from the tool list sent to the LLM.
If you need to allow trusted private ranges such as Tailscale / CGNAT addresses, you can explicitly exempt them from SSRF blocking with `tools.ssrfWhitelist`:
```json
{
"tools": {
"ssrfWhitelist": ["100.64.0.0/10"]
}
}
```
| Provider | Config fields | Env var fallback | Free |
|----------|--------------|------------------|------|
| `brave` | `apiKey` | `BRAVE_API_KEY` | No |
| `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
| `kagi` | `apiKey` | `KAGI_API_KEY` | No |
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
| `duckduckgo` (default) | — | — | Yes |
**Disable all built-in web tools:**
```json
{
"tools": {
"web": {
"enable": false
}
}
}
```
**Brave:**
```json
{
"tools": {
"web": {
"search": {
"provider": "brave",
"apiKey": "BSA..."
}
}
}
}
```
**Tavily:**
```json
{
"tools": {
"web": {
"search": {
"provider": "tavily",
"apiKey": "tvly-..."
}
}
}
}
```
**Jina** (free tier with 10M tokens):
```json
{
"tools": {
"web": {
"search": {
"provider": "jina",
"apiKey": "jina_..."
}
}
}
}
```
**Kagi:**
```json
{
"tools": {
"web": {
"search": {
"provider": "kagi",
"apiKey": "your-kagi-api-key"
}
}
}
}
```
**SearXNG** (self-hosted, no API key needed):
```json
{
"tools": {
"web": {
"search": {
"provider": "searxng",
"baseUrl": "https://searx.example"
}
}
}
}
```
**DuckDuckGo** (zero config):
```json
{
"tools": {
"web": {
"search": {
"provider": "duckduckgo"
}
}
}
}
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) |
| `proxy` | string or null | `null` | Proxy for all web requests, for example `http://127.0.0.1:7890` |
### `tools.web.search`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `searxng`, `duckduckgo` |
| `apiKey` | string | `""` | API key for Brave or Tavily |
| `baseUrl` | string | `""` | Base URL for SearXNG |
| `maxResults` | integer | `5` | Results per search (110) |
## MCP (Model Context Protocol)
> [!TIP]
> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
Add MCP servers to your `config.json`:
```json
{
"tools": {
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
},
"my-remote-mcp": {
"url": "https://example.com/mcp/",
"headers": {
"Authorization": "Bearer xxxxx"
}
}
}
}
}
```
Two transport modes are supported:
| Mode | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
```json
{
"tools": {
"mcpServers": {
"my-slow-server": {
"url": "https://example.com/mcp/",
"toolTimeout": 120
}
}
}
}
```
Use `enabledTools` to register only a subset of tools from an MCP server:
```json
{
"tools": {
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
"enabledTools": ["read_file", "mcp_filesystem_write_file"]
}
}
}
}
```
`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
- Omit `enabledTools`, or set it to `["*"]`, to register all tools.
- Set `enabledTools` to `[]` to register no tools from that server.
- Set `enabledTools` to a non-empty list of names to register only that subset.
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
## Security
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`.
| Option | Default | Description |
|--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
## Auto Compact
When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input.
```json
{
"agents": {
"defaults": {
"idleCompactAfterMinutes": 15
}
}
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `agents.defaults.idleCompactAfterMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction starts. Set to `0` to disable. Recommended: `15` — close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
How it works:
1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration.
2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
> [!NOTE]
> Mental model: "summarize older context, keep the freshest live turns, **and overwrite the session file with the compact form.**" It is not a full `session.clear()`, but it is a write — not a soft cursor move.
>
> Concretely, auto compact rewrites `sessions/<key>.jsonl` in place: older messages (including their structured `tool_calls` / `tool_call_id` / `reasoning_content`) are replaced by just the retained recent suffix (currently 8 messages), while the archived prefix is preserved only as a plain-text summary appended to `memory/history.jsonl` (or a `[RAW] ...` flattened dump if LLM summarization fails). The original structured JSON of those turns is no longer recoverable from the session file.
>
> This differs from the **token-driven soft consolidation** that fires when a prompt exceeds the context budget: that path only advances an internal `last_consolidated` cursor and leaves the session file untouched, so the raw tool-call trail stays on disk and can still be replayed or audited. If you rely on that trail for debugging or auditing, leave `idleCompactAfterMinutes` at the default `0` and let only the token-driven path run.
## Timezone
Time is context. Context should be precise.
By default, nanobot uses `UTC` for runtime time context. If you want the agent to think in your local time, set `agents.defaults.timezone` to a valid [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones):
```json
{
"agents": {
"defaults": {
"timezone": "Asia/Shanghai"
}
}
}
```
This affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.
Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.
> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
## Unified Session
By default, each channel × chat ID combination gets its own session. If you use nanobot across multiple channels (e.g. Telegram + Discord + CLI) and want them to share the same conversation, enable `unifiedSession`:
```json
{
"agents": {
"defaults": {
"unifiedSession": true
}
}
}
```
When enabled, all incoming messages — regardless of which channel they arrive on — are routed into a single shared session. Switching from Telegram to Discord (or any other channel) continues the same conversation seamlessly.
| Behavior | `false` (default) | `true` |
|----------|-------------------|--------|
| Session key | `channel:chat_id` | `unified:default` |
| Cross-channel continuity | No | Yes |
| `/new` clears | Current channel session | Shared session |
| `/stop` finds tasks | By channel session | By shared session |
| Existing `session_key_override` (e.g. Telegram thread) | Respected | Still respected — not overwritten |
> This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change.
## Disabled Skills
nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
```json
{
"agents": {
"defaults": {
"disabledSkills": ["github", "weather"]
}
}
}
```
Disabled skills are excluded from the main agent's skill summary, from always-on skill injection, and from subagent skill summaries. This is useful when some bundled skills are unnecessary for your deployment or should not be exposed to end users.
| Option | Default | Description |
|--------|---------|-------------|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
+94
View File
@@ -0,0 +1,94 @@
# Deployment
## Docker
> [!TIP]
> The `-v ~/.nanobot:/home/nanobot/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
> The container runs as user `nanobot` (UID 1000). If you get **Permission denied**, fix ownership on the host first: `sudo chown -R 1000:1000 ~/.nanobot`, or pass `--user $(id -u):$(id -g)` to match your host UID. Podman users can use `--userns=keep-id` instead.
### Docker Compose
```bash
docker compose run --rm nanobot-cli onboard # first-time setup
vim ~/.nanobot/config.json # add API keys
docker compose up -d nanobot-gateway # start gateway
```
```bash
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
docker compose logs -f nanobot-gateway # view logs
docker compose down # stop
```
### Docker
```bash
# Build the image
docker build -t nanobot .
# Initialize config (first time only)
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
# Edit config on host to add API keys
vim ~/.nanobot/config.json
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
# Or run a single command
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status
```
## Linux Service
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
**1. Find the nanobot binary path:**
```bash
which nanobot # e.g. /home/user/.local/bin/nanobot
```
**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
```ini
[Unit]
Description=Nanobot Gateway
After=network.target
[Service]
Type=simple
ExecStart=%h/.local/bin/nanobot gateway
Restart=always
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=%h
[Install]
WantedBy=default.target
```
**3. Enable and start:**
```bash
systemctl --user daemon-reload
systemctl --user enable --now nanobot-gateway
```
**Common operations:**
```bash
systemctl --user status nanobot-gateway # check status
systemctl --user restart nanobot-gateway # restart after config changes
journalctl --user -u nanobot-gateway -f # follow logs
```
If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
>
> ```bash
> loginctl enable-linger $USER
> ```
+1 -3
View File
@@ -1,7 +1,5 @@
# Memory in nanobot # Memory in nanobot
> **Note:** This design is currently an experiment in the latest source code version and is planned to officially ship in `v0.1.5`.
nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic. nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic.
Good memory is not a pile of notes. It is a quiet system of attention. It notices what is worth keeping, lets go of what no longer needs the spotlight, and turns lived experience into something calm, durable, and useful. Good memory is not a pile of notes. It is a quiet system of attention. It notices what is worth keeping, lets go of what no longer needs the spotlight, and turns lived experience into something calm, durable, and useful.
@@ -65,7 +63,7 @@ This is why nanobot's memory is not just archival. It is interpretive.
## The Files ## The Files
``` ```text
workspace/ workspace/
├── SOUL.md # The bot's long-term voice and communication style ├── SOUL.md # The bot's long-term voice and communication style
├── USER.md # Stable knowledge about the user ├── USER.md # Stable knowledge about the user
+126
View File
@@ -0,0 +1,126 @@
# Multiple Instances
Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance.
## Quick Start
If you want each instance to have its own dedicated workspace from the start, pass both `--config` and `--workspace` during onboarding.
**Initialize instances:**
```bash
# Create separate instance configs and workspaces
nanobot onboard --config ~/.nanobot-telegram/config.json --workspace ~/.nanobot-telegram/workspace
nanobot onboard --config ~/.nanobot-discord/config.json --workspace ~/.nanobot-discord/workspace
nanobot onboard --config ~/.nanobot-feishu/config.json --workspace ~/.nanobot-feishu/workspace
```
**Configure each instance:**
Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. with different channel settings. The workspace you passed during `onboard` is saved into each config as that instance's default workspace.
**Run instances:**
```bash
# Instance A - Telegram bot
nanobot gateway --config ~/.nanobot-telegram/config.json
# Instance B - Discord bot
nanobot gateway --config ~/.nanobot-discord/config.json
# Instance C - Feishu bot with custom port
nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
```
## Path Resolution
When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`.
To open a CLI session against one of these instances locally:
```bash
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
# Optional one-off workspace override
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
```
> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
| Component | Resolved From | Example |
|-----------|---------------|---------|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
## How It Works
- `--config` selects which config file to load
- By default, the workspace comes from `agents.defaults.workspace` in that config
- If you pass `--workspace`, it overrides the workspace from the config file
## Minimal Setup
1. Copy your base config into a new instance directory.
2. Set a different `agents.defaults.workspace` for that instance.
3. Start the instance with `--config`.
Example config:
```json
{
"agents": {
"defaults": {
"workspace": "~/.nanobot-telegram/workspace",
"model": "anthropic/claude-sonnet-4-6"
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_TELEGRAM_BOT_TOKEN"
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
}
}
```
Start separate instances:
```bash
nanobot gateway --config ~/.nanobot-telegram/config.json
nanobot gateway --config ~/.nanobot-discord/config.json
```
Each gateway instance also exposes a lightweight HTTP health endpoint on
`gateway.host:gateway.port`. By default, the gateway binds to `127.0.0.1`,
so the endpoint stays local unless you explicitly set `gateway.host` to a
public or LAN-facing address.
- `GET /health` returns `{"status":"ok"}`
- Other paths return `404`
Override workspace for one-off runs when needed:
```bash
nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
```
## Common Use Cases
- Run separate bots for Telegram, Discord, Feishu, and other platforms
- Keep testing and production instances isolated
- Use different models or providers for different teams
- Serve multiple tenants with separate configs and runtime data
## Notes
- Each instance must use a different port if they run at the same time
- Use a different workspace per instance if you want isolated memory, sessions, and skills
- `--workspace` overrides the workspace defined in the config file
- Cron jobs and runtime media/state are derived from the config directory
+10 -10
View File
@@ -36,7 +36,7 @@ All modifications are held in memory only — restart restores defaults.
Without parameters, returns a key config overview: Without parameters, returns a key config overview:
``` ```text
my(action="check") my(action="check")
# → max_iterations: 40 # → max_iterations: 40
# context_window_tokens: 65536 # context_window_tokens: 65536
@@ -51,7 +51,7 @@ my(action="check")
With a key parameter, drill into a specific config: With a key parameter, drill into a specific config:
``` ```text
my(action="check", key="_last_usage.prompt_tokens") my(action="check", key="_last_usage.prompt_tokens")
# → How many prompt tokens I've used so far # → How many prompt tokens I've used so far
@@ -79,7 +79,7 @@ my(action="check", key="web_config.enable")
Changes take effect immediately, no restart required. Changes take effect immediately, no restart required.
``` ```text
my(action="set", key="max_iterations", value=80) my(action="set", key="max_iterations", value=80)
# → Bump iteration limit from 40 to 80 # → Bump iteration limit from 40 to 80
@@ -92,7 +92,7 @@ my(action="set", key="context_window_tokens", value=131072)
You can also store custom state in your scratchpad: You can also store custom state in your scratchpad:
``` ```text
my(action="set", key="current_project", value="nanobot") my(action="set", key="current_project", value="nanobot")
my(action="set", key="user_style_preference", value="concise") my(action="set", key="user_style_preference", value="concise")
my(action="set", key="task_complexity", value="high") my(action="set", key="task_complexity", value="high")
@@ -117,21 +117,21 @@ Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_char
### "This task is complex, I need more room" ### "This task is complex, I need more room"
``` ```text
Agent: This codebase is large, let me expand my context window to handle it. Agent: This codebase is large, let me expand my context window to handle it.
→ my(action="set", key="context_window_tokens", value=131072) → my(action="set", key="context_window_tokens", value=131072)
``` ```
### "Simple question, don't waste compute" ### "Simple question, don't waste compute"
``` ```text
Agent: This is a straightforward question, let me switch to a faster model. Agent: This is a straightforward question, let me switch to a faster model.
→ my(action="set", key="model", value="fast-model") → my(action="set", key="model", value="fast-model")
``` ```
### "Remember user preferences across turns" ### "Remember user preferences across turns"
``` ```text
Turn 1: my(action="set", key="user_prefers_concise", value=True) Turn 1: my(action="set", key="user_prefers_concise", value=True)
Turn 2: my(action="check", key="user_prefers_concise") Turn 2: my(action="check", key="user_prefers_concise")
# → True (still remembers the user likes concise replies) # → True (still remembers the user likes concise replies)
@@ -139,7 +139,7 @@ Turn 2: my(action="check", key="user_prefers_concise")
### "Self-diagnosis" ### "Self-diagnosis"
``` ```text
User: "Why aren't you searching the web?" User: "Why aren't you searching the web?"
Agent: Let me check my web config. Agent: Let me check my web config.
→ my(action="check", key="web_config.enable") → my(action="check", key="web_config.enable")
@@ -149,7 +149,7 @@ Agent: Web search is disabled — please set web.enable: true in your config.
### "Token budget management" ### "Token budget management"
``` ```text
Agent: Let me check how much budget I have left. Agent: Let me check how much budget I have left.
→ my(action="check", key="_last_usage") → my(action="check", key="_last_usage")
# → {"prompt_tokens": 45000, "completion_tokens": 8000} # → {"prompt_tokens": 45000, "completion_tokens": 8000}
@@ -158,7 +158,7 @@ Agent: I've used ~53k tokens total so far. I'll keep my remaining replies concis
### "Subagent monitoring" ### "Subagent monitoring"
``` ```text
Agent: Let me check on the background tasks. Agent: Let me check on the background tasks.
→ my(action="check", key="subagents") → my(action="check", key="subagents")
# → 2 subagent(s): # → 2 subagent(s):
+121
View File
@@ -0,0 +1,121 @@
# OpenAI-Compatible API
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
```bash
pip install "nanobot-ai[api]"
nanobot serve
```
By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`.
## Behavior
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
- Single-message input: each request must contain exactly one `user` message
- Fixed model: omit `model`, or pass the same model shown by `/v1/models`
- Streaming: set `stream=true` to receive Server-Sent Events (`text/event-stream`) with OpenAI-compatible delta chunks, terminated by `data: [DONE]`; omit or set `stream=false` for a single JSON response
- **File uploads**: supports images, PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) via JSON base64 or `multipart/form-data` (max 10MB per file)
- API requests run in the synthetic `api` channel, so the `message` tool does **not** automatically deliver to Telegram/Discord/etc. To proactively send to another chat, call `message` with an explicit `channel` and `chat_id` for an enabled channel.
Example tool call for cross-channel delivery from an API session:
```json
{
"content": "Build finished successfully.",
"channel": "telegram",
"chat_id": "123456789"
}
```
If `channel` points to a channel that is not enabled in your config, nanobot will queue the outbound event but no platform delivery will occur.
## Endpoints
- `GET /health`
- `GET /v1/models`
- `POST /v1/chat/completions`
## curl
```bash
curl http://127.0.0.1:8900/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "hi"}],
"session_id": "my-session"
}'
```
## File Upload (JSON base64)
Send images inline using the OpenAI multimodal content format:
```bash
curl http://127.0.0.1:8900/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR..."}}
]}]
}'
```
## File Upload (multipart/form-data)
Upload any supported file type (images, PDF, Word, Excel, PPT) via multipart:
```bash
# Single file
curl http://127.0.0.1:8900/v1/chat/completions \
-F "message=Summarize this report" \
-F "files=@report.docx"
# Multiple files with session isolation
curl http://127.0.0.1:8900/v1/chat/completions \
-F "message=Compare these files" \
-F "files=@chart.png" \
-F "files=@data.xlsx" \
-F "session_id=my-session"
```
Supported file types:
- **Images**: PNG, JPEG, GIF, WebP (sent to AI as base64 for vision analysis)
- **Documents**: PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) (text extracted and sent to AI)
- **Text**: TXT, Markdown, CSV, JSON, etc. (read directly)
## Python (`requests`)
```python
import requests
resp = requests.post(
"http://127.0.0.1:8900/v1/chat/completions",
json={
"messages": [{"role": "user", "content": "hi"}],
"session_id": "my-session", # optional: isolate conversation
},
timeout=120,
)
resp.raise_for_status()
print(resp.json()["choices"][0]["message"]["content"])
```
## Python (`openai`)
```python
from openai import OpenAI
client = OpenAI(
base_url="http://127.0.0.1:8900/v1",
api_key="dummy",
)
resp = client.chat.completions.create(
model="MiniMax-M2.7",
messages=[{"role": "user", "content": "hi"}],
extra_body={"session_id": "my-session"}, # optional: isolate conversation
)
print(resp.choices[0].message.content)
```
+219
View File
@@ -0,0 +1,219 @@
# Python SDK
Use nanobot as a library — no CLI, no gateway, just Python.
## Quick Start
```python
import asyncio
from nanobot import Nanobot
async def main() -> None:
bot = Nanobot.from_config()
result = await bot.run("What time is it in Tokyo?")
print(result.content)
asyncio.run(main())
```
`Nanobot.from_config()` reuses your normal `~/.nanobot/config.json`, so the SDK follows the same provider, model, tools, and workspace defaults as the CLI unless you override them.
## Common Patterns
### Use a specific config or workspace
```python
from nanobot import Nanobot
bot = Nanobot.from_config(
config_path="~/.nanobot/config.json",
workspace="/my/project",
)
```
### Isolate conversations with `session_key`
Different session keys keep independent conversation history:
```python
await bot.run("hi", session_key="user-alice")
await bot.run("hi", session_key="task-42")
```
### Attach hooks for observability
Hooks let you inspect tool calls, streaming, and iteration state without modifying nanobot internals:
```python
from nanobot.agent import AgentHook, AgentHookContext
class AuditHook(AgentHook):
async def before_execute_tools(self, context: AgentHookContext) -> None:
for tc in context.tool_calls:
print(f"[tool] {tc.name}")
result = await bot.run("Review this change", hooks=[AuditHook()])
```
## API Reference
### `Nanobot.from_config(config_path=None, *, workspace=None)`
Create a `Nanobot` instance from a config file.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.nanobot/config.json`. |
| `workspace` | `str \| Path \| None` | `None` | Override the workspace directory from config. |
Raises `FileNotFoundError` if an explicit config path does not exist.
### `await bot.run(message, *, session_key="sdk:default", hooks=None)`
Run the agent once and return a `RunResult`.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `message` | `str` | *(required)* | The user message to process. |
| `session_key` | `str` | `"sdk:default"` | Session identifier for conversation isolation. Different keys get independent history. |
| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. |
### `RunResult`
| Field | Type | Description |
|-------|------|-------------|
| `content` | `str` | The agent's final text response. |
| `tools_used` | `list[str]` | Reserved for richer SDK introspection; may be empty in current versions. |
| `messages` | `list[dict]` | Reserved for richer SDK introspection; may be empty in current versions. |
## Hooks
Hooks let you observe or customize the agent loop. Subclass `AgentHook` and override the methods you need.
### Hook lifecycle
| Method | When |
|--------|------|
| `wants_streaming()` | Return `True` if you want token-by-token `on_stream()` callbacks |
| `before_iteration(context)` | Before each LLM call |
| `on_stream(context, delta)` | On each streamed token when streaming is enabled |
| `on_stream_end(context, *, resuming)` | When streaming finishes |
| `before_execute_tools(context)` | Before tool execution |
| `after_iteration(context)` | After each iteration |
| `finalize_content(context, content)` | Transform final output text |
Useful fields on `AgentHookContext` include:
- `iteration`
- `messages`
- `response`
- `usage`
- `tool_calls`
- `tool_results`
- `tool_events`
- `final_content`
- `stop_reason`
- `error`
### Example: audit tool calls
```python
from nanobot.agent import AgentHook, AgentHookContext
class AuditHook(AgentHook):
def __init__(self) -> None:
super().__init__()
self.calls: list[str] = []
async def before_execute_tools(self, context: AgentHookContext) -> None:
for tc in context.tool_calls:
self.calls.append(tc.name)
print(f"[audit] {tc.name}({tc.arguments})")
```
```python
hook = AuditHook()
result = await bot.run("List files in /tmp", hooks=[hook])
print(result.content)
print(f"Tools observed: {hook.calls}")
```
### Example: receive streaming tokens
```python
from nanobot.agent import AgentHook, AgentHookContext
class StreamingHook(AgentHook):
def wants_streaming(self) -> bool:
return True
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
print(delta, end="", flush=True)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
print()
```
### Compose multiple hooks
Pass multiple hooks when you want to combine behaviors:
```python
result = await bot.run("hi", hooks=[AuditHook(), MetricsHook()])
```
Async hook methods are fan-out with error isolation. `finalize_content` is a pipeline: each hook receives the previous hook's output.
### Example: post-process final content
```python
from nanobot.agent import AgentHook
class Censor(AgentHook):
def finalize_content(self, context, content):
return content.replace("secret", "***") if content else content
```
## Full Example
```python
import asyncio
import time
from nanobot import Nanobot
from nanobot.agent import AgentHook, AgentHookContext
class TimingHook(AgentHook):
def __init__(self) -> None:
super().__init__()
self._started_at = 0.0
async def before_iteration(self, context: AgentHookContext) -> None:
self._started_at = time.perf_counter()
async def after_iteration(self, context: AgentHookContext) -> None:
elapsed_ms = (time.perf_counter() - self._started_at) * 1000
print(f"[timing] iteration {context.iteration} took {elapsed_ms:.1f}ms")
async def main() -> None:
bot = Nanobot.from_config(workspace="/my/project")
result = await bot.run(
"Explain the main function",
session_key="sdk:demo",
hooks=[TimingHook()],
)
print(result.content)
asyncio.run(main())
```
+104
View File
@@ -0,0 +1,104 @@
# Install and Quick Start
## Install
> [!IMPORTANT]
> This README may describe features that are available first in the latest source code.
> If you want the newest features and experiments, install from source.
> If you want the most stable day-to-day experience, install from PyPI or with `uv`.
**Install from source** (latest features, experimental changes may land here first; recommended for development)
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
pip install -e .
```
**Install with [uv](https://github.com/astral-sh/uv)** (stable release, fast)
```bash
uv tool install nanobot-ai
```
**Install from PyPI** (stable release)
```bash
pip install nanobot-ai
```
### Update to latest version
**PyPI / pip**
```bash
pip install -U nanobot-ai
nanobot --version
```
**uv**
```bash
uv tool upgrade nanobot-ai
nanobot --version
```
**Using WhatsApp?** Rebuild the local bridge after upgrading:
```bash
rm -rf ~/.nanobot/bridge
nanobot channels login whatsapp
```
## Quick Start
> [!TIP]
> Set your API key in `~/.nanobot/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global)
>
> For other LLM providers, please see [`configuration.md`](./configuration.md).
>
> For web search capability setup, please see the web-search section in [`configuration.md`](./configuration.md#web-search).
**1. Initialize**
```bash
nanobot onboard
```
Use `nanobot onboard --wizard` if you want the interactive setup wizard.
**2. Configure** (`~/.nanobot/config.json`)
Configure these **two parts** in your config (other options have defaults).
*Set your API key* (e.g. OpenRouter, recommended for global users):
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
}
}
```
*Set your model* (optionally pin a provider — defaults to auto-detection):
```json
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5",
"provider": "openrouter"
}
}
}
```
**3. Chat**
```bash
nanobot agent
```
That's it! You have a working AI agent in 2 minutes.
+73 -8
View File
@@ -7,7 +7,7 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs
- Bidirectional real-time communication over WebSocket - Bidirectional real-time communication over WebSocket
- Streaming support — receive agent responses token by token - Streaming support — receive agent responses token by token
- Token-based authentication (static tokens and short-lived issued tokens) - Token-based authentication (static tokens and short-lived issued tokens)
- Per-connection sessions — each connection gets a unique `chat_id` - Multi-chat multiplexing — one connection can run many concurrent `chat_id`s
- TLS/SSL support (WSS) with enforced TLSv1.2 minimum - TLS/SSL support (WSS) with enforced TLSv1.2 minimum
- Client allow-list via `allowFrom` - Client allow-list via `allowFrom`
- Auto-cleanup of dead connections - Auto-cleanup of dead connections
@@ -42,7 +42,7 @@ nanobot gateway
You should see: You should see:
``` ```text
WebSocket server listening on ws://127.0.0.1:8765/ WebSocket server listening on ws://127.0.0.1:8765/
``` ```
@@ -68,7 +68,7 @@ asyncio.run(main())
## Connection URL ## Connection URL
``` ```text
ws://{host}:{port}{path}?client_id={id}&token={token} ws://{host}:{port}{path}?client_id={id}&token={token}
``` ```
@@ -98,6 +98,7 @@ All frames are JSON text. Each message has an `event` field.
```json ```json
{ {
"event": "message", "event": "message",
"chat_id": "uuid-v4",
"text": "Hello! How can I help?", "text": "Hello! How can I help?",
"media": ["/tmp/image.png"], "media": ["/tmp/image.png"],
"reply_to": "msg-id" "reply_to": "msg-id"
@@ -111,6 +112,7 @@ All frames are JSON text. Each message has an `event` field.
```json ```json
{ {
"event": "delta", "event": "delta",
"chat_id": "uuid-v4",
"text": "Hello", "text": "Hello",
"stream_id": "s1" "stream_id": "s1"
} }
@@ -121,25 +123,46 @@ All frames are JSON text. Each message has an `event` field.
```json ```json
{ {
"event": "stream_end", "event": "stream_end",
"chat_id": "uuid-v4",
"stream_id": "s1" "stream_id": "s1"
} }
``` ```
**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
```json
{"event": "attached", "chat_id": "uuid-v4"}
```
**`error`** — soft error for malformed inbound envelopes. The connection stays open:
```json
{"event": "error", "detail": "invalid chat_id"}
```
### Client → Server ### Client → Server
Send plain text: **Legacy (default chat):** send a plain string, or a JSON object with a recognized text field:
```json ```json
"Hello nanobot!" "Hello nanobot!"
``` ```
Or send a JSON object with a recognized text field:
```json ```json
{"content": "Hello nanobot!"} {"content": "Hello nanobot!"}
``` ```
Recognized fields: `content`, `text`, `message` (checked in that order). Invalid JSON is treated as plain text. Recognized fields: `content`, `text`, `message` (checked in that order). Invalid JSON is treated as plain text. These frames route to the connection's default `chat_id` (the one announced in `ready`).
**Typed envelopes (multi-chat):** any JSON object with a string `type` field is a typed envelope:
| `type` | Fields | Effect |
|--------|--------|--------|
| `new_chat` | — | Server mints a new `chat_id`, subscribes this connection, replies with `attached`. |
| `attach` | `chat_id` | Subscribe to an existing `chat_id` (e.g. after a page reload). Replies with `attached`. |
| `message` | `chat_id`, `content` | Send `content` on `chat_id`. First use auto-attaches; no explicit `attach` needed. |
See [Multi-chat multiplexing](#multi-chat-multiplexing) for the full flow.
## Configuration Reference ## Configuration Reference
@@ -243,11 +266,53 @@ websocat "ws://127.0.0.1:8765/ws?client_id=alice&token=nbwt_aBcDeFg..."
- Outstanding tokens are capped at 10,000. Requests beyond this return HTTP 429. - Outstanding tokens are capped at 10,000. Requests beyond this return HTTP 429.
- Expired tokens are purged lazily on each issue or validation request. - Expired tokens are purged lazily on each issue or validation request.
## Multi-chat multiplexing
A single WebSocket can carry many concurrent chats. The server tracks `chat_id -> {connections}` as a fan-out set, so the same chat can also be mirrored across multiple connections (e.g. two browser tabs).
### Typical flow (web UI with a sidebar)
```text
client server
| --- connect --------------------> |
| <-- {"event":"ready", |
| "chat_id":"d3..."} (default)|
| |
| --- {"type":"new_chat"} ---------> |
| <-- {"event":"attached", |
| "chat_id":"a1..."} |
| |
| --- {"type":"message", |
| "chat_id":"a1...", |
| "content":"hi"} ------------> |
| <-- {"event":"delta", ...} |
| <-- {"event":"stream_end", ...} |
| |
| --- {"type":"attach", | # after page reload
| "chat_id":"a1..."} ---------> |
| <-- {"event":"attached", ...} |
```
### Rules
- Every outbound event carries `chat_id`. Clients must dispatch by that field.
- `chat_id` format: `^[A-Za-z0-9_:-]{1,64}$`. Non-matching values return `error`.
- `message` auto-attaches on first use — no separate `attach` is required for chats the server minted (`new_chat`) on the same connection.
- Errors (invalid envelope, unknown `type`, bad `chat_id`) are soft: the server replies with `{"event":"error","detail":"..."}` and keeps the connection open.
### Backward compatibility
Legacy clients that only send plain text or `{"content": ...}` keep working unchanged: those frames route to the connection's default `chat_id` (the one from `ready`). No config flag is needed.
### Security boundary
`chat_id` is a *capability*: anyone holding a valid WebSocket auth credential and the chat_id can attach to that conversation and see its output. This is safe for nanobot's local, single-user model. Multi-tenant deployments should namespace chat_ids per user (or introduce a per-tenant auth gate) — nanobot does not do this today.
## Security Notes ## Security Notes
- **Timing-safe comparison**: Static token validation uses `hmac.compare_digest` to prevent timing attacks. - **Timing-safe comparison**: Static token validation uses `hmac.compare_digest` to prevent timing attacks.
- **Defense in depth**: `allowFrom` is checked at both the HTTP handshake level and the message level. - **Defense in depth**: `allowFrom` is checked at both the HTTP handshake level and the message level.
- **Token isolation**: Each WebSocket connection gets a unique `chat_id`. Clients cannot access other sessions. - **chat_id as capability**: see [Multi-chat multiplexing](#multi-chat-multiplexing). Auth on the WebSocket handshake is the single line of defense; callers who pass it can attach to any chat_id they know.
- **TLS enforcement**: When SSL is enabled, TLSv1.2 is the minimum allowed version. - **TLS enforcement**: When SSL is enabled, TLSv1.2 is the minimum allowed version.
- **Default-secure**: `websocketRequiresToken` defaults to `true`. Explicitly set it to `false` only on trusted networks. - **Default-secure**: `websocketRequiresToken` defaults to `true`. Explicitly set it to `false` only on trusted networks.
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

+1 -1
View File
@@ -21,7 +21,7 @@ def _resolve_version() -> str:
return _pkg_version("nanobot-ai") return _pkg_version("nanobot-ai")
except PackageNotFoundError: except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info. # Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.1.5.post1" return _read_pyproject_version() or "0.1.5.post2"
__version__ = _resolve_version() __version__ = _resolve_version()
+133 -9
View File
@@ -318,10 +318,16 @@ class AgentLoop:
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Update context for all tools that need routing info.""" """Update context for all tools that need routing info."""
# Compute the effective session key (accounts for unified sessions)
# so that subagent results route to the correct pending queue.
effective_key = UNIFIED_SESSION_KEY if self._unified_session else f"{channel}:{chat_id}"
for name in ("message", "spawn", "cron", "my"): for name in ("message", "spawn", "cron", "my"):
if tool := self.tools.get(name): if tool := self.tools.get(name):
if hasattr(tool, "set_context"): if hasattr(tool, "set_context"):
tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) if name == "spawn":
tool.set_context(channel, chat_id, effective_key=effective_key)
else:
tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
@staticmethod @staticmethod
def _strip_think(text: str | None) -> str | None: def _strip_think(text: str | None) -> str | None:
@@ -339,6 +345,36 @@ class AgentLoop:
return format_tool_hints(tool_calls) return format_tool_hints(tool_calls)
async def _dispatch_command_inline(
self,
msg: InboundMessage,
key: str,
raw: str,
dispatch_fn: Callable[[CommandContext], Awaitable[OutboundMessage | None]],
) -> None:
"""Dispatch a command directly from the run() loop and publish the result."""
ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self)
result = await dispatch_fn(ctx)
if result:
await self.bus.publish_outbound(result)
else:
logger.warning("Command '{}' matched but dispatch returned None", raw)
async def _cancel_active_tasks(self, key: str) -> int:
"""Cancel and await all active tasks and subagents for *key*.
Returns the total number of cancelled tasks + subagents.
"""
tasks = self._active_tasks.pop(key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
sub_cancelled = await self.subagents.cancel_by_session(key)
return cancelled + sub_cancelled
def _effective_session_key(self, msg: InboundMessage) -> str: def _effective_session_key(self, msg: InboundMessage) -> str:
"""Return the session key used for task routing and mid-turn injections.""" """Return the session key used for task routing and mid-turn injections."""
if self._unified_session and not msg.session_key_override: if self._unified_session and not msg.session_key_override:
@@ -351,6 +387,7 @@ class AgentLoop:
on_progress: Callable[..., Awaitable[None]] | None = None, on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None, on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None, on_stream_end: Callable[..., Awaitable[None]] | None = None,
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
*, *,
session: Session | None = None, session: Session | None = None,
channel: str = "cli", channel: str = "cli",
@@ -428,6 +465,7 @@ class AgentLoop:
context_block_limit=self.context_block_limit, context_block_limit=self.context_block_limit,
provider_retry_mode=self.provider_retry_mode, provider_retry_mode=self.provider_retry_mode,
progress_callback=on_progress, progress_callback=on_progress,
retry_wait_callback=on_retry_wait,
checkpoint_callback=_checkpoint, checkpoint_callback=_checkpoint,
injection_callback=_drain_pending, injection_callback=_drain_pending,
)) ))
@@ -470,16 +508,24 @@ class AgentLoop:
raw = msg.content.strip() raw = msg.content.strip()
if self.commands.is_priority(raw): if self.commands.is_priority(raw):
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, loop=self) await self._dispatch_command_inline(
result = await self.commands.dispatch_priority(ctx) msg, msg.session_key, raw,
if result: self.commands.dispatch_priority,
await self.bus.publish_outbound(result) )
continue continue
effective_key = self._effective_session_key(msg) effective_key = self._effective_session_key(msg)
# If this session already has an active pending queue (i.e. a task # If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn # is processing this session), route the message there for mid-turn
# injection instead of creating a competing task. # injection instead of creating a competing task.
if effective_key in self._pending_queues: if effective_key in self._pending_queues:
# Non-priority commands must not be queued for injection;
# dispatch them directly (same pattern as priority commands).
if self.commands.is_dispatchable_command(raw):
await self._dispatch_command_inline(
msg, effective_key, raw,
self.commands.dispatch,
)
continue
pending_msg = msg pending_msg = msg
if effective_key != msg.session_key: if effective_key != msg.session_key:
pending_msg = dataclasses.replace( pending_msg = dataclasses.replace(
@@ -571,6 +617,29 @@ class AgentLoop:
)) ))
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key) 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
# messages accumulated before /stop. The checkpoint was
# already persisted to session metadata by
# _emit_checkpoint during tool execution; materializing
# it into session history now makes it visible in the
# next conversation turn.
try:
key = self._effective_session_key(msg)
session = self.sessions.get_or_create(key)
if self._restore_runtime_checkpoint(session):
self._clear_pending_user_turn(session)
self.sessions.save(session)
logger.info(
"Restored partial context for cancelled session {}",
key,
)
except Exception:
logger.debug(
"Could not restore checkpoint for cancelled session {}",
session_key,
exc_info=True,
)
raise raise
except Exception: except Exception:
logger.exception("Error processing message for session {}", session_key) logger.exception("Error processing message for session {}", session_key)
@@ -646,14 +715,29 @@ class AgentLoop:
session, pending = self.auto_compact.prepare_session(session, key) session, pending = self.auto_compact.prepare_session(session, key)
await self.consolidator.maybe_consolidate_by_tokens(session) await self.consolidator.maybe_consolidate_by_tokens(
session,
session_summary=pending,
)
# Persist subagent follow-ups into durable history BEFORE prompt
# assembly. ContextBuilder merges adjacent same-role messages for
# provider compatibility, which previously caused the follow-up to
# disappear from session.messages while still being visible to the
# LLM via the merged prompt. See _persist_subagent_followup.
is_subagent = msg.sender_id == "subagent"
if is_subagent and self._persist_subagent_followup(session, msg):
self.sessions.save(session)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
history = session.get_history(max_messages=0) history = session.get_history(max_messages=0)
current_role = "assistant" if msg.sender_id == "subagent" else "user" current_role = "assistant" if is_subagent else "user"
# Subagent content is already in `history` above; passing it again
# as current_message would double-project it into the prompt.
messages = self.context.build_messages( messages = self.context.build_messages(
history=history, history=history,
current_message=msg.content, channel=channel, chat_id=chat_id, current_message="" if is_subagent else msg.content,
channel=channel,
chat_id=chat_id,
session_summary=pending, session_summary=pending,
current_role=current_role, current_role=current_role,
) )
@@ -695,7 +779,10 @@ class AgentLoop:
if result := await self.commands.dispatch(ctx): if result := await self.commands.dispatch(ctx):
return result return result
await self.consolidator.maybe_consolidate_by_tokens(session) await self.consolidator.maybe_consolidate_by_tokens(
session,
session_summary=pending,
)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"): if message_tool := self.tools.get("message"):
@@ -726,6 +813,18 @@ class AgentLoop:
) )
) )
async def _on_retry_wait(content: str) -> None:
meta = dict(msg.metadata or {})
meta["_retry_wait"] = True
await self.bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
# Persist the triggering user message immediately, before running the # Persist the triggering user message immediately, before running the
# agent loop. If the process is killed mid-turn (OOM, SIGKILL, self- # agent loop. If the process is killed mid-turn (OOM, SIGKILL, self-
# restart, etc.), the existing runtime_checkpoint preserves the # restart, etc.), the existing runtime_checkpoint preserves the
@@ -744,6 +843,7 @@ class AgentLoop:
on_progress=on_progress or _bus_progress, on_progress=on_progress or _bus_progress,
on_stream=on_stream, on_stream=on_stream,
on_stream_end=on_stream_end, on_stream_end=on_stream_end,
on_retry_wait=_on_retry_wait,
session=session, session=session,
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
@@ -870,6 +970,30 @@ class AgentLoop:
session.messages.append(entry) session.messages.append(entry)
session.updated_at = datetime.now() session.updated_at = datetime.now()
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
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None: def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
"""Persist the latest in-flight turn state into session metadata.""" """Persist the latest in-flight turn state into session metadata."""
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
+102 -22
View File
@@ -8,7 +8,7 @@ import re
import weakref import weakref
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable, Iterator
from loguru import logger from loguru import logger
@@ -49,6 +49,7 @@ class MemoryStore:
self.user_file = workspace / "USER.md" self.user_file = workspace / "USER.md"
self._cursor_file = self.memory_dir / ".cursor" self._cursor_file = self.memory_dir / ".cursor"
self._dream_cursor_file = self.memory_dir / ".dream_cursor" self._dream_cursor_file = self.memory_dir / ".dream_cursor"
self._corruption_logged = False # rate-limit non-int cursor warning
self._git = GitStore(workspace, tracked_files=[ self._git = GitStore(workspace, tracked_files=[
"SOUL.md", "USER.md", "memory/MEMORY.md", "SOUL.md", "USER.md", "memory/MEMORY.md",
]) ])
@@ -221,31 +222,77 @@ class MemoryStore:
# -- history.jsonl — append-only, JSONL format --------------------------- # -- history.jsonl — append-only, JSONL format ---------------------------
def append_history(self, entry: str) -> int: def append_history(self, entry: str) -> int:
"""Append *entry* to history.jsonl and return its auto-incrementing cursor.""" """Append *entry* to history.jsonl and return its auto-incrementing cursor.
Entries are passed through `strip_think` to drop template-level leaks
(e.g. unclosed `<think` prefixes, `<channel|>` markers) before being
persisted. If the cleaned content is empty but the raw entry wasn't,
the record is persisted with an empty string rather than falling back
to the raw leak — otherwise `strip_think`'s guarantees would be
undone by history replay / consolidation downstream.
"""
cursor = self._next_cursor() cursor = self._next_cursor()
ts = datetime.now().strftime("%Y-%m-%d %H:%M") ts = datetime.now().strftime("%Y-%m-%d %H:%M")
record = {"cursor": cursor, "timestamp": ts, "content": strip_think(entry.rstrip()) or entry.rstrip()} raw = entry.rstrip()
content = strip_think(raw)
if raw and not content:
logger.debug(
"history entry {} stripped to empty (likely template leak); "
"persisting empty content to avoid re-polluting context",
cursor,
)
record = {"cursor": cursor, "timestamp": ts, "content": content}
with open(self.history_file, "a", encoding="utf-8") as f: with open(self.history_file, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n") f.write(json.dumps(record, ensure_ascii=False) + "\n")
self._cursor_file.write_text(str(cursor), encoding="utf-8") self._cursor_file.write_text(str(cursor), encoding="utf-8")
return cursor return cursor
@staticmethod
def _valid_cursor(value: Any) -> int | None:
"""Int cursors only — reject bool (``isinstance(True, int)`` is True)."""
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]:
"""Yield ``(entry, cursor)`` for entries with int cursors; warn once on corruption."""
poisoned: Any = None
for entry in self._read_entries():
raw = entry.get("cursor")
if raw is None:
continue
cursor = self._valid_cursor(raw)
if cursor is None:
poisoned = raw
continue
yield entry, cursor
if poisoned is not None and not self._corruption_logged:
self._corruption_logged = True
logger.warning(
"history.jsonl contains a non-int cursor ({!r}); dropping it. "
"Usually caused by an external writer; further occurrences suppressed.",
poisoned,
)
def _next_cursor(self) -> int: def _next_cursor(self) -> int:
"""Read the current cursor counter and return next value.""" """Read the current cursor counter and return the next value."""
if self._cursor_file.exists(): if self._cursor_file.exists():
try: try:
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1 return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
except (ValueError, OSError): except (ValueError, OSError):
pass pass
# Fallback: read last line's cursor from the JSONL file. # Fast path: trust the tail when intact. Otherwise scan the whole
last = self._read_last_entry() # file and take ``max`` — that stays correct even if the monotonic
if last and last.get("cursor"): # invariant was broken by external writes.
return last["cursor"] + 1 last = self._read_last_entry() or {}
return 1 cursor = self._valid_cursor(last.get("cursor"))
if cursor is not None:
return cursor + 1
return max((c for _, c in self._iter_valid_entries()), default=0) + 1
def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]: def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
"""Return history entries with cursor > *since_cursor*.""" """Return history entries with a valid cursor > *since_cursor*."""
return [e for e in self._read_entries() if e.get("cursor", 0) > since_cursor] return [e for e, c in self._iter_valid_entries() if c > since_cursor]
def compact_history(self) -> None: def compact_history(self) -> None:
"""Drop oldest entries if the file exceeds *max_history_entries*.""" """Drop oldest entries if the file exceeds *max_history_entries*."""
@@ -416,7 +463,12 @@ class Consolidator:
return idx return idx
return None return None
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]: def estimate_session_prompt_tokens(
self,
session: Session,
*,
session_summary: str | None = None,
) -> tuple[int, str]:
"""Estimate current prompt size for the normal session history view.""" """Estimate current prompt size for the normal session history view."""
history = session.get_history(max_messages=0) history = session.get_history(max_messages=0)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None)) channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
@@ -425,6 +477,7 @@ class Consolidator:
current_message="[token-probe]", current_message="[token-probe]",
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
session_summary=session_summary,
) )
return estimate_prompt_tokens_chain( return estimate_prompt_tokens_chain(
self.provider, self.provider,
@@ -457,6 +510,8 @@ class Consolidator:
tools=None, tools=None,
tool_choice=None, tool_choice=None,
) )
if response.finish_reason == "error":
raise RuntimeError(f"LLM returned error: {response.content}")
summary = response.content or "[no summary]" summary = response.content or "[no summary]"
self.store.append_history(summary) self.store.append_history(summary)
return summary return summary
@@ -465,7 +520,12 @@ class Consolidator:
self.store.raw_archive(messages) self.store.raw_archive(messages)
return None return None
async def maybe_consolidate_by_tokens(self, session: Session) -> None: async def maybe_consolidate_by_tokens(
self,
session: Session,
*,
session_summary: str | None = None,
) -> None:
"""Loop: archive old messages until prompt fits within safe budget. """Loop: archive old messages until prompt fits within safe budget.
The budget reserves space for completion tokens and a safety buffer The budget reserves space for completion tokens and a safety buffer
@@ -479,7 +539,10 @@ class Consolidator:
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
target = budget // 2 target = budget // 2
try: try:
estimated, source = self.estimate_session_prompt_tokens(session) estimated, source = self.estimate_session_prompt_tokens(
session,
session_summary=session_summary,
)
except Exception: except Exception:
logger.exception("Token estimation failed for {}", session.key) logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error" estimated, source = 0, "error"
@@ -497,9 +560,10 @@ class Consolidator:
) )
return return
last_summary = None
for round_num in range(self._MAX_CONSOLIDATION_ROUNDS): for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
if estimated <= target: if estimated <= target:
return break
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target)) boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
if boundary is None: if boundary is None:
@@ -508,7 +572,7 @@ class Consolidator:
session.key, session.key,
round_num, round_num,
) )
return break
end_idx = boundary[0] end_idx = boundary[0]
end_idx = self._cap_consolidation_boundary(session, end_idx) end_idx = self._cap_consolidation_boundary(session, end_idx)
@@ -518,11 +582,11 @@ class Consolidator:
session.key, session.key,
round_num, round_num,
) )
return break
chunk = session.messages[session.last_consolidated:end_idx] chunk = session.messages[session.last_consolidated:end_idx]
if not chunk: if not chunk:
return break
logger.info( logger.info(
"Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs", "Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
@@ -533,18 +597,34 @@ class Consolidator:
source, source,
len(chunk), len(chunk),
) )
if not await self.archive(chunk): summary = await self.archive(chunk)
return if summary:
last_summary = summary
else:
break
session.last_consolidated = end_idx session.last_consolidated = end_idx
self.sessions.save(session) self.sessions.save(session)
try: try:
estimated, source = self.estimate_session_prompt_tokens(session) estimated, source = self.estimate_session_prompt_tokens(
session,
session_summary=session_summary,
)
except Exception: except Exception:
logger.exception("Token estimation failed for {}", session.key) logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error" estimated, source = 0, "error"
if estimated <= 0: if estimated <= 0:
return break
# Persist the last summary to session metadata so it can be injected
# into the runtime context on the next prepare_session() call, aligning
# the summary injection strategy with AutoCompact._archive().
if last_summary and last_summary != "(nothing)":
session.metadata["_last_summary"] = {
"text": last_summary,
"last_active": session.updated_at.isoformat(),
}
self.sessions.save(session)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+20 -2
View File
@@ -71,6 +71,7 @@ class AgentRunSpec:
context_block_limit: int | None = None context_block_limit: int | None = None
provider_retry_mode: str = "standard" provider_retry_mode: str = "standard"
progress_callback: Any | None = None progress_callback: Any | None = None
retry_wait_callback: Any | None = None
checkpoint_callback: Any | None = None checkpoint_callback: Any | None = None
injection_callback: Any | None = None injection_callback: Any | None = None
@@ -273,7 +274,7 @@ class AgentRunner:
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
self._accumulate_usage(usage, raw_usage) self._accumulate_usage(usage, raw_usage)
if response.has_tool_calls: if response.should_execute_tools:
if hook.wants_streaming(): if hook.wants_streaming():
await hook.on_stream_end(context, resuming=True) await hook.on_stream_end(context, resuming=True)
@@ -362,6 +363,13 @@ class AgentRunner:
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
if response.has_tool_calls:
logger.warning(
"Ignoring tool calls under finish_reason='{}' for {}",
response.finish_reason,
spec.session_key or "default",
)
clean = hook.finalize_content(context, response.content) clean = hook.finalize_content(context, response.content)
if response.finish_reason != "error" and is_blank_text(clean): if response.finish_reason != "error" and is_blank_text(clean):
empty_content_retries += 1 empty_content_retries += 1
@@ -545,7 +553,7 @@ class AgentRunner:
"tools": tools, "tools": tools,
"model": spec.model, "model": spec.model,
"retry_mode": spec.provider_retry_mode, "retry_mode": spec.provider_retry_mode,
"on_retry_wait": spec.progress_callback, "on_retry_wait": spec.retry_wait_callback,
} }
if spec.temperature is not None: if spec.temperature is not None:
kwargs["temperature"] = spec.temperature kwargs["temperature"] = spec.temperature
@@ -932,6 +940,16 @@ class AgentRunner:
if message.get("role") == "user": if message.get("role") == "user":
kept = kept[i:] kept = kept[i:]
break break
else:
# Recover nearest user message from outside the kept window;
# GLM rejects system→assistant (error 1214). Budget is
# intentionally exceeded — oversized beats invalid.
for idx in range(len(non_system) - 1, -1, -1):
if non_system[idx].get("role") == "user":
kept = non_system[idx:]
break
# If no user exists at all, _enforce_role_alternation
# will insert a synthetic one as a safety net.
start = find_legal_message_start(kept) start = find_legal_message_start(kept)
if start: if start:
kept = kept[start:] kept = kept[start:]
+13 -2
View File
@@ -107,7 +107,7 @@ class SubagentManager:
"""Spawn a subagent to execute a task in the background.""" """Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "") display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {"channel": origin_channel, "chat_id": origin_chat_id} origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
status = SubagentStatus( status = SubagentStatus(
task_id=task_id, task_id=task_id,
@@ -170,6 +170,7 @@ class SubagentManager:
restrict_to_workspace=self.restrict_to_workspace, restrict_to_workspace=self.restrict_to_workspace,
sandbox=self.exec_config.sandbox, sandbox=self.exec_config.sandbox,
path_append=self.exec_config.path_append, path_append=self.exec_config.path_append,
allowed_env_keys=self.exec_config.allowed_env_keys,
)) ))
if self.web_config.enable: if self.web_config.enable:
tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy)) tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy))
@@ -239,12 +240,22 @@ class SubagentManager:
result=result, result=result,
) )
# Inject as system message to trigger main agent # Inject as system message to trigger main agent.
# Use session_key_override to align with the main agent's effective
# session key (which accounts for unified sessions) so the result is
# routed to the correct pending queue (mid-turn injection) instead of
# being dispatched as a competing independent task.
override = origin.get("session_key") or f"{origin['channel']}:{origin['chat_id']}"
msg = InboundMessage( msg = InboundMessage(
channel="system", channel="system",
sender_id="subagent", sender_id="subagent",
chat_id=f"{origin['channel']}:{origin['chat_id']}", chat_id=f"{origin['channel']}:{origin['chat_id']}",
content=announce_content, content=announce_content,
session_key_override=override,
metadata={
"injected_event": "subagent_result",
"subagent_task_id": task_id,
},
) )
await self.bus.publish_inbound(msg) await self.bus.publish_inbound(msg)
+66 -38
View File
@@ -5,54 +5,67 @@ from datetime import datetime
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronSchedule from nanobot.cron.types import CronJob, CronJobState, CronSchedule
_CRON_PARAMETERS = tool_parameters_schema(
@tool_parameters( action=StringSchema("Action to perform", enum=["add", "list", "remove"]),
tool_parameters_schema( name=StringSchema(
action=StringSchema("Action to perform", enum=["add", "list", "remove"]), "Optional short human-readable label for the job "
name=StringSchema( "(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message."
"Optional short human-readable label for the job " ),
"(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message." message=StringSchema(
), "REQUIRED when action='add'. Instruction for the agent to execute when the job triggers "
message=StringSchema( "(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
"Instruction for the agent to execute when the job triggers " "Not used for action='list' or action='remove'."
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report')" ),
), every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"), cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"), tz=StringSchema(
tz=StringSchema( "Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). " "When omitted with cron_expr, the tool's default timezone applies."
"When omitted with cron_expr, the tool's default timezone applies." ),
), at=StringSchema(
at=StringSchema( "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
"ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). " "Naive values use the tool's default timezone."
"Naive values use the tool's default timezone." ),
), deliver=BooleanSchema(
deliver=BooleanSchema( description="Whether to deliver the execution result to the user channel (default true)",
description="Whether to deliver the execution result to the user channel (default true)", default=True,
default=True, ),
), job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
job_id=StringSchema("Job ID (for remove)"), required=["action"],
required=["action"], description=(
) "Action-specific parameters: add requires a non-empty message plus one schedule "
"(every_seconds, cron_expr, or at); remove requires job_id; list only needs action. "
"Per-action requirements are enforced at runtime (see field descriptions) so the "
"top-level schema stays compatible with providers (e.g. OpenAI Codex/Responses) that "
"reject oneOf/anyOf/allOf/enum/not at the root of function parameters."
),
) )
@tool_parameters(_CRON_PARAMETERS)
class CronTool(Tool): class CronTool(Tool):
"""Tool to schedule reminders and recurring tasks.""" """Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"): def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
self._cron = cron_service self._cron = cron_service
self._default_timezone = default_timezone self._default_timezone = default_timezone
self._channel = "" self._channel: ContextVar[str] = ContextVar("cron_channel", default="")
self._chat_id = "" self._chat_id: ContextVar[str] = ContextVar("cron_chat_id", default="")
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
def set_context(self, channel: str, chat_id: str) -> None: def set_context(self, channel: str, chat_id: str) -> None:
"""Set the current session context for delivery.""" """Set the current session context for delivery."""
self._channel = channel self._channel.set(channel)
self._chat_id = chat_id self._chat_id.set(chat_id)
def set_cron_context(self, active: bool): def set_cron_context(self, active: bool):
"""Mark whether the tool is executing inside a cron job callback.""" """Mark whether the tool is executing inside a cron job callback."""
@@ -94,6 +107,15 @@ class CronTool(Tool):
f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}." f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}."
) )
def validate_params(self, params: dict[str, Any]) -> list[str]:
errors = super().validate_params(params)
action = params.get("action")
if action == "add" and not str(params.get("message") or "").strip():
errors.append("message is required when action='add'")
if action == "remove" and not str(params.get("job_id") or "").strip():
errors.append("job_id is required when action='remove'")
return errors
async def execute( async def execute(
self, self,
action: str, action: str,
@@ -128,8 +150,14 @@ class CronTool(Tool):
deliver: bool = True, deliver: bool = True,
) -> str: ) -> str:
if not message: if not message:
return "Error: message is required for add" return (
if not self._channel or not self._chat_id: "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=\"...\"."
)
channel = self._channel.get()
chat_id = self._chat_id.get()
if not channel or not chat_id:
return "Error: no session context (channel/chat_id)" return "Error: no session context (channel/chat_id)"
if tz and not cron_expr: if tz and not cron_expr:
return "Error: tz can only be used with cron_expr" return "Error: tz can only be used with cron_expr"
@@ -168,8 +196,8 @@ class CronTool(Tool):
schedule=schedule, schedule=schedule,
message=message, message=message,
deliver=deliver, deliver=deliver,
channel=self._channel, channel=channel,
to=self._chat_id, to=chat_id,
delete_after_run=delete_after, delete_after_run=delete_after,
) )
return f"Created job '{job.name}' (id: {job.id})" return f"Created job '{job.name}' (id: {job.id})"
+16 -2
View File
@@ -80,11 +80,14 @@ def check_read(path: str | Path) -> str | None:
entry.mtime = current_mtime entry.mtime = current_mtime
return None return None
return "Warning: file has been modified since last read. Re-read to verify content before editing." return "Warning: file has been modified since last read. Re-read to verify content before editing."
# mtime unchanged - still check content hash to detect quick modifications
if entry.content_hash and _hash_file(p) != entry.content_hash:
return "Warning: file has been modified since last read. Re-read to verify content before editing."
return None return None
def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool: def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
"""Return True if file was previously read with same params and mtime is unchanged.""" """Return True if file was previously read with same params and content is unchanged."""
p = str(Path(path).resolve()) p = str(Path(path).resolve())
entry = _state.get(p) entry = _state.get(p)
if entry is None: if entry is None:
@@ -97,7 +100,18 @@ def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) ->
current_mtime = os.path.getmtime(p) current_mtime = os.path.getmtime(p)
except OSError: except OSError:
return False return False
return current_mtime == entry.mtime if current_mtime != entry.mtime:
# mtime changed - check if content also changed
current_hash = _hash_file(p)
if current_hash != entry.content_hash:
# Content actually changed - don't dedup
entry.can_dedup = False
return False
# Content identical despite mtime change (e.g. touch) - mark as not dedupable to force full read next time
entry.can_dedup = False
return True
# mtime unchanged - content must be identical
return True
def clear() -> None: def clear() -> None:
+82 -6
View File
@@ -2,6 +2,7 @@
import difflib import difflib
import mimetypes import mimetypes
import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -74,10 +75,23 @@ def _is_blocked_device(path: str | Path) -> bool:
"""Check if path is a blocked device that could hang or produce infinite output.""" """Check if path is a blocked device that could hang or produce infinite output."""
import re import re
raw = str(path) raw = str(path)
if raw in _BLOCKED_DEVICE_PATHS:
# Resolve symlinks to check the actual target
try:
resolved = str(Path(raw).resolve())
except (OSError, ValueError):
resolved = raw
if raw in _BLOCKED_DEVICE_PATHS or resolved in _BLOCKED_DEVICE_PATHS:
return True return True
if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw): if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw):
return True return True
if re.match(r"/proc/\d+/fd/[012]$", resolved) or re.match(r"/proc/self/fd/[012]$", resolved):
return True
# Check if resolved path starts with /dev/ (covers symlinks to devices)
if resolved.startswith("/dev/"):
return True
return False return False
@@ -123,10 +137,11 @@ class ReadFileTool(_FsTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
"Read a file (text or image). Text output format: LINE_NUM|CONTENT. " "Read a file (text, image, or document). "
"Text output format: LINE_NUM|CONTENT. "
"Images return visual content for analysis. " "Images return visual content for analysis. "
"Use offset and limit for large files. " "Supports PDF, DOCX, XLSX, PPTX documents. "
"Cannot read non-image binary files. " "Use offset and limit for large text files. "
"Reads exceeding ~128K chars are truncated." "Reads exceeding ~128K chars are truncated."
) )
@@ -155,6 +170,10 @@ class ReadFileTool(_FsTool):
if fp.suffix.lower() == ".pdf": if fp.suffix.lower() == ".pdf":
return self._read_pdf(fp, pages) return self._read_pdf(fp, pages)
# Office document support
if fp.suffix.lower() in {".docx", ".xlsx", ".pptx"}:
return self._read_office_doc(fp)
raw = fp.read_bytes() raw = fp.read_bytes()
if not raw: if not raw:
return f"(Empty file: {path})" return f"(Empty file: {path})"
@@ -164,14 +183,52 @@ class ReadFileTool(_FsTool):
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})") return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
# Read dedup: same path + offset + limit + unchanged mtime → stub # Read dedup: same path + offset + limit + unchanged mtime → stub
if file_state.is_unchanged(fp, offset=offset, limit=limit): # Always check for external modifications before dedup
return f"[File unchanged since last read: {path}]" entry = file_state._state.get(str(fp.resolve()))
try:
current_mtime = os.path.getmtime(fp)
except OSError:
current_mtime = 0.0
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
if current_mtime != entry.mtime:
# File was modified externally - force full read and mark as not dedupable
entry.can_dedup = False
file_state.record_read(fp, offset=offset, limit=limit) # Update state with new mtime
# Continue to read full content (don't return dedup message)
else:
# File unchanged - return dedup message
# But only if content is actually unchanged (not just mtime)
current_hash = file_state._hash_file(str(fp))
if current_hash == entry.content_hash:
return f"[File unchanged since last read: {path}]"
else:
# Content changed despite same mtime - force full read
entry.can_dedup = False
file_state.record_read(fp, offset=offset, limit=limit)
else:
# No previous state or marked as not dedupable - read full content
file_state.record_read(fp, offset=offset, limit=limit)
# Force full read by setting can_dedup to False for this read
if entry:
entry.can_dedup = False
# Read the file content after dedup check
raw = fp.read_bytes()
try: try:
text_content = raw.decode("utf-8") text_content = raw.decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
# Binary file - return error message
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 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
# applied on all platforms so downstream StrReplace/Grep behavior
# is consistent regardless of where the file was written.
text_content = text_content.replace("\r\n", "\n")
all_lines = text_content.splitlines() all_lines = text_content.splitlines()
total = len(all_lines) total = len(all_lines)
@@ -252,6 +309,25 @@ class ReadFileTool(_FsTool):
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)" result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
return result return result
def _read_office_doc(self, fp: Path) -> str:
from nanobot.utils.document import extract_text
result = extract_text(fp)
if result is None:
return f"Error: Unsupported file format: {fp.suffix}"
if result.startswith("[error:"):
return f"Error reading {fp.suffix.upper()} file: {result}"
if not result:
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
if len(result) > self._MAX_CHARS:
result = result[:self._MAX_CHARS] + "\n\n(Document text truncated at ~128K chars)"
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# write_file # write_file
+190 -109
View File
@@ -10,6 +10,25 @@ from loguru import logger
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
# Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network
# connection is interrupted between calls.
_TRANSIENT_EXC_NAMES: frozenset[str] = frozenset((
"ClosedResourceError",
"BrokenResourceError",
"EndOfStream",
"BrokenPipeError",
"ConnectionResetError",
"ConnectionRefusedError",
"ConnectionAbortedError",
"ConnectionError",
))
def _is_transient(exc: BaseException) -> bool:
"""Check if an exception looks like a transient connection error."""
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None: def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None:
"""Return the single non-null branch for nullable unions.""" """Return the single non-null branch for nullable unions."""
@@ -99,38 +118,61 @@ class MCPToolWrapper(Tool):
async def execute(self, **kwargs: Any) -> str: async def execute(self, **kwargs: Any) -> str:
from mcp import types from mcp import types
try: for attempt in range(2): # At most 1 retry
result = await asyncio.wait_for( try:
self._session.call_tool(self._original_name, arguments=kwargs), result = await asyncio.wait_for(
timeout=self._tool_timeout, self._session.call_tool(self._original_name, arguments=kwargs),
) timeout=self._tool_timeout,
except asyncio.TimeoutError: )
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout) except asyncio.TimeoutError:
return f"(MCP tool call timed out after {self._tool_timeout}s)" logger.warning(
except asyncio.CancelledError: "MCP tool '{}' timed out after {}s", self._name, self._tool_timeout
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure. )
# Re-raise only if our task was externally cancelled (e.g. /stop). return f"(MCP tool call timed out after {self._tool_timeout}s)"
task = asyncio.current_task() except asyncio.CancelledError:
if task is not None and task.cancelling() > 0: # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
raise # Re-raise only if our task was externally cancelled (e.g. /stop).
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) task = asyncio.current_task()
return "(MCP tool call was cancelled)" if task is not None and task.cancelling() > 0:
except Exception as exc: raise
logger.exception( logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
"MCP tool '{}' failed: {}: {}", return "(MCP tool call was cancelled)"
self._name, except Exception as exc:
type(exc).__name__, if _is_transient(exc):
exc, if attempt == 0:
) logger.warning(
return f"(MCP tool call failed: {type(exc).__name__})" "MCP tool '{}' hit transient error ({}), retrying once...",
self._name,
parts = [] type(exc).__name__,
for block in result.content: )
if isinstance(block, types.TextContent): await asyncio.sleep(1) # Brief backoff before retry
parts.append(block.text) continue
# Second transient failure — give up with retry-specific message
logger.error(
"MCP tool '{}' failed after retry: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP tool call failed after retry: {type(exc).__name__})"
logger.exception(
"MCP tool '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP tool call failed: {type(exc).__name__})"
else: else:
parts.append(str(block)) # Success — extract result
return "\n".join(parts) or "(no output)" 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)"
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
class MCPResourceWrapper(Tool): class MCPResourceWrapper(Tool):
@@ -168,40 +210,59 @@ class MCPResourceWrapper(Tool):
async def execute(self, **kwargs: Any) -> str: async def execute(self, **kwargs: Any) -> str:
from mcp import types from mcp import types
try: for attempt in range(2):
result = await asyncio.wait_for( try:
self._session.read_resource(self._uri), result = await asyncio.wait_for(
timeout=self._resource_timeout, self._session.read_resource(self._uri),
) timeout=self._resource_timeout,
except asyncio.TimeoutError: )
logger.warning( except asyncio.TimeoutError:
"MCP resource '{}' timed out after {}s", self._name, self._resource_timeout logger.warning(
) "MCP resource '{}' timed out after {}s", self._name, self._resource_timeout
return f"(MCP resource read timed out after {self._resource_timeout}s)" )
except asyncio.CancelledError: return f"(MCP resource read timed out after {self._resource_timeout}s)"
task = asyncio.current_task() except asyncio.CancelledError:
if task is not None and task.cancelling() > 0: task = asyncio.current_task()
raise if task is not None and task.cancelling() > 0:
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name) raise
return "(MCP resource read was cancelled)" logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
except Exception as exc: return "(MCP resource read was cancelled)"
logger.exception( except Exception as exc:
"MCP resource '{}' failed: {}: {}", if _is_transient(exc):
self._name, if attempt == 0:
type(exc).__name__, logger.warning(
exc, "MCP resource '{}' hit transient error ({}), retrying once...",
) self._name,
return f"(MCP resource read failed: {type(exc).__name__})" type(exc).__name__,
)
parts: list[str] = [] await asyncio.sleep(1)
for block in result.contents: continue
if isinstance(block, types.TextResourceContents): logger.error(
parts.append(block.text) "MCP resource '{}' failed after retry: {}: {}",
elif isinstance(block, types.BlobResourceContents): self._name,
parts.append(f"[Binary resource: {len(block.blob)} bytes]") type(exc).__name__,
exc,
)
return f"(MCP resource read failed after retry: {type(exc).__name__})"
logger.exception(
"MCP resource '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP resource read failed: {type(exc).__name__})"
else: else:
parts.append(str(block)) parts: list[str] = []
return "\n".join(parts) or "(no output)" for block in result.contents:
if isinstance(block, types.TextResourceContents):
parts.append(block.text)
elif isinstance(block, types.BlobResourceContents):
parts.append(f"[Binary resource: {len(block.blob)} bytes]")
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
return "(MCP resource read failed)" # Unreachable
class MCPPromptWrapper(Tool): class MCPPromptWrapper(Tool):
@@ -254,52 +315,72 @@ class MCPPromptWrapper(Tool):
from mcp import types from mcp import types
from mcp.shared.exceptions import McpError from mcp.shared.exceptions import McpError
try: for attempt in range(2):
result = await asyncio.wait_for( try:
self._session.get_prompt(self._prompt_name, arguments=kwargs), result = await asyncio.wait_for(
timeout=self._prompt_timeout, self._session.get_prompt(self._prompt_name, arguments=kwargs),
) timeout=self._prompt_timeout,
except asyncio.TimeoutError: )
logger.warning("MCP prompt '{}' timed out after {}s", self._name, self._prompt_timeout) except asyncio.TimeoutError:
return f"(MCP prompt call timed out after {self._prompt_timeout}s)" logger.warning(
except asyncio.CancelledError: "MCP prompt '{}' timed out after {}s", self._name, self._prompt_timeout
task = asyncio.current_task() )
if task is not None and task.cancelling() > 0: return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
raise except asyncio.CancelledError:
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name) task = asyncio.current_task()
return "(MCP prompt call was cancelled)" if task is not None and task.cancelling() > 0:
except McpError as exc: raise
logger.error( logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
"MCP prompt '{}' failed: code={} message={}", return "(MCP prompt call was cancelled)"
self._name, except McpError as exc:
exc.error.code, logger.error(
exc.error.message, "MCP prompt '{}' failed: code={} message={}",
) self._name,
return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])" exc.error.code,
except Exception as exc: exc.error.message,
logger.exception( )
"MCP prompt '{}' failed: {}: {}", return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])"
self._name, except Exception as exc:
type(exc).__name__, if _is_transient(exc):
exc, if attempt == 0:
) logger.warning(
return f"(MCP prompt call failed: {type(exc).__name__})" "MCP prompt '{}' hit transient error ({}), retrying once...",
self._name,
parts: list[str] = [] type(exc).__name__,
for message in result.messages: )
content = message.content await asyncio.sleep(1)
# content is a single ContentBlock (not a list) in MCP SDK >= 1.x continue
if isinstance(content, types.TextContent): logger.error(
parts.append(content.text) "MCP prompt '{}' failed after retry: {}: {}",
elif isinstance(content, list): self._name,
for block in content: type(exc).__name__,
if isinstance(block, types.TextContent): exc,
parts.append(block.text) )
else: return f"(MCP prompt call failed after retry: {type(exc).__name__})"
parts.append(str(block)) logger.exception(
"MCP prompt '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP prompt call failed: {type(exc).__name__})"
else: else:
parts.append(str(content)) parts: list[str] = []
return "\n".join(parts) or "(no output)" for message in result.messages:
content = message.content
if isinstance(content, types.TextContent):
parts.append(content.text)
elif isinstance(content, list):
for block in content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
else:
parts.append(str(content))
return "\n".join(parts) or "(no output)"
return "(MCP prompt call failed)" # Unreachable
async def connect_mcp_servers( async def connect_mcp_servers(
+28 -13
View File
@@ -1,5 +1,6 @@
"""Message tool for sending messages to users.""" """Message tool for sending messages to users."""
from contextvars import ContextVar
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
@@ -30,16 +31,19 @@ class MessageTool(Tool):
default_message_id: str | None = None, default_message_id: str | None = None,
): ):
self._send_callback = send_callback self._send_callback = send_callback
self._default_channel = default_channel self._default_channel: ContextVar[str] = ContextVar("message_default_channel", default=default_channel)
self._default_chat_id = default_chat_id self._default_chat_id: ContextVar[str] = ContextVar("message_default_chat_id", default=default_chat_id)
self._default_message_id = default_message_id self._default_message_id: ContextVar[str | None] = ContextVar(
self._sent_in_turn: bool = False "message_default_message_id",
default=default_message_id,
)
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Set the current message context.""" """Set the current message context."""
self._default_channel = channel self._default_channel.set(channel)
self._default_chat_id = chat_id self._default_chat_id.set(chat_id)
self._default_message_id = message_id self._default_message_id.set(message_id)
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages.""" """Set the callback for sending messages."""
@@ -49,6 +53,14 @@ class MessageTool(Tool):
"""Reset per-turn send tracking.""" """Reset per-turn send tracking."""
self._sent_in_turn = False self._sent_in_turn = False
@property
def _sent_in_turn(self) -> bool:
return self._sent_in_turn_var.get()
@_sent_in_turn.setter
def _sent_in_turn(self, value: bool) -> None:
self._sent_in_turn_var.set(value)
@property @property
def name(self) -> str: def name(self) -> str:
return "message" return "message"
@@ -73,16 +85,19 @@ class MessageTool(Tool):
) -> str: ) -> str:
from nanobot.utils.helpers import strip_think from nanobot.utils.helpers import strip_think
content = strip_think(content) content = strip_think(content)
channel = channel or self._default_channel default_channel = self._default_channel.get()
chat_id = chat_id or self._default_chat_id default_chat_id = self._default_chat_id.get()
channel = channel or default_channel
chat_id = chat_id or default_chat_id
# Only inherit default message_id when targeting the same channel+chat. # Only inherit default message_id when targeting the same channel+chat.
# Cross-chat sends must not carry the original message_id, because # Cross-chat sends must not carry the original message_id, because
# some channels (e.g. Feishu) use it to determine the target # some channels (e.g. Feishu) use it to determine the target
# conversation via their Reply API, which would route the message # conversation via their Reply API, which would route the message
# to the wrong chat entirely. # to the wrong chat entirely.
if channel == self._default_channel and chat_id == self._default_chat_id: if channel == default_channel and chat_id == default_chat_id:
message_id = message_id or self._default_message_id message_id = message_id or self._default_message_id.get()
else: else:
message_id = None message_id = None
@@ -104,7 +119,7 @@ class MessageTool(Tool):
try: try:
await self._send_callback(msg) await self._send_callback(msg)
if channel == self._default_channel and chat_id == self._default_chat_id: if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else "" media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}" return f"Message sent to {channel}:{chat_id}{media_info}"
+12 -2
View File
@@ -67,6 +67,13 @@ class MyTool(Tool):
"private_key", "access_token", "refresh_token", "auth", "private_key", "access_token", "refresh_token", "auth",
}) })
@classmethod
def _is_sensitive_field_name(cls, name: str) -> bool:
lowered = name.lower()
return lowered in cls._SENSITIVE_NAMES or any(
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
)
RESTRICTED: dict[str, dict[str, Any]] = { RESTRICTED: dict[str, dict[str, Any]] = {
"max_iterations": {"type": int, "min": 1, "max": 100}, "max_iterations": {"type": int, "min": 1, "max": 100},
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000}, "context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
@@ -248,13 +255,16 @@ class MyTool(Tool):
return f"{key}: {r}" if key else r return f"{key}: {r}" if key else r
# Complex object — small Pydantic models: show values; others: show field names for navigation # Complex object — small Pydantic models: show values; others: show field names for navigation
cls_name = type(val).__name__ cls_name = type(val).__name__
if hasattr(val, "model_fields"): model_fields = getattr(type(val), "model_fields", None)
fields = list(val.model_fields.keys()) if model_fields:
fields = list(model_fields.keys())
if len(fields) <= 8: if len(fields) <= 8:
# Small config objects: show field=value pairs # Small config objects: show field=value pairs
pairs = [] pairs = []
for f in fields: for f in fields:
fv = getattr(val, f, "?") fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f):
continue
if isinstance(fv, (str, int, float, bool, type(None))): if isinstance(fv, (str, int, float, bool, type(None))):
pairs.append(f"{f}={fv!r}") pairs.append(f"{f}={fv!r}")
else: else:
+11 -10
View File
@@ -1,5 +1,6 @@
"""Spawn tool for creating background subagents.""" """Spawn tool for creating background subagents."""
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
@@ -21,15 +22,15 @@ class SpawnTool(Tool):
def __init__(self, manager: "SubagentManager"): def __init__(self, manager: "SubagentManager"):
self._manager = manager self._manager = manager
self._origin_channel = "cli" self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli")
self._origin_chat_id = "direct" self._origin_chat_id: ContextVar[str] = ContextVar("spawn_origin_chat_id", default="direct")
self._session_key = "cli:direct" self._session_key: ContextVar[str] = ContextVar("spawn_session_key", default="cli:direct")
def set_context(self, channel: str, chat_id: str) -> None: def set_context(self, channel: str, chat_id: str, effective_key: str | None = None) -> None:
"""Set the origin context for subagent announcements.""" """Set the origin context for subagent announcements."""
self._origin_channel = channel self._origin_channel.set(channel)
self._origin_chat_id = chat_id self._origin_chat_id.set(chat_id)
self._session_key = f"{channel}:{chat_id}" self._session_key.set(effective_key or f"{channel}:{chat_id}")
@property @property
def name(self) -> str: def name(self) -> str:
@@ -50,7 +51,7 @@ class SpawnTool(Tool):
return await self._manager.spawn( return await self._manager.spawn(
task=task, task=task,
label=label, label=label,
origin_channel=self._origin_channel, origin_channel=self._origin_channel.get(),
origin_chat_id=self._origin_chat_id, origin_chat_id=self._origin_chat_id.get(),
session_key=self._session_key, session_key=self._session_key.get(),
) )
+6 -2
View File
@@ -256,6 +256,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
queue: asyncio.Queue[str | None] = asyncio.Queue() queue: asyncio.Queue[str | None] = asyncio.Queue()
stream_failed = False
async def _on_stream(token: str) -> None: async def _on_stream(token: str) -> None:
await queue.put(token) await queue.put(token)
@@ -264,6 +265,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
await queue.put(None) await queue.put(None)
async def _run() -> None: async def _run() -> None:
nonlocal stream_failed
try: try:
async with session_lock: async with session_lock:
await asyncio.wait_for( await asyncio.wait_for(
@@ -279,6 +281,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
timeout=timeout_s, timeout=timeout_s,
) )
except Exception: except Exception:
stream_failed = True
logger.exception("Streaming error for session {}", session_key) logger.exception("Streaming error for session {}", session_key)
await queue.put(None) await queue.put(None)
@@ -292,8 +295,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
finally: finally:
task.cancel() task.cancel()
await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop")) if not stream_failed:
await resp.write(_SSE_DONE) await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop"))
await resp.write(_SSE_DONE)
return resp return resp
# -- non-streaming path (original logic) -- # -- non-streaming path (original logic) --
+10 -3
View File
@@ -135,7 +135,7 @@ if DISCORD_AVAILABLE:
def _register_app_commands(self) -> None: def _register_app_commands(self) -> None:
commands = ( commands = (
("new", "Start a new conversation", "/new"), ("new", "Stop current task and start a new conversation", "/new"),
("stop", "Stop the current task", "/stop"), ("stop", "Stop the current task", "/stop"),
("restart", "Restart the bot", "/restart"), ("restart", "Restart the bot", "/restart"),
("status", "Show bot status", "/status"), ("status", "Show bot status", "/status"),
@@ -433,8 +433,15 @@ class DiscordChannel(BaseChannel):
raise raise
async def _handle_discord_message(self, message: discord.Message) -> None: async def _handle_discord_message(self, message: discord.Message) -> None:
"""Handle incoming Discord messages from discord.py.""" """Handle incoming Discord messages from discord.py.
if message.author.bot:
Self-loop guard: only drop messages from this bot's own account. Messages
from other bots are allowed through so multi-agent setups (one bot asking
another for help, a bot mentioning another by @name, etc.) can work.
Bot-from-bot loops are still prevented per-instance because each bot
still ignores its own outbound messages. (#3217)
"""
if self._bot_user_id is not None and str(message.author.id) == self._bot_user_id:
return return
sender_id = str(message.author.id) sender_id = str(message.author.id)
+54 -8
View File
@@ -118,6 +118,7 @@ class EmailChannel(BaseChannel):
config = EmailConfig.model_validate(config) config = EmailConfig.model_validate(config)
super().__init__(config, bus) super().__init__(config, bus)
self.config: EmailConfig = config self.config: EmailConfig = config
self._self_addresses = self._collect_self_addresses()
self._last_subject_by_chat: dict[str, str] = {} self._last_subject_by_chat: dict[str, str] = {}
self._last_message_id_by_chat: dict[str, str] = {} self._last_message_id_by_chat: dict[str, str] = {}
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
@@ -379,6 +380,12 @@ class EmailChannel(BaseChannel):
sender = parseaddr(parsed.get("From", ""))[1].strip().lower() sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
if not sender: if not sender:
continue continue
if self._is_self_address(sender):
logger.info("Email from {} ignored: matches bot-owned address", sender)
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
continue
# --- Anti-spoofing: verify Authentication-Results --- # --- Anti-spoofing: verify Authentication-Results ---
spf_pass, dkim_pass = self._check_authentication_results(parsed) spf_pass, dkim_pass = self._check_authentication_results(parsed)
@@ -388,6 +395,7 @@ class EmailChannel(BaseChannel):
"(no 'spf=pass' in Authentication-Results header)", "(no 'spf=pass' in Authentication-Results header)",
sender, sender,
) )
self._remember_processed_uid(uid, dedupe, cycle_uids)
continue continue
if self.config.verify_dkim and not dkim_pass: if self.config.verify_dkim and not dkim_pass:
logger.warning( logger.warning(
@@ -395,6 +403,7 @@ class EmailChannel(BaseChannel):
"(no 'dkim=pass' in Authentication-Results header)", "(no 'dkim=pass' in Authentication-Results header)",
sender, sender,
) )
self._remember_processed_uid(uid, dedupe, cycle_uids)
continue continue
subject = self._decode_header_value(parsed.get("Subject", "")) subject = self._decode_header_value(parsed.get("Subject", ""))
@@ -446,14 +455,7 @@ class EmailChannel(BaseChannel):
} }
) )
if uid: self._remember_processed_uid(uid, dedupe, cycle_uids)
cycle_uids.add(uid)
if dedupe and uid:
self._processed_uids.add(uid)
# mark_seen is the primary dedup; this set is a safety net
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
# Evict a random half to cap memory; mark_seen is the primary dedup
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
if mark_seen: if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen") client.store(imap_id, "+FLAGS", "\\Seen")
@@ -463,6 +465,50 @@ class EmailChannel(BaseChannel):
except Exception: except Exception:
pass pass
def _collect_self_addresses(self) -> set[str]:
"""Return normalized email addresses owned by this channel instance."""
candidates = (
self.config.from_address,
self.config.smtp_username,
self.config.imap_username,
)
normalized = {
addr
for candidate in candidates
if (addr := self._normalize_address(candidate))
}
return normalized
@staticmethod
def _normalize_address(value: str) -> str:
"""Normalize an address or mailbox-like identifier for comparisons."""
raw = (value or "").strip()
if not raw:
return ""
parsed = parseaddr(raw)[1].strip().lower()
if parsed:
return parsed
if "@" in raw:
return raw.lower()
return ""
def _is_self_address(self, sender: str) -> bool:
"""Return True when an inbound sender belongs to the bot itself."""
normalized_sender = self._normalize_address(sender)
return bool(normalized_sender) and normalized_sender in self._self_addresses
def _remember_processed_uid(self, uid: str, dedupe: bool, cycle_uids: set[str]) -> None:
"""Track a fetched UID so skipped messages are not reprocessed forever."""
if not uid:
return
cycle_uids.add(uid)
if dedupe:
self._processed_uids.add(uid)
# mark_seen is the primary dedup; this set is a safety net
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
# Evict a random half to cap memory; mark_seen is the primary dedup
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
@classmethod @classmethod
def _is_stale_imap_error(cls, exc: Exception) -> bool: def _is_stale_imap_error(cls, exc: Exception) -> bool:
message = str(exc).lower() message = str(exc).lower()
+35 -3
View File
@@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
@@ -13,6 +14,19 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
def _default_webui_dist() -> Path | None:
"""Return the absolute path to the bundled webui dist directory if it exists."""
try:
import nanobot.web as web_pkg # type: ignore[import-not-found]
except ImportError:
return None
candidate = Path(web_pkg.__file__).resolve().parent / "dist"
return candidate if candidate.is_dir() else None
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s) # Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
_SEND_RETRY_DELAYS = (1, 2, 4) _SEND_RETRY_DELAYS = (1, 2, 4)
@@ -27,9 +41,16 @@ class ChannelManager:
- Route outbound messages - Route outbound messages
""" """
def __init__(self, config: Config, bus: MessageBus): def __init__(
self,
config: Config,
bus: MessageBus,
*,
session_manager: "SessionManager | None" = None,
):
self.config = config self.config = config
self.bus = bus self.bus = bus
self._session_manager = session_manager
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None self._dispatch_task: asyncio.Task | None = None
@@ -55,7 +76,15 @@ class ChannelManager:
if not enabled: if not enabled:
continue continue
try: try:
channel = cls(section, self.bus) kwargs: dict[str, Any] = {}
# Only the WebSocket channel currently hosts the embedded webui
# surface; other channels stay oblivious to these knobs.
if cls.name == "websocket" and self._session_manager is not None:
kwargs["session_manager"] = self._session_manager
static_path = _default_webui_dist()
if static_path is not None:
kwargs["static_dist_path"] = static_path
channel = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key channel.transcription_api_key = transcription_key
channel.transcription_api_base = transcription_base channel.transcription_api_base = transcription_base
@@ -189,6 +218,9 @@ class ChannelManager:
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
continue continue
if msg.metadata.get("_retry_wait"):
continue
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id) # Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
# to reduce API calls and improve streaming latency # to reduce API calls and improve streaming latency
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"): if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
+114 -13
View File
@@ -26,6 +26,11 @@ from nanobot.security.network import validate_url_target
from nanobot.utils.helpers import split_message from nanobot.utils.helpers import split_message
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
# safety margin for mid-stream edits (plain text). For _stream_end, we
# convert to HTML first and then split at the true 4096-char boundary so
# the final rendered message never overflows.
TELEGRAM_HTML_MAX_LEN = 4096
TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message
@@ -48,6 +53,34 @@ def _strip_md(s: str) -> str:
return s.strip() return s.strip()
def _strip_md_block(text: str) -> str:
"""Strip block-level and inline markdown for readable plain-text preview.
Used during streaming mid-edits so users see clean text instead of raw
markdown syntax while the response is still being generated.
"""
# Code blocks -> just the code
text = re.sub(r'```[\w]*\n?([\s\S]*?)```', r'\1', text)
# Headers -> plain text
text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE)
# Blockquotes
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
# Bold / italic / strikethrough
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
text = re.sub(r'(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])', r'\1', text)
text = re.sub(r'~~(.+?)~~', r'\1', text)
# Inline code
text = re.sub(r'`([^`]+)`', r'\1', text)
# Links [text](url) -> text
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
# Bullet lists
text = re.sub(r'^[-*]\s+', '', text, flags=re.MULTILINE)
# Numbered lists (normalize spacing)
text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE)
return text
def _render_table_box(table_lines: list[str]) -> str: def _render_table_box(table_lines: list[str]) -> str:
"""Convert markdown pipe-table to compact aligned text for <pre> display.""" """Convert markdown pipe-table to compact aligned text for <pre> display."""
@@ -124,8 +157,8 @@ def _markdown_to_telegram_html(text: str) -> str:
text = re.sub(r'`([^`]+)`', save_inline_code, text) text = re.sub(r'`([^`]+)`', save_inline_code, text)
# 3. Headers # Title -> just the title text # 3. Headers # Title -> <b>Title</b> (preserve visual hierarchy)
text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) text = re.sub(r'^#{1,6}\s+(.+)$', r'⟪B⟫\1⟪/B⟫', text, flags=re.MULTILINE)
# 4. Blockquotes > text -> just the text (before HTML escaping) # 4. Blockquotes > text -> just the text (before HTML escaping)
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
@@ -149,6 +182,9 @@ def _markdown_to_telegram_html(text: str) -> str:
# 10. Bullet lists - item -> • item # 10. Bullet lists - item -> • item
text = re.sub(r'^[-*]\s+', '', text, flags=re.MULTILINE) text = re.sub(r'^[-*]\s+', '', text, flags=re.MULTILINE)
# 10.5. Numbered lists 1. item -> 1. item (keep number, normalize indent)
text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE)
# 11. Restore inline code with HTML tags # 11. Restore inline code with HTML tags
for i, code in enumerate(inline_codes): for i, code in enumerate(inline_codes):
# Escape HTML in code content # Escape HTML in code content
@@ -161,6 +197,9 @@ def _markdown_to_telegram_html(text: str) -> str:
escaped = _escape_telegram_html(code) escaped = _escape_telegram_html(code)
text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>") text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>")
# 13. Restore header bold markers (inserted in step 3, after HTML escaping)
text = text.replace('⟪B⟫', '<b>').replace('⟪/B⟫', '</b>')
return text return text
@@ -561,14 +600,23 @@ class TelegramChannel(BaseChannel):
await self._remove_reaction(chat_id, int(reply_to_message_id)) await self._remove_reaction(chat_id, int(reply_to_message_id))
except ValueError: except ValueError:
pass pass
chunks = split_message(buf.text, TELEGRAM_MAX_MESSAGE_LEN) thread_kwargs = {}
primary_text = chunks[0] if chunks else buf.text if message_thread_id := meta.get("message_thread_id"):
thread_kwargs["message_thread_id"] = message_thread_id
raw_text = buf.text
html = _markdown_to_telegram_html(raw_text)
if len(html) <= TELEGRAM_HTML_MAX_LEN:
primary_html = html
extra_html_chunks = []
else:
html_chunks = split_message(html, TELEGRAM_HTML_MAX_LEN)
primary_html = html_chunks[0]
extra_html_chunks = html_chunks[1:]
try: try:
html = _markdown_to_telegram_html(primary_text)
await self._call_with_retry( await self._call_with_retry(
self._app.bot.edit_message_text, self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id, chat_id=int_chat_id, message_id=buf.message_id,
text=html, parse_mode="HTML", text=primary_html, parse_mode="HTML",
) )
except BadRequest as e: except BadRequest as e:
# Only fall back to plain text on actual HTML parse/format errors. # Only fall back to plain text on actual HTML parse/format errors.
@@ -579,11 +627,13 @@ class TelegramChannel(BaseChannel):
self._stream_bufs.pop(chat_id, None) self._stream_bufs.pop(chat_id, None)
return return
logger.debug("Final stream edit failed (HTML), trying plain: {}", e) logger.debug("Final stream edit failed (HTML), trying plain: {}", e)
# Fall back to raw markdown (not HTML) so users don't see raw tags.
primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text
try: try:
await self._call_with_retry( await self._call_with_retry(
self._app.bot.edit_message_text, self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id, chat_id=int_chat_id, message_id=buf.message_id,
text=primary_text, text=primary_plain,
) )
except Exception as e2: except Exception as e2:
if self._is_not_modified_error(e2): if self._is_not_modified_error(e2):
@@ -591,10 +641,17 @@ class TelegramChannel(BaseChannel):
else: else:
logger.warning("Final stream edit failed: {}", e2) logger.warning("Final stream edit failed: {}", e2)
raise # Let ChannelManager handle retry raise # Let ChannelManager handle retry
# If final content exceeds Telegram limit, keep the first chunk in for extra_html_chunk in extra_html_chunks:
# the edited stream message and send the rest as follow-up messages. try:
for extra_chunk in chunks[1:]: await self._call_with_retry(
await self._send_text(int_chat_id, extra_chunk) self._app.bot.send_message,
chat_id=int_chat_id, text=extra_html_chunk,
parse_mode="HTML",
**thread_kwargs,
)
except Exception:
# Fall back to _send_text which handles HTML→plain gracefully.
await self._send_text(int_chat_id, extra_html_chunk)
self._stream_bufs.pop(chat_id, None) self._stream_bufs.pop(chat_id, None)
return return
@@ -614,10 +671,11 @@ class TelegramChannel(BaseChannel):
if message_thread_id := meta.get("message_thread_id"): if message_thread_id := meta.get("message_thread_id"):
thread_kwargs["message_thread_id"] = message_thread_id thread_kwargs["message_thread_id"] = message_thread_id
if buf.message_id is None: if buf.message_id is None:
preview = _strip_md_block(buf.text)
try: try:
sent = await self._call_with_retry( sent = await self._call_with_retry(
self._app.bot.send_message, self._app.bot.send_message,
chat_id=int_chat_id, text=buf.text, chat_id=int_chat_id, text=preview,
**thread_kwargs, **thread_kwargs,
) )
buf.message_id = sent.message_id buf.message_id = sent.message_id
@@ -626,11 +684,16 @@ class TelegramChannel(BaseChannel):
logger.warning("Stream initial send failed: {}", e) logger.warning("Stream initial send failed: {}", e)
raise # Let ChannelManager handle retry raise # Let ChannelManager handle retry
elif (now - buf.last_edit) >= self.config.stream_edit_interval: elif (now - buf.last_edit) >= self.config.stream_edit_interval:
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
await self._flush_stream_overflow(int_chat_id, buf, thread_kwargs)
buf.last_edit = now
return
preview = _strip_md_block(buf.text)
try: try:
await self._call_with_retry( await self._call_with_retry(
self._app.bot.edit_message_text, self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id, chat_id=int_chat_id, message_id=buf.message_id,
text=buf.text, text=preview,
) )
buf.last_edit = now buf.last_edit = now
except Exception as e: except Exception as e:
@@ -640,6 +703,44 @@ class TelegramChannel(BaseChannel):
logger.warning("Stream edit failed: {}", e) logger.warning("Stream edit failed: {}", e)
raise # Let ChannelManager handle retry raise # Let ChannelManager handle retry
async def _flush_stream_overflow(
self,
chat_id: int,
buf: "_StreamBuf",
thread_kwargs: dict,
) -> None:
"""Split an oversized stream buffer mid-flight.
Edits the current stream message with the first chunk, sends any
intermediate chunks as standalone messages, then opens a new message
for the tail so subsequent deltas continue streaming into it.
"""
chunks = split_message(buf.text, TELEGRAM_MAX_MESSAGE_LEN)
if len(chunks) <= 1:
return
try:
await self._call_with_retry(
self._app.bot.edit_message_text,
chat_id=chat_id, message_id=buf.message_id,
text=chunks[0],
)
except Exception as e:
if not self._is_not_modified_error(e):
logger.warning("Stream overflow edit failed: {}", e)
raise
for chunk in chunks[1:-1]:
await self._call_with_retry(
self._app.bot.send_message,
chat_id=chat_id, text=chunk, **thread_kwargs,
)
tail = chunks[-1]
sent = await self._call_with_retry(
self._app.bot.send_message,
chat_id=chat_id, text=tail, **thread_kwargs,
)
buf.message_id = sent.message_id
buf.text = tail
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command.""" """Handle /start command."""
if not update.message or not update.effective_user: if not update.message or not update.effective_user:
+462 -43
View File
@@ -7,25 +7,32 @@ import email.utils
import hmac import hmac
import http import http
import json import json
import mimetypes
import re
import secrets import secrets
import ssl import ssl
import time import time
import uuid import uuid
from typing import Any, Self from pathlib import Path
from urllib.parse import parse_qs, urlparse from typing import TYPE_CHECKING, Any, Self
from urllib.parse import parse_qs, unquote, urlparse
from loguru import logger from loguru import logger
from pydantic import Field, field_validator, model_validator from pydantic import Field, field_validator, model_validator
from websockets.asyncio.server import ServerConnection, serve from websockets.asyncio.server import ServerConnection, serve
from websockets.datastructures import Headers from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest, Response from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Base from nanobot.config.schema import Base
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
def _strip_trailing_slash(path: str) -> str: def _strip_trailing_slash(path: str) -> str:
if len(path) > 1 and path.endswith("/"): if len(path) > 1 and path.endswith("/"):
@@ -114,6 +121,18 @@ def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
return Response(status, reason, headers, body) return Response(status, reason, headers, body)
def _read_webui_model_name() -> str | None:
"""Return the configured default model for readonly webui display."""
try:
from nanobot.config.loader import load_config
model = load_config().agents.defaults.model.strip()
return model or None
except Exception as e:
logger.debug("webui bootstrap could not load model name: {}", e)
return None
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]: def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
"""Parse normalized path and query parameters in one pass.""" """Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query) parsed = urlparse("ws://x" + path_with_query)
@@ -156,6 +175,109 @@ def _parse_inbound_payload(raw: str) -> str | None:
return text return text
# Accept UUIDs and short scoped keys like "unified:default". Keeps the capability
# namespace small enough to rule out path traversal / quote injection tricks.
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
def _is_valid_chat_id(value: Any) -> bool:
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
def _parse_envelope(raw: str) -> dict[str, Any] | None:
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
A frame qualifies when it parses as a JSON object with a string ``type`` field.
Legacy frames (plain text, or ``{"content": ...}`` without ``type``) return None;
callers should fall back to :func:`_parse_inbound_payload` for those.
"""
text = raw.strip()
if not text.startswith("{"):
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
t = data.get("type")
if not isinstance(t, str):
return None
return data
_LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
# Matches the legacy chat-id pattern but allows file-system-safe stems too,
# so the API can address sessions whose keys came from non-WebSocket channels.
_API_KEY_RE = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
def _decode_api_key(raw_key: str) -> str | None:
"""Decode a percent-encoded API path segment, then validate the result."""
key = unquote(raw_key)
if _API_KEY_RE.match(key) is None:
return None
return key
def _is_localhost(connection: Any) -> bool:
"""Return True if *connection* originated from the loopback interface."""
addr = getattr(connection, "remote_address", None)
if not addr:
return False
host = addr[0] if isinstance(addr, tuple) else addr
if not isinstance(host, str):
return False
# ``::ffff:127.0.0.1`` is loopback in IPv6-mapped form.
if host.startswith("::ffff:"):
host = host[7:]
return host in _LOCALHOSTS
def _http_response(
body: bytes,
*,
status: int = 200,
content_type: str = "text/plain; charset=utf-8",
extra_headers: list[tuple[str, str]] | None = None,
) -> Response:
headers = [
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", content_type),
]
if extra_headers:
headers.extend(extra_headers)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body)
def _http_error(status: int, message: str | None = None) -> Response:
body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
return _http_response(body, status=status)
def _bearer_token(headers: Any) -> str | None:
"""Pull a Bearer token out of standard or query-style headers."""
auth = headers.get("Authorization") or headers.get("authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
def _is_websocket_upgrade(request: WsRequest) -> bool:
"""Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through."""
upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade")
connection = request.headers.get("Connection") or request.headers.get("connection")
if not upgrade or "websocket" not in upgrade.lower():
return False
if not connection or "upgrade" not in connection.lower():
return False
return True
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool: def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``.""" """Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
if not configured_secret: if not configured_secret:
@@ -176,15 +298,65 @@ class WebSocketChannel(BaseChannel):
name = "websocket" name = "websocket"
display_name = "WebSocket" display_name = "WebSocket"
def __init__(self, config: Any, bus: MessageBus): def __init__(
self,
config: Any,
bus: MessageBus,
*,
session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None,
):
if isinstance(config, dict): if isinstance(config, dict):
config = WebSocketConfig.model_validate(config) config = WebSocketConfig.model_validate(config)
super().__init__(config, bus) super().__init__(config, bus)
self.config: WebSocketConfig = config self.config: WebSocketConfig = config
self._connections: dict[str, Any] = {} # chat_id -> connections subscribed to it (fan-out target).
self._subs: dict[str, set[Any]] = {}
# connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect).
self._conn_chats: dict[Any, set[str]] = {}
# connection -> default chat_id for legacy frames that omit routing.
self._conn_default: dict[Any, str] = {}
# Single-use tokens consumed at WebSocket handshake.
self._issued_tokens: dict[str, float] = {} self._issued_tokens: dict[str, float] = {}
# Multi-use tokens for the embedded webui's REST surface; checked but not consumed.
self._api_tokens: dict[str, float] = {}
self._stop_event: asyncio.Event | None = None self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None self._server_task: asyncio.Task[None] | None = None
self._session_manager = session_manager
self._static_dist_path: Path | None = (
static_dist_path.resolve() if static_dist_path is not None else None
)
# -- Subscription bookkeeping -------------------------------------------
def _attach(self, connection: Any, chat_id: str) -> None:
"""Idempotently subscribe *connection* to *chat_id*."""
self._subs.setdefault(chat_id, set()).add(connection)
self._conn_chats.setdefault(connection, set()).add(chat_id)
def _cleanup_connection(self, connection: Any) -> None:
"""Remove *connection* from every subscription set; safe to call multiple times."""
chat_ids = self._conn_chats.pop(connection, set())
for cid in chat_ids:
subs = self._subs.get(cid)
if subs is None:
continue
subs.discard(connection)
if not subs:
self._subs.pop(cid, None)
self._conn_default.pop(connection, None)
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
"""Send a control event (attached, error, ...) to a single connection."""
payload: dict[str, Any] = {"event": event}
payload.update(fields)
raw = json.dumps(payload, ensure_ascii=False)
try:
await connection.send(raw)
except ConnectionClosed:
self._cleanup_connection(connection)
except Exception as e:
logger.warning("websocket: failed to send {} event: {}", event, e)
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
@@ -255,6 +427,209 @@ class WebSocketChannel(BaseChannel):
{"token": token_value, "expires_in": self.config.token_ttl_s} {"token": token_value, "expires_in": self.config.token_ttl_s}
) )
# -- HTTP dispatch ------------------------------------------------------
async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any:
"""Route an inbound HTTP request to a handler or to the WS upgrade path."""
got, query = _parse_request_path(request.path)
# 1. Token issue endpoint (legacy, optional, gated by configured secret).
if self.config.token_issue_path:
issue_expected = _normalize_config_path(self.config.token_issue_path)
if got == issue_expected:
return self._handle_token_issue_http(connection, request)
# 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI.
if got == "/webui/bootstrap":
return self._handle_webui_bootstrap(connection)
# 3. REST surface for the embedded UI.
if got == "/api/sessions":
return self._handle_sessions_list(request)
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
# NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a
# true ``DELETE`` verb. The action is folded into the path instead.
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
if m:
return self._handle_session_delete(request, m.group(1))
# 4. WebSocket upgrade (the channel's primary purpose). Only run the
# handshake gate on requests that actually ask to upgrade; otherwise
# a bare ``GET /`` from the browser would be rejected as an
# unauthorized WS handshake instead of serving the SPA's index.html.
expected_ws = self._expected_path()
if got == expected_ws and _is_websocket_upgrade(request):
client_id = _query_first(query, "client_id") or ""
if len(client_id) > 128:
client_id = client_id[:128]
if not self.is_allowed(client_id):
return connection.respond(403, "Forbidden")
return self._authorize_websocket_handshake(connection, query)
# 5. Static SPA serving (only if a build directory was wired in).
if self._static_dist_path is not None:
response = self._serve_static(got)
if response is not None:
return response
return connection.respond(404, "Not Found")
# -- HTTP route handlers ------------------------------------------------
def _check_api_token(self, request: WsRequest) -> bool:
"""Validate a request against the API token pool (multi-use, TTL-bound)."""
self._purge_expired_api_tokens()
token = _bearer_token(request.headers) or _query_first(
_parse_query(request.path), "token"
)
if not token:
return False
expiry = self._api_tokens.get(token)
if expiry is None or time.monotonic() > expiry:
self._api_tokens.pop(token, None)
return False
return True
def _purge_expired_api_tokens(self) -> None:
now = time.monotonic()
for token_key, expiry in list(self._api_tokens.items()):
if now > expiry:
self._api_tokens.pop(token_key, None)
def _handle_webui_bootstrap(self, connection: Any) -> Response:
if not _is_localhost(connection):
return _http_error(403, "webui bootstrap is localhost-only")
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
self._purge_expired_issued_tokens()
self._purge_expired_api_tokens()
if (
len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS
or len(self._api_tokens) >= self._MAX_ISSUED_TOKENS
):
return _http_response(
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
status=429,
content_type="application/json; charset=utf-8",
)
token = f"nbwt_{secrets.token_urlsafe(32)}"
expiry = time.monotonic() + float(self.config.token_ttl_s)
# Same string registered in both pools: the WS handshake consumes one copy
# while the REST surface keeps validating the other until TTL expiry.
self._issued_tokens[token] = expiry
self._api_tokens[token] = expiry
return _http_json_response(
{
"token": token,
"ws_path": self._expected_path(),
"expires_in": self.config.token_ttl_s,
"model_name": _read_webui_model_name(),
}
)
def _handle_sessions_list(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
if self._session_manager is None:
return _http_error(503, "session manager unavailable")
sessions = self._session_manager.list_sessions()
# The webui is only meaningful for websocket-channel chats — CLI /
# Slack / Lark / Discord sessions can't be resumed from the browser,
# so leaking them into the sidebar is just noise. Filter to the
# ``websocket:`` prefix and strip absolute paths on the way out.
cleaned = [
{k: v for k, v in s.items() if k != "path"}
for s in sessions
if isinstance(s.get("key"), str) and s["key"].startswith("websocket:")
]
return _http_json_response({"sessions": cleaned})
@staticmethod
def _is_webui_session_key(key: str) -> bool:
"""Return True when *key* belongs to the webui's websocket-only surface."""
return key.startswith("websocket:")
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
if self._session_manager is None:
return _http_error(503, "session manager unavailable")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
# The embedded webui only understands websocket-channel sessions. Keep
# its read surface aligned with ``/api/sessions`` instead of letting a
# caller probe arbitrary CLI / Slack / Lark history by handcrafted URL.
if not self._is_webui_session_key(decoded_key):
return _http_error(404, "session not found")
data = self._session_manager.read_session_file(decoded_key)
if data is None:
return _http_error(404, "session not found")
return _http_json_response(data)
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
if self._session_manager is None:
return _http_error(503, "session manager unavailable")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
# Same boundary as ``_handle_session_messages``: the webui may only
# mutate websocket sessions, and deletion really does unlink the local
# JSONL, so keep the blast radius narrow and explicit.
if not self._is_webui_session_key(decoded_key):
return _http_error(404, "session not found")
deleted = self._session_manager.delete_session(decoded_key)
return _http_json_response({"deleted": bool(deleted)})
def _serve_static(self, request_path: str) -> Response | None:
"""Resolve *request_path* against the built SPA directory; SPA fallback to index.html."""
assert self._static_dist_path is not None
rel = request_path.lstrip("/")
if not rel:
rel = "index.html"
# Reject path-traversal attempts and absolute targets.
if ".." in rel.split("/") or rel.startswith("/"):
return _http_error(403, "Forbidden")
candidate = (self._static_dist_path / rel).resolve()
try:
candidate.relative_to(self._static_dist_path)
except ValueError:
return _http_error(403, "Forbidden")
if not candidate.is_file():
# SPA history-mode fallback: unknown routes serve index.html so the
# client-side router can render them.
index = self._static_dist_path / "index.html"
if index.is_file():
candidate = index
else:
return None
try:
body = candidate.read_bytes()
except OSError as e:
logger.warning("websocket static: failed to read {}: {}", candidate, e)
return _http_error(500, "Internal Server Error")
ctype, _ = mimetypes.guess_type(candidate.name)
if ctype is None:
ctype = "application/octet-stream"
if ctype.startswith("text/") or ctype in {"application/javascript", "application/json"}:
ctype = f"{ctype}; charset=utf-8"
# Hash-named build assets are cache-friendly; index.html must stay fresh.
if candidate.name == "index.html":
cache = "no-cache"
else:
cache = "public, max-age=31536000, immutable"
return _http_response(
body,
status=200,
content_type=ctype,
extra_headers=[("Cache-Control", cache)],
)
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any: def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
supplied = _query_first(query, "token") supplied = _query_first(query, "token")
static_token = self.config.token.strip() static_token = self.config.token.strip()
@@ -286,24 +661,7 @@ class WebSocketChannel(BaseChannel):
connection: ServerConnection, connection: ServerConnection,
request: WsRequest, request: WsRequest,
) -> Any: ) -> Any:
got, _ = _parse_request_path(request.path) return await self._dispatch_http(connection, request)
if self.config.token_issue_path:
issue_expected = _normalize_config_path(self.config.token_issue_path)
if got == issue_expected:
return self._handle_token_issue_http(connection, request)
expected_ws = self._expected_path()
if got != expected_ws:
return connection.respond(404, "Not Found")
# Early reject before WebSocket upgrade to avoid unnecessary overhead;
# _handle_message() performs a second check as defense-in-depth.
query = _parse_query(request.path)
client_id = _query_first(query, "client_id") or ""
if len(client_id) > 128:
client_id = client_id[:128]
if not self.is_allowed(client_id):
return connection.respond(403, "Forbidden")
return self._authorize_websocket_handshake(connection, query)
async def handler(connection: ServerConnection) -> None: async def handler(connection: ServerConnection) -> None:
await self._connection_loop(connection) await self._connection_loop(connection)
@@ -353,21 +711,22 @@ class WebSocketChannel(BaseChannel):
logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id)) logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id))
client_id = client_id[:128] client_id = client_id[:128]
chat_id = str(uuid.uuid4()) default_chat_id = str(uuid.uuid4())
try: try:
await connection.send( await connection.send(
json.dumps( json.dumps(
{ {
"event": "ready", "event": "ready",
"chat_id": chat_id, "chat_id": default_chat_id,
"client_id": client_id, "client_id": client_id,
}, },
ensure_ascii=False, ensure_ascii=False,
) )
) )
# Register only after ready is successfully sent to avoid out-of-order sends # Register only after ready is successfully sent to avoid out-of-order sends
self._connections[chat_id] = connection self._conn_default[connection] = default_chat_id
self._attach(connection, default_chat_id)
async for raw in connection: async for raw in connection:
if isinstance(raw, bytes): if isinstance(raw, bytes):
@@ -376,19 +735,66 @@ class WebSocketChannel(BaseChannel):
except UnicodeDecodeError: except UnicodeDecodeError:
logger.warning("websocket: ignoring non-utf8 binary frame") logger.warning("websocket: ignoring non-utf8 binary frame")
continue continue
envelope = _parse_envelope(raw)
if envelope is not None:
await self._dispatch_envelope(connection, client_id, envelope)
continue
content = _parse_inbound_payload(raw) content = _parse_inbound_payload(raw)
if content is None: if content is None:
continue continue
await self._handle_message( await self._handle_message(
sender_id=client_id, sender_id=client_id,
chat_id=chat_id, chat_id=default_chat_id,
content=content, content=content,
metadata={"remote": getattr(connection, "remote_address", None)}, metadata={"remote": getattr(connection, "remote_address", None)},
) )
except Exception as e: except Exception as e:
logger.debug("websocket connection ended: {}", e) logger.debug("websocket connection ended: {}", e)
finally: finally:
self._connections.pop(chat_id, None) self._cleanup_connection(connection)
async def _dispatch_envelope(
self,
connection: Any,
client_id: str,
envelope: dict[str, Any],
) -> None:
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
t = envelope.get("type")
if t == "new_chat":
new_id = str(uuid.uuid4())
self._attach(connection, new_id)
await self._send_event(connection, "attached", chat_id=new_id)
return
if t == "attach":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
self._attach(connection, cid)
await self._send_event(connection, "attached", chat_id=cid)
return
if t == "message":
cid = envelope.get("chat_id")
content = envelope.get("content")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
if not isinstance(content, str) or not content.strip():
await self._send_event(connection, "error", detail="missing content")
return
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
await self._handle_message(
sender_id=client_id,
chat_id=cid,
content=content,
metadata={"remote": getattr(connection, "remote_address", None)},
)
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
async def stop(self) -> None: async def stop(self) -> None:
if not self._running: if not self._running:
@@ -402,38 +808,48 @@ class WebSocketChannel(BaseChannel):
except Exception as e: except Exception as e:
logger.warning("websocket: server task error during shutdown: {}", e) logger.warning("websocket: server task error during shutdown: {}", e)
self._server_task = None self._server_task = None
self._connections.clear() self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._issued_tokens.clear() self._issued_tokens.clear()
self._api_tokens.clear()
async def _safe_send(self, chat_id: str, raw: str, *, label: str = "") -> None: async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
"""Send a raw frame, cleaning up dead connections on ConnectionClosed.""" """Send a raw frame to one connection, cleaning up on ConnectionClosed."""
connection = self._connections.get(chat_id)
if connection is None:
return
try: try:
await connection.send(raw) await connection.send(raw)
except ConnectionClosed: except ConnectionClosed:
self._connections.pop(chat_id, None) self._cleanup_connection(connection)
logger.warning("websocket{}connection gone for chat_id={}", label, chat_id) logger.warning("websocket{}connection gone", label)
except Exception as e: except Exception as e:
logger.error("websocket{}send failed: {}", label, e) logger.error("websocket{}send failed: {}", label, e)
raise raise
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
connection = self._connections.get(msg.chat_id) # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
if connection is None: conns = list(self._subs.get(msg.chat_id, ()))
logger.warning("websocket: no active connection for chat_id={}", msg.chat_id) if not conns:
logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id)
return return
payload: dict[str, Any] = { payload: dict[str, Any] = {
"event": "message", "event": "message",
"chat_id": msg.chat_id,
"text": msg.content, "text": msg.content,
} }
if msg.media: if msg.media:
payload["media"] = msg.media payload["media"] = msg.media
if msg.reply_to: if msg.reply_to:
payload["reply_to"] = msg.reply_to payload["reply_to"] = msg.reply_to
# 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"):
payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"):
payload["kind"] = "progress"
raw = json.dumps(payload, ensure_ascii=False) raw = json.dumps(payload, ensure_ascii=False)
await self._safe_send(msg.chat_id, raw, label=" ") for connection in conns:
await self._safe_send_to(connection, raw, label=" ")
async def send_delta( async def send_delta(
self, self,
@@ -441,17 +857,20 @@ class WebSocketChannel(BaseChannel):
delta: str, delta: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> None: ) -> None:
if self._connections.get(chat_id) is None: conns = list(self._subs.get(chat_id, ()))
if not conns:
return return
meta = metadata or {} meta = metadata or {}
if meta.get("_stream_end"): if meta.get("_stream_end"):
body: dict[str, Any] = {"event": "stream_end"} body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
else: else:
body = { body = {
"event": "delta", "event": "delta",
"chat_id": chat_id,
"text": delta, "text": delta,
} }
if meta.get("_stream_id") is not None: if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"] body["stream_id"] = meta["_stream_id"]
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
await self._safe_send(chat_id, raw, label=" stream ") for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ")
+53 -10
View File
@@ -636,6 +636,21 @@ def gateway(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
): ):
"""Start the nanobot gateway.""" """Start the nanobot gateway."""
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
cfg = _load_runtime_config(config, workspace)
_run_gateway(cfg, port=port)
def _run_gateway(
config: Config,
*,
port: int | None = None,
open_browser_url: str | None = None,
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
@@ -644,12 +659,6 @@ def gateway(
from nanobot.heartbeat.service import HeartbeatService from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
port = port if port is not None else config.gateway.port port = port if port is not None else config.gateway.port
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...") console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
@@ -717,12 +726,17 @@ def gateway(
cron_token = None cron_token = None
if isinstance(cron_tool, CronTool): if isinstance(cron_tool, CronTool):
cron_token = cron_tool.set_cron_context(True) cron_token = cron_tool.set_cron_context(True)
async def _silent(*_args, **_kwargs):
pass
try: try:
resp = await agent.process_direct( resp = await agent.process_direct(
reminder_note, reminder_note,
session_key=f"cron:{job.id}", session_key=f"cron:{job.id}",
channel=job.payload.channel or "cli", channel=job.payload.channel or "cli",
chat_id=job.payload.to or "direct", chat_id=job.payload.to or "direct",
on_progress=_silent,
) )
finally: finally:
if isinstance(cron_tool, CronTool) and cron_token is not None: if isinstance(cron_tool, CronTool) and cron_token is not None:
@@ -749,8 +763,9 @@ def gateway(
cron.on_job = on_cron_job cron.on_job = on_cron_job
# Create channel manager # Create channel manager (forwards SessionManager so the WebSocket channel
channels = ChannelManager(config, bus) # can serve the embedded webui's REST surface).
channels = ChannelManager(config, bus, session_manager=session_manager)
def _pick_heartbeat_target() -> tuple[str, str]: def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages.""" """Pick a routable channel/chat target for heartbeat-triggered messages."""
@@ -881,15 +896,43 @@ def gateway(
)) ))
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}") console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
async def _open_browser_when_ready() -> None:
"""Wait for the gateway to bind, then point the user's browser at the webui."""
if not open_browser_url:
return
import webbrowser
# Channels start asynchronously; a short poll lets us avoid racing the bind.
for _ in range(40): # ~4s max
try:
reader, writer = await asyncio.open_connection(
config.gateway.host or "127.0.0.1", port
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
break
except OSError:
await asyncio.sleep(0.1)
try:
webbrowser.open(open_browser_url)
console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
except Exception as e:
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
async def run(): async def run():
try: try:
await cron.start() await cron.start()
await heartbeat.start() await heartbeat.start()
await asyncio.gather( tasks = [
agent.run(), agent.run(),
channels.start_all(), channels.start_all(),
_health_server(config.gateway.host, port), _health_server(config.gateway.host, port),
) ]
if open_browser_url:
tasks.append(_open_browser_when_ready())
await asyncio.gather(*tasks)
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\nShutting down...") console.print("\nShutting down...")
except Exception: except Exception:
+113 -10
View File
@@ -4,7 +4,7 @@ import json
import types import types
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any, NamedTuple, get_args, get_origin from typing import Any, Literal, NamedTuple, get_args, get_origin
try: try:
import questionary import questionary
@@ -202,6 +202,8 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
return FieldTypeInfo(name, None) return FieldTypeInfo(name, None)
if isinstance(annotation, type) and issubclass(annotation, BaseModel): if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return FieldTypeInfo("model", annotation) return FieldTypeInfo("model", annotation)
if origin is Literal:
return FieldTypeInfo("literal", list(args))
return FieldTypeInfo("str", None) return FieldTypeInfo("str", None)
@@ -264,7 +266,12 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
if isinstance(value, list): if isinstance(value, list):
return ", ".join(str(v) for v in value) return ", ".join(str(v) for v in value)
if isinstance(value, dict): if isinstance(value, dict):
return json.dumps(value) # Handle dicts containing BaseModel instances
parts = []
for k, v in value.items():
formatted = _format_value(v, rich=False, field_name=str(k))
parts.append(f"{k}: {formatted}")
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
return str(value) return str(value)
@@ -279,6 +286,63 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
return str(value) return str(value)
def _validate_field_constraint(value: Any, field_info) -> str | None:
"""Validate a value against Pydantic Field constraints.
Returns an error message string if validation fails, None if valid.
Uses attribute-based detection to handle Pydantic v2 internal types.
"""
if field_info is None or not hasattr(field_info, "metadata"):
return None
for m in field_info.metadata:
if hasattr(m, "ge") and isinstance(value, (int, float)):
if value < m.ge:
return f"Value must be >= {m.ge}"
if hasattr(m, "gt") and isinstance(value, (int, float)):
if value <= m.gt:
return f"Value must be > {m.gt}"
if hasattr(m, "le") and isinstance(value, (int, float)):
if value > m.le:
return f"Value must be <= {m.le}"
if hasattr(m, "lt") and isinstance(value, (int, float)):
if value >= m.lt:
return f"Value must be < {m.lt}"
if hasattr(m, "min_length") and hasattr(value, "__len__"):
if len(value) < m.min_length:
return f"Length must be >= {m.min_length}"
if hasattr(m, "max_length") and hasattr(value, "__len__"):
if len(value) > m.max_length:
return f"Length must be <= {m.max_length}"
return None
def _get_constraint_hint(field_info) -> str:
"""Derive a human-readable constraint hint from field metadata.
Returns a string like "(0-10)" or "(>= 0)" to append to field display names.
"""
if field_info is None or not hasattr(field_info, "metadata"):
return ""
ge_val = None
le_val = None
for m in field_info.metadata:
if hasattr(m, "ge"):
ge_val = m.ge
if hasattr(m, "le"):
le_val = m.le
if ge_val is not None and le_val is not None:
return f" ({ge_val}-{le_val})"
if ge_val is not None:
return f" (>= {ge_val})"
if le_val is not None:
return f" (<= {le_val})"
return ""
# --- Rich UI Components --- # --- Rich UI Components ---
@@ -333,7 +397,7 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
).ask() ).ask()
def _input_text(display_name: str, current: Any, field_type: str) -> Any: def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:
"""Get text input and parse based on field type.""" """Get text input and parse based on field type."""
default = _format_value_for_input(current, field_type) default = _format_value_for_input(current, field_type)
@@ -344,16 +408,28 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any:
if field_type == "int": if field_type == "int":
try: try:
return int(value) parsed = int(value)
except ValueError: except ValueError:
console.print("[yellow]! Invalid number format, value not saved[/yellow]") console.print("[yellow]! Invalid number format, value not saved[/yellow]")
return None return None
if field_info:
error = _validate_field_constraint(parsed, field_info)
if error:
console.print(f"[yellow]! {error}, value not saved[/yellow]")
return None
return parsed
elif field_type == "float": elif field_type == "float":
try: try:
return float(value) parsed = float(value)
except ValueError: except ValueError:
console.print("[yellow]! Invalid number format, value not saved[/yellow]") console.print("[yellow]! Invalid number format, value not saved[/yellow]")
return None return None
if field_info:
error = _validate_field_constraint(parsed, field_info)
if error:
console.print(f"[yellow]! {error}, value not saved[/yellow]")
return None
return parsed
elif field_type == "list": elif field_type == "list":
return [v.strip() for v in value.split(",") if v.strip()] return [v.strip() for v in value.split(",") if v.strip()]
elif field_type == "dict": elif field_type == "dict":
@@ -367,7 +443,7 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any:
def _input_with_existing( def _input_with_existing(
display_name: str, current: Any, field_type: str display_name: str, current: Any, field_type: str, field_info=None
) -> Any: ) -> Any:
"""Handle input with 'keep existing' option for non-empty values.""" """Handle input with 'keep existing' option for non-empty values."""
has_existing = current is not None and current != "" and current != {} and current != [] has_existing = current is not None and current != "" and current != {} and current != []
@@ -381,7 +457,7 @@ def _input_with_existing(
if choice == "Keep existing value" or choice is None: if choice == "Keep existing value" or choice is None:
return None return None
return _input_text(display_name, current, field_type) return _input_text(display_name, current, field_type, field_info=field_info)
# --- Pydantic Model Configuration --- # --- Pydantic Model Configuration ---
@@ -568,7 +644,7 @@ def _configure_pydantic_model(
field_name, field_info = fields[field_idx] field_name, field_info = fields[field_idx]
current_value = getattr(working_model, field_name, None) current_value = getattr(working_model, field_name, None)
ftype = _get_field_type_info(field_info) ftype = _get_field_type_info(field_info)
field_display = _get_field_display_name(field_name, field_info) field_display = _get_field_display_name(field_name, field_info) + _get_constraint_hint(field_info)
# Nested Pydantic model - recurse # Nested Pydantic model - recurse
if ftype.type_name == "model": if ftype.type_name == "model":
@@ -607,10 +683,19 @@ def _configure_pydantic_model(
continue continue
# Generic field input # Generic field input
if ftype.type_name == "literal" and ftype.inner_type:
select_choices = [str(v) for v in ftype.inner_type]
default_choice = str(current_value) if current_value in ftype.inner_type else select_choices[0]
new_value = _select_with_back(field_display, select_choices, default=default_choice)
if new_value is _BACK_PRESSED:
continue
if new_value is not None:
setattr(working_model, field_name, new_value)
continue
if ftype.type_name == "bool": if ftype.type_name == "bool":
new_value = _input_bool(field_display, current_value) new_value = _input_bool(field_display, current_value)
else: else:
new_value = _input_with_existing(field_display, current_value, ftype.type_name) new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
if new_value is not None: if new_value is not None:
setattr(working_model, field_name, new_value) setattr(working_model, field_name, new_value)
@@ -821,18 +906,24 @@ def _configure_channels(config: Config) -> None:
_SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = { _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None), "Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
"Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None), "Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None),
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}), "Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
} }
_SETTINGS_GETTER = { _SETTINGS_GETTER = {
"Agent Settings": lambda c: c.agents.defaults, "Agent Settings": lambda c: c.agents.defaults,
"Channel Common": lambda c: c.channels,
"API Server": lambda c: c.api,
"Gateway": lambda c: c.gateway, "Gateway": lambda c: c.gateway,
"Tools": lambda c: c.tools, "Tools": lambda c: c.tools,
} }
_SETTINGS_SETTER = { _SETTINGS_SETTER = {
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v), "Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
"Channel Common": lambda c, v: setattr(c, "channels", v),
"API Server": lambda c, v: setattr(c, "api", v),
"Gateway": lambda c, v: setattr(c, "gateway", v), "Gateway": lambda c, v: setattr(c, "gateway", v),
"Tools": lambda c, v: setattr(c, "tools", v), "Tools": lambda c, v: setattr(c, "tools", v),
} }
@@ -915,12 +1006,20 @@ def _show_summary(config: Config) -> None:
# Settings sections # Settings sections
for title, model in [ for title, model in [
("Agent Settings", config.agents.defaults), ("Agent Settings", config.agents.defaults),
("Channel Common", config.channels),
("API Server", config.api),
("Gateway", config.gateway), ("Gateway", config.gateway),
("Tools", config.tools), ("Tools", config.tools),
("Channel Common", config.channels),
]: ]:
_print_summary_panel(_summarize_model(model), title) _print_summary_panel(_summarize_model(model), title)
_pause()
def _pause() -> None:
"""Pause for user acknowledgement before clearing the screen."""
_get_questionary().text("Press Enter to continue...", default="").ask()
# --- Main Entry Point --- # --- Main Entry Point ---
@@ -984,7 +1083,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
choices=[ choices=[
"[P] LLM Provider", "[P] LLM Provider",
"[C] Chat Channel", "[C] Chat Channel",
"[H] Channel Common",
"[A] Agent Settings", "[A] Agent Settings",
"[I] API Server",
"[G] Gateway", "[G] Gateway",
"[T] Tools", "[T] Tools",
"[V] View Configuration Summary", "[V] View Configuration Summary",
@@ -1007,7 +1108,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
_MENU_DISPATCH = { _MENU_DISPATCH = {
"[P] LLM Provider": lambda: _configure_providers(config), "[P] LLM Provider": lambda: _configure_providers(config),
"[C] Chat Channel": lambda: _configure_channels(config), "[C] Chat Channel": lambda: _configure_channels(config),
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"), "[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
"[I] API Server": lambda: _configure_general_settings(config, "API Server"),
"[G] Gateway": lambda: _configure_general_settings(config, "Gateway"), "[G] Gateway": lambda: _configure_general_settings(config, "Gateway"),
"[T] Tools": lambda: _configure_general_settings(config, "Tools"), "[T] Tools": lambda: _configure_general_settings(config, "Tools"),
"[V] View Configuration Summary": lambda: _show_summary(config), "[V] View Configuration Summary": lambda: _show_summary(config),
+11 -1
View File
@@ -18,7 +18,17 @@ from nanobot import __logo__
def _make_console() -> Console: def _make_console() -> Console:
return Console(file=sys.stdout, force_terminal=True) """Create a Console that emits plain text when stdout is not a TTY.
Rich's spinner, Live render, and cursor-visibility escape codes all
key off ``Console.is_terminal``. Forcing ``force_terminal=True`` overrode
the ``isatty()`` check and caused control sequences (``\\x1b[?25l``,
braille spinner frames) to pollute programmatic consumers such as
``docker exec -i`` or pipes, even with ``NO_COLOR`` or ``TERM=dumb``.
Deferring to ``isatty()`` keeps Rich output in interactive terminals
and plain text everywhere else (#3265).
"""
return Console(file=sys.stdout, force_terminal=sys.stdout.isatty())
class ThinkingSpinner: class ThinkingSpinner:
+4 -11
View File
@@ -17,15 +17,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session.""" """Cancel all active tasks and subagents for the session."""
loop = ctx.loop loop = ctx.loop
msg = ctx.msg msg = ctx.msg
tasks = loop._active_tasks.pop(msg.session_key, []) total = await loop._cancel_active_tasks(msg.session_key)
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled
content = f"Stopped {total} task(s)." if total else "No active task to stop." content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage( return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, channel=msg.channel, chat_id=msg.chat_id, content=content,
@@ -100,8 +92,9 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
async def cmd_new(ctx: CommandContext) -> OutboundMessage: async def cmd_new(ctx: CommandContext) -> OutboundMessage:
"""Start a fresh session.""" """Stop active task and start a fresh session."""
loop = ctx.loop loop = ctx.loop
await loop._cancel_active_tasks(ctx.key)
session = ctx.session or loop.sessions.get_or_create(ctx.key) session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:] snapshot = session.messages[session.last_consolidated:]
session.clear() session.clear()
@@ -327,7 +320,7 @@ def build_help_text() -> str:
"""Build canonical help text shared across channels.""" """Build canonical help text shared across channels."""
lines = [ lines = [
"🐈 nanobot commands:", "🐈 nanobot commands:",
"/new — Start a new conversation", "/new — Stop current task and start a new conversation",
"/stop — Stop the current task", "/stop — Stop the current task",
"/restart — Restart the bot", "/restart — Restart the bot",
"/status — Show bot status", "/status — Show bot status",
+14
View File
@@ -57,6 +57,20 @@ class CommandRouter:
def is_priority(self, text: str) -> bool: def is_priority(self, text: str) -> bool:
return text.strip().lower() in self._priority return text.strip().lower() in self._priority
def is_dispatchable_command(self, text: str) -> bool:
"""Check whether *text* matches any non-priority command tier (exact or prefix).
Does NOT check priority or interceptor tiers.
If this returns True, ``dispatch()`` is guaranteed to match a handler.
"""
cmd = text.strip().lower()
if cmd in self._exact:
return True
for pfx, _ in self._prefix:
if cmd.startswith(pfx):
return True
return False
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None: async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
"""Dispatch a priority command. Called from run() without the lock.""" """Dispatch a priority command. Called from run() without the lock."""
handler = self._priority.get(ctx.raw.lower()) handler = self._priority.get(ctx.raw.lower())
+2 -4
View File
@@ -319,17 +319,15 @@ class Config(BaseSettings):
return p.api_key if p else None return p.api_key if p else None
def get_api_base(self, model: str | None = None) -> str | None: def get_api_base(self, model: str | None = None) -> str | None:
"""Get API base URL for the given model. Applies default URLs for gateway/local providers.""" """Get API base URL for the given model, falling back to the provider default when present."""
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import find_by_name
p, name = self._match_provider(model) p, name = self._match_provider(model)
if p and p.api_base: if p and p.api_base:
return p.api_base return p.api_base
# Only gateways get a default api_base here. Standard providers
# resolve their base URL from the registry in the provider constructor.
if name: if name:
spec = find_by_name(name) spec = find_by_name(name)
if spec and (spec.is_gateway or spec.is_local) and spec.default_api_base: if spec and spec.default_api_base:
return spec.default_api_base return spec.default_api_base
return None return None
+6 -1
View File
@@ -104,7 +104,12 @@ class HeartbeatService:
model=self.model, model=self.model,
) )
if not response.has_tool_calls: if not response.should_execute_tools:
if response.has_tool_calls:
logger.warning(
"Ignoring heartbeat tool calls under finish_reason='{}'",
response.finish_reason,
)
return "skip", "" return "skip", ""
args = response.tool_calls[0].arguments args = response.tool_calls[0].arguments
+63 -1
View File
@@ -245,9 +245,41 @@ class AnthropicProvider(LLMProvider):
"source": {"type": "url", "url": url}, "source": {"type": "url", "url": url},
} }
@staticmethod
def _has_tool_use(msg: dict[str, Any]) -> bool:
"""True if ``msg.content`` carries any ``tool_use`` block.
Anthropic forbids ``tool_use`` inside ``user`` turns, so messages that
issued a tool call cannot be safely rerouted when we patch the role.
"""
content = msg.get("content")
if not isinstance(content, list):
return False
return any(
isinstance(block, dict) and block.get("type") == "tool_use"
for block in content
)
@staticmethod @staticmethod
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]: def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Anthropic requires alternating user/assistant roles.""" """Normalize a message sequence for Anthropic's ``/messages`` endpoint.
Anthropic's contract is stricter than OpenAI's:
1. Consecutive same-role turns must be collapsed into one.
2. The conversation cannot end with an ``assistant`` turn Anthropic
does not support assistant-message prefill and returns 400.
3. The conversation cannot start with an ``assistant`` turn the
first message must be ``user``.
Rules 2 and 3 mirror ``LLMProvider._enforce_role_alternation`` in
``base.py``, which applies the equivalent invariants to OpenAI-compat
providers. The only Anthropic-specific wrinkle: ``tool_use`` blocks
live inside ``content`` (not a separate ``tool_calls`` field) and are
invalid inside ``user`` turns, so the recovery paths below must skip
any message carrying them rather than silently producing a malformed
request.
"""
merged: list[dict[str, Any]] = [] merged: list[dict[str, Any]] = []
for msg in msgs: for msg in msgs:
if merged and merged[-1]["role"] == msg["role"]: if merged and merged[-1]["role"] == msg["role"]:
@@ -262,6 +294,36 @@ class AnthropicProvider(LLMProvider):
merged[-1]["content"] = prev_c merged[-1]["content"] = prev_c
else: else:
merged.append(msg) merged.append(msg)
# Rule 2: strip trailing assistant turns — Anthropic rejects prefill.
last_popped: dict[str, Any] | None = None
while merged and merged[-1].get("role") == "assistant":
last_popped = merged.pop()
# Recovery for rule 2: if stripping removed every turn, reroute the
# last popped assistant as a user turn so upstream code still gets a
# valid request instead of a secondary "messages array empty" 400.
# Skip when the message carried ``tool_use`` blocks (see _has_tool_use).
if (
not merged
and last_popped is not None
and not AnthropicProvider._has_tool_use(last_popped)
):
merged.append({"role": "user", "content": last_popped.get("content")})
# Rule 3: prepend a synthetic opener if the first surviving turn is an
# assistant (e.g. upstream history truncation dropped the original
# user request). ``tool_use``-carrying assistants are left alone —
# that message will still fail validation, but injecting an opener
# before it would orphan the tool_use/tool_result pair that follows,
# turning a recoverable 400 into a harder-to-diagnose one.
if (
merged
and merged[0].get("role") == "assistant"
and not AnthropicProvider._has_tool_use(merged[0])
):
merged.insert(0, {"role": "user", "content": "(conversation continued)"})
return merged return merged
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+24
View File
@@ -67,6 +67,14 @@ class LLMResponse:
"""Check if response contains tool calls.""" """Check if response contains tool calls."""
return len(self.tool_calls) > 0 return len(self.tool_calls) > 0
@property
def should_execute_tools(self) -> bool:
"""Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``.
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
if not self.has_tool_calls:
return False
return self.finish_reason in ("tool_calls", "stop")
@dataclass(frozen=True) @dataclass(frozen=True)
class GenerationSettings: class GenerationSettings:
@@ -77,6 +85,9 @@ class GenerationSettings:
reasoning_effort: str | None = None reasoning_effort: str | None = None
_SYNTHETIC_USER_CONTENT = "(conversation continued)"
class LLMProvider(ABC): class LLMProvider(ABC):
"""Base class for LLM providers.""" """Base class for LLM providers."""
@@ -97,6 +108,7 @@ class LLMProvider(ABC):
"connection", "connection",
"server error", "server error",
"temporarily unavailable", "temporarily unavailable",
"速率限制",
) )
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429}) _RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"}) _TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
@@ -143,6 +155,7 @@ class LLMProvider(ABC):
"temporarily unavailable", "temporarily unavailable",
"overloaded", "overloaded",
"concurrency limit", "concurrency limit",
"速率限制",
) )
_SENTINEL = object() _SENTINEL = object()
@@ -409,6 +422,17 @@ class LLMProvider(ABC):
recovered["role"] = "user" recovered["role"] = "user"
merged.append(recovered) merged.append(recovered)
# Safety net: ensure the first non-system message is not a bare
# ``assistant`` message. Providers like GLM reject system→assistant
# with error 1214. This can happen when upstream truncation (e.g.
# _snip_history) drops the only user message. Insert a synthetic
# user message to keep the sequence valid.
for i, msg in enumerate(merged):
if msg.get("role") != "system":
if msg.get("role") == "assistant" and not msg.get("tool_calls"):
merged.insert(i, {"role": "user", "content": _SYNTHETIC_USER_CONTENT})
break
return merged return merged
@staticmethod @staticmethod
+63 -4
View File
@@ -9,11 +9,13 @@ import importlib.util
import os import os
import secrets import secrets
import string import string
import time
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import json_repair import json_repair
from loguru import logger
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"): if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI from langfuse.openai import AsyncOpenAI
@@ -52,6 +54,7 @@ _DEFAULT_OPENROUTER_HEADERS = {
} }
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({ _KIMI_THINKING_MODELS: frozenset[str] = frozenset({
"kimi-k2.5", "kimi-k2.5",
"kimi-k2.6",
"k2.6-code-preview", "k2.6-code-preview",
}) })
@@ -60,7 +63,7 @@ def _is_kimi_thinking_model(model_name: str) -> bool:
"""Return True if model_name refers to a Kimi thinking-capable model. """Return True if model_name refers to a Kimi thinking-capable model.
Supports two forms: Supports two forms:
- Exact match: kimi-k2.5 in _KIMI_THINKING_MODELS - Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/" - Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
is checked against _KIMI_THINKING_MODELS is checked against _KIMI_THINKING_MODELS
@@ -143,6 +146,10 @@ def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | No
return bool(api_base and "openrouter" in api_base.lower()) return bool(api_base and "openrouter" in api_base.lower())
_RESPONSES_FAILURE_THRESHOLD = 3
_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes
def _is_direct_openai_base(api_base: str | None) -> bool: def _is_direct_openai_base(api_base: str | None) -> bool:
"""Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways.""" """Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways."""
if not api_base: if not api_base:
@@ -151,6 +158,16 @@ def _is_direct_openai_base(api_base: str | None) -> bool:
return "api.openai.com" in normalized and "openrouter" not in normalized return "api.openai.com" in normalized and "openrouter" not in normalized
def _responses_circuit_key(
model: str | None,
default_model: str,
reasoning_effort: str | None,
) -> str:
model_name = (model or default_model).lower()
effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else ""
return f"{model_name}:{effort}"
class OpenAICompatProvider(LLMProvider): class OpenAICompatProvider(LLMProvider):
"""Unified provider for all OpenAI-compatible APIs. """Unified provider for all OpenAI-compatible APIs.
@@ -189,6 +206,11 @@ class OpenAICompatProvider(LLMProvider):
max_retries=0, max_retries=0,
) )
# Responses API circuit breaker: skip after repeated failures,
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
self._responses_failures: dict[str, int] = {}
self._responses_tripped_at: dict[str, float] = {}
def _setup_env(self, api_key: str, api_base: str | None) -> None: def _setup_env(self, api_key: str, api_base: str | None) -> None:
"""Set environment variables based on provider spec.""" """Set environment variables based on provider spec."""
spec = self._spec spec = self._spec
@@ -376,6 +398,8 @@ class OpenAICompatProvider(LLMProvider):
extra: dict[str, Any] | None = None extra: dict[str, Any] | None = None
if spec.name == "dashscope": if spec.name == "dashscope":
extra = {"enable_thinking": thinking_enabled} extra = {"enable_thinking": thinking_enabled}
elif spec.name == "minimax":
extra = {"reasoning_split": thinking_enabled}
elif spec.name in ( elif spec.name in (
"volcengine", "volcengine_coding_plan", "volcengine", "volcengine_coding_plan",
"byteplus", "byteplus_coding_plan", "byteplus", "byteplus_coding_plan",
@@ -414,9 +438,39 @@ class OpenAICompatProvider(LLMProvider):
return False return False
model_name = (model or self.default_model).lower() model_name = (model or self.default_model).lower()
wants = False
if reasoning_effort and reasoning_effort.lower() != "none": if reasoning_effort and reasoning_effort.lower() != "none":
return True wants = True
return any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")) elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True
if not wants:
return False
# Circuit breaker: skip after repeated failures, probe periodically.
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
failures = self._responses_failures.get(key, 0)
if failures >= _RESPONSES_FAILURE_THRESHOLD:
tripped = self._responses_tripped_at.get(key, 0.0)
if (time.monotonic() - tripped) < _RESPONSES_PROBE_INTERVAL_S:
return False
# Half-open: allow one probe attempt
return True
def _record_responses_failure(self, model: str | None, reasoning_effort: str | None) -> None:
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
count = self._responses_failures.get(key, 0) + 1
self._responses_failures[key] = count
if count >= _RESPONSES_FAILURE_THRESHOLD:
self._responses_tripped_at[key] = time.monotonic()
logger.warning(
"Responses API circuit open for {} — falling back to Chat Completions",
key,
)
def _record_responses_success(self, model: str | None, reasoning_effort: str | None) -> None:
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
self._responses_failures.pop(key, None)
self._responses_tripped_at.pop(key, None)
@staticmethod @staticmethod
def _should_fallback_from_responses_error(e: Exception) -> bool: def _should_fallback_from_responses_error(e: Exception) -> bool:
@@ -915,10 +969,13 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature, messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice, reasoning_effort, tool_choice,
) )
return parse_response_output(await self._client.responses.create(**body)) result = parse_response_output(await self._client.responses.create(**body))
self._record_responses_success(model, reasoning_effort)
return result
except Exception as responses_error: except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise
self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs( kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature, messages, tools, model, max_tokens, temperature,
@@ -965,6 +1022,7 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(), _timed_stream(),
on_content_delta, on_content_delta,
) )
self._record_responses_success(model, reasoning_effort)
return LLMResponse( return LLMResponse(
content=content or None, content=content or None,
tool_calls=tool_calls, tool_calls=tool_calls,
@@ -975,6 +1033,7 @@ class OpenAICompatProvider(LLMProvider):
except Exception as responses_error: except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise
self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs( kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature, messages, tools, model, max_tokens, temperature,
+5 -2
View File
@@ -261,7 +261,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
), ),
# Moonshot (月之暗面): Kimi models. K2.5 enforces temperature >= 1.0. # Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0.
ProviderSpec( ProviderSpec(
name="moonshot", name="moonshot",
keywords=("moonshot", "kimi"), keywords=("moonshot", "kimi"),
@@ -269,7 +269,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="Moonshot", display_name="Moonshot",
backend="openai_compat", backend="openai_compat",
default_api_base="https://api.moonshot.ai/v1", default_api_base="https://api.moonshot.ai/v1",
model_overrides=(("kimi-k2.5", {"temperature": 1.0}),), model_overrides=(
("kimi-k2.5", {"temperature": 1.0}),
("kimi-k2.6", {"temperature": 1.0}),
),
), ),
# MiniMax: OpenAI-compatible API # MiniMax: OpenAI-compatible API
ProviderSpec( ProviderSpec(
+170 -19
View File
@@ -1,6 +1,7 @@
"""Session management for conversation history.""" """Session management for conversation history."""
import json import json
import os
import shutil import shutil
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -106,15 +107,18 @@ class SessionManager:
self.legacy_sessions_dir = get_legacy_sessions_dir() self.legacy_sessions_dir = get_legacy_sessions_dir()
self._cache: dict[str, Session] = {} self._cache: dict[str, Session] = {}
@staticmethod
def safe_key(key: str) -> str:
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
return safe_filename(key.replace(":", "_"))
def _get_session_path(self, key: str) -> Path: def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session.""" """Get the file path for a session."""
safe_key = safe_filename(key.replace(":", "_")) return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
return self.sessions_dir / f"{safe_key}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path: def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/).""" """Legacy global session path (~/.nanobot/sessions/)."""
safe_key = safe_filename(key.replace(":", "_")) return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
def get_or_create(self, key: str) -> Session: def get_or_create(self, key: str) -> Session:
""" """
@@ -184,24 +188,103 @@ class SessionManager:
) )
except Exception as e: except Exception as e:
logger.warning("Failed to load session {}: {}", key, e) logger.warning("Failed to load session {}: {}", key, e)
repaired = self._repair(key)
if repaired is not None:
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
return repaired
def _repair(self, key: str) -> Session | None:
"""Attempt to recover a session from a corrupt JSONL file."""
path = self._get_session_path(key)
if not path.exists():
return None return None
def save(self, session: Session) -> None: try:
"""Save a session to disk.""" messages: list[dict[str, Any]] = []
path = self._get_session_path(session.key) metadata: dict[str, Any] = {}
created_at: datetime | None = None
updated_at: datetime | None = None
last_consolidated = 0
skipped = 0
with open(path, "w", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
metadata_line = { for line in f:
"_type": "metadata", line = line.strip()
"key": session.key, if not line:
"created_at": session.created_at.isoformat(), continue
"updated_at": session.updated_at.isoformat(), try:
"metadata": session.metadata, data = json.loads(line)
"last_consolidated": session.last_consolidated except json.JSONDecodeError:
} skipped += 1
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") continue
for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n") if data.get("_type") == "metadata":
metadata = data.get("metadata", {})
if data.get("created_at"):
try:
created_at = datetime.fromisoformat(data["created_at"])
except (ValueError, TypeError):
pass
if data.get("updated_at"):
try:
updated_at = datetime.fromisoformat(data["updated_at"])
except (ValueError, TypeError):
pass
last_consolidated = data.get("last_consolidated", 0)
else:
messages.append(data)
if skipped:
logger.warning("Skipped {} corrupt lines in session {}", skipped, key)
if not messages and not metadata:
return None
return Session(
key=key,
messages=messages,
created_at=created_at or datetime.now(),
updated_at=updated_at or datetime.now(),
metadata=metadata,
last_consolidated=last_consolidated
)
except Exception as e:
logger.warning("Repair failed for session {}: {}", key, e)
return None
@staticmethod
def _session_payload(session: Session) -> dict[str, Any]:
return {
"key": session.key,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
"metadata": session.metadata,
"messages": session.messages,
}
def save(self, session: Session) -> None:
"""Save a session to disk atomically."""
path = self._get_session_path(session.key)
tmp_path = path.with_suffix(".jsonl.tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
metadata_line = {
"_type": "metadata",
"key": session.key,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
"metadata": session.metadata,
"last_consolidated": session.last_consolidated
}
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
os.replace(tmp_path, path)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
self._cache[session.key] = session self._cache[session.key] = session
@@ -209,6 +292,65 @@ class SessionManager:
"""Remove a session from the in-memory cache.""" """Remove a session from the in-memory cache."""
self._cache.pop(key, None) self._cache.pop(key, None)
def delete_session(self, key: str) -> bool:
"""Remove a session from disk and the in-memory cache.
Returns True if a JSONL file was found and unlinked.
"""
path = self._get_session_path(key)
self.invalidate(key)
if not path.exists():
return False
try:
path.unlink()
return True
except OSError as e:
logger.warning("Failed to delete session file {}: {}", path, e)
return False
def read_session_file(self, key: str) -> dict[str, Any] | None:
"""Load a session from disk without caching; intended for read-only HTTP endpoints.
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
``None`` when the session file does not exist or fails to parse.
"""
path = self._get_session_path(key)
if not path.exists():
return None
try:
messages: list[dict[str, Any]] = []
metadata: dict[str, Any] = {}
created_at: str | None = None
updated_at: str | None = None
stored_key: str | None = None
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
if data.get("_type") == "metadata":
metadata = data.get("metadata", {})
created_at = data.get("created_at")
updated_at = data.get("updated_at")
stored_key = data.get("key")
else:
messages.append(data)
return {
"key": stored_key or key,
"created_at": created_at,
"updated_at": updated_at,
"metadata": metadata,
"messages": messages,
}
except Exception as e:
logger.warning("Failed to read session {}: {}", key, e)
repaired = self._repair(key)
if repaired is not None:
logger.info("Recovered read-only session view {} from corrupt file", key)
return self._session_payload(repaired)
return None
def list_sessions(self) -> list[dict[str, Any]]: def list_sessions(self) -> list[dict[str, Any]]:
""" """
List all sessions. List all sessions.
@@ -219,6 +361,7 @@ class SessionManager:
sessions = [] sessions = []
for path in self.sessions_dir.glob("*.jsonl"): for path in self.sessions_dir.glob("*.jsonl"):
fallback_key = path.stem.replace("_", ":", 1)
try: try:
# Read just the metadata line # Read just the metadata line
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
@@ -234,6 +377,14 @@ class SessionManager:
"path": str(path) "path": str(path)
}) })
except Exception: except Exception:
repaired = self._repair(fallback_key)
if repaired is not None:
sessions.append({
"key": repaired.key,
"created_at": repaired.created_at.isoformat(),
"updated_at": repaired.updated_at.isoformat(),
"path": str(path)
})
continue continue
return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True) return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True)
+16 -5
View File
@@ -2,8 +2,19 @@
I am nanobot 🐈, a personal AI assistant. I am nanobot 🐈, a personal AI assistant.
I solve problems by doing, not by describing what I would do. ## Core Principles
I keep responses short unless depth is asked for.
I say what I know, flag what I don't, and never fake confidence. - Solve by doing, not by describing what I would do.
I stay friendly and curious — I'd rather ask a good question than guess wrong. - Keep responses short unless depth is asked for.
I treat the user's time as the scarcest resource, and their trust as the most valuable. - Say what I know, flag what I don't, and never fake confidence.
- Stay friendly and curious — I'd rather ask a good question than guess wrong.
- Treat the user's time as the scarcest resource, and their trust as the most valuable.
## Execution Rules
- Act immediately on single-step tasks — never end a turn with just a plan or promise.
- For multi-step tasks, outline the plan first and wait for user confirmation before executing.
- Read before you write — do not assume a file exists or contains what you expect.
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure.
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer.
- After multi-step changes, verify the result (re-read the file, run the test, check the output).
-12
View File
@@ -1,7 +1,3 @@
# nanobot 🐈
You are nanobot, a helpful AI assistant.
## Runtime ## Runtime
{{ runtime }} {{ runtime }}
@@ -26,14 +22,6 @@ This conversation is via email. Structure with clear sections. Markdown may not
Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting. Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting.
{% endif %} {% endif %}
## Execution Rules
- Act, don't narrate. If you can do it with a tool, do it now — never end a turn with just a plan or promise.
- Read before you write. Do not assume a file exists or contains what you expect.
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure.
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer.
- After multi-step changes, verify the result (re-read the file, run the test, check the output).
## Search & Discovery ## Search & Discovery
- Prefer built-in `grep` / `glob` over `exec` for workspace search. - Prefer built-in `grep` / `glob` over `exec` for workspace search.
+40 -14
View File
@@ -134,18 +134,20 @@ def _extract_xlsx(path: Path) -> str:
"""Extract text from XLSX using openpyxl.""" """Extract text from XLSX using openpyxl."""
try: try:
wb = load_workbook(path, read_only=True, data_only=True) wb = load_workbook(path, read_only=True, data_only=True)
sheets: list[str] = [] try:
for sheet_name in wb.sheetnames: sheets: list[str] = []
ws = wb[sheet_name] for sheet_name in wb.sheetnames:
rows: list[str] = [] ws = wb[sheet_name]
for row in ws.iter_rows(values_only=True): rows: list[str] = []
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row) for row in ws.iter_rows(values_only=True):
if row_text.strip(): row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
rows.append(row_text) if row_text.strip():
if rows: rows.append(row_text)
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows)) if rows:
wb.close() sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH) return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
finally:
wb.close()
except Exception as e: except Exception as e:
logger.error("Failed to extract XLSX {}: {}", path, e) logger.error("Failed to extract XLSX {}: {}", path, e)
return f"[error: failed to extract XLSX: {e!s}]" return f"[error: failed to extract XLSX: {e!s}]"
@@ -159,8 +161,7 @@ def _extract_pptx(path: Path) -> str:
for i, slide in enumerate(prs.slides, 1): for i, slide in enumerate(prs.slides, 1):
slide_text: list[str] = [] slide_text: list[str] = []
for shape in slide.shapes: for shape in slide.shapes:
if hasattr(shape, "text") and shape.text: _collect_pptx_shape_text(shape, slide_text)
slide_text.append(shape.text)
if slide_text: if slide_text:
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text)) slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH) return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
@@ -169,6 +170,31 @@ def _extract_pptx(path: Path) -> str:
return f"[error: failed to extract PPTX: {e!s}]" return f"[error: failed to extract PPTX: {e!s}]"
def _collect_pptx_shape_text(shape, out: list[str]) -> None:
"""Collect text from a PPTX shape, recursing into groups and tables.
Groups have ``has_text_frame=False`` and must be walked via ``.shapes``;
tables are GraphicFrame objects whose cell text lives under ``.table``.
"""
sub_shapes = getattr(shape, "shapes", None)
if sub_shapes is not None:
for sub in sub_shapes:
_collect_pptx_shape_text(sub, out)
return
if getattr(shape, "has_table", False):
for row in shape.table.rows:
cells = [cell.text.strip() for cell in row.cells]
line = "\t".join(cell for cell in cells if cell)
if line:
out.append(line)
return
text = getattr(shape, "text", "")
if text:
out.append(text)
def _extract_text_file(path: Path) -> str: def _extract_text_file(path: Path) -> str:
"""Extract text from a plain text file.""" """Extract text from a plain text file."""
try: try:
+8 -2
View File
@@ -68,8 +68,14 @@ async def evaluate_response(
temperature=0.0, temperature=0.0,
) )
if not llm_response.has_tool_calls: if not llm_response.should_execute_tools:
logger.warning("evaluate_response: no tool call returned, defaulting to notify") if llm_response.has_tool_calls:
logger.warning(
"evaluate_response: ignoring tool calls under finish_reason='{}', defaulting to notify",
llm_response.finish_reason,
)
else:
logger.warning("evaluate_response: no tool call returned, defaulting to notify")
return True return True
args = llm_response.tool_calls[0].arguments args = llm_response.tool_calls[0].arguments
+39 -2
View File
@@ -64,14 +64,35 @@ class GitStore:
if self.is_initialized(): if self.is_initialized():
return False return False
if self._is_inside_git_repo():
logger.warning(
"Workspace {} is already inside a git repo; "
"skipping nested repo initialization",
self._workspace,
)
return False
try: try:
from dulwich import porcelain from dulwich import porcelain
porcelain.init(str(self._workspace)) porcelain.init(str(self._workspace))
# Write .gitignore # Write .gitignore (merge with existing if present)
gitignore = self._workspace / ".gitignore" gitignore = self._workspace / ".gitignore"
gitignore.write_text(self._build_gitignore(), encoding="utf-8") dream_entries = self._build_gitignore()
if gitignore.exists():
existing = gitignore.read_text(encoding="utf-8")
existing_lines = set(existing.splitlines())
new_lines = [
line
for line in dream_entries.splitlines()
if line not in existing_lines
]
if new_lines:
merged = existing.rstrip("\n") + "\n" + "\n".join(new_lines) + "\n"
gitignore.write_text(merged, encoding="utf-8")
else:
gitignore.write_text(dream_entries, encoding="utf-8")
# Ensure tracked files exist (touch them if missing) so the initial # Ensure tracked files exist (touch them if missing) so the initial
# commit has something to track. # commit has something to track.
@@ -155,6 +176,22 @@ class GitStore:
except Exception: except Exception:
return None return None
def _is_inside_git_repo(self) -> bool:
"""Check if self._workspace is already inside a git repository.
Walks up from self._workspace to the filesystem root, returning True
if any parent directory contains a .git entry.
Git worktrees and submodules can use a ``.git`` file instead of a
directory, so we must treat either form as "already inside a repo".
"""
current = self._workspace.resolve()
while current != current.parent:
if (current / ".git").exists():
return True
current = current.parent
return False
def _build_gitignore(self) -> str: def _build_gitignore(self) -> str:
"""Generate .gitignore content from tracked files.""" """Generate .gitignore content from tracked files."""
dirs: set[str] = set() dirs: set[str] = set()
+62 -11
View File
@@ -15,12 +15,48 @@ from loguru import logger
def strip_think(text: str) -> str: def strip_think(text: str) -> str:
"""Remove thinking blocks and any unclosed trailing tag.""" """Remove thinking blocks, unclosed trailing tags, and tokenizer-level
template leaks occasionally emitted by some models (notably Gemma 4's
Ollama renderer).
Covers:
1. Well-formed `<think>...</think>` and `<thought>...</thought>` blocks.
2. Streaming prefixes where the block is never closed.
3. *Malformed* opening tags missing the `>` e.g. `<think广场`. The
model sometimes emits the tag name directly followed by user-facing
content with no delimiter; without this step the literal `<think`
leaks into the rendered message.
4. Harmony-style channel markers like `<channel|>` / `<|channel|>`
**at the start of the text** conservative to avoid eating
explanatory prose that mentions these tokens.
5. Orphan closing tags `</think>` / `</thought>` **at the very start
or end of the text** only, for the same reason.
Since this is also applied before persisting to history (memory.py),
the edge-only stripping of (4) and (5) is deliberate: stripping those
tokens mid-text would silently rewrite any message where a user or the
assistant discusses the tokens themselves.
"""
# Well-formed blocks first.
text = re.sub(r"<think>[\s\S]*?</think>", "", text) text = re.sub(r"<think>[\s\S]*?</think>", "", text)
text = re.sub(r"^\s*<think>[\s\S]*$", "", text) text = re.sub(r"^\s*<think>[\s\S]*$", "", text)
# Gemma 4 and similar models use <thought>...</thought> blocks
text = re.sub(r"<thought>[\s\S]*?</thought>", "", text) text = re.sub(r"<thought>[\s\S]*?</thought>", "", text)
text = re.sub(r"^\s*<thought>[\s\S]*$", "", text) text = re.sub(r"^\s*<thought>[\s\S]*$", "", text)
# Malformed opening tags: `<think` / `<thought` where the next char is
# NOT one that could continue a valid tag / identifier name. Explicitly
# listing ASCII tag-name chars (letters, digits, `_`, `-`, `:`) plus
# `>` / `/` — we can't use `\w` here because in Python's default
# Unicode regex mode it matches CJK characters too, which would defeat
# the primary fix for `<think广场…` leaks.
text = re.sub(r"<think(?![A-Za-z0-9_\-:>/])", "", text)
text = re.sub(r"<thought(?![A-Za-z0-9_\-:>/])", "", text)
# Edge-only orphan closing tags (start or end of text).
text = re.sub(r"^\s*</think>\s*", "", text)
text = re.sub(r"\s*</think>\s*$", "", text)
text = re.sub(r"^\s*</thought>\s*", "", text)
text = re.sub(r"\s*</thought>\s*$", "", text)
# Edge-only channel markers (harmony / Gemma 4 variant leaks).
text = re.sub(r"^\s*<\|?channel\|?>\s*", "", text)
return text.strip() return text.strip()
@@ -37,7 +73,9 @@ def detect_image_mime(data: bytes) -> str | None:
return None return None
def build_image_content_blocks(raw: bytes, mime: str, path: str, label: str) -> list[dict[str, Any]]: def build_image_content_blocks(
raw: bytes, mime: str, path: str, label: str
) -> list[dict[str, Any]]:
"""Build native image blocks plus a short text label.""" """Build native image blocks plus a short text label."""
b64 = base64.b64encode(raw).decode() b64 = base64.b64encode(raw).decode()
return [ return [
@@ -83,6 +121,7 @@ _TOOL_RESULTS_DIR = ".nanobot/tool-results"
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60 _TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
_TOOL_RESULT_MAX_BUCKETS = 32 _TOOL_RESULT_MAX_BUCKETS = 32
def safe_filename(name: str) -> str: def safe_filename(name: str) -> str:
"""Replace unsafe path characters with underscores.""" """Replace unsafe path characters with underscores."""
return _UNSAFE_CHARS.sub("_", name).strip() return _UNSAFE_CHARS.sub("_", name).strip()
@@ -258,9 +297,9 @@ def split_message(content: str, max_len: int = 2000) -> list[str]:
break break
cut = content[:max_len] cut = content[:max_len]
# Try to break at newline first, then space, then hard break # Try to break at newline first, then space, then hard break
pos = cut.rfind('\n') pos = cut.rfind("\n")
if pos <= 0: if pos <= 0:
pos = cut.rfind(' ') pos = cut.rfind(" ")
if pos <= 0: if pos <= 0:
pos = max_len pos = max_len
chunks.append(content[:pos]) chunks.append(content[:pos])
@@ -404,7 +443,7 @@ def build_status_content(
max_completion_tokens: int = 8192, max_completion_tokens: int = 8192,
) -> str: ) -> str:
"""Build a human-readable runtime status snapshot. """Build a human-readable runtime status snapshot.
Args: Args:
search_usage_text: Optional pre-formatted web search usage string search_usage_text: Optional pre-formatted web search usage string
(produced by SearchUsageInfo.format()). When provided (produced by SearchUsageInfo.format()). When provided
@@ -423,7 +462,11 @@ def build_status_content(
# Budget mirrors Consolidator formula: ctx_window - max_completion - _SAFETY_BUFFER # Budget mirrors Consolidator formula: ctx_window - max_completion - _SAFETY_BUFFER
ctx_budget = max(ctx_total - int(max_completion_tokens) - 1024, 1) ctx_budget = max(ctx_total - int(max_completion_tokens) - 1024, 1)
ctx_pct = min(int((context_tokens_estimate / ctx_budget) * 100), 999) if ctx_budget > 0 else 0 ctx_pct = min(int((context_tokens_estimate / ctx_budget) * 100), 999) if ctx_budget > 0 else 0
ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate) ctx_used_str = (
f"{context_tokens_estimate // 1000}k"
if context_tokens_estimate >= 1000
else str(context_tokens_estimate)
)
ctx_total_str = f"{ctx_total // 1000}k" if ctx_total > 0 else "n/a" ctx_total_str = f"{ctx_total // 1000}k" if ctx_total > 0 else "n/a"
token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out" token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out"
if cached and last_in: if cached and last_in:
@@ -439,12 +482,13 @@ def build_status_content(
] ]
if search_usage_text: if search_usage_text:
lines.append(search_usage_text) lines.append(search_usage_text)
return "\n".join(lines) return "\n".join(lines)
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
"""Sync bundled templates to workspace. Only creates missing files.""" """Sync bundled templates to workspace. Only creates missing files."""
from importlib.resources import files as pkg_files from importlib.resources import files as pkg_files
try: try:
tpl = pkg_files("nanobot") / "templates" tpl = pkg_files("nanobot") / "templates"
except Exception: except Exception:
@@ -470,15 +514,22 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
if added and not silent: if added and not silent:
from rich.console import Console from rich.console import Console
for name in added: for name in added:
Console().print(f" [dim]Created {name}[/dim]") Console().print(f" [dim]Created {name}[/dim]")
# Initialize git for memory version control # Initialize git for memory version control
try: try:
from nanobot.utils.gitstore import GitStore from nanobot.utils.gitstore import GitStore
gs = GitStore(workspace, tracked_files=[
"SOUL.md", "USER.md", "memory/MEMORY.md", gs = GitStore(
]) workspace,
tracked_files=[
"SOUL.md",
"USER.md",
"memory/MEMORY.md",
],
)
gs.init() gs.init()
except Exception: except Exception:
logger.warning("Failed to initialize git store for {}", workspace) logger.warning("Failed to initialize git store for {}", workspace)
+6
View File
@@ -0,0 +1,6 @@
"""Embedded web UI assets.
The ``dist/`` subdirectory is populated by ``cd webui && bun run build`` and
is shipped in the wheel; it stays empty in source checkouts until that command
has been run.
"""
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

+9 -3
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanobot-ai" name = "nanobot-ai"
version = "0.1.5.post1" version = "0.1.5.post2"
description = "A lightweight personal AI assistant framework" description = "A lightweight personal AI assistant framework"
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -16,6 +16,10 @@ classifiers = [
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
] ]
license-files = [
"LICENSE",
"THIRD_PARTY_NOTICES.md",
]
dependencies = [ dependencies = [
"typer>=0.20.0,<1.0.0", "typer>=0.20.0,<1.0.0",
@@ -40,7 +44,7 @@ dependencies = [
"slack-sdk>=3.39.0,<4.0.0", "slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0", "slackify-markdown>=0.2.0,<1.0.0",
"qq-botpy>=1.2.0,<2.0.0", "qq-botpy>=1.2.0,<2.0.0",
"python-socks[asyncio]>=2.8.0,<3.0.0", "python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'",
"prompt-toolkit>=3.0.50,<4.0.0", "prompt-toolkit>=3.0.50,<4.0.0",
"questionary>=2.0.0,<3.0.0", "questionary>=2.0.0,<3.0.0",
"mcp>=1.26.0,<2.0.0", "mcp>=1.26.0,<2.0.0",
@@ -75,7 +79,7 @@ msteams = [
] ]
matrix = [ matrix = [
"matrix-nio[e2e]>=0.25.2", "matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'",
"mistune>=3.0.0,<4.0.0", "mistune>=3.0.0,<4.0.0",
"nh3>=0.2.17,<1.0.0", "nh3>=0.2.17,<1.0.0",
] ]
@@ -130,6 +134,8 @@ include = [
"bridge/", "bridge/",
"README.md", "README.md",
"LICENSE", "LICENSE",
"THIRD_PARTY_NOTICES.md",
"pyproject.toml",
] ]
[tool.ruff] [tool.ruff]
+40
View File
@@ -65,6 +65,46 @@ class TestConsolidatorSummarize:
assert result is None assert result is None
class TestConsolidatorArchiveErrorHandling:
"""archive() must fall back to raw_archive when the LLM returns an error
response (finish_reason == 'error'), e.g. overloaded / quota exceeded.
See https://github.com/HKUDS/nanobot/issues/3244
"""
async def test_archive_falls_back_on_error_finish_reason(self, consolidator, mock_provider, store):
"""LLM returning finish_reason='error' should trigger raw_archive, not write error text."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="Error: {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'overloaded_error (529)'}}",
finish_reason="error",
)
messages = [
{"role": "user", "content": "fix the auth bug"},
{"role": "assistant", "content": "Done, fixed the race condition."},
]
result = await consolidator.archive(messages)
assert result is None
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" in entries[0]["content"]
assert "Error:" not in entries[0]["content"]
async def test_archive_preserves_summary_on_success(self, consolidator, mock_provider, store):
"""Normal LLM response should still produce a proper summary entry."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="User fixed a bug in the auth module.",
finish_reason="stop",
)
messages = [
{"role": "user", "content": "fix the auth bug"},
{"role": "assistant", "content": "Done."},
]
result = await consolidator.archive(messages)
assert result == "User fixed a bug in the auth module."
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" not in entries[0]["content"]
class TestConsolidatorTokenBudget: class TestConsolidatorTokenBudget:
async def test_prompt_below_threshold_does_not_consolidate(self, consolidator): async def test_prompt_below_threshold_does_not_consolidate(self, consolidator):
"""No consolidation when tokens are within budget.""" """No consolidation when tokens are within budget."""
+25 -2
View File
@@ -149,16 +149,39 @@ def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
def test_execution_rules_in_system_prompt(tmp_path) -> None: def test_execution_rules_in_system_prompt(tmp_path) -> None:
"""New execution rules should appear in the system prompt.""" """Execution rules should appear in the system prompt via default SOUL.md."""
from nanobot.utils.helpers import sync_workspace_templates
workspace = _make_workspace(tmp_path) workspace = _make_workspace(tmp_path)
sync_workspace_templates(workspace, silent=True)
builder = ContextBuilder(workspace) builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt() prompt = builder.build_system_prompt()
assert "Act, don't narrate" in prompt assert "single-step tasks" in prompt
assert "multi-step tasks" in prompt
assert "Read before you write" in prompt assert "Read before you write" in prompt
assert "verify the result" in prompt assert "verify the result" in prompt
def test_identity_has_no_behavioral_instructions(tmp_path) -> None:
"""Identity template should not contain behavioral rules or hardcoded name."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
identity = builder._get_identity(channel=None)
assert "You are nanobot" not in identity
assert "Act, don't narrate" not in identity
assert "Execution Rules" not in identity
def test_default_soul_template_contains_execution_rules() -> None:
"""Default SOUL.md template must contain execution rules with act/plan layering."""
soul = (pkg_files("nanobot") / "templates" / "SOUL.md").read_text(encoding="utf-8")
assert "## Execution Rules" in soul
assert "single-step tasks" in soul
assert "multi-step tasks" in soul
def test_channel_format_hint_telegram(tmp_path) -> None: def test_channel_format_hint_telegram(tmp_path) -> None:
"""Telegram channel should get messaging-app format hint.""" """Telegram channel should get messaging-app format hint."""
workspace = _make_workspace(tmp_path) workspace = _make_workspace(tmp_path)
+188
View File
@@ -0,0 +1,188 @@
"""Regression tests for cursor recovery after non-integer cursor corruption.
Root cause: cron jobs and other callers occasionally wrote string cursors to
history.jsonl (e.g. ``"cursor": "abc"``). The original ``_next_cursor`` and
``read_unprocessed_history`` assumed integer cursors and crashed with
``TypeError`` / ``ValueError``, blocking all subsequent history appends.
"""
import json
import pytest
from nanobot.agent.memory import MemoryStore
@pytest.fixture
def store(tmp_path):
return MemoryStore(tmp_path)
class TestNextCursorRecovery:
"""``_next_cursor`` must recover a valid int even when the last entry's
cursor is corrupted (non-int)."""
def test_string_cursor_falls_back_to_scan(self, store):
"""Last entry has a string cursor — scan backwards to find a valid int."""
store.history_file.write_text(
'{"cursor": 5, "timestamp": "2026-04-01 10:00", "content": "good"}\n'
'{"cursor": 6, "timestamp": "2026-04-01 10:01", "content": "also good"}\n'
'{"cursor": "bad", "timestamp": "2026-04-01 10:02", "content": "corrupted"}\n',
encoding="utf-8",
)
# Delete .cursor file so _next_cursor falls back to reading JSONL
store._cursor_file.unlink(missing_ok=True)
cursor = store.append_history("recovered event")
assert cursor == 7
def test_all_corrupted_cursors_return_one(self, store):
"""Every entry has a non-int cursor — should restart at 1."""
store.history_file.write_text(
'{"cursor": "a", "timestamp": "2026-04-01 10:00", "content": "bad1"}\n'
'{"cursor": "b", "timestamp": "2026-04-01 10:01", "content": "bad2"}\n',
encoding="utf-8",
)
store._cursor_file.unlink(missing_ok=True)
cursor = store.append_history("fresh start")
assert cursor == 1
def test_non_int_cursor_types(self, store):
"""Float, None, list — all non-int types handled gracefully."""
store.history_file.write_text(
'{"cursor": 3, "timestamp": "2026-04-01 10:00", "content": "valid"}\n'
'{"cursor": 3.5, "timestamp": "2026-04-01 10:01", "content": "float"}\n'
'{"cursor": null, "timestamp": "2026-04-01 10:02", "content": "null"}\n'
'{"cursor": [1,2], "timestamp": "2026-04-01 10:03", "content": "list"}\n',
encoding="utf-8",
)
store._cursor_file.unlink(missing_ok=True)
cursor = store.append_history("handles weird types")
assert cursor == 4
def test_cursor_file_with_string_content(self, store):
"""Cursor file contains a non-numeric string — should fall back."""
store._cursor_file.write_text("not_a_number", encoding="utf-8")
# Also add valid JSONL so the fallback scan finds something
store.history_file.write_text(
'{"cursor": 10, "timestamp": "2026-04-01 10:00", "content": "valid"}\n',
encoding="utf-8",
)
cursor = store.append_history("after bad cursor file")
assert cursor == 11
class TestReadUnprocessedWithCorruption:
"""``read_unprocessed_history`` must skip entries with non-int cursors
instead of crashing on comparison."""
def test_skips_string_cursor_entries(self, store):
"""Entries with string cursors are silently skipped."""
store.history_file.write_text(
'{"cursor": 1, "timestamp": "2026-04-01 10:00", "content": "valid1"}\n'
'{"cursor": "bad", "timestamp": "2026-04-01 10:01", "content": "corrupted"}\n'
'{"cursor": 3, "timestamp": "2026-04-01 10:02", "content": "valid3"}\n',
encoding="utf-8",
)
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 2
assert [e["cursor"] for e in entries] == [1, 3]
def test_mixed_corruption_preserves_order(self, store):
"""Valid entries maintain correct order despite corrupt neighbors."""
store.history_file.write_text(
'{"cursor": "x", "timestamp": "2026-04-01 10:00", "content": "bad"}\n'
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "good2"}\n'
'{"cursor": null, "timestamp": "2026-04-01 10:02", "content": "also bad"}\n'
'{"cursor": 4, "timestamp": "2026-04-01 10:03", "content": "good4"}\n',
encoding="utf-8",
)
entries = store.read_unprocessed_history(since_cursor=0)
assert [e["cursor"] for e in entries] == [2, 4]
def test_all_valid_still_works(self, store):
"""Normal operation unaffected — baseline regression check."""
store.append_history("event 1")
store.append_history("event 2")
store.append_history("event 3")
entries = store.read_unprocessed_history(since_cursor=1)
assert len(entries) == 2
assert entries[0]["cursor"] == 2
assert entries[1]["cursor"] == 3
class TestCursorValidationInvariant:
"""First-principles checks: the cursor validity rules and the
observability we layer on top of them."""
def test_bool_cursor_rejected(self, store):
"""``isinstance(True, int) is True`` in Python; the guard must
still treat ``{"cursor": true}`` as corruption, otherwise a
boolean silently becomes cursor ``1`` / ``0`` downstream.
"""
assert MemoryStore._valid_cursor(True) is None
assert MemoryStore._valid_cursor(False) is None
assert MemoryStore._valid_cursor(5) == 5
assert MemoryStore._valid_cursor(0) == 0
store.history_file.write_text(
'{"cursor": 4, "timestamp": "2026-04-01 10:00", "content": "real"}\n'
'{"cursor": true, "timestamp": "2026-04-01 10:01", "content": "bool"}\n',
encoding="utf-8",
)
store._cursor_file.unlink(missing_ok=True)
assert store.append_history("next") == 5
entries = store.read_unprocessed_history(since_cursor=0)
assert [e["cursor"] for e in entries] == [4, 5]
def test_next_cursor_returns_max_not_just_last_int(self, store):
"""Under adversarial corruption, file order ≠ numeric order. The
recovery scan must return ``max(valid cursors) + 1``, not the
first int seen from the tail, so the returned cursor is strictly
greater than every legitimate cursor already on disk.
"""
# Tail is corrupt → recovery scan runs. Valid cursors are 100
# and 5, in that order on disk; a naive "first int from the tail"
# recovery would return 6, which would then silently collide with
# the existing cursor 100. ``max`` is the only safe choice.
store.history_file.write_text(
'{"cursor": 100, "timestamp": "2026-04-01 10:00", "content": "high"}\n'
'{"cursor": 5, "timestamp": "2026-04-01 10:01", "content": "out of order"}\n'
'{"cursor": "poison", "timestamp": "2026-04-01 10:02", "content": "tail corrupt"}\n',
encoding="utf-8",
)
store._cursor_file.unlink(missing_ok=True)
assert store.append_history("safe next") == 101
def test_corruption_is_logged_exactly_once_per_store(self, store, caplog):
"""Observability without spam: the first non-int cursor emits one
warning, subsequent reads on the same store stay quiet. Without
this, a poisoned file produces one warning per agent turn."""
import logging
from loguru import logger as loguru_logger
store.history_file.write_text(
'{"cursor": "bad1", "timestamp": "2026-04-01 10:00", "content": "x"}\n'
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "y"}\n',
encoding="utf-8",
)
store._cursor_file.unlink(missing_ok=True)
handler_id = loguru_logger.add(
caplog.handler, format="{message}", level="WARNING"
)
try:
with caplog.at_level(logging.WARNING):
store.read_unprocessed_history(since_cursor=0)
store.read_unprocessed_history(since_cursor=0)
store.append_history("another")
finally:
loguru_logger.remove(handler_id)
corruption_warnings = [
r for r in caplog.records if "non-int cursor" in r.getMessage()
]
assert len(corruption_warnings) == 1, (
"Expected exactly one corruption warning per store instance; "
f"got {len(corruption_warnings)}: {[r.getMessage() for r in corruption_warnings]}"
)
+59 -3
View File
@@ -102,7 +102,7 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
loop.sessions.save(session) loop.sessions.save(session)
call_count = [0] call_count = [0]
def mock_estimate(_session): def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1 call_count[0] += 1
if call_count[0] == 1: if call_count[0] == 1:
return (500, "test") return (500, "test")
@@ -139,7 +139,7 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
call_count = [0] call_count = [0]
def mock_estimate(_session): def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1 call_count[0] += 1
if call_count[0] == 1: if call_count[0] == 1:
return (500, "test") return (500, "test")
@@ -156,6 +156,61 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
assert session.last_consolidated == 6 assert session.last_consolidated == 6
@pytest.mark.asyncio
async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path, monkeypatch) -> None:
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.consolidator.archive = AsyncMock(return_value="User discussed project status.") # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
]
loop.sessions.save(session)
call_count = [0]
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
return (80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 150)
await loop.consolidator.maybe_consolidate_by_tokens(session)
reloaded = loop.sessions.get_or_create("cli:test")
meta = reloaded.metadata.get("_last_summary")
assert meta is not None
assert meta["text"] == "User discussed project status."
reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert pending is not None
assert "User discussed project status." in pending
assert "_last_summary" not in reloaded.metadata
@pytest.mark.asyncio
async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> None:
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
session = loop.sessions.get_or_create("cli:test")
loop.auto_compact.prepare_session = MagicMock(
return_value=(session, "Previous conversation summary: earlier context")
) # type: ignore[method-assign]
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
await loop.process_direct("hello", session_key="cli:test")
loop.consolidator.maybe_consolidate_by_tokens.assert_awaited_once_with(
session,
session_summary="Previous conversation summary: earlier context",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> None: async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> None:
"""Verify preflight consolidation runs before the LLM call in process_direct.""" """Verify preflight consolidation runs before the LLM call in process_direct."""
@@ -173,6 +228,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
return LLMResponse(content="ok", tool_calls=[]) return LLMResponse(content="ok", tool_calls=[])
loop.provider.chat_with_retry = track_llm loop.provider.chat_with_retry = track_llm
loop.provider.chat_stream_with_retry = track_llm loop.provider.chat_stream_with_retry = track_llm
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
@@ -184,7 +240,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 500) monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 500)
call_count = [0] call_count = [0]
def mock_estimate(_session): def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1 call_count[0] += 1
return (1000 if call_count[0] <= 1 else 80, "test") return (1000 if call_count[0] <= 1 else 80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign] loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
+159
View File
@@ -417,3 +417,162 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
] ]
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
assert AgentLoop._RUNTIME_CHECKPOINT_KEY not in session.metadata assert AgentLoop._RUNTIME_CHECKPOINT_KEY not in session.metadata
@pytest.mark.asyncio
async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.add_message("user", "question")
session.add_message("assistant", "working")
loop.sessions.save(session)
seen: dict[str, list[dict]] = {}
async def fake_run_agent_loop(initial_messages, **_kwargs):
seen["initial_messages"] = initial_messages
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"stop",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:test",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
)
non_system = [m for m in seen["initial_messages"] if m.get("role") != "system"]
assert [m["content"] for m in non_system[:2]] == ["question", "working"]
assert non_system[2]["content"].count("subagent result") == 1
assert "Current Time:" in non_system[2]["content"]
loop.sessions.invalidate("cli:test")
persisted = loop.sessions.get_or_create("cli:test")
assert [
{k: v for k, v in m.items() if k in {"role", "content", "injected_event", "subagent_task_id"}}
for m in persisted.messages
] == [
{"role": "user", "content": "question"},
{"role": "assistant", "content": "working"},
{
"role": "assistant",
"content": "subagent result",
"injected_event": "subagent_result",
"subagent_task_id": "sub-1",
},
{"role": "assistant", "content": "done"},
]
@pytest.mark.asyncio
async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
async def fake_run_agent_loop(initial_messages, **_kwargs):
return (
"ack",
[],
[*initial_messages, {"role": "assistant", "content": "ack"}],
"stop",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
for idx in range(3):
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:multi",
content=f"subagent result {idx}",
metadata={"subagent_task_id": f"sub-{idx}"},
)
)
loop.sessions.invalidate("cli:multi")
persisted = loop.sessions.get_or_create("cli:multi")
followups = [m for m in persisted.messages if m.get("injected_event") == "subagent_result"]
assert [m["content"] for m in followups] == [
"subagent result 0",
"subagent result 1",
"subagent result 2",
]
def test_prompt_merge_does_not_replace_standalone_subagent_history_entry(tmp_path: Path) -> None:
loop = _mk_loop()
session = Session(key="cli:merge")
session.add_message("assistant", "previous assistant")
inserted = loop._persist_subagent_followup(
session,
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:merge",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
),
)
assert inserted is True
builder = ContextBuilder(tmp_path)
projected = builder.build_messages(
history=session.get_history(max_messages=0),
current_message="",
current_role="assistant",
channel="cli",
chat_id="merge",
)
non_system = [m for m in projected if m.get("role") != "system"]
assert len(non_system) == 2
assert "subagent result" in non_system[-1]["content"]
assert session.messages[-1]["content"] == "subagent result"
assert session.messages[-1]["injected_event"] == "subagent_result"
def test_subagent_followup_dedupes_by_task_id() -> None:
loop = _mk_loop()
session = Session(key="cli:dedupe")
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:dedupe",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
assert loop._persist_subagent_followup(session, msg) is True
assert loop._persist_subagent_followup(session, msg) is False
assert len(session.messages) == 1
def test_subagent_followup_skips_empty_content() -> None:
loop = _mk_loop()
session = Session(key="cli:empty")
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:empty",
content="",
metadata={"subagent_task_id": "sub-empty"},
)
assert loop._persist_subagent_followup(session, msg) is False
assert session.messages == []
+368
View File
@@ -0,0 +1,368 @@
"""Tests for MCP tool/resource/prompt transient error retry."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nanobot.agent.tools.mcp import (
MCPPromptWrapper,
MCPResourceWrapper,
MCPToolWrapper,
_is_transient,
)
# ---------------------------------------------------------------------------
# _is_transient helper
# ---------------------------------------------------------------------------
class _FakeClosedResourceError(Exception):
pass
_FakeClosedResourceError.__name__ = "ClosedResourceError"
class _FakeEndOfStreamError(Exception):
pass
_FakeEndOfStreamError.__name__ = "EndOfStream"
def test_is_transient_recognizes_closed_resource():
assert _is_transient(_FakeClosedResourceError("gone"))
def test_is_transient_recognizes_broken_pipe():
assert _is_transient(BrokenPipeError("pipe"))
def test_is_transient_recognizes_connection_reset():
assert _is_transient(ConnectionResetError("reset"))
def test_is_transient_recognizes_connection_refused():
assert _is_transient(ConnectionRefusedError("refused"))
def test_is_transient_recognizes_end_of_stream():
assert _is_transient(_FakeEndOfStreamError("eof"))
def test_is_transient_rejects_value_error():
assert not _is_transient(ValueError("nope"))
def test_is_transient_rejects_runtime_error():
assert not _is_transient(RuntimeError("nope"))
def test_is_transient_rejects_timeout():
assert not _is_transient(TimeoutError("timeout"))
# ---------------------------------------------------------------------------
# MCPToolWrapper retry behaviour
# ---------------------------------------------------------------------------
def _make_tool_def(name="test_tool"):
return SimpleNamespace(
name=name,
description="A test tool",
inputSchema={"type": "object", "properties": {}},
)
def _make_tool_result(text):
"""Build a mock tool result with proper MCP TextContent."""
return SimpleNamespace(content=[mcp_types.TextContent(type="text", text=text)])
@pytest.mark.asyncio
async def test_tool_retries_on_transient_error():
"""Tool should retry once when a transient error occurs, then succeed."""
session = AsyncMock()
result = _make_tool_result("ok")
exc = _FakeClosedResourceError("connection lost")
session.call_tool = AsyncMock(side_effect=[exc, result])
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute(foo="bar")
assert output == "ok"
assert session.call_tool.call_count == 2
@pytest.mark.asyncio
async def test_tool_fails_after_retry_exhausted():
"""Tool should fail with retry message when both attempts hit transient errors."""
session = AsyncMock()
exc1 = _FakeClosedResourceError("still dead")
exc2 = _FakeClosedResourceError("still dead again")
session.call_tool = AsyncMock(side_effect=[exc1, exc2])
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute()
assert "failed after retry" in output
assert "ClosedResourceError" in output
assert session.call_tool.call_count == 2
@pytest.mark.asyncio
async def test_tool_no_retry_on_non_transient_error():
"""Tool should NOT retry on non-transient errors like ValueError."""
session = AsyncMock()
session.call_tool = AsyncMock(side_effect=ValueError("bad input"))
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
output = await wrapper.execute()
assert "ValueError" in output
assert "retry" not in output
assert session.call_tool.call_count == 1
@pytest.mark.asyncio
async def test_tool_no_retry_on_timeout():
"""Timeouts should not trigger retry (they have their own handling)."""
session = AsyncMock()
session.call_tool = AsyncMock(side_effect=asyncio.TimeoutError())
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
output = await wrapper.execute()
assert "timed out" in output
assert session.call_tool.call_count == 1
@pytest.mark.asyncio
async def test_tool_success_on_first_try_no_retry():
"""Normal success path — no retry logic involved."""
session = AsyncMock()
result = _make_tool_result("hello")
session.call_tool = AsyncMock(return_value=result)
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
output = await wrapper.execute()
assert output == "hello"
assert session.call_tool.call_count == 1
@pytest.mark.asyncio
async def test_tool_does_not_retry_on_cancelled_error():
"""`asyncio.CancelledError` must short-circuit the retry loop.
Regression guard: the retry branch lives under ``except Exception``,
but ``CancelledError`` inherits from ``BaseException``, not
``Exception``, so it naturally bypasses the retry branch today. If a
future refactor ever widens the retry branch to ``BaseException`` (or
re-orders the handlers), ``/stop`` would start retrying instead of
cancelling this test pins that invariant.
"""
session = AsyncMock()
session.call_tool = AsyncMock(side_effect=asyncio.CancelledError())
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
output = await wrapper.execute()
assert "cancelled" in output
assert session.call_tool.call_count == 1
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_tool_retry_on_connection_reset():
"""ConnectionResetError (a stdlib exception) should also trigger retry."""
session = AsyncMock()
result = _make_tool_result("recovered")
session.call_tool = AsyncMock(
side_effect=[ConnectionResetError("reset by peer"), result]
)
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute()
assert output == "recovered"
assert session.call_tool.call_count == 2
@pytest.mark.asyncio
async def test_tool_retry_on_end_of_stream():
"""EndOfStream (anyio) should trigger retry."""
session = AsyncMock()
result = _make_tool_result("back")
session.call_tool = AsyncMock(side_effect=[_FakeEndOfStreamError("eof"), result])
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute()
assert output == "back"
assert session.call_tool.call_count == 2
# ---------------------------------------------------------------------------
# MCPResourceWrapper retry behaviour
# ---------------------------------------------------------------------------
def _make_resource_def(name="test_resource"):
return SimpleNamespace(
name=name,
uri="file:///test",
description="A test resource",
)
def _make_resource_result(text):
return SimpleNamespace(
contents=[mcp_types.TextResourceContents(uri="file:///test", text=text)]
)
@pytest.mark.asyncio
async def test_resource_retries_on_transient_error():
"""Resource should retry once on transient connection error."""
session = AsyncMock()
result = _make_resource_result("data")
exc = _FakeClosedResourceError("gone")
session.read_resource = AsyncMock(side_effect=[exc, result])
wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute()
assert output == "data"
assert session.read_resource.call_count == 2
@pytest.mark.asyncio
async def test_resource_fails_after_retry_exhausted():
"""Resource should fail with retry message when both attempts fail."""
session = AsyncMock()
exc = _FakeClosedResourceError("dead")
session.read_resource = AsyncMock(side_effect=[exc, exc])
wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute()
assert "failed after retry" in output
assert session.read_resource.call_count == 2
@pytest.mark.asyncio
async def test_resource_no_retry_on_non_transient():
"""Resource should not retry on non-transient errors."""
session = AsyncMock()
session.read_resource = AsyncMock(side_effect=RuntimeError("bad"))
wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
output = await wrapper.execute()
assert "RuntimeError" in output
assert session.read_resource.call_count == 1
# ---------------------------------------------------------------------------
# MCPPromptWrapper retry behaviour
# ---------------------------------------------------------------------------
def _make_prompt_def(name="test_prompt"):
return SimpleNamespace(
name=name,
description="A test prompt",
arguments=[],
)
def _make_prompt_result(text):
return SimpleNamespace(
messages=[
SimpleNamespace(
content=mcp_types.TextContent(type="text", text=text),
)
]
)
@pytest.mark.asyncio
async def test_prompt_retries_on_transient_error():
"""Prompt should retry once on transient connection error."""
session = AsyncMock()
result = _make_prompt_result("prompt text")
exc = _FakeClosedResourceError("gone")
session.get_prompt = AsyncMock(side_effect=[exc, result])
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute()
assert output == "prompt text"
assert session.get_prompt.call_count == 2
@pytest.mark.asyncio
async def test_prompt_fails_after_retry_exhausted():
"""Prompt should fail with retry message when both attempts fail."""
session = AsyncMock()
exc = _FakeClosedResourceError("dead")
session.get_prompt = AsyncMock(side_effect=[exc, exc])
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
output = await wrapper.execute()
assert "failed after retry" in output
assert session.get_prompt.call_count == 2
@pytest.mark.asyncio
async def test_prompt_no_retry_on_mcp_error():
"""McpError (application-level) should NOT trigger retry."""
session = AsyncMock()
session.get_prompt = AsyncMock(
side_effect=McpError(ErrorData(code=-1, message="not found"))
)
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
output = await wrapper.execute()
assert "not found" in output
assert session.get_prompt.call_count == 1
@pytest.mark.asyncio
async def test_prompt_no_retry_on_non_transient():
"""Non-transient errors should not trigger retry for prompts."""
session = AsyncMock()
session.get_prompt = AsyncMock(side_effect=RuntimeError("bad"))
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
output = await wrapper.execute()
assert "RuntimeError" in output
assert session.get_prompt.call_count == 1
+33 -8
View File
@@ -1,8 +1,7 @@
"""Tests for the restructured MemoryStore — pure file I/O layer.""" """Tests for the restructured MemoryStore — pure file I/O layer."""
from datetime import datetime
import json import json
from pathlib import Path from datetime import datetime
import pytest import pytest
@@ -65,6 +64,34 @@ class TestHistoryWithCursor:
cursor = store.append_history("event 3") cursor = store.append_history("event 3")
assert cursor == 3 assert cursor == 3
def test_append_history_strips_thinking_content(self, store):
"""`strip_think` must run before persistence — well-formed thinking
blocks shouldn't land in history."""
cursor = store.append_history("<think>reasoning</think>final answer")
content = store.read_file(store.history_file)
data = json.loads(content)
assert data["cursor"] == cursor
assert data["content"] == "final answer"
def test_append_history_drops_pure_leak_content(self, store):
"""Regression: entries that strip down to empty (pure template-token
leak) must NOT fall back to the raw leak. Persisting the raw text
would re-pollute context via consolidation / replay, undoing the
protection `strip_think` provides."""
cursor = store.append_history("<think>nothing user-facing</think>")
content = store.read_file(store.history_file)
data = json.loads(content)
assert data["cursor"] == cursor
assert data["content"] == ""
def test_append_history_drops_malformed_leak_prefix(self, store):
"""Channel-marker / malformed opening leaks should not survive."""
cursor = store.append_history("<channel|>")
content = store.read_file(store.history_file)
data = json.loads(content)
assert data["cursor"] == cursor
assert data["content"] == ""
def test_read_unprocessed_history(self, store): def test_read_unprocessed_history(self, store):
store.append_history("event 1") store.append_history("event 1")
store.append_history("event 2") store.append_history("event 2")
@@ -134,7 +161,8 @@ class TestLegacyHistoryMigration:
"""JSONL entries with cursor=1 are correctly parsed and returned.""" """JSONL entries with cursor=1 are correctly parsed and returned."""
store.history_file.write_text( store.history_file.write_text(
'{"cursor": 1, "timestamp": "2026-03-30 14:30", "content": "Old event"}\n', '{"cursor": 1, "timestamp": "2026-03-30 14:30", "content": "Old event"}\n',
encoding="utf-8") encoding="utf-8",
)
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1 assert len(entries) == 1
assert entries[0]["cursor"] == 1 assert entries[0]["cursor"] == 1
@@ -218,8 +246,7 @@ class TestLegacyHistoryMigration:
memory_dir.mkdir() memory_dir.mkdir()
legacy_file = memory_dir / "HISTORY.md" legacy_file = memory_dir / "HISTORY.md"
legacy_content = ( legacy_content = (
"[2026-03-252026-04-02] Multi-day summary.\n" "[2026-03-252026-04-02] Multi-day summary.\n[2026-03-26/27] Cross-day summary.\n"
"[2026-03-26/27] Cross-day summary.\n"
) )
legacy_file.write_text(legacy_content, encoding="utf-8") legacy_file.write_text(legacy_content, encoding="utf-8")
@@ -277,9 +304,7 @@ class TestLegacyHistoryMigration:
memory_dir = tmp_path / "memory" memory_dir = tmp_path / "memory"
memory_dir.mkdir() memory_dir.mkdir()
legacy_file = memory_dir / "HISTORY.md" legacy_file = memory_dir / "HISTORY.md"
legacy_file.write_bytes( legacy_file.write_bytes(b"[2026-04-01 10:00] Broken \xff data still needs migration.\n\n")
b"[2026-04-01 10:00] Broken \xff data still needs migration.\n\n"
)
store = MemoryStore(tmp_path) store = MemoryStore(tmp_path)
+467
View File
@@ -22,6 +22,9 @@ from nanobot.cli.onboard import (
_format_value, _format_value,
_get_field_display_name, _get_field_display_name,
_get_field_type_info, _get_field_type_info,
_get_constraint_hint,
_input_text,
_validate_field_constraint,
run_onboard, run_onboard,
) )
from nanobot.config.schema import Config from nanobot.config.schema import Config
@@ -207,6 +210,25 @@ class TestGetFieldTypeInfo:
assert type_name == "str" assert type_name == "str"
assert inner is None assert inner is None
def test_literal_type_returns_literal_with_choices(self):
"""Literal["a", "b"] should return ("literal", ["a", "b"])."""
from typing import Literal
class Model(BaseModel):
mode: Literal["standard", "persistent"] = "standard"
type_name, inner = _get_field_type_info(Model.model_fields["mode"])
assert type_name == "literal"
assert inner == ["standard", "persistent"]
def test_real_provider_retry_mode_field(self):
"""Validate against actual AgentDefaults.provider_retry_mode field."""
from nanobot.config.schema import AgentDefaults
type_name, inner = _get_field_type_info(AgentDefaults.model_fields["provider_retry_mode"])
assert type_name == "literal"
assert inner == ["standard", "persistent"]
class TestGetFieldDisplayName: class TestGetFieldDisplayName:
"""Tests for _get_field_display_name human-readable name generation.""" """Tests for _get_field_display_name human-readable name generation."""
@@ -493,3 +515,448 @@ class TestRunOnboardExitBehavior:
assert result.should_save is False assert result.should_save is False
assert result.config.model_dump(by_alias=True) == initial_config.model_dump(by_alias=True) assert result.config.model_dump(by_alias=True) == initial_config.model_dump(by_alias=True)
class TestValidateFieldConstraint:
"""Tests for _validate_field_constraint schema-aware input validation."""
def test_returns_none_when_no_constraints(self):
"""Fields without constraints should pass validation."""
from pydantic import BaseModel
class M(BaseModel):
name: str = "hello"
field_info = M.model_fields["name"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint("anything", field_info) is None
def test_rejects_value_below_ge_bound(self):
"""Value below ge (>=) bound should return error."""
from pydantic import BaseModel, Field
class M(BaseModel):
count: int = Field(default=3, ge=0)
field_info = M.model_fields["count"]
from nanobot.cli.onboard import _validate_field_constraint
result = _validate_field_constraint(-1, field_info)
assert result is not None
assert "0" in result
def test_accepts_value_at_ge_bound(self):
"""Value exactly at ge (>=) bound should pass."""
from pydantic import BaseModel, Field
class M(BaseModel):
count: int = Field(default=3, ge=0)
field_info = M.model_fields["count"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(0, field_info) is None
def test_rejects_value_above_le_bound(self):
"""Value above le (<=) bound should return error."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, le=10)
field_info = M.model_fields["retries"]
from nanobot.cli.onboard import _validate_field_constraint
result = _validate_field_constraint(11, field_info)
assert result is not None
assert "10" in result
def test_accepts_value_at_le_bound(self):
"""Value exactly at le (<=) bound should pass."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, le=10)
field_info = M.model_fields["retries"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(10, field_info) is None
def test_combined_ge_and_le_bounds(self):
"""Field with both ge and le should validate both."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(5, field_info) is None
assert _validate_field_constraint(-1, field_info) is not None
assert _validate_field_constraint(11, field_info) is not None
def test_gt_and_lt_bounds(self):
"""Strict inequality bounds (gt, lt) should exclude boundary."""
from pydantic import BaseModel, Field
class M(BaseModel):
ratio: float = Field(default=0.5, gt=0.0, lt=1.0)
field_info = M.model_fields["ratio"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint(0.5, field_info) is None
assert _validate_field_constraint(0.0, field_info) is not None
assert _validate_field_constraint(1.0, field_info) is not None
def test_min_length_constraint(self):
"""min_length should validate string/list length."""
from pydantic import BaseModel, Field
class M(BaseModel):
name: str = Field(default="x", min_length=1)
field_info = M.model_fields["name"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint("a", field_info) is None
assert _validate_field_constraint("", field_info) is not None
def test_max_length_constraint(self):
"""max_length should validate string/list length."""
from pydantic import BaseModel, Field
class M(BaseModel):
tag: str = Field(default="x", max_length=5)
field_info = M.model_fields["tag"]
from nanobot.cli.onboard import _validate_field_constraint
assert _validate_field_constraint("abc", field_info) is None
assert _validate_field_constraint("abcdef", field_info) is not None
def test_real_send_max_retries_field(self):
"""Validate against the actual ChannelsConfig.send_max_retries field."""
from nanobot.config.schema import ChannelsConfig
from nanobot.cli.onboard import _validate_field_constraint
field_info = ChannelsConfig.model_fields["send_max_retries"]
assert _validate_field_constraint(3, field_info) is None
assert _validate_field_constraint(0, field_info) is None
assert _validate_field_constraint(10, field_info) is None
assert _validate_field_constraint(-1, field_info) is not None
assert _validate_field_constraint(11, field_info) is not None
class TestGetConstraintHint:
"""Tests for _get_constraint_hint field display suffix."""
def test_no_constraints_returns_empty(self):
"""Fields without constraints should return empty string."""
from pydantic import BaseModel
class M(BaseModel):
name: str = "hello"
field_info = M.model_fields["name"]
assert _get_constraint_hint(field_info) == ""
def test_ge_le_range(self):
"""Field with ge+le should show '(min-max)'."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
hint = _get_constraint_hint(field_info)
assert "0" in hint
assert "10" in hint
def test_ge_only(self):
"""Field with only ge should show '(>= N)'."""
from pydantic import BaseModel, Field
class M(BaseModel):
count: int = Field(default=1, ge=0)
field_info = M.model_fields["count"]
hint = _get_constraint_hint(field_info)
assert "0" in hint
assert ">=" in hint
def test_le_only(self):
"""Field with only le should show '(<= N)'."""
from pydantic import BaseModel, Field
class M(BaseModel):
ratio: float = Field(default=1.0, le=100.0)
field_info = M.model_fields["ratio"]
hint = _get_constraint_hint(field_info)
assert "100" in hint
assert "<=" in hint
def test_real_send_max_retries_hint(self):
"""Actual ChannelsConfig.send_max_retries should show '(0-10)'."""
from nanobot.config.schema import ChannelsConfig
field_info = ChannelsConfig.model_fields["send_max_retries"]
hint = _get_constraint_hint(field_info)
assert "0" in hint
assert "10" in hint
class TestInputTextWithValidation:
"""Tests for _input_text integration with constraint validation."""
def test_rejects_out_of_range_int(self, monkeypatch):
"""_input_text with field_info should reject values violating ge/le constraints."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
monkeypatch.setattr(
onboard_wizard,
"_get_questionary",
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "15")),
)
result = _input_text("Retries", 3, "int", field_info=field_info)
assert result is None
def test_accepts_valid_int(self, monkeypatch):
"""_input_text with field_info should accept valid constrained values."""
from pydantic import BaseModel, Field
class M(BaseModel):
retries: int = Field(default=3, ge=0, le=10)
field_info = M.model_fields["retries"]
monkeypatch.setattr(
onboard_wizard,
"_get_questionary",
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "5")),
)
result = _input_text("Retries", 3, "int", field_info=field_info)
assert result == 5
def test_works_without_field_info(self, monkeypatch):
"""_input_text without field_info should work as before (no validation)."""
monkeypatch.setattr(
onboard_wizard,
"_get_questionary",
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "42")),
)
result = _input_text("Count", 0, "int")
assert result == 42
class TestChannelCommonRegistration:
"""Tests for Channel Common menu registration."""
def test_channel_common_in_settings_sections(self):
"""Channel Common should be registered in _SETTINGS_SECTIONS."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS
assert "Channel Common" in _SETTINGS_SECTIONS
def test_channel_common_getter_returns_channels(self):
"""Channel Common getter should return config.channels."""
from nanobot.cli.onboard import _SETTINGS_GETTER
config = Config()
result = _SETTINGS_GETTER["Channel Common"](config)
assert result is config.channels
def test_channel_common_setter_writes_channels(self):
"""Channel Common setter should update config.channels."""
from nanobot.cli.onboard import _SETTINGS_SETTER
config = Config()
original = config.channels
new_channels = original.model_copy(deep=True)
new_channels.send_tool_hints = True
_SETTINGS_SETTER["Channel Common"](config, new_channels)
assert config.channels.send_tool_hints is True
def test_channel_common_edit_preserves_extras(self):
"""Editing Channel Common should not lose per-channel extras."""
config = Config()
config.channels.feishu = {"enabled": True, "appId": "test123"}
channels = config.channels.model_copy(deep=True)
channels.send_tool_hints = True
config.channels = channels
assert config.channels.send_tool_hints is True
assert config.channels.feishu["appId"] == "test123"
class TestApiServerRegistration:
"""Tests for API Server menu registration."""
def test_api_server_in_settings_sections(self):
"""API Server should be registered in _SETTINGS_SECTIONS."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS
assert "API Server" in _SETTINGS_SECTIONS
def test_api_server_getter_returns_api(self):
"""API Server getter should return config.api."""
from nanobot.cli.onboard import _SETTINGS_GETTER
config = Config()
result = _SETTINGS_GETTER["API Server"](config)
assert result is config.api
def test_api_server_setter_writes_api(self):
"""API Server setter should update config.api."""
from nanobot.cli.onboard import _SETTINGS_SETTER
config = Config()
from nanobot.config.schema import ApiConfig
new_api = ApiConfig(host="0.0.0.0", port=9999)
_SETTINGS_SETTER["API Server"](config, new_api)
assert config.api.host == "0.0.0.0"
assert config.api.port == 9999
class TestMainMenuUpdate:
"""Tests for main menu including new Channel Common and API Server items."""
def test_main_menu_dispatch_includes_channel_common(self):
"""Main menu dispatch should route [H] to Channel Common."""
from nanobot.cli.onboard import run_onboard
# We verify by checking the dispatch table is set up correctly
# The menu items are defined inline in run_onboard, so we test
# that _configure_general_settings handles the new sections.
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
assert "Channel Common" in _SETTINGS_SECTIONS
assert "Channel Common" in _SETTINGS_GETTER
assert "Channel Common" in _SETTINGS_SETTER
def test_main_menu_dispatch_includes_api_server(self):
"""Main menu dispatch should route [I] to API Server."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
assert "API Server" in _SETTINGS_SECTIONS
assert "API Server" in _SETTINGS_GETTER
assert "API Server" in _SETTINGS_SETTER
def test_run_onboard_channel_common_edit(self, monkeypatch):
"""run_onboard should handle [H] Channel Common correctly."""
initial_config = Config()
responses = iter([
"[H] Channel Common",
KeyboardInterrupt(),
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_configure_general_settings(config, section):
if section == "Channel Common":
config.channels.send_tool_hints = True
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings)
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert result.config.channels.send_tool_hints is True
def test_run_onboard_api_server_edit(self, monkeypatch):
"""run_onboard should handle [I] API Server correctly."""
initial_config = Config()
responses = iter([
"[I] API Server",
KeyboardInterrupt(),
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_configure_general_settings(config, section):
if section == "API Server":
config.api.port = 9999
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings)
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert result.config.api.port == 9999
def test_view_summary_calls_pause(self, monkeypatch):
"""[V] View Summary should pause before returning to main menu."""
initial_config = Config()
pause_called = {"n": 0}
responses = iter([
"[V] View Configuration Summary",
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
if isinstance(self.response, BaseException):
raise self.response
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_pause():
pause_called["n"] += 1
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
# _pause is called inside _show_summary, so we patch it there
monkeypatch.setattr(onboard_wizard, "_pause", fake_pause)
# Suppress summary output but still call _pause
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_get_provider_names", lambda: {})
monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {})
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert pause_called["n"] == 1
+177 -5
View File
@@ -643,10 +643,11 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
trimmed = runner._snip_history(spec, messages) trimmed = runner._snip_history(spec, messages)
assert trimmed == [ # After the fix, the user message is recovered so the sequence is valid
{"role": "system", "content": "system"}, # for providers that require system → user (e.g. GLM error 1214).
{"role": "assistant", "content": "after tool"}, assert trimmed[0]["role"] == "system"
] non_system = [m for m in trimmed if m["role"] != "system"]
assert non_system[0]["role"] == "user", f"Expected user after system, got {non_system[0]['role']}"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -2787,4 +2788,175 @@ async def test_injection_cycle_cap_on_error_path():
assert result.had_injections is True assert result.had_injections is True
# Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks # Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks
assert call_count["n"] == _MAX_INJECTION_CYCLES + 1 assert call_count["n"] == _MAX_INJECTION_CYCLES + 1
assert drain_count["n"] == _MAX_INJECTION_CYCLES
# ---------------------------------------------------------------------------
# Regression tests for GLM-1214: _snip_history must preserve a user message
# ---------------------------------------------------------------------------
def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
"""When _snip_history truncates messages and the only user message ends up
outside the kept window, the method must recover the nearest user message
so the resulting sequence is valid for providers like GLM (which reject
systemassistant with error 1214).
This reproduces the exact scenario from the bug report:
- Normal interaction: user asks, assistant calls tool, tool returns,
assistant replies.
- Injection adds a phantom user message, triggering more tool calls.
- _snip_history activates, keeping only recent assistant/tool pairs.
- The injected user message is in the truncated prefix and gets lost.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
messages = [
{"role": "system", "content": "system"},
{"role": "assistant", "content": "previous reply"},
{"role": "user", "content": ".nanobot的同目录"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "tc_1", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "tool output 1"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "tc_2", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "tc_2", "content": "tool output 2"},
]
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
context_window_tokens=2000,
context_block_limit=100,
)
# Make estimate_prompt_tokens_chain report above budget so _snip_history activates.
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
# Make kept window small: only the last 2 messages fit the budget.
token_sizes = {
"system": 0,
"previous reply": 200,
".nanobot的同目录": 80,
"tool output 1": 80,
"tool output 2": 80,
}
monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens",
lambda msg: token_sizes.get(str(msg.get("content")), 100),
)
trimmed = runner._snip_history(spec, messages)
# The first non-system message MUST be user (not assistant).
non_system = [m for m in trimmed if m.get("role") != "system"]
assert non_system, "trimmed should contain at least one non-system message"
assert non_system[0]["role"] == "user", (
f"First non-system message must be 'user', got '{non_system[0]['role']}'. "
f"Roles: {[m['role'] for m in trimmed]}"
)
def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
"""Edge case: if non_system has zero user messages, _snip_history should
still return a valid sequence (not crash or produce systemassistant)."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
messages = [
{"role": "system", "content": "system"},
{"role": "assistant", "content": "reply"},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
{"role": "assistant", "content": "reply 2"},
{"role": "tool", "tool_call_id": "tc_2", "content": "result 2"},
]
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
context_window_tokens=2000,
context_block_limit=100,
)
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens",
lambda msg: 100,
)
trimmed = runner._snip_history(spec, messages)
# Should not crash. The result should still be a valid list.
assert isinstance(trimmed, list)
# Must have at least system.
assert any(m.get("role") == "system" for m in trimmed)
# The _enforce_role_alternation safety net must be able to fix whatever
# _snip_history returns here — verify it produces a valid sequence.
from nanobot.providers.base import LLMProvider
fixed = LLMProvider._enforce_role_alternation(trimmed)
non_system = [m for m in fixed if m["role"] != "system"]
if non_system:
assert non_system[0]["role"] in ("user", "tool"), (
f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}"
)
@pytest.mark.asyncio
async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
"""Regression: provider retry heartbeats must route through
``retry_wait_callback``, not ``progress_callback``. Binding them to
the progress callback (as an earlier runtime refactor did) caused
internal retry diagnostics like "Model request failed, retry in 1s"
to leak to end-user channels as normal progress updates.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
captured: dict = {}
async def chat_with_retry(**kwargs):
captured.update(kwargs)
return LLMResponse(content="done", tool_calls=[], usage={})
provider = MagicMock()
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
retry_wait_cb = AsyncMock()
runner = AgentRunner(provider)
await runner.run(AgentRunSpec(
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "hi"},
],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
retry_wait_callback=retry_wait_cb,
))
assert captured["on_retry_wait"] is retry_wait_cb
assert captured["on_retry_wait"] is not progress_cb
+263
View File
@@ -0,0 +1,263 @@
"""Tests for atomic session save and corrupt-file repair."""
import json
from datetime import datetime
from pathlib import Path
from nanobot.session.manager import Session, SessionManager
class TestAtomicSave:
def test_save_creates_valid_jsonl(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
session = Session(key="test:1")
session.add_message("user", "hello")
session.add_message("assistant", "hi")
mgr.save(session)
path = mgr._get_session_path("test:1")
lines = path.read_text(encoding="utf-8").strip().split("\n")
assert len(lines) == 3
meta = json.loads(lines[0])
assert meta["_type"] == "metadata"
assert meta["key"] == "test:1"
msg1 = json.loads(lines[1])
assert msg1["role"] == "user"
assert msg1["content"] == "hello"
def test_no_tmp_file_left_after_successful_save(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
session = Session(key="test:clean")
mgr.save(session)
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
assert tmp_files == []
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
session = Session(key="test:fail")
path = mgr._get_session_path("test:fail")
tmp_path_file = path.with_suffix(".jsonl.tmp")
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path_file.write_text("stale")
class BadMessage:
def __init__(self, data):
self.data = data
original_dumps = json.dumps
def failing_dumps(obj, **kwargs):
if isinstance(obj, dict) and obj.get("role") == "assistant":
raise OSError("simulated disk full")
return original_dumps(obj, **kwargs)
session = Session(key="test:fail")
session.messages = [
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "will fail"},
]
import unittest.mock
with unittest.mock.patch("nanobot.session.manager.json.dumps", side_effect=failing_dumps):
try:
mgr.save(session)
except OSError:
pass
assert not tmp_path_file.exists()
def test_overwrite_preserves_latest_data(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
session = Session(key="test:overwrite")
session.add_message("user", "first")
mgr.save(session)
session.add_message("user", "second")
mgr.save(session)
mgr.invalidate("test:overwrite")
loaded = mgr.get_or_create("test:overwrite")
assert len(loaded.messages) == 2
assert loaded.messages[0]["content"] == "first"
assert loaded.messages[1]["content"] == "second"
def test_consecutive_saves_are_consistent(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
session = Session(key="test:consistency")
for i in range(5):
session.add_message("user", f"msg{i}")
mgr.save(session)
mgr.invalidate("test:consistency")
loaded = mgr.get_or_create("test:consistency")
assert len(loaded.messages) == 5
for i in range(5):
assert loaded.messages[i]["content"] == f"msg{i}"
class TestRepairCorruptFile:
def _write_corrupt_jsonl(self, path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_truncated_last_line_recovered(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:trunc")
valid_meta = json.dumps({
"_type": "metadata",
"key": "test:trunc",
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"metadata": {},
"last_consolidated": 0,
})
valid_msg = json.dumps({"role": "user", "content": "hello"})
self._write_corrupt_jsonl(path, [
valid_meta,
valid_msg,
'{"role": "assistant", "content": "partial...',
])
session = mgr._load("test:trunc")
assert session is not None
assert len(session.messages) == 1
assert session.messages[0]["content"] == "hello"
def test_corrupt_metadata_line_skipped(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:badmeta")
self._write_corrupt_jsonl(path, [
"NOT VALID JSON!!!",
'{"role": "user", "content": "survived"}',
])
session = mgr._load("test:badmeta")
assert session is not None
assert len(session.messages) == 1
assert session.messages[0]["content"] == "survived"
def test_all_corrupt_lines_returns_none(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:allbad")
self._write_corrupt_jsonl(path, [
"garbage line 1",
"garbage line 2",
"{{invalid json",
])
session = mgr._load("test:allbad")
assert session is None
def test_empty_file_returns_empty_session(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:empty")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("", encoding="utf-8")
session = mgr._load("test:empty")
assert session is not None
assert session.messages == []
assert session.key == "test:empty"
def test_repair_preserves_valid_messages_amid_corruption(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:mixed")
self._write_corrupt_jsonl(path, [
json.dumps({"_type": "metadata", "key": "test:mixed",
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"metadata": {}, "last_consolidated": 0}),
"BROKEN",
json.dumps({"role": "user", "content": "msg1"}),
'{"role": "assistant", "content": "broken',
json.dumps({"role": "user", "content": "msg2"}),
])
session = mgr._load("test:mixed")
assert session is not None
assert len(session.messages) == 2
assert session.messages[0]["content"] == "msg1"
assert session.messages[1]["content"] == "msg2"
def test_repair_with_bad_timestamp_uses_fallback(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:badts")
self._write_corrupt_jsonl(path, [
json.dumps({"_type": "metadata", "key": "test:badts",
"created_at": "not-a-date",
"updated_at": "also-bad",
"metadata": {}, "last_consolidated": 5}),
json.dumps({"role": "user", "content": "hi"}),
])
session = mgr._load("test:badts")
assert session is not None
assert session.last_consolidated == 5
assert isinstance(session.created_at, datetime)
def test_read_session_file_repairs_corrupt_jsonl(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:read-repair")
self._write_corrupt_jsonl(path, [
json.dumps({
"_type": "metadata",
"key": "test:read-repair",
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"metadata": {"source": "repair"},
"last_consolidated": 0,
}),
json.dumps({"role": "user", "content": "survived"}),
'{"role": "assistant", "content": "partial...',
])
payload = mgr.read_session_file("test:read-repair")
assert payload is not None
assert payload["key"] == "test:read-repair"
assert payload["metadata"] == {"source": "repair"}
assert payload["messages"] == [{"role": "user", "content": "survived"}]
def test_list_sessions_keeps_repaired_corrupt_file(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:list-repair")
self._write_corrupt_jsonl(path, [
"NOT VALID JSON",
json.dumps({
"_type": "metadata",
"key": "test:list-repair",
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"metadata": {},
"last_consolidated": 0,
}),
json.dumps({"role": "user", "content": "hello"}),
])
sessions = mgr.list_sessions()
assert any(s["key"] == "test:list-repair" for s in sessions)
def test_get_or_create_returns_new_session_for_corrupt_file(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:fallback")
self._write_corrupt_jsonl(path, ["{{{{"])
session = mgr.get_or_create("test:fallback")
assert session is not None
assert session.messages == []
assert session.key == "test:fallback"
+65
View File
@@ -0,0 +1,65 @@
"""Tests for SessionManager.delete_session and read_session_file."""
from pathlib import Path
from nanobot.session.manager import Session, SessionManager
def _seed(workspace: Path, key: str = "telegram:abc") -> SessionManager:
sm = SessionManager(workspace)
session = Session(key=key)
session.add_message("user", "hello")
session.add_message("assistant", "hi back")
sm.save(session)
return sm
def test_delete_session_removes_file_and_invalidates_cache(tmp_path: Path) -> None:
sm = _seed(tmp_path, "telegram:abc")
file_path = sm._get_session_path("telegram:abc")
assert file_path.exists()
# Populate cache as a real consumer would.
cached = sm.get_or_create("telegram:abc")
assert cached.messages
assert sm.delete_session("telegram:abc") is True
assert not file_path.exists()
# Subsequent get_or_create returns a fresh, empty Session (no stale cache).
fresh = sm.get_or_create("telegram:abc")
assert fresh.messages == []
def test_delete_session_returns_false_when_missing(tmp_path: Path) -> None:
sm = SessionManager(tmp_path)
assert sm.delete_session("nope:none") is False
def test_read_session_file_returns_metadata_and_messages(tmp_path: Path) -> None:
sm = _seed(tmp_path, "telegram:abc")
data = sm.read_session_file("telegram:abc")
assert data is not None
assert data["key"] == "telegram:abc"
assert isinstance(data["messages"], list)
assert [m["role"] for m in data["messages"]] == ["user", "assistant"]
assert data["created_at"]
assert data["updated_at"]
def test_read_session_file_does_not_populate_cache(tmp_path: Path) -> None:
sm = _seed(tmp_path, "telegram:abc")
sm.invalidate("telegram:abc")
assert "telegram:abc" not in sm._cache
sm.read_session_file("telegram:abc")
assert "telegram:abc" not in sm._cache
def test_read_session_file_missing(tmp_path: Path) -> None:
sm = SessionManager(tmp_path)
assert sm.read_session_file("nope:none") is None
def test_safe_key_matches_internal_path(tmp_path: Path) -> None:
sm = SessionManager(tmp_path)
key = "telegram:abc/def"
expected = sm._get_session_path(key).name
assert SessionManager.safe_key(key) + ".jsonl" == expected
+162
View File
@@ -0,0 +1,162 @@
"""Tests for /stop preserving partial context from interrupted turns.
When /stop cancels an active task, the runtime checkpoint (tool results,
assistant messages accumulated so far) should be materialized into session
history rather than silently discarded.
See: https://github.com/HKUDS/nanobot/issues/2966
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch, AsyncMock
import pytest
from nanobot.agent.loop import AgentLoop
@pytest.fixture
def mock_loop():
"""Create a minimal AgentLoop with mocked dependencies."""
with patch.object(AgentLoop, "__init__", lambda self: None):
loop = AgentLoop()
loop.sessions = MagicMock()
loop._pending_queues = {}
loop._session_locks = {}
loop._active_tasks = {}
loop._concurrency_gate = None
loop._RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
loop._PENDING_USER_TURN_KEY = "pending_user_turn"
loop.bus = MagicMock()
loop.bus.publish_outbound = AsyncMock()
loop.bus.publish_inbound = AsyncMock()
loop.commands = MagicMock()
loop.commands.dispatch_priority = AsyncMock(return_value=None)
return loop
class TestStopPreservesContext:
"""Verify that /stop restores partial context via checkpoint."""
def test_restore_checkpoint_method_exists(self, mock_loop):
"""AgentLoop should have _restore_runtime_checkpoint."""
assert hasattr(mock_loop, "_restore_runtime_checkpoint")
def test_checkpoint_key_constant(self, mock_loop):
"""The runtime checkpoint key should be defined."""
assert mock_loop._RUNTIME_CHECKPOINT_KEY == "runtime_checkpoint"
def test_cancel_dispatch_restores_checkpoint(self, mock_loop):
"""When a task is cancelled, the checkpoint should be restored."""
# Create a mock session with a checkpoint
session = MagicMock()
session.metadata = {
"runtime_checkpoint": {
"phase": "awaiting_tools",
"iteration": 0,
"assistant_message": {
"role": "assistant",
"content": "Let me search for that.",
"tool_calls": [{"id": "tc_1", "type": "function",
"function": {"name": "web_search", "arguments": "{}"}}],
},
"completed_tool_results": [
{"role": "tool", "tool_call_id": "tc_1",
"content": "Search results: ..."},
],
"pending_tool_calls": [],
}
}
session.messages = [
{"role": "user", "content": "Search for something"},
]
mock_loop.sessions.get_or_create.return_value = session
# The restore method should add checkpoint messages to session history
restored = mock_loop._restore_runtime_checkpoint(session)
assert restored is True
# After restore, session should have more messages
assert len(session.messages) > 1
# The checkpoint should be cleared
assert "runtime_checkpoint" not in session.metadata
@pytest.mark.asyncio
async def test_dispatch_cancellation_restores_checkpoint():
"""Regression for #2966: /stop interrupting _dispatch must materialize the
in-flight runtime checkpoint into session.messages before the cancellation
unwinds, so the next turn can see the partial work.
This exercises the real _dispatch path (locks, pending queues, the
CancelledError handler) rather than poking _restore_runtime_checkpoint in
isolation, so a future refactor that drops the cancel-time restore is
caught by CI instead of silently regressing.
"""
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
workspace = MagicMock()
workspace.__truediv__ = MagicMock(return_value=MagicMock())
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)
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
checkpoint_key = loop._RUNTIME_CHECKPOINT_KEY
session = SimpleNamespace(
key="test:c1",
metadata={
checkpoint_key: {
"phase": "awaiting_tools",
"iteration": 0,
"assistant_message": {
"role": "assistant",
"content": "Let me search.",
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "web_search", "arguments": "{}"},
}
],
},
"completed_tool_results": [
{"role": "tool", "tool_call_id": "tc_1", "content": "Search hit."},
],
"pending_tool_calls": [],
}
},
messages=[{"role": "user", "content": "Search for something"}],
)
loop.sessions.get_or_create = MagicMock(return_value=session)
loop.sessions.save = MagicMock()
async def _cancel(*_args, **_kwargs):
raise asyncio.CancelledError()
loop._process_message = _cancel
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="work")
with pytest.raises(asyncio.CancelledError):
await loop._dispatch(msg)
roles = [m.get("role") for m in session.messages]
assert roles == ["user", "assistant", "tool"], (
"Expected the assistant message and completed tool result from the "
f"interrupted turn to be materialized into session.messages; got {roles}"
)
assert checkpoint_key not in session.metadata, \
"Checkpoint metadata should be cleared after restore"
assert loop.sessions.save.called, \
"Session should be persisted so the restored state survives process restart"
+88
View File
@@ -412,3 +412,91 @@ class TestSubagentCancellation:
assert cancelled.is_set() assert cancelled.is_set()
assert task.cancelled() assert task.cancelled()
mgr._announce_result.assert_not_awaited() mgr._announce_result.assert_not_awaited()
class TestSubagentAnnounceSessionKey:
"""Verify _announce_result uses the effective session key for mid-turn routing."""
def _make_mgr(self):
"""Create a SubagentManager with mocked deps and its bus."""
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(
provider=provider,
workspace=MagicMock(),
bus=bus,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
)
return mgr, bus
@pytest.mark.asyncio
async def test_announce_uses_effective_key_in_unified_mode(self):
"""In unified session mode, session_key_override must be 'unified:default'
so the result matches the pending queue key."""
mgr, bus = self._make_mgr()
origin = {"channel": "telegram", "chat_id": "111", "session_key": "unified:default"}
await mgr._announce_result("sub-1", "label", "task", "result", origin, "ok")
msg = await bus.consume_inbound()
assert msg.session_key_override == "unified:default"
assert msg.session_key == "unified:default"
@pytest.mark.asyncio
async def test_announce_uses_raw_key_in_normal_mode(self):
"""Without unified sessions, session_key_override is the raw channel:chat_id."""
mgr, bus = self._make_mgr()
origin = {"channel": "telegram", "chat_id": "222", "session_key": "telegram:222"}
await mgr._announce_result("sub-2", "label", "task", "result", origin, "ok")
msg = await bus.consume_inbound()
assert msg.session_key_override == "telegram:222"
assert msg.session_key == "telegram:222"
@pytest.mark.asyncio
async def test_announce_falls_back_to_origin_when_no_session_key(self):
"""When session_key is None, fallback to f'{channel}:{chat_id}'."""
mgr, bus = self._make_mgr()
origin = {"channel": "discord", "chat_id": "333", "session_key": None}
await mgr._announce_result("sub-3", "label", "task", "result", origin, "ok")
msg = await bus.consume_inbound()
assert msg.session_key_override == "discord:333"
assert msg.channel == "system"
assert msg.chat_id == "discord:333"
@pytest.mark.asyncio
async def test_session_key_flows_through_run_subagent(self):
"""Verify session_key in origin propagates from _run_subagent to _announce_result."""
from nanobot.agent.subagent import SubagentStatus
mgr, bus = self._make_mgr()
async def fake_run(spec):
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
)
mgr.runner.run = AsyncMock(side_effect=fake_run)
status = SubagentStatus(
task_id="sub-4", label="label", task_description="task",
started_at=time.monotonic(),
)
await mgr._run_subagent(
"sub-4", "task", "label",
{"channel": "telegram", "chat_id": "444", "session_key": "unified:default"},
status,
)
msg = await bus.consume_inbound()
assert msg.session_key_override == "unified:default"
+6 -1
View File
@@ -241,6 +241,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace( loop = SimpleNamespace(
sessions=sessions, sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)), consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
) )
loop._schedule_background = lambda coro: asyncio.ensure_future(coro) loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
@@ -274,6 +275,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace( loop = SimpleNamespace(
sessions=sessions, sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)), consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
) )
loop._schedule_background = lambda coro: asyncio.ensure_future(coro) loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
@@ -395,7 +397,10 @@ class TestConsolidationUnaffectedByUnifiedSession:
await consolidator.maybe_consolidate_by_tokens(session) await consolidator.maybe_consolidate_by_tokens(session)
# estimate was called (consolidation was attempted) # estimate was called (consolidation was attempted)
consolidator.estimate_session_prompt_tokens.assert_called_once_with(session) consolidator.estimate_session_prompt_tokens.assert_called_once_with(
session,
session_summary=None,
)
# but archive was not called (no valid boundary) # but archive was not called (no valid boundary)
consolidator.archive.assert_not_called() consolidator.archive.assert_not_called()
+20
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from pydantic import BaseModel
from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.self import MyTool
@@ -168,6 +169,25 @@ class TestInspectPathNavigation:
result = await tool.execute(action="check", key="tools") result = await tool.execute(action="check", key="tools")
assert "not accessible" in result assert "not accessible" in result
@pytest.mark.asyncio
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
class SearchConfig(BaseModel):
provider: str = "tavily"
api_key: str = "sk-test-secret"
base_url: str = ""
max_results: int = 5
loop = _make_mock_loop()
loop.web_config = MagicMock()
loop.web_config.search = SearchConfig()
tool = _make_tool(loop)
result = await tool.execute(action="check", key="web_config.search")
assert "provider='tavily'" in result
assert "sk-test-secret" not in result
assert "api_key" not in result.lower()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+53
View File
@@ -0,0 +1,53 @@
"""Tests for subagent tool registration and wiring."""
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.config.schema import AgentDefaults
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio
async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
"""allowed_env_keys from ExecToolConfig must be forwarded to the subagent's ExecTool."""
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(
provider=provider,
workspace=tmp_path,
bus=bus,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
exec_config=ExecToolConfig(allowed_env_keys=["GOPATH", "JAVA_HOME"]),
)
mgr._announce_result = AsyncMock()
async def fake_run(spec):
exec_tool = spec.tools.get("exec")
assert exec_tool is not None
assert exec_tool.allowed_env_keys == ["GOPATH", "JAVA_HOME"]
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
)
mgr.runner.run = AsyncMock(side_effect=fake_run)
status = SubagentStatus(
task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()
)
await mgr._run_subagent(
"sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status
)
mgr.runner.run.assert_awaited_once()
@@ -296,3 +296,50 @@ class TestDispatchOutboundWithCoalescing:
# Should have pending regular message # Should have pending regular message
assert len(pending) == 1 assert len(pending) == 1
assert pending[0].content == "Final" assert pending[0].content == "Final"
class TestRetryWaitFiltering:
"""Internal provider retry heartbeats must never reach channels."""
@pytest.mark.asyncio
async def test_retry_wait_message_dropped(self, manager, bus):
"""A ``_retry_wait`` message must be filtered before channel dispatch.
Regression: provider retry diagnostics like
``Model request failed, retry in 1s (attempt 1).`` were being
delivered to end-user channels because the runner bound
``on_retry_wait`` to the progress callback.
"""
retry_msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="Model request failed, retry in 1s (attempt 1).",
metadata={"_retry_wait": True},
)
real_msg = OutboundMessage(
channel="mock",
chat_id="chat1",
content="final answer",
metadata={},
)
await bus.publish_outbound(retry_msg)
await bus.publish_outbound(real_msg)
task = asyncio.create_task(manager._dispatch_outbound())
try:
for _ in range(30):
if manager.channels["mock"]._send_mock.await_count >= 1:
break
await asyncio.sleep(0.05)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
send_mock = manager.channels["mock"]._send_mock
assert send_mock.await_count == 1
sent = send_mock.await_args_list[0].args[0]
assert sent.content == "final answer"
assert not sent.metadata.get("_retry_wait")
+27 -3
View File
@@ -273,17 +273,41 @@ async def test_stop_is_safe_after_partial_start(monkeypatch) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_on_message_ignores_bot_messages() -> None: async def test_on_message_ignores_self_messages() -> None:
# Incoming bot-authored messages must be ignored to prevent feedback loops. # Self-loop guard: messages from this bot's own account must be dropped (#3217).
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
channel._bot_user_id = "999" # simulate bot identity populated in on_ready()
handled: list[dict] = [] handled: list[dict] = []
channel._handle_message = lambda **kwargs: handled.append(kwargs) # type: ignore[method-assign] channel._handle_message = lambda **kwargs: handled.append(kwargs) # type: ignore[method-assign]
await channel._on_message(_make_message(author_bot=True)) await channel._on_message(_make_message(author_id=999, author_bot=True))
assert handled == [] assert handled == []
@pytest.mark.asyncio
async def test_on_message_accepts_messages_from_other_bots() -> None:
# Multi-agent setups: messages from OTHER bots must be processed, not dropped (#3217).
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
channel._bot_user_id = "999"
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
await channel._on_message(_make_message(author_id=123, author_bot=True))
assert len(handled) == 1
assert handled[0]["sender_id"] == "123"
@pytest.mark.asyncio
async def test_on_message_stops_typing_on_handle_exception() -> None:
# If inbound handling raises, typing should be stopped for that channel. # If inbound handling raises, typing should be stopped for that channel.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
async def fail_handle(**kwargs) -> None: async def fail_handle(**kwargs) -> None:
raise RuntimeError("boom") raise RuntimeError("boom")
+103
View File
@@ -92,6 +92,109 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
assert items_again == [] assert items_again == []
def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) -> None:
raw = _make_raw_email(from_addr="Nanobot <bot@example.com>", subject="Loop test")
class FakeIMAP:
def __init__(self) -> None:
self.store_calls: list[tuple[bytes, str, str]] = []
def login(self, _user: str, _pw: str):
return "OK", [b"logged in"]
def select(self, _mailbox: str):
return "OK", [b"1"]
def search(self, *_args):
return "OK", [b"1"]
def fetch(self, _imap_id: bytes, _parts: str):
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
def store(self, imap_id: bytes, op: str, flags: str):
self.store_calls.append((imap_id, op, flags))
return "OK", [b""]
def logout(self):
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus())
items = channel._fetch_new_messages()
assert items == []
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
# Same UID should still be deduped after being ignored.
items_again = channel._fetch_new_messages()
assert items_again == []
@pytest.mark.parametrize(
"config_override,from_header",
[
# Only smtp_username matches — simulates an SMTP relay where
# outbound From gets rewritten to the SMTP login identity.
(
{"from_address": "", "smtp_username": "bot@example.com", "imap_username": "other@imap.com"},
"bot@example.com",
),
# Only imap_username matches — simulates mailbox-based identity
# with no explicit from_address set.
(
{"from_address": "", "smtp_username": "other@smtp.com", "imap_username": "bot@example.com"},
"bot@example.com",
),
# Case-insensitive: inbound From arrives upper-cased.
(
{"from_address": "bot@example.com", "smtp_username": "other@smtp.com", "imap_username": "other@imap.com"},
"BOT@EXAMPLE.COM",
),
],
ids=["smtp_username_only", "imap_username_only", "case_insensitive"],
)
def test_fetch_new_messages_skips_self_sent_across_identity_sources(
monkeypatch, config_override, from_header
) -> None:
"""Self-address detection must fire when any of from_address / smtp_username /
imap_username matches, and must be case-insensitive."""
raw = _make_raw_email(from_addr=from_header, subject="Loop test")
class FakeIMAP:
def __init__(self) -> None:
self.store_calls: list[tuple[bytes, str, str]] = []
def login(self, _user: str, _pw: str):
return "OK", [b"logged in"]
def select(self, _mailbox: str):
return "OK", [b"1"]
def search(self, *_args):
return "OK", [b"1"]
def fetch(self, _imap_id: bytes, _parts: str):
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
def store(self, imap_id: bytes, op: str, flags: str):
self.store_calls.append((imap_id, op, flags))
return "OK", [b""]
def logout(self):
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(**config_override), MessageBus())
items = channel._fetch_new_messages()
assert items == []
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeypatch) -> None: def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeypatch) -> None:
raw = _make_raw_email(subject="Invoice", body="Please pay") raw = _make_raw_email(subject="Invoice", body="Please pay")
fail_once = {"pending": True} fail_once = {"pending": True}
+203 -5
View File
@@ -467,8 +467,46 @@ async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_stream_end_splits_oversized_reply() -> None: async def test_send_delta_stream_end_splits_oversized_reply() -> None:
"""Final streamed reply exceeding Telegram limit is split into chunks.""" """Final streamed reply exceeding Telegram limit is split into chunks.
from nanobot.channels.telegram import TELEGRAM_MAX_MESSAGE_LEN
The fix converts markdown to HTML first, then splits by 4096 (actual Telegram
limit), ensuring the edited message always fits within Telegram's constraint.
Previously, the code split by 4000 (TELEGRAM_MAX_MESSAGE_LEN) before HTML
conversion, which could still overflow when HTML tags were added.
"""
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
oversized = "x" * (4000 + 500)
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
assert len(edit_text) <= 4096, f"edit_text length {len(edit_text)} exceeds Telegram's 4096 limit"
channel._app.bot.send_message.assert_called_once()
send_text = channel._app.bot.send_message.call_args.kwargs.get("text", "")
assert len(send_text) <= 4096
assert "123" not in channel._stream_bufs
@pytest.mark.asyncio
async def test_send_delta_stream_end_html_expansion_does_not_overflow() -> None:
"""Markdown that expands when converted to HTML is still split correctly.
This is the actual bug from issue #3315: markdown like **bold** expands to
<b>bold</b>, adding ~33% characters. A 3600-char message with heavy markdown
could become 4800+ chars after HTML conversion, exceeding 4096 limit.
The fix converts to HTML first, THEN splits by 4096.
"""
from nanobot.channels.telegram import _markdown_to_telegram_html
channel = TelegramChannel( channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
@@ -478,14 +516,21 @@ async def test_send_delta_stream_end_splits_oversized_reply() -> None:
channel._app.bot.edit_message_text = AsyncMock() channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99)) channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
oversized = "x" * (TELEGRAM_MAX_MESSAGE_LEN + 500) markdown_text = "**bold** " * 400 # 3600 chars raw, expands ~33% to 4800 HTML
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0) raw_len = len(markdown_text)
html_len = len(_markdown_to_telegram_html(markdown_text))
assert html_len > 4096, f"Test precondition failed: HTML should exceed 4096 (was {html_len})"
channel._stream_bufs["123"] = _StreamBuf(text=markdown_text, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True}) await channel.send_delta("123", "", {"_stream_end": True})
channel._app.bot.edit_message_text.assert_called_once() channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "") edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
assert len(edit_text) <= TELEGRAM_MAX_MESSAGE_LEN assert len(edit_text) <= 4096, (
f"HTML text length {len(edit_text)} exceeds Telegram's 4096 limit. "
f"Raw was {raw_len}, HTML was {html_len}."
)
channel._app.bot.send_message.assert_called_once() channel._app.bot.send_message.assert_called_once()
assert "123" not in channel._stream_bufs assert "123" not in channel._stream_bufs
@@ -530,6 +575,39 @@ async def test_send_delta_incremental_edit_treats_not_modified_as_success() -> N
assert channel._stream_bufs["123"].last_edit > 0.0 assert channel._stream_bufs["123"].last_edit > 0.0
@pytest.mark.asyncio
async def test_send_delta_incremental_edit_splits_oversized_buffer() -> None:
"""Mid-stream overflow: once buf.text exceeds Telegram's limit, split into
chunks, edit the current message with the first chunk, and re-anchor the
buffer to a new message for the tail so further deltas keep streaming."""
from nanobot.channels.telegram import TELEGRAM_MAX_MESSAGE_LEN
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
oversized = "x" * (TELEGRAM_MAX_MESSAGE_LEN + 500)
channel._stream_bufs["123"] = _StreamBuf(
text=oversized, message_id=7, last_edit=0.0, stream_id="s:0"
)
await channel.send_delta("123", "y", {"_stream_delta": True, "_stream_id": "s:0"})
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
assert len(edit_text) <= TELEGRAM_MAX_MESSAGE_LEN
channel._app.bot.send_message.assert_called_once()
buf = channel._stream_bufs["123"]
assert buf.message_id == 99
assert len(buf.text) <= TELEGRAM_MAX_MESSAGE_LEN
assert buf.last_edit > 0.0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_initial_send_keeps_message_in_thread() -> None: async def test_send_delta_initial_send_keeps_message_in_thread() -> None:
channel = TelegramChannel( channel = TelegramChannel(
@@ -1393,3 +1471,123 @@ async def test_send_text_bad_request_plain_fallback_exhausted() -> None:
# so HTML fails after 1 attempt → fallback to plain also fails after 1 attempt. # so HTML fails after 1 attempt → fallback to plain also fails after 1 attempt.
# Before the fix: 2 total. After the fix: still 2 (BadRequest SHOULD fallback). # Before the fix: 2 total. After the fix: still 2 (BadRequest SHOULD fallback).
assert call_count == 2, f"Expected 2 calls (1 HTML + 1 plain), got {call_count}" assert call_count == 2, f"Expected 2 calls (1 HTML + 1 plain), got {call_count}"
# ---------------------------------------------------------------------------
# _markdown_to_telegram_html formatting tests
# ---------------------------------------------------------------------------
def test_markdown_to_html_headers_become_bold() -> None:
from nanobot.channels.telegram import _markdown_to_telegram_html
assert _markdown_to_telegram_html("# Title") == "<b>Title</b>"
assert _markdown_to_telegram_html("## Subtitle") == "<b>Subtitle</b>"
assert _markdown_to_telegram_html("### Deep") == "<b>Deep</b>"
def test_markdown_to_html_numbered_lists_preserved() -> None:
from nanobot.channels.telegram import _markdown_to_telegram_html
text = "1. First\n2. Second\n3. Third"
result = _markdown_to_telegram_html(text)
assert "1. First" in result
assert "2. Second" in result
assert "3. Third" in result
def test_markdown_to_html_numbered_list_normalizes_whitespace() -> None:
from nanobot.channels.telegram import _markdown_to_telegram_html
# Extra spaces after dot should be normalized
text = "1. Lots of space\n2. Two spaces"
result = _markdown_to_telegram_html(text)
assert "1. Lots of space" in result
assert "2. Two spaces" in result
def test_markdown_to_html_headers_survive_html_escaping() -> None:
"""Headers containing special HTML chars should still render as bold."""
from nanobot.channels.telegram import _markdown_to_telegram_html
result = _markdown_to_telegram_html("# A < B & C > D")
assert "<b>A &lt; B &amp; C &gt; D</b>" == result
def test_markdown_to_html_mixed_formatting() -> None:
"""Headers, bullets, numbered lists, and bold coexist correctly."""
from nanobot.channels.telegram import _markdown_to_telegram_html
text = "# Overview\n\n- bullet one\n- bullet two\n\n1. step one\n2. step two\n\n**bold text**"
result = _markdown_to_telegram_html(text)
assert "<b>Overview</b>" in result
assert "\u2022 bullet one" in result
assert "1. step one" in result
assert "<b>bold text</b>" in result
# ---------------------------------------------------------------------------
# _strip_md_block tests
# ---------------------------------------------------------------------------
def test_strip_md_block_removes_inline_formatting() -> None:
from nanobot.channels.telegram import _strip_md_block
text = "**bold** and _italic_ and ~~struck~~"
result = _strip_md_block(text)
assert result == "bold and italic and struck"
def test_strip_md_block_strips_headers() -> None:
from nanobot.channels.telegram import _strip_md_block
assert _strip_md_block("## Title\nBody") == "Title\nBody"
def test_strip_md_block_converts_bullets_and_numbers() -> None:
from nanobot.channels.telegram import _strip_md_block
text = "- item a\n1. item b\n2. item c"
result = _strip_md_block(text)
assert "\u2022 item a" in result
assert "1. item b" in result
assert "2. item c" in result
def test_strip_md_block_strips_links() -> None:
from nanobot.channels.telegram import _strip_md_block
assert _strip_md_block("[click here](https://example.com)") == "click here"
# ---------------------------------------------------------------------------
# Streaming mid-edit uses _strip_md_block
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_delta_mid_stream_strips_markdown() -> None:
"""Mid-stream edits should strip markdown so users see clean text."""
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42))
channel._app.bot.edit_message_text = AsyncMock()
# Initial send with markdown
await channel.send_delta("999", "**hello** world")
sent_text = channel._app.bot.send_message.call_args.kwargs.get("text", "")
# Should NOT contain raw markdown asterisks
assert "**" not in sent_text
assert "hello world" in sent_text
# Mid-stream edit
import time
buf = channel._stream_bufs["999"]
buf.last_edit = time.monotonic() - 10 # force edit interval
await channel.send_delta("999", "\n### Title\n1. step")
edited_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
assert "###" not in edited_text
assert "**" not in edited_text
assert "Title" in edited_text
assert "1. step" in edited_text
+234 -7
View File
@@ -17,9 +17,11 @@ from nanobot.bus.events import OutboundMessage
from nanobot.channels.websocket import ( from nanobot.channels.websocket import (
WebSocketChannel, WebSocketChannel,
WebSocketConfig, WebSocketConfig,
_is_valid_chat_id,
_issue_route_secret_matches, _issue_route_secret_matches,
_normalize_config_path, _normalize_config_path,
_normalize_http_path, _normalize_http_path,
_parse_envelope,
_parse_inbound_payload, _parse_inbound_payload,
_parse_query, _parse_query,
_parse_request_path, _parse_request_path,
@@ -168,7 +170,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock() bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock() mock_ws = AsyncMock()
channel._connections["chat-1"] = mock_ws channel._attach(mock_ws, "chat-1")
msg = OutboundMessage( msg = OutboundMessage(
channel="websocket", channel="websocket",
@@ -182,6 +184,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
mock_ws.send.assert_awaited_once() mock_ws.send.assert_awaited_once()
payload = json.loads(mock_ws.send.call_args[0][0]) payload = json.loads(mock_ws.send.call_args[0][0])
assert payload["event"] == "message" assert payload["event"] == "message"
assert payload["chat_id"] == "chat-1"
assert payload["text"] == "hello" assert payload["text"] == "hello"
assert payload["reply_to"] == "m1" assert payload["reply_to"] == "m1"
assert payload["media"] == ["/tmp/a.png"] assert payload["media"] == ["/tmp/a.png"]
@@ -201,12 +204,13 @@ async def test_send_removes_connection_on_connection_closed() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock() mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True) mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._connections["chat-1"] = mock_ws channel._attach(mock_ws, "chat-1")
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello") msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
await channel.send(msg) await channel.send(msg)
assert "chat-1" not in channel._connections assert "chat-1" not in channel._subs
assert mock_ws not in channel._conn_chats
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -215,11 +219,12 @@ async def test_send_delta_removes_connection_on_connection_closed() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
mock_ws = AsyncMock() mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True) mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._connections["chat-1"] = mock_ws channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "chunk", {"_stream_delta": True, "_stream_id": "s1"}) await channel.send_delta("chat-1", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
assert "chat-1" not in channel._connections assert "chat-1" not in channel._subs
assert mock_ws not in channel._conn_chats
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -227,7 +232,7 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
bus = MagicMock() bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
mock_ws = AsyncMock() mock_ws = AsyncMock()
channel._connections["chat-1"] = mock_ws channel._attach(mock_ws, "chat-1")
await channel.send_delta("chat-1", "part", {"_stream_delta": True, "_stream_id": "sid"}) await channel.send_delta("chat-1", "part", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"}) await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
@@ -236,9 +241,11 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
first = json.loads(mock_ws.send.call_args_list[0][0][0]) first = json.loads(mock_ws.send.call_args_list[0][0][0])
second = json.loads(mock_ws.send.call_args_list[1][0][0]) second = json.loads(mock_ws.send.call_args_list[1][0][0])
assert first["event"] == "delta" assert first["event"] == "delta"
assert first["chat_id"] == "chat-1"
assert first["text"] == "part" assert first["text"] == "part"
assert first["stream_id"] == "sid" assert first["stream_id"] == "sid"
assert second["event"] == "stream_end" assert second["event"] == "stream_end"
assert second["chat_id"] == "chat-1"
assert second["stream_id"] == "sid" assert second["stream_id"] == "sid"
@@ -248,7 +255,7 @@ async def test_send_non_connection_closed_exception_is_raised() -> None:
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock() mock_ws = AsyncMock()
mock_ws.send.side_effect = RuntimeError("unexpected") mock_ws.send.side_effect = RuntimeError("unexpected")
channel._connections["chat-1"] = mock_ws channel._attach(mock_ws, "chat-1")
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello") msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
with pytest.raises(RuntimeError, match="unexpected"): with pytest.raises(RuntimeError, match="unexpected"):
@@ -596,3 +603,223 @@ async def test_websocket_requires_token_without_issue_path(bus: MagicMock) -> No
finally: finally:
await channel.stop() await channel.stop()
await server_task await server_task
# -- Multi-chat multiplexing -------------------------------------------------
#
# The multiplex protocol lets one WS connection route N logical chats over
# typed envelopes (`new_chat` / `attach` / `message`). Legacy frames must keep
# working on the connection's default chat_id.
@pytest.mark.asyncio
async def test_multiplex_legacy_still_works(bus: MagicMock) -> None:
port = 29930
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=legacy") as client:
ready = json.loads(await client.recv())
default_chat = ready["chat_id"]
# Plain text frame routes to default chat_id
await client.send("hello from legacy")
await asyncio.sleep(0.1)
inbound = bus.publish_inbound.call_args[0][0]
assert inbound.chat_id == default_chat
assert inbound.content == "hello from legacy"
# {"content": ...} frame routes to default chat_id
await client.send(json.dumps({"content": "structured legacy"}))
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].chat_id == default_chat
assert bus.publish_inbound.call_args[0][0].content == "structured legacy"
# Outbound still reaches the legacy client, with chat_id annotated
await channel.send(
OutboundMessage(channel="websocket", chat_id=default_chat, content="reply")
)
reply = json.loads(await client.recv())
assert reply["event"] == "message"
assert reply["chat_id"] == default_chat
assert reply["text"] == "reply"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None:
port = 29931
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=mp") as client:
ready = json.loads(await client.recv())
default_chat = ready["chat_id"]
await client.send(json.dumps({"type": "new_chat"}))
attached = json.loads(await client.recv())
assert attached["event"] == "attached"
new_chat = attached["chat_id"]
assert new_chat and new_chat != default_chat
# Send on the new chat via typed envelope
await client.send(
json.dumps({"type": "message", "chat_id": new_chat, "content": "hi on new"})
)
await asyncio.sleep(0.1)
inbound = bus.publish_inbound.call_args[0][0]
assert inbound.chat_id == new_chat
assert inbound.content == "hi on new"
# Server pushes a message back; chat_id must match
await channel.send(
OutboundMessage(channel="websocket", chat_id=new_chat, content="ok")
)
reply = json.loads(await client.recv())
assert reply["event"] == "message"
assert reply["chat_id"] == new_chat
assert reply["text"] == "ok"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
port = 29932
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=two") as client:
await client.recv() # ready
await client.send(json.dumps({"type": "new_chat"}))
chat_a = json.loads(await client.recv())["chat_id"]
await client.send(json.dumps({"type": "new_chat"}))
chat_b = json.loads(await client.recv())["chat_id"]
assert chat_a != chat_b
# Push A → client sees A only (FIFO over the single WS).
await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_a, content="for-A")
)
msg_a = json.loads(await client.recv())
assert msg_a["chat_id"] == chat_a
assert msg_a["text"] == "for-A"
# Push B → client sees B only.
await channel.send(
OutboundMessage(channel="websocket", chat_id=chat_b, content="for-B")
)
msg_b = json.loads(await client.recv())
assert msg_b["chat_id"] == chat_b
assert msg_b["text"] == "for-B"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_invalid_frames_return_error(bus: MagicMock) -> None:
port = 29933
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=bad") as client:
await client.recv() # ready
# attach with bad chat_id
await client.send(json.dumps({"type": "attach", "chat_id": "has space"}))
err1 = json.loads(await client.recv())
assert err1["event"] == "error"
# message with missing content
await client.send(json.dumps({"type": "message", "chat_id": "abc", "content": ""}))
err2 = json.loads(await client.recv())
assert err2["event"] == "error"
# unknown type
await client.send(json.dumps({"type": "nope"}))
err3 = json.loads(await client.recv())
assert err3["event"] == "error"
# Connection survives: legacy frame still works.
await client.send("still-alive")
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited()
assert bus.publish_inbound.call_args[0][0].content == "still-alive"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_multiplex_cleanup_on_disconnect(bus: MagicMock) -> None:
port = 29934
channel = _ch(bus, port=port)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=dc") as client:
ready = json.loads(await client.recv())
default_chat = ready["chat_id"]
await client.send(json.dumps({"type": "new_chat"}))
extra_chat = json.loads(await client.recv())["chat_id"]
assert default_chat in channel._subs
assert extra_chat in channel._subs
# Client gone. Server-side tracking must be empty.
await asyncio.sleep(0.2)
assert default_chat not in channel._subs
assert extra_chat not in channel._subs
assert not channel._conn_chats
assert not channel._conn_default
finally:
await channel.stop()
await server_task
def test_parse_envelope_detects_typed_frames() -> None:
assert _parse_envelope('{"type":"new_chat"}') == {"type": "new_chat"}
env = _parse_envelope('{"type":"message","chat_id":"abc","content":"hi"}')
assert env == {"type": "message", "chat_id": "abc", "content": "hi"}
def test_parse_envelope_rejects_legacy_and_garbage() -> None:
# No `type` field → legacy, caller falls back to _parse_inbound_payload.
assert _parse_envelope('{"content":"hi"}') is None
assert _parse_envelope("plain text") is None
assert _parse_envelope("{broken") is None
assert _parse_envelope("[1,2,3]") is None
# Non-string `type` is not a valid envelope.
assert _parse_envelope('{"type":123}') is None
@pytest.mark.parametrize(
("value", "expected"),
[
("abc", True),
("a1b2_c:d-e", True),
("x" * 64, True),
("unified:default", True),
("", False),
("x" * 65, False),
("has space", False),
("a/b", False),
("a.b", False),
(None, False),
(123, False),
],
)
def test_is_valid_chat_id(value: Any, expected: bool) -> None:
assert _is_valid_chat_id(value) is expected
@@ -0,0 +1,381 @@
"""End-to-end tests for the embedded webui's HTTP routes on the WebSocket channel."""
import asyncio
import functools
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from nanobot.channels.websocket import WebSocketChannel
from nanobot.session.manager import Session, SessionManager
_PORT = 29900
def _ch(
bus: Any,
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
port: int = _PORT,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
"enabled": True,
"allowFrom": ["*"],
"host": "127.0.0.1",
"port": port,
"path": "/",
"websocketRequiresToken": False,
}
cfg.update(extra)
return WebSocketChannel(
cfg,
bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
)
@pytest.fixture()
def bus() -> MagicMock:
b = MagicMock()
b.publish_inbound = AsyncMock()
return b
async def _http_get(
url: str, headers: dict[str, str] | None = None
) -> httpx.Response:
return await asyncio.to_thread(
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
)
def _seed_session(workspace: Path, key: str = "websocket:test") -> SessionManager:
sm = SessionManager(workspace)
s = Session(key=key)
s.add_message("user", "hi")
s.add_message("assistant", "hello back")
sm.save(s)
return sm
def _seed_many(workspace: Path, keys: list[str]) -> SessionManager:
sm = SessionManager(workspace)
for k in keys:
s = Session(key=k)
s.add_message("user", f"hi from {k}")
sm.save(s)
return sm
@pytest.mark.asyncio
async def test_bootstrap_returns_token_for_localhost(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path)
channel = _ch(bus, session_manager=sm, port=29901)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
assert resp.status_code == 200
body = resp.json()
assert body["token"].startswith("nbwt_")
assert body["ws_path"] == "/"
assert body["expires_in"] > 0
assert isinstance(body.get("model_name"), str)
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_sessions_routes_require_bearer_token(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path, key="websocket:abc")
channel = _ch(bus, session_manager=sm, port=29902)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
# Unauthenticated → 401.
deny = await _http_get("http://127.0.0.1:29902/api/sessions")
assert deny.status_code == 401
# Mint a token via bootstrap, then call the API with it.
boot = await _http_get("http://127.0.0.1:29902/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
listing = await _http_get("http://127.0.0.1:29902/api/sessions", headers=auth)
assert listing.status_code == 200
keys = [s["key"] for s in listing.json()["sessions"]]
assert "websocket:abc" in keys
# Server stays an opaque source: filesystem paths must not leak to the wire.
assert all("path" not in s for s in listing.json()["sessions"])
msgs = await _http_get(
"http://127.0.0.1:29902/api/sessions/websocket:abc/messages",
headers=auth,
)
assert msgs.status_code == 200
body = msgs.json()
assert body["key"] == "websocket:abc"
assert [m["role"] for m in body["messages"]] == ["user", "assistant"]
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_sessions_list_only_returns_websocket_sessions_by_default(
bus: MagicMock, tmp_path: Path
) -> None:
# Seed a realistic multi-channel disk state: CLI, Slack, Lark and
# websocket sessions all live in the same ``sessions/`` directory.
sm = _seed_many(
tmp_path,
[
"cli:direct",
"slack:C123",
"lark:oc_abc",
"websocket:alpha",
"websocket:beta",
],
)
channel = _ch(bus, session_manager=sm, port=29906)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29906/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
listing = await _http_get(
"http://127.0.0.1:29906/api/sessions", headers=auth
)
assert listing.status_code == 200
keys = {s["key"] for s in listing.json()["sessions"]}
# Only websocket-channel sessions are part of the webui surface; CLI /
# Slack / Lark rows would be non-resumable from the browser.
assert keys == {"websocket:alpha", "websocket:beta"}
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> None:
sm = _seed_session(tmp_path, key="websocket:doomed")
channel = _ch(bus, session_manager=sm, port=29903)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29903/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
path = sm._get_session_path("websocket:doomed")
assert path.exists()
resp = await _http_get(
"http://127.0.0.1:29903/api/sessions/websocket:doomed/delete",
headers=auth,
)
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert not path.exists()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_routes_accept_percent_encoded_websocket_keys(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path, key="websocket:encoded-key")
channel = _ch(bus, session_manager=sm, port=29910)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29910/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
msgs = await _http_get(
"http://127.0.0.1:29910/api/sessions/websocket%3Aencoded-key/messages",
headers=auth,
)
assert msgs.status_code == 200
assert msgs.json()["key"] == "websocket:encoded-key"
path = sm._get_session_path("websocket:encoded-key")
assert path.exists()
deleted = await _http_get(
"http://127.0.0.1:29910/api/sessions/websocket%3Aencoded-key/delete",
headers=auth,
)
assert deleted.status_code == 200
assert deleted.json()["deleted"] is True
assert not path.exists()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_routes_reject_non_websocket_keys(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_many(
tmp_path,
[
"websocket:kept",
"cli:direct",
"slack:C123",
],
)
channel = _ch(bus, session_manager=sm, port=29909)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29909/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
# The webui list already hides non-websocket sessions; handcrafted URLs
# should hit the same boundary rather than exposing or deleting them.
msgs = await _http_get(
"http://127.0.0.1:29909/api/sessions/cli:direct/messages",
headers=auth,
)
assert msgs.status_code == 404
doomed = sm._get_session_path("slack:C123")
assert doomed.exists()
deny_delete = await _http_get(
"http://127.0.0.1:29909/api/sessions/slack:C123/delete",
headers=auth,
)
assert deny_delete.status_code == 404
assert doomed.exists()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_routes_reject_invalid_key(
bus: MagicMock, tmp_path: Path
) -> None:
sm = _seed_session(tmp_path)
channel = _ch(bus, session_manager=sm, port=29904)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get("http://127.0.0.1:29904/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
# Invalid characters in the key -> regex match fails -> 404
# (route doesn't match, falls through to channel 404).
resp = await _http_get(
"http://127.0.0.1:29904/api/sessions/bad%20key/messages",
headers=auth,
)
assert resp.status_code in {400, 404}
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_static_serves_index_when_dist_present(
bus: MagicMock, tmp_path: Path
) -> None:
dist = tmp_path / "dist"
dist.mkdir()
(dist / "index.html").write_text("<!doctype html><title>nbweb</title>")
(dist / "favicon.svg").write_text("<svg/>")
sm = _seed_session(tmp_path / "ws_state")
channel = _ch(bus, session_manager=sm, static_dist_path=dist, port=29905)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
# Bare ``GET /`` is a browser opening the app: it must return the SPA
# index.html, not the WS-upgrade handler's 401/426.
root = await _http_get("http://127.0.0.1:29905/")
assert root.status_code == 200
assert "nbweb" in root.text
asset = await _http_get("http://127.0.0.1:29905/favicon.svg")
assert asset.status_code == 200
assert "<svg" in asset.text
# Unknown SPA route falls back to index.html.
spa = await _http_get("http://127.0.0.1:29905/sessions/abc")
assert spa.status_code == 200
assert "nbweb" in spa.text
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_static_rejects_path_traversal(
bus: MagicMock, tmp_path: Path
) -> None:
dist = tmp_path / "dist"
dist.mkdir()
(dist / "index.html").write_text("ok")
secret = tmp_path / "secret.txt"
secret.write_text("classified")
channel = _ch(bus, static_dist_path=dist, port=29906)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get("http://127.0.0.1:29906/../secret.txt")
# Normalized by httpx into /secret.txt → falls back to index.html, not 'classified'.
assert "classified" not in resp.text
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_unknown_route_returns_404(bus: MagicMock) -> None:
channel = _ch(bus, port=29907)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get("http://127.0.0.1:29907/api/unknown")
assert resp.status_code == 404
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) -> None:
sm = _seed_session(tmp_path)
channel = _ch(bus, session_manager=sm, port=29908)
# Don't start a server — directly inject and validate.
import time as _time
channel._api_tokens["expired"] = _time.monotonic() - 1
channel._api_tokens["live"] = _time.monotonic() + 60
class _FakeReq:
path = "/api/sessions"
headers = {"Authorization": "Bearer expired"}
assert channel._check_api_token(_FakeReq()) is False
class _LiveReq:
path = "/api/sessions"
headers = {"Authorization": "Bearer live"}
assert channel._check_api_token(_LiveReq()) is True
+41 -1
View File
@@ -189,6 +189,45 @@ async def test_server_send_message(bus: MagicMock) -> None:
await ch.stop(); await t await ch.stop(); await t
@pytest.mark.asyncio
async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
"""``_tool_hint`` metadata must surface as ``kind: "tool_hint"`` so WS
clients render breadcrumbs separately from conversational replies."""
ch = _ch(bus, 29919)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c:
ready = await c.recv_ready()
# Plain reply: no "kind" field.
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, content="hi",
))
plain = await c.recv_message()
assert plain.raw.get("kind") is None
# Tool-hint breadcrumb: kind == "tool_hint".
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content='weather("get")',
metadata={"_progress": True, "_tool_hint": True},
))
hint = await c.recv_message()
assert hint.raw.get("kind") == "tool_hint"
assert hint.text == 'weather("get")'
# Generic progress (non-tool-hint) gets the softer "progress" label.
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content="thinking…",
metadata={"_progress": True},
))
prog = await c.recv_message()
assert prog.raw.get("kind") == "progress"
finally:
await ch.stop(); await t
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None: async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
ch = _ch(bus, 29910) ch = _ch(bus, 29910)
@@ -290,10 +329,11 @@ async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c: async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c:
chat_id = (await c.recv_ready()).chat_id chat_id = (await c.recv_ready()).chat_id
# disconnected # disconnected
await asyncio.sleep(0.1)
await ch.send(OutboundMessage( await ch.send(OutboundMessage(
channel="websocket", chat_id=chat_id, content="orphan", channel="websocket", chat_id=chat_id, content="orphan",
)) ))
assert chat_id not in ch._connections assert chat_id not in ch._subs
finally: finally:
await ch.stop(); await t await ch.stop(); await t
+16 -4
View File
@@ -167,7 +167,19 @@ def test_stream_renderer_stop_for_input_stops_spinner():
spinner.stop.assert_called_once() spinner.stop.assert_called_once()
def test_make_console_uses_force_terminal(): def test_make_console_force_terminal_when_stdout_is_tty():
"""Console should be created with force_terminal=True for proper ANSI handling.""" """Console should set force_terminal=True when stdout is a TTY (rich output)."""
console = stream_mod._make_console() import sys
assert console._force_terminal is True with patch.object(sys.stdout, "isatty", return_value=True):
console = stream_mod._make_console()
assert console._force_terminal is True
def test_make_console_force_terminal_false_when_stdout_is_not_tty():
"""Console should set force_terminal=False when stdout is not a TTY so that
ANSI escape codes (cursor visibility, braille spinner frames) don't pollute
piped output such as `docker exec -i` (#3265)."""
import sys
with patch.object(sys.stdout, "isatty", return_value=False):
console = stream_mod._make_console()
assert console._force_terminal is False
+125 -1
View File
@@ -286,6 +286,39 @@ def test_find_by_name_accepts_camel_case_and_hyphen_aliases():
assert find_by_name("github-copilot").name == "github_copilot" assert find_by_name("github-copilot").name == "github_copilot"
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "xiaomi_mimo",
"model": "MiniMax-M1-80k",
}
},
"providers": {
"xiaomiMimo": {
"apiKey": "test-key",
}
},
}
)
assert config.get_provider_name() == "xiaomi_mimo"
assert config.get_api_base() == "https://api.xiaomimimo.com/v1"
def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "mimo/MiniMax-M1-80k"}},
"providers": {"xiaomiMimo": {"apiKey": "test-key"}},
}
)
assert config.get_provider_name() == "xiaomi_mimo"
assert config.get_api_base() == "https://api.xiaomimimo.com/v1"
def test_config_auto_detects_ollama_from_local_api_base(): def test_config_auto_detects_ollama_from_local_api_base():
config = Config.model_validate( config = Config.model_validate(
{ {
@@ -999,6 +1032,97 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
) )
def test_gateway_cron_job_suppresses_intermediate_progress(
monkeypatch, tmp_path: Path
) -> None:
"""Cron jobs must pass on_progress=_silent to process_direct so that
tool hints and streaming deltas are never leaked to the user channel
before evaluate_response decides whether to deliver."""
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
bus = MagicMock()
bus.publish_outbound = AsyncMock()
seen: dict[str, object] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
class _FakeCron:
def __init__(self, _store_path: Path) -> None:
self.on_job = None
seen["cron"] = self
class _FakeAgentLoop:
def __init__(self, *args, **kwargs) -> None:
self.model = "test-model"
self.tools = {}
async def process_direct(self, *_args, on_progress=None, **_kwargs):
seen["on_progress"] = on_progress
return OutboundMessage(
channel="telegram",
chat_id="user-1",
content="Done.",
)
async def close_mcp(self) -> None:
return None
async def run(self) -> None:
return None
def stop(self) -> None:
return None
class _StopAfterCronSetup:
def __init__(self, *_args, **_kwargs) -> None:
raise _StopGatewayError("stop")
async def _always_reject(*_args, **_kwargs) -> bool:
return False
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
monkeypatch.setattr(
"nanobot.utils.evaluator.evaluate_response",
_always_reject,
)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert isinstance(result.exception, _StopGatewayError)
cron = seen["cron"]
job = CronJob(
id="cron-silent-test",
name="test-silent",
payload=CronPayload(
message="Run something.",
deliver=True,
channel="telegram",
to="user-1",
),
)
response = asyncio.run(cron.on_job(job))
assert response == "Done."
# on_progress must be a callable (the _silent noop), not None and not bus_progress
assert seen["on_progress"] is not None
assert callable(seen["on_progress"])
# Verify it actually swallows calls (no side effects)
asyncio.run(seen["on_progress"]("tool_hint", "🔧 $ echo test"))
# Nothing published to bus since evaluator rejected
bus.publish_outbound.assert_not_awaited()
def test_gateway_workspace_override_does_not_migrate_legacy_cron( def test_gateway_workspace_override_does_not_migrate_legacy_cron(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
@@ -1179,7 +1303,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
return None return None
class _FakeChannelManager: class _FakeChannelManager:
def __init__(self, _config, _bus) -> None: def __init__(self, _config, _bus, **_kwargs) -> None:
self.enabled_channels = ["telegram", "discord"] self.enabled_channels = ["telegram", "discord"]
async def start_all(self) -> None: async def start_all(self) -> None:
+28 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import time import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -31,6 +32,15 @@ def _make_loop():
return loop, bus return loop, bus
async def _wait_until(predicate, *, timeout: float = 0.2, interval: float = 0.01) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
await asyncio.sleep(interval)
assert predicate()
class TestRestartCommand: class TestRestartCommand:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -47,7 +57,23 @@ class TestRestartCommand:
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop) ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
async def _fast_sleep(_delay: float) -> None:
return None
scheduled: list[asyncio.Task] = []
def _capture_task(coro):
task = asyncio.create_task(coro)
scheduled.append(task)
return task
fake_asyncio = SimpleNamespace(
sleep=_fast_sleep,
create_task=_capture_task,
)
with patch.dict(os.environ, {}, clear=False), \ with patch.dict(os.environ, {}, clear=False), \
patch("nanobot.command.builtin.asyncio", new=fake_asyncio), \
patch("nanobot.command.builtin.os.execv") as mock_execv: patch("nanobot.command.builtin.os.execv") as mock_execv:
out = await cmd_restart(ctx) out = await cmd_restart(ctx)
assert "Restarting" in out.content assert "Restarting" in out.content
@@ -55,7 +81,8 @@ class TestRestartCommand:
assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct" assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct"
assert os.environ.get(RESTART_STARTED_AT_ENV) assert os.environ.get(RESTART_STARTED_AT_ENV)
await asyncio.sleep(1.5) assert scheduled
await scheduled[0]
mock_execv.assert_called_once() mock_execv.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
+143
View File
@@ -0,0 +1,143 @@
"""Tests for CommandRouter.is_dispatchable_command and mid-turn command interception."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.command.builtin import register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter
class TestIsDispatchableCommand:
"""Unit tests for the is_dispatchable_command() predicate."""
@pytest.fixture()
def router(self) -> CommandRouter:
r = CommandRouter()
register_builtin_commands(r)
return r
def test_exact_commands_match(self, router: CommandRouter) -> None:
assert router.is_dispatchable_command("/new")
assert router.is_dispatchable_command("/help")
assert router.is_dispatchable_command("/dream")
assert router.is_dispatchable_command("/dream-log")
assert router.is_dispatchable_command("/dream-restore")
def test_prefix_commands_match(self, router: CommandRouter) -> None:
assert router.is_dispatchable_command("/dream-log abc123")
assert router.is_dispatchable_command("/dream-restore def456")
def test_priority_commands_not_matched(self, router: CommandRouter) -> None:
# Priority commands are NOT in the dispatchable tiers — they are
# handled by is_priority() separately.
assert not router.is_dispatchable_command("/stop")
assert not router.is_dispatchable_command("/restart")
def test_regular_text_not_matched(self, router: CommandRouter) -> None:
assert not router.is_dispatchable_command("hello")
assert not router.is_dispatchable_command("what is 2+2?")
assert not router.is_dispatchable_command("")
def test_case_insensitive(self, router: CommandRouter) -> None:
assert router.is_dispatchable_command("/NEW")
assert router.is_dispatchable_command("/Help")
def test_strips_whitespace(self, router: CommandRouter) -> None:
assert router.is_dispatchable_command(" /new ")
def test_unknown_slash_command_not_matched(self, router: CommandRouter) -> None:
assert not router.is_dispatchable_command("/unknown")
assert not router.is_dispatchable_command("/foo bar")
class TestMidTurnCommandDispatchedDirectly:
"""Verify that commands matching is_dispatchable_command() are dispatched
correctly when session=None (the mid-turn path)."""
@pytest.fixture()
def router(self) -> CommandRouter:
r = CommandRouter()
register_builtin_commands(r)
return r
@pytest.fixture()
def fake_loop(self) -> MagicMock:
loop = MagicMock()
loop.sessions = MagicMock()
loop.sessions.get_or_create = MagicMock(return_value=MagicMock(
messages=[], last_consolidated=0, clear=MagicMock(),
))
loop.sessions.save = MagicMock()
loop.sessions.invalidate = MagicMock()
loop._schedule_background = MagicMock()
loop._cancel_active_tasks = AsyncMock(return_value=0)
return loop
@pytest.fixture()
def fake_msg(self) -> MagicMock:
msg = MagicMock()
msg.channel = "test"
msg.chat_id = "chat1"
msg.content = "/new"
msg.metadata = {}
return msg
@pytest.mark.asyncio
async def test_new_dispatched_with_session_none(
self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
) -> None:
"""cmd_new works when session=None (mid-turn dispatch path)."""
ctx = CommandContext(
msg=fake_msg, session=None,
key="test:chat1", raw="/new", loop=fake_loop,
)
result = await router.dispatch(ctx)
assert result is not None
assert "New session" in result.content
fake_loop.sessions.get_or_create.assert_called_once_with("test:chat1")
@pytest.mark.asyncio
async def test_help_dispatched_with_session_none(
self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
) -> None:
ctx = CommandContext(
msg=fake_msg, session=None,
key="test:chat1", raw="/help", loop=fake_loop,
)
result = await router.dispatch(ctx)
assert result is not None
@pytest.mark.asyncio
async def test_prefix_command_args_populated(self, router: CommandRouter) -> None:
"""Prefix commands have args populated correctly in mid-turn path."""
# Use a custom prefix handler to avoid needing full mock setup.
custom = CommandRouter()
captured_args = []
async def fake_handler(ctx: CommandContext) -> None:
captured_args.append(ctx.args)
return None
custom.prefix("/test ", fake_handler)
ctx = CommandContext(
msg=MagicMock(channel="test", chat_id="c1", metadata={}),
session=None, key="test:c1", raw="/test hello world", loop=MagicMock(),
)
await custom.dispatch(ctx)
assert captured_args == ["hello world"]
@pytest.mark.asyncio
async def test_non_command_returns_none(
self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
) -> None:
"""Regular text returns None from dispatch (not a command)."""
ctx = CommandContext(
msg=fake_msg, session=None,
key="test:chat1", raw="hello world", loop=fake_loop,
)
result = await router.dispatch(ctx)
assert result is None
+19 -10
View File
@@ -8,6 +8,15 @@ from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronPayload, CronSchedule
async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
await asyncio.sleep(interval)
assert predicate()
def test_add_job_rejects_unknown_timezone(tmp_path) -> None: def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json") service = CronService(tmp_path / "cron" / "jobs.json")
@@ -201,18 +210,18 @@ async def test_start_server_not_jobs(tmp_path):
async def on_job(job): async def on_job(job):
called.append(job.name) called.append(job.name)
service = CronService(store_path, on_job=on_job, max_sleep_ms=1000) service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
await service.start() await service.start()
assert len(service.list_jobs()) == 0 assert len(service.list_jobs()) == 0
service2 = CronService(tmp_path / "cron" / "jobs.json") service2 = CronService(tmp_path / "cron" / "jobs.json")
service2.add_job( service2.add_job(
name="hist", name="hist",
schedule=CronSchedule(kind="every", every_ms=500), schedule=CronSchedule(kind="every", every_ms=100),
message="hello", message="hello",
) )
assert len(service.list_jobs()) == 1 assert len(service.list_jobs()) == 1
await asyncio.sleep(2) await _wait_until(lambda: bool(called), timeout=0.8)
assert len(called) != 0 assert len(called) != 0
service.stop() service.stop()
@@ -248,10 +257,10 @@ async def test_running_service_picks_up_external_add(tmp_path):
async def on_job(job): async def on_job(job):
called.append(job.name) called.append(job.name)
service = CronService(store_path, on_job=on_job) service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
service.add_job( service.add_job(
name="heartbeat", name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=150), schedule=CronSchedule(kind="every", every_ms=100),
message="tick", message="tick",
) )
await service.start() await service.start()
@@ -261,11 +270,11 @@ async def test_running_service_picks_up_external_add(tmp_path):
external = CronService(store_path) external = CronService(store_path)
external.add_job( external.add_job(
name="external", name="external",
schedule=CronSchedule(kind="every", every_ms=150), schedule=CronSchedule(kind="every", every_ms=100),
message="ping", message="ping",
) )
await asyncio.sleep(2) await _wait_until(lambda: "external" in called, timeout=0.8)
assert "external" in called assert "external" in called
finally: finally:
service.stop() service.stop()
@@ -287,16 +296,16 @@ async def test_add_job_during_jobs_exec(tmp_path):
) )
run_once = False run_once = False
service = CronService(store_path, on_job=on_job) service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
service.add_job( service.add_job(
name="heartbeat", name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=150), schedule=CronSchedule(kind="every", every_ms=100),
message="tick", message="tick",
) )
assert len(service.list_jobs()) == 1 assert len(service.list_jobs()) == 1
await service.start() await service.start()
try: try:
await asyncio.sleep(3) await _wait_until(lambda: len(service.list_jobs()) == 2, timeout=0.8)
jobs = service.list_jobs() jobs = service.list_jobs()
assert len(jobs) == 2 assert len(jobs) == 2
assert "test" in [j.name for j in jobs] assert "test" in [j.name for j in jobs]
+37 -1
View File
@@ -7,7 +7,6 @@ import pytest
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
from tests.test_openai_api import pytest_plugins
def _make_tool(tmp_path) -> CronTool: def _make_tool(tmp_path) -> CronTool:
@@ -346,6 +345,43 @@ def test_add_job_can_disable_delivery(tmp_path) -> None:
assert job.payload.deliver is False assert job.payload.deliver is False
def test_cron_schema_advertises_action_specific_requirements(tmp_path) -> None:
tool = _make_tool(tmp_path)
# Only ``action`` is required at the schema root — per-action requirements
# are enforced at runtime via ``validate_params`` and surfaced to the LLM
# through field descriptions. We intentionally do NOT set top-level
# ``oneOf``/``anyOf``/``allOf``/``enum``/``not``: OpenAI Codex/Responses
# reject those at the root of function parameters (#3265 regression).
assert tool.parameters["required"] == ["action"]
for disallowed in ("oneOf", "anyOf", "allOf", "not"):
assert disallowed not in tool.parameters, (
f"Top-level '{disallowed}' is rejected by OpenAI Codex/Responses tool schemas"
)
message_desc = tool.parameters["properties"]["message"]["description"]
assert "REQUIRED" in message_desc and "action='add'" in message_desc
job_id_desc = tool.parameters["properties"]["job_id"]["description"]
assert "REQUIRED" in job_id_desc and "action='remove'" in job_id_desc
def test_validate_params_requires_message_only_for_add(tmp_path) -> None:
tool = _make_tool(tmp_path)
assert "message is required when action='add'" in tool.validate_params({"action": "add"})
assert tool.validate_params({"action": "list"}) == []
assert "job_id is required when action='remove'" in tool.validate_params({"action": "remove"})
def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context("telegram", "chat-1")
result = tool._add_job(None, "", 60, None, None, None)
assert "action='add' requires a non-empty 'message'" in result
assert "Retry including message=" in result
def test_list_excludes_disabled_jobs(tmp_path) -> None: def test_list_excludes_disabled_jobs(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
job = tool._cron.add_job( job = tool._cron.add_job(
@@ -0,0 +1,99 @@
"""Regression tests for the cron tool's JSON-schema / runtime contract (#3113).
The schema advertised ``required=["action"]`` while ``_add_job`` rejected empty
``message``; LLMs rationally omitted ``message`` and looped on the runtime
error. The fix keeps ``required=["action"]`` (so ``list``/``remove`` stay
callable) but states the per-action requirement in each field's description
and tightens the runtime error for ``add`` without ``message``.
"""
from __future__ import annotations
import pytest
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.registry import ToolRegistry
class _SvcStub:
"""Minimal CronService stand-in; we only exercise schema/dispatch paths."""
def list_jobs(self):
return []
def get_job(self, _job_id):
return None
def remove_job(self, _job_id):
return "not-found"
def add_job(self, **kwargs):
class _J:
pass
j = _J()
j.id = "id1"
j.name = kwargs.get("name", "x")
return j
@pytest.fixture
def registry() -> ToolRegistry:
tool = CronTool(_SvcStub(), default_timezone="UTC")
tool.set_context("channel", "chat-id")
reg = ToolRegistry()
reg.register(tool)
return reg
class TestSchemaContract:
def test_list_accepted_without_message(self, registry: ToolRegistry) -> None:
# action='list' must pass schema validation with nothing but 'action'.
_, _, err = registry.prepare_call("cron", {"action": "list"})
assert err is None
def test_remove_accepted_without_message(self, registry: ToolRegistry) -> None:
# action='remove' must pass schema validation with just 'action' + 'job_id'.
_, _, err = registry.prepare_call("cron", {"action": "remove", "job_id": "abc"})
assert err is None
def test_add_with_message_accepted(self, registry: ToolRegistry) -> None:
_, _, err = registry.prepare_call(
"cron", {"action": "add", "message": "ping", "at": "2030-01-01T00:00:00"}
)
assert err is None
def test_add_without_message_surfaces_actionable_runtime_error(
self, registry: ToolRegistry
) -> None:
# Schema permits omitting message; the runtime must return a message
# that tells the LLM exactly what's missing and how to retry, so it
# doesn't loop like #3113 reports.
import asyncio
tool = registry._tools["cron"] # type: ignore[attr-defined]
out = asyncio.run(tool.execute(action="add", at="2030-01-01T00:00:00"))
assert "message" in out
assert "add" in out
assert "Retry" in out or "retry" in out
class TestSchemaSelfDescribesRequirements:
def test_message_description_flags_add_requirement(self) -> None:
# LLMs rely on field descriptions to infer when something is actually
# needed. Without this hint, #3113's loop returns.
tool = CronTool(_SvcStub())
desc = tool.parameters["properties"]["message"]["description"]
assert "REQUIRED" in desc and "action='add'" in desc
def test_job_id_description_flags_remove_requirement(self) -> None:
tool = CronTool(_SvcStub())
desc = tool.parameters["properties"]["job_id"]["description"]
assert "REQUIRED" in desc and "action='remove'" in desc
def test_top_level_required_stays_narrow(self) -> None:
# If 'message' or 'job_id' ever creep back into top-level required,
# list/remove start failing schema validation (the bug PR #3163 v1
# accidentally introduced).
tool = CronTool(_SvcStub())
assert tool.parameters["required"] == ["action"]
@@ -0,0 +1,139 @@
"""Tests for AnthropicProvider._merge_consecutive."""
from nanobot.providers.anthropic_provider import AnthropicProvider
class TestMergeConsecutive:
"""Verify role alternation and trailing-assistant stripping."""
def test_basic_alternation(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "bye"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 3
assert [m["role"] for m in result] == ["user", "assistant", "user"]
def test_consecutive_same_role_merged(self):
msgs = [
{"role": "user", "content": "a"},
{"role": "user", "content": "b"},
{"role": "assistant", "content": "reply"},
]
result = AnthropicProvider._merge_consecutive(msgs)
# Two user messages merged into one, trailing assistant stripped
assert len(result) == 1
assert result[0]["role"] == "user"
def test_trailing_assistant_stripped(self):
"""Anthropic rejects prefill — trailing assistant must be removed."""
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
assert result[0]["content"] == "hello"
def test_multiple_trailing_assistant_stripped(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "a"},
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "b"},
{"role": "assistant", "content": "c"},
]
result = AnthropicProvider._merge_consecutive(msgs)
# b+c merged into one assistant, then stripped as trailing
assert len(result) == 3
assert result[-1]["role"] == "user"
assert result[-1]["content"] == "ok"
def test_empty_messages(self):
assert AnthropicProvider._merge_consecutive([]) == []
def test_single_user_message(self):
msgs = [{"role": "user", "content": "hi"}]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
def test_single_assistant_rerouted_to_user(self):
"""When stripping leaves nothing, the last assistant is rerouted to
``user`` so we don't produce an empty messages array."""
msgs = [{"role": "assistant", "content": "hi"}]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
assert result[0]["content"] == "hi"
def test_all_assistants_collapse_then_rerouted(self):
"""Consecutive trailing assistants merge into one, which is then
rerouted as a user turn carrying the merged content."""
msgs = [
{"role": "assistant", "content": "a"},
{"role": "assistant", "content": "b"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
# "b" was merged into "a"'s block list during the merge pass.
assert result[0]["content"] == [
{"type": "text", "text": "a"},
{"type": "text", "text": "b"},
]
def test_assistant_with_tool_use_not_rerouted(self):
"""A trailing assistant carrying ``tool_use`` blocks cannot become a
user turn (Anthropic rejects ``tool_use`` inside user messages), so
the method returns an empty list rather than forging a bad request."""
msgs = [
{
"role": "assistant",
"content": [
{"type": "text", "text": "let me search"},
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
],
}
]
result = AnthropicProvider._merge_consecutive(msgs)
assert result == []
def test_leading_assistant_gets_synthetic_user(self):
"""If the first turn is a bare assistant (e.g. history truncation
dropped the original user request), prepend a synthetic opener so
the conversation still starts with ``user``."""
msgs = [
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "reply"},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert [m["role"] for m in result] == ["user", "assistant", "user"]
assert result[0]["content"] == "(conversation continued)"
assert result[1]["content"] == "hi"
assert result[2]["content"] == "ok"
def test_leading_assistant_with_tool_use_left_alone(self):
"""Don't prepend a synthetic opener before an assistant carrying
``tool_use``; doing so would orphan the paired ``tool_result`` that
follows. The caller will see the original 400 rather than a
harder-to-diagnose tool-pair mismatch."""
msgs = [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
],
},
]
result = AnthropicProvider._merge_consecutive(msgs)
assert [m["role"] for m in result] == ["assistant", "user"]
@@ -1,6 +1,6 @@
"""Tests for LLMProvider._enforce_role_alternation.""" """Tests for LLMProvider._enforce_role_alternation."""
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider, _SYNTHETIC_USER_CONTENT
class TestEnforceRoleAlternation: class TestEnforceRoleAlternation:
@@ -195,3 +195,46 @@ class TestEnforceRoleAlternation:
assert result[3]["role"] == "user" assert result[3]["role"] == "user"
assert "And 3+3?" in result[3]["content"] assert "And 3+3?" in result[3]["content"]
assert "(please be quick)" in result[3]["content"] assert "(please be quick)" in result[3]["content"]
def test_leading_assistant_after_system_inserts_synthetic_user(self):
"""When the first non-system message is assistant (no tool_calls), a
synthetic user message is inserted to prevent GLM error 1214."""
msgs = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "previous reply"},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
{"role": "assistant", "content": "after tool"},
]
result = LLMProvider._enforce_role_alternation(msgs)
non_system = [m for m in result if m["role"] != "system"]
assert non_system[0]["role"] == "user"
assert non_system[0]["content"] == _SYNTHETIC_USER_CONTENT
# The original assistant should follow.
assert non_system[1]["role"] == "assistant"
def test_leading_assistant_with_tool_calls_not_patched(self):
"""An assistant message with tool_calls at the start is left as-is
because tool messages will follow and some providers accept this."""
msgs = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "tc_1", "type": "function", "function": {"name": "ls", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
]
result = LLMProvider._enforce_role_alternation(msgs)
non_system = [m for m in result if m["role"] != "system"]
# The assistant has tool_calls so it should NOT be patched.
assert non_system[0]["role"] == "assistant"
assert non_system[0].get("tool_calls") is not None
def test_user_after_system_not_patched(self):
"""Normal system→user sequence is not modified."""
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert result[1]["role"] == "user"
assert result[1]["content"] == "hello"
+63
View File
@@ -441,6 +441,35 @@ async def test_direct_openai_responses_404_falls_back_to_chat_completions() -> N
mock_chat.assert_awaited_once() mock_chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_direct_openai_open_circuit_skips_responses_api() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5-chat",
spec=spec,
)
for _ in range(3):
provider._record_responses_failure("gpt-5-chat", None)
result = await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5-chat",
)
assert result.content == "from chat"
mock_responses.assert_not_awaited()
mock_chat.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_direct_openai_stream_responses_unsupported_param_falls_back() -> None: async def test_direct_openai_stream_responses_unsupported_param_falls_back() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_stream("fallback stream")) mock_chat = AsyncMock(return_value=_fake_chat_stream("fallback stream"))
@@ -711,6 +740,21 @@ def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None:
assert "extra_body" not in kw assert "extra_body" not in kw
def test_minimax_reasoning_split_enabled_with_reasoning_effort() -> None:
kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort="medium")
assert kw["extra_body"] == {"reasoning_split": True}
def test_minimax_reasoning_split_disabled_for_minimal() -> None:
kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort="minimal")
assert kw["extra_body"] == {"reasoning_split": False}
def test_minimax_no_extra_body_when_reasoning_effort_none() -> None:
kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort=None)
assert "extra_body" not in kw
def test_volcengine_thinking_enabled() -> None: def test_volcengine_thinking_enabled() -> None:
kw = _build_kwargs_for("volcengine", "doubao-seed-2-0-pro", reasoning_effort="high") kw = _build_kwargs_for("volcengine", "doubao-seed-2-0-pro", reasoning_effort="high")
assert kw["extra_body"] == {"thinking": {"type": "enabled"}} assert kw["extra_body"] == {"thinking": {"type": "enabled"}}
@@ -755,6 +799,25 @@ def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium") kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}} assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_kimi_k26_thinking_enabled() -> None:
"""kimi-k2.6 with reasoning_effort set should opt in to thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.6", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_moonshot_kimi_k26_temperature_override() -> None:
"""Moonshot registry forces temperature 1.0 for kimi-k2.6 (API requirement)."""
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort=None)
assert kw["temperature"] == 1.0
def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None: def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None:
"""OpenRouter names must NOT trigger thinking without reasoning_effort.""" """OpenRouter names must NOT trigger thinking without reasoning_effort."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None) kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None)
+57
View File
@@ -0,0 +1,57 @@
"""Regression tests for ``LLMResponse.should_execute_tools`` (#3220).
The agent used to execute tool calls whenever ``has_tool_calls`` was true, regardless
of ``finish_reason``. Non-compliant API gateways that inject empty / bogus tool calls
under ``refusal`` / ``content_filter`` / ``error`` pushed the agent into a tight loop
until ``max_iterations`` fired. ``should_execute_tools`` is the single guard that
every tool-execution site now funnels through.
"""
from __future__ import annotations
import pytest
from nanobot.providers.base import LLMResponse, ToolCallRequest
def _response(finish_reason: str, *, with_tool_call: bool = True) -> LLMResponse:
tool_calls = (
[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})]
if with_tool_call
else []
)
return LLMResponse(content=None, tool_calls=tool_calls, finish_reason=finish_reason)
class TestShouldExecuteTools:
def test_no_tool_calls_never_executes(self) -> None:
# No tool calls present -> guard must reject regardless of finish_reason.
for reason in ("tool_calls", "stop", "length", "error", "refusal", "content_filter"):
resp = _response(reason, with_tool_call=False)
assert resp.should_execute_tools is False, f"rejected for finish_reason={reason!r}"
def test_tool_calls_with_tool_calls_reason_executes(self) -> None:
# The canonical case: provider explicitly signals tool intent.
resp = _response("tool_calls")
assert resp.has_tool_calls is True
assert resp.should_execute_tools is True
def test_tool_calls_with_stop_reason_executes(self) -> None:
# Some compliant providers emit "stop" together with tool_calls; the
# guard must accept this to avoid breaking real tool-calling flows.
# See openai_compat_provider.py:~633,678 where ("tool_calls", "stop")
# are both treated as terminal tool-call states.
resp = _response("stop")
assert resp.should_execute_tools is True
@pytest.mark.parametrize(
"anomalous_reason",
["refusal", "content_filter", "error", "length", "function_call", ""],
)
def test_tool_calls_under_anomalous_reason_blocked(self, anomalous_reason: str) -> None:
# This is the #3220 bug: gateways injecting tool_calls under any of these
# finish_reasons must not cause execution. Blocking here is what prevents
# the infinite empty tool-call loop.
resp = _response(anomalous_reason)
assert resp.has_tool_calls is True
assert resp.should_execute_tools is False

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