Compare commits

...
Author SHA1 Message Date
chengyongruandchengyongru 08c5ce95f2 feat(feishu): per-message session for group top-level messages
Align with deer-flow: group top-level messages (no root_id) now get
their own session keyed by message_id instead of sharing a single
group-wide session. Topic replies continue to share session via
root_id.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru d5122f6df8 chore(test): remove stale reaction_id from test metadata
The production code no longer reads reaction_id from metadata, so
remove the leftover key from the test_no_removal_when_message_id_missing
test case.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru 1b231eb69f fix(test): update reaction cleanup test for _reaction_ids dict
The stream-end reaction cleanup now reads from _reaction_ids instead
of metadata, so pre-populate the dict in the test instead of passing
reaction_id via metadata.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru 91d5f14fbd fix(feishu): use message_id as reply target and fix keyword-only arg
Align reply targeting with deer-flow: always reply to the inbound
message_id (not root_id). The Feishu Reply API keeps responses in
the same topic automatically when the target message is inside a topic.

Also fix run_in_executor calls that passed reply_in_thread as a
positional arg to a keyword-only parameter, and route standalone
tool hints through the reply API for group chats.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru 3ece7256d1 perf(feishu): make reaction non-blocking to speed up inbound dispatch
Reaction emoji is now added as a fire-and-forget background task
instead of blocking the inbound message pipeline. This removes
one API round-trip from the critical path before the agent starts
processing.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru a0e97e360e feat(feishu): add reply_in_thread for visual topic grouping
When reply_to_message config is enabled, the bot's first reply now
uses reply_in_thread=True to create a visual topic/thread in the
Feishu client. Subsequent chunks fall back to regular create.

The reply_to_message default remains False for backward compatibility.
Failed replies still fall back to regular send — messages are never
silently dropped.
2026-04-26 03:18:13 +08:00
934372d90b feat(feishu): add thread-scoped session isolation for group chats
Thread replies (messages with root_id != message_id) in group chats
now get their own session key: feishu:{chat_id}:{root_id}. This
means each Feishu thread has an independent conversation context.

Top-level group messages and all private chat messages keep the
default session key (no override), consistent with Telegram and
Slack channel behavior.

Co-authored-by: shenchengtsi <228445050+shenchengtsi@users.noreply.github.com>
2026-04-26 03:18:13 +08:00
T3chC0wb0yandchengyongru 9eff9a70bb fix(msteams): normalize nbsp in inbound text 2026-04-25 15:26:27 +08:00
T3chC0wb0yandchengyongru 5c2c1bb9ef fix(msteams): prune bad notify refs 2026-04-25 15:26:27 +08:00
T3chC0wb0yandchengyongru d40ce81a3d fix(msteams): send threaded replies via replyToId 2026-04-25 15:26:27 +08:00
chengyongruandchengyongru 8a646d9aec fix(agent): cap recent history section in system prompt
Truncate the "Recent History" section injected by build_system_prompt()
to 32K chars. Without this, many accumulated history.jsonl entries could
still bloat the system prompt even with per-entry truncation in place.
2026-04-24 01:53:31 +08:00
chengyongruandchengyongru 93bcb0a649 fix(agent): prevent history.jsonl bloat from raw_archive and stuck consolidation
Root cause: when consolidation LLM fails, raw_archive() dumped full message
content (~1MB) into history.jsonl with no size limit. Since build_system_prompt()
injects history.jsonl into every system prompt, all subsequent LLM calls exceeded
the 200K context window with error 1261.

Additionally, _cap_consolidation_boundary's 60-message cap caused consolidation
to get stuck on sessions with long tool chains (200+ iterations), triggering
the raw_archive fallback in the first place.

Three-layer fix:
- Remove _cap_consolidation_boundary: let pick_consolidation_boundary drive
  chunk sizing based solely on token budget
- Truncate archive() input: use tiktoken to cap formatted text to the model's
  input token budget before sending to consolidation LLM
- Truncate raw_archive() output: cap history.jsonl entries at 16K chars
2026-04-24 01:41:01 +08:00
chengyongru da0ebc64fb fix(agent): prevent duplicate responses when sub-agents complete concurrently
When the main agent spawns multiple sub-agents, each completion
independently triggered a new _dispatch, causing 3-4 user-visible
responses instead of a single comprehensive report.

- Extend _drain_pending to block-wait on pending_queue when sub-agents
  are still running, keeping the runner loop alive for in-order injection
- Pass pending_queue in the system message path so subsequent sub-agent
  results can still be injected mid-turn via a new dispatch
2026-04-22 18:20:36 +08:00
chengyongru 9bf7f3b420 Merge remote-tracking branch 'origin/main' into nightly 2026-04-22 13:29:10 +08:00
hussein1362andXubin Ren 0932189860 fix: handle Windows PermissionError on directory fsync
On Windows, opening a directory with O_RDONLY raises PermissionError.
Wrap the directory fsync in a try/except PermissionError — NTFS journals
metadata synchronously so the directory sync is unnecessary there.

Also adjust test assertions to expect 1 fsync call (file only) on
Windows vs 2 (file + directory) on POSIX.
2026-04-22 13:19:53 +08:00
hussein1362andXubin Ren 512bf59b3c fix(session): fsync sessions on graceful shutdown to prevent data loss
On filesystems with write-back caching (rclone VFS, NFS, FUSE mounts)
the OS page cache may buffer recent session writes. If the process is
killed before the cache flushes, the most recent conversation turns are
silently lost — causing the agent to "forget" recent context and
respond to stale history on the next startup.

Changes:

- session/manager.py: add fsync=True option to save() that flushes the
  file and its parent directory to durable storage. Add flush_all() that
  re-saves every cached session with fsync. Default save() behavior is
  unchanged (no fsync) to avoid performance regression in normal
  operation.

- cli/commands.py: call agent.sessions.flush_all() in the gateway
  shutdown finally block, after stopping heartbeat/cron/channels.

- tests/session/test_session_fsync.py: 8 tests covering fsync flag
  behavior, flush_all with empty/multiple/errored sessions, and
  data survival across simulated process restart.

- tests/cli/test_commands.py: add sessions attribute to _FakeAgentLoop
  so the gateway health endpoint test passes with the new shutdown
  flush.
2026-04-22 13:19:53 +08:00
Xubin RenandXubin Ren ef8bbab7b3 test(cli): lock _render_interactive_ansi force_terminal to isatty
Made-with: Cursor
2026-04-22 13:12:29 +08:00
wood3nandXubin Ren 2e419f9ba2 fix(cli): respect sys.stdout.isatty() in commands.py 2026-04-22 13:12:29 +08:00
Xubin RenandXubin Ren 88c619901e review(providers): tighten comments in reasoning_effort normalize path
Made-with: Cursor
2026-04-22 12:49:55 +08:00
hlgandXubin Ren 28c42628b0 fix: normalize DashScope reasoning_effort (minimal vs minimum)
DashScope rejects the OpenAI-style value "minimal" with
`'reasoning_effort.effort' must be one of: 'none', 'minimum', 'low',
'medium', 'high', 'xhigh'`, but nanobot was passing the string through
verbatim. Users who tried the documented "minimal" to disable thinking
got a 400; users who tried the DashScope-native "minimum" to work
around it got `enable_thinking=True` because the internal comparison
was a hard string match on "minimal".

Introduce a semantic/wire split in `_build_kwargs`:

- `semantic_effort` is the internal canonical form (OpenAI vocabulary).
  "minimum" on the way in is normalized to "minimal" here so both
  spellings share one meaning.
- `wire_effort` is what we actually serialize. For DashScope with
  semantic_effort == "minimal" we translate to "minimum" on the way
  out; other providers are unchanged.
- `thinking_enabled` and the Kimi thinking branch now compare on
  `semantic_effort`, so either user spelling correctly disables
  provider-side thinking.

Tests:

- Strengthen `test_dashscope_thinking_disabled_for_minimal` to assert
  the wire value is "minimum" in addition to the extra_body signal;
  the original version only checked extra_body and let the
  invalid-value bug slip through.
- Add `test_dashscope_thinking_disabled_for_minimum_alias` so a user
  who read the DashScope docs and configured "minimum" still gets
  thinking off.
- Add `test_non_dashscope_minimal_not_retranslated` to pin down that
  the DashScope-specific translation does not leak to OpenAI et al.
2026-04-22 12:49:55 +08:00
chengyongruandXubin Ren f6a417e77d fix(transcription): harden language parameter validation and tests
- Add ISO-639 pattern validation (2-3 lowercase letters) to schema
- Normalize empty language to None in provider constructors
- Extract shared httpx mock stubs, parameterize provider tests
- Add test for language=None omitting field from multipart body
- Add test for Pydantic pattern validation rejecting invalid codes
2026-04-22 12:41:32 +08:00
kandXubin Ren 123d69bfb7 fix: allow specifying transcription language 2026-04-22 12:41:32 +08:00
flobo3andXubin Ren 1826ab44fa feat(transcription): add language parameter for Groq Whisper STT 2026-04-22 12:41:32 +08:00
chengyongruandchengyongru a4a197fea5 fix(transcription): harden language parameter validation and tests
- Add ISO-639 pattern validation (2-3 lowercase letters) to schema
- Normalize empty language to None in provider constructors
- Extract shared httpx mock stubs, parameterize provider tests
- Add test for language=None omitting field from multipart body
- Add test for Pydantic pattern validation rejecting invalid codes
2026-04-22 11:02:07 +08:00
kandchengyongru bc3d734df5 fix: allow specifying transcription language 2026-04-22 11:02:07 +08:00
flobo3andchengyongru 1835f94d8e feat(transcription): add language parameter for Groq Whisper STT 2026-04-22 11:02:07 +08:00
Xubin Ren f5b8ee9f78 docs: update v0.1.5.post2 release news 2026-04-21 17:50:54 +00:00
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
chengyongru c51b653154 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 17:39:51 +08: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
chengyongruandchengyongru 51cb260f05 test(tools): add basic regression tests for ContextVar routing context 2026-04-21 11:25:57 +08:00
jr_blue_551andchengyongru e4fa58ef45 agent: use ContextVar for tool routing context 2026-04-21 11:25:57 +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
chengyongru f2848b9b94 Merge remote-tracking branch 'origin/main' into nightly 2026-04-20 23:38:08 +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
chengyongru e49b56525b Merge remote-tracking branch 'origin/main' into nightly 2026-04-20 16:43:39 +08:00
chengyongruandchengyongru 3454efcd98 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:30:55 +08:00
c7057cb3bf 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:30:55 +08:00
himax12andchengyongru 8301a3a741 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:30:55 +08:00
jhkim43andchengyongru 1826bfd05a feat(telegram): change to mid-stream split per review feedback(#2967 PR) 2026-04-20 16:30:55 +08:00
Xubin Ren 26fd2c099a build: ship THIRD_PARTY_NOTICES and fix webui packaging in wheel 2026-04-20 08:22:10 +00:00
chengyongruandchengyongru 197ecb02ca 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 14:56:13 +08:00
flobo3andchengyongru 74d314d3ef fix: deduplicate SPF/DKIM-rejected emails to stop log spam 2026-04-20 14:56:13 +08: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
chengyongru 375b1f0328 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:17:22 +08:00
chengyongru a7caee1186 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:03:38 +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
Mohamed ElkholyandXubin Ren ce5272c153 fix(transcription): honor api_base for OpenAI transcription provider
Complete the symmetry left by #3214: ChannelManager._resolve_transcription_base
already resolves providers.openai.api_base, but BaseChannel.transcribe_audio
instantiated OpenAITranscriptionProvider without forwarding it, and the provider
__init__ did not accept the parameter. Self-hosted OpenAI-compatible Whisper
endpoints (LiteLLM, vLLM, etc.) configured via config.json were therefore
ignored for the OpenAI backend.

- OpenAITranscriptionProvider.__init__ now accepts api_base with env fallback
  (OPENAI_TRANSCRIPTION_BASE_URL) matching the Groq pattern.
- BaseChannel.transcribe_audio forwards self.transcription_api_base to OpenAI.
- Tests mirror the existing Groq coverage: manager propagation for provider
  "openai", BaseChannel-to-provider argument passing, and provider default vs
  override for api_url.

Fully backward-compatible: when api_base is None and the env var is unset,
the default https://api.openai.com/v1/audio/transcriptions is used.

Refs #3213, follow-up to #3214.
2026-04-17 13:46:51 +08:00
Xubin RenandXubin Ren d57af5c1d1 test(channels): cover groq transcription api base propagation 2026-04-17 13:46:51 +08:00
flobo3andXubin Ren 0401ca9dbc fix: pass apiBase from config to GroqTranscriptionProvider 2026-04-17 13:46:51 +08:00
Xubin RenandXubin Ren cc5a666d5d review(dream): harden line-age annotation per review feedback
Follow-up to #3212, fully backward compatible:

- Extract the 14-day staleness threshold as `_STALE_THRESHOLD_DAYS` module
  constant and pass it into the Phase 1 prompt template as
  `{{ stale_threshold_days }}`. The number lived in three places before
  (code threshold, prompt instruction, docstring); now there is one.
- Add `DreamConfig.annotate_line_ages` (default True = current behavior)
  and propagate it through `Dream.__init__` and the gateway wiring in
  cli/commands.py. Gives users a knob to disable the feature without a
  code patch if an LLM reacts poorly to the `← Nd` suffix.
- Harden `_annotate_with_ages` against dirty working trees: when HEAD
  blob line count disagrees with the working-tree content length, skip
  annotation entirely instead of assigning ages to the wrong lines. The
  previous `i >= len(ages)` guard only handled one direction of the
  mismatch.
- Inline-comment the `max_iterations` 10→15 bump with a pointer to
  exp002 so future blame has context.
- Add 4 regression tests: end-to-end `← 30d` reaches prompt, 14/15
  threshold boundary, `annotate_line_ages=False` bypasses git entirely
  (verified via `assert_not_called`), length-mismatch defense, and
  template-var rendering.

Made-with: Cursor
2026-04-17 13:45:38 +08:00
chengyongruandXubin Ren 35f3084c03 feat(dream): per-line age annotations + dedup-aware prompt + max_iter=15
Three improvements to Dream's memory consolidation:

1. Per-line git-blame age annotations: MEMORY.md lines get `← Nd` suffixes
   (N>14) from dulwich annotate. SOUL.md/USER.md excluded as permanent.
   LLM uses content judgment, not just age, to decide what to prune.

2. Dedup-aware Phase 1 prompt: reframed as dual-task (extract facts +
   deduplicate existing files) with explicit redundancy patterns to scan for.
   Validated through 20 experiments (exp-002 prompt + max_iter=15 was best,
   averaging -1643 chars/5.4% compression per run).

3. Phase 1 analysis as commit body: dream git commits now include the full
   Phase 1 analysis for transparency via /dream-log.

4. max_iterations raised from 10 to 15: 30% improvement over 10 with no
   risk; 20 showed diminishing returns (exp-020: -701 vs exp-017: -1643).
2026-04-17 13:45:38 +08:00
Xubin RenandXubin Ren ddf2fe443e docs(readme): document Discord allowChannels config field
Mention the new allowChannels field in the Discord config example and
add a TIP bullet explaining the empty-list default (respond in all
channels) and that it composes with allowFrom.

Made-with: Cursor
2026-04-17 02:14:33 +08:00
Xubin RenandXubin Ren 459a4d7311 test(discord): cover allow_channels filtering in _should_accept_inbound
Locks in the two key boundaries of the new channel-based filter:

1. When an incoming channel id is in allow_channels, messages are forwarded.
2. When an incoming channel id is not in allow_channels, messages are
   silently dropped.

The empty-list backward-compatible path is already covered by every
existing test that omits allow_channels (default_factory=list).

Made-with: Cursor
2026-04-17 02:14:33 +08:00
48d430bf5e feat: add channel-based filtering for Discord
Add `allow_channels` config option to DiscordConfig that restricts
bot responses to specific Discord channels. When the list is empty
(default), the bot responds in all channels (backward compatible).

- Add `allow_channels: list[str]` field to DiscordConfig schema
- Add channel ID check in _handle_message_create after user filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 02:14:33 +08:00
Xubin RenandXubin Ren 619c7fc20b docs(readme): reflect SSE streaming in OpenAI-compatible API section
The Behavior bullet previously claimed `stream=true` is not supported.
With this PR, /v1/chat/completions returns text/event-stream with
OpenAI-compatible delta chunks when stream=true, so flip the bullet
to describe the actual behavior instead of lying to readers.

Made-with: Cursor
2026-04-17 01:54:49 +08:00
whsandXubin Ren 4fce8d8b8d feat(api): add SSE streaming for /v1/chat/completions Wire up the existing on_stream/on_stream_end callbacks from process_direct() to emit OpenAI-compatible SSE chunks when stream=true. Non-streaming path is untouched. 2026-04-17 01:54:49 +08:00
Xubin Ren db78574cb8 docs(README): update auto compact section to clarify session file behavior and mental model 2026-04-16 17:20:38 +00:00
Xubin Ren 90b7d940e8 refactor(config): nest MyTool settings under tools.my (with legacy-key migration) 2026-04-16 15:58:20 +00:00
chengyongruandXubin Ren b51da93cbb feat(agent): add SelfTool for runtime self-inspection and configuration
Add a built-in tool that lets the agent inspect and modify its own
runtime state (model, iterations, context window, etc.).

Key features:
- inspect: view current config, usage stats, and subagent status
- modify: adjust parameters at runtime (protected by type/range validation)
- Subagent observability: inspect running subagent tasks (phase,
  iteration, tool events, errors) — subagents are no longer a black box
- Watchdog corrects out-of-bounds values on each iteration
- Enabled by default in read-only mode (self_modify: false)
- All changes are in-memory only; restart restores defaults
- Comprehensive test suite (90 tests)

Includes a self-awareness skill (always-on) with progressive disclosure:
SKILL.md for core rules, references/examples.md for detailed scenarios.
2026-04-16 23:44:26 +08:00
1304ff78cc perf(tools): cache ToolRegistry.get_definitions() between mutations
get_definitions() sorts tools on every LLM iteration for prompt cache
stability.  Cache the sorted result and invalidate on register/unregister
so the sort only runs when the tool set actually changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:52:36 +08:00
Xubin RenandXubin Ren 7ce8f247a0 test(api): cover remote image URL rejection 2026-04-16 21:02:33 +08:00
Mohamed ElkholyandXubin Ren 54b48a7431 fix(api): prevent upload filename collisions, reject unsupported image URLs
Three fixes in the API upload handling:

1. Multipart uploads now prefix filenames with a UUID to prevent
   overwrites when two requests upload files with the same name.
2. JSON image_url content blocks with remote HTTPS URLs now return
   a 400 error instead of silently dropping the image.
3. Model validation runs for both JSON and multipart requests,
   fixing an inconsistency where multipart bypassed the check.
2026-04-16 21:02:33 +08:00
chengyongruandXubin Ren e1fdca7d40 fix(status): correct context percentage calculation and sync consolidator
- Pass resolved self.context_window_tokens to Consolidator instead of
  raw parameter that could be None, preventing consolidation failures
- Calculate percentage against input budget (ctx - max_completion - 1024)
  instead of raw context window, consistent with Consolidator/snip formulas
- Pass actual max_completion_tokens from provider to build_status_content
- Cap percentage display at 999 to prevent runaway values
- Add tests for budget-based percentage and cap behavior
2026-04-16 20:30:39 +08:00
Xubin RenandGitHub 92a5125108 Merge PR #3141: fix(skills): use yaml.safe_load for frontmatter parsing to handle multiline descriptions
fix(skills): use yaml.safe_load for frontmatter parsing to handle multiline descriptions
2026-04-16 20:07:15 +08:00
Xubin RenandXubin Ren a2f4090e41 fix(msteams): secure inbound defaults and ref persistence
Default Microsoft Teams inbound auth validation to enabled, update the README to match, and prevent denied senders from persisting conversation refs before allowlist checks pass.

Made-with: Cursor
2026-04-16 13:22:07 +08:00
chengyongruandXubin Ren abe0145f99 fix(msteams): harden availability check and migrate docs to README
- Check both jwt and cryptography in MSTEAMS_AVAILABLE guard so
  partial installs fail early with a clear message instead of at runtime
- Add aclose() to test FakeHttpClient so stop() won't crash
- Move MSTEAMS.md into README.md following the same details/summary
  pattern used by every other channel
- Note in README that validateInboundAuth defaults to false
2026-04-16 13:22:07 +08:00
chengyongruandXubin Ren 49223e639e fix(msteams): add auth warning and restore unrelated pyproject change
Warn when validate_inbound_auth is disabled (default) so operators are
aware the webhook accepts unverified requests.  Restore pymupdf to the
dev optional-dependencies group — its removal in the original PR was
unrelated to the Teams channel feature.
2026-04-16 13:22:07 +08:00
T3chC0wb0yandXubin Ren 818a095a90 style(msteams): hoist time import 2026-04-16 13:22:07 +08:00
T3chC0wb0yandXubin Ren ee99200341 refactor(msteams): remove business references 2026-04-16 13:22:07 +08:00
T3chC0wb0yandXubin Ren 9b4264fce2 refactor(msteams): remove FWDIOC references 2026-04-16 13:22:07 +08:00
T3chC0wb0yandXubin Ren fecef07c60 refactor(msteams): remove obsolete restart notify config 2026-04-16 13:22:07 +08:00
Bob JohnsonandXubin Ren 9f8774fbdd fix(msteams): remove hardcoded quote test fallback 2026-04-16 13:22:07 +08:00
chengyongruandXubin Ren 63753dbfea fix(msteams): remove optional deps from dev extras and gate tests
PyJWT and cryptography are optional msteams deps; they should not be
bundled into the generic dev install.  Tests now skip the entire file
when the deps are missing, following the dingtalk pattern.
2026-04-16 13:22:07 +08:00
Bob JohnsonandXubin Ren 4d795f74d5 Fix MSTeams PR review follow-ups 2026-04-16 13:22:07 +08:00
T3chC0wb0yandXubin Ren 824dcca5e2 Add Microsoft Teams channel on current nightly base 2026-04-16 13:22:07 +08:00
chengyongruandXubin Ren d64e963258 test(memory): add regression tests for missing cursor key
Cover read_unprocessed_history skipping cursorless entries and
_next_cursor safe fallback when last entry has no cursor.
2026-04-16 12:32:38 +08:00
chengyongruandXubin Ren 524c097f76 refactor(memory): simplify read_unprocessed_history cursor guard
Replace verbose loop with one-liner list comprehension using
e.get("cursor", 0) to handle missing cursor keys.
2026-04-16 12:32:38 +08:00
Jiajun XieandXubin Ren f4a7ad16aa fix(memory): handle missing cursor key in history entries
- Use .get('cursor') instead of direct dict access to prevent KeyError
- Skip entries without cursor and log a warning
- Fix _next_cursor fallback to safely check for cursor existence

Fixes #3190
2026-04-16 12:32:38 +08:00
Xubin RenandXubin Ren 2b8e90d8fd test(config): cover LM Studio nullable api key 2026-04-16 02:49:54 +08:00
Soham BhattacharyaandXubin Ren 41a1b0058d Add support for nullable API keys and LM Studio 2026-04-16 02:49:54 +08:00
Xubin Ren d46c1b14b0 docs: update .gitignore 2026-04-15 18:17:18 +00:00
Leo fuandXubin Ren 2c0cd085a4 fix(discord): remove duplicate channel_id assignment in message handler
channel_id is already assigned from self._channel_key(message.channel)
earlier in the same function. The second identical assignment on line 453
is dead code left over from a copy-paste.
2026-04-16 02:11:13 +08:00
Xubin RenandXubin Ren a6ea06e6bf docs(providers): explain MiniMax thinking endpoint
Document why MiniMax thinking mode uses a separate Anthropic-compatible provider and list the matching base URLs. Add a small registry test so the new provider stays wired to the expected backend and API key.

Made-with: Cursor
2026-04-16 01:00:45 +08:00
AishtandXubin Ren d0a282e766 feat(provider): add MiniMax Anthropic endpoint for thinking mode
- Add minimax_anthropic provider using Anthropic-compatible endpoint
- Endpoint: https://api.minimax.io/anthropic
- Supports reasoning_effort parameter for thinking mode (low/medium/high/adaptive)
- Uses same MINIMAX_API_KEY as existing minimax provider
2026-04-16 01:00:45 +08:00
Jiajun XieandXubin Ren e18eab8054 fix(cron): respect deliver flag before message tool check
When deliver: false is set in cron job payload, suppress all output even
when agent calls message tool during the turn.

Closes #3115
2026-04-15 23:53:08 +08:00
04cbandXubin Ren eacc9fbb5f refactor(providers): drop unreachable GenerationSettings fallback 2026-04-15 23:52:38 +08:00
04cbandXubin Ren 54f7ad3752 fix(providers): guard chat_with_retry against explicit None max_tokens (#3102) 2026-04-15 23:52:38 +08:00
chengyongruandGitHub 015833e34b Merge branch 'main' into fix/skills-yaml-frontmatter 2026-04-15 16:56:23 +08:00
dongzeyu001andXubin Ren 6829b8b475 unit test fix 2026-04-15 16:51:02 +08:00
dongzeyu001andXubin Ren cbd2315d76 unit test fix 2026-04-15 16:51:02 +08:00
dongzeyu001andXubin Ren cf47fa7d23 add test for wecom mixed msg parse fix 2026-04-15 16:51:02 +08:00
dongzeyu001andXubin Ren 8572b7478f Fix wecom mix msg parse 2026-04-15 16:51:02 +08:00
chengyongruandXubin Ren 6fbada5363 refactor(context): deduplicate system prompt — markdown skills index, skip template MEMORY.md
- Convert skills summary from verbose XML (4-5 lines/skill) to compact
  markdown list (1 line/skill) with inline path for read_file lookup
- Exclude always-loaded skills (e.g. memory) from the skills index to
  avoid duplicating content already in the Active Skills section
- Skip injecting the Memory section when MEMORY.md still matches the
  bundled template (i.e. Dream hasn't populated it yet)
2026-04-15 15:49:30 +08:00
Xubin Ren 5683c79a6e chore: update README with new release notes of v0.1.5.post1 2026-04-14 19:01:43 +00:00
yanghan-cyber a1b544fd23 fix(skills): use yaml.safe_load for frontmatter parsing to handle multiline descriptions
The hand-rolled line-by-line YAML parser treated each line independently,
so YAML multiline scalars (folded `>` and literal `|`) were captured as
the literal characters ">" or "|" instead of the actual text content.
2026-04-14 15:29:59 +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
221 changed files with 28345 additions and 2872 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:
test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
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:
- uses: actions/checkout@v4
@@ -24,10 +25,11 @@ jobs:
- name: Install uv
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
- name: Install all dependencies
- name: Install dependencies
run: uv sync --all-extras
- name: Lint with ruff
+8
View File
@@ -4,6 +4,14 @@
.docs
.env
.web
.orion
# webui (monorepo frontend)
webui/node_modules/
webui/dist/
webui/coverage/
webui/.vite/
*.tsbuildinfo
# Python bytecode & caches
*.pyc
+143 -2006
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
```
```text
nanobot-channel-webhook/
├── nanobot_channel_webhook/
│ ├── __init__.py # re-export WebhookChannel
@@ -135,14 +135,17 @@ class WebhookChannel(BaseChannel):
[project]
name = "nanobot-channel-webhook"
version = "0.1.0"
dependencies = ["nanobot", "aiohttp"]
dependencies = ["nanobot-ai", "aiohttp"]
[project.entry-points."nanobot.channels"]
webhook = "nanobot_channel_webhook:WebhookChannel"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.backends._legacy:_Backend"
requires = ["hatchling"]
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.
+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`.
+811
View File
@@ -0,0 +1,811 @@
# 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, and optionally set `"transcriptionLanguage": "en"` (or another ISO-639-1 code) for more accurate transcription. 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",
"transcriptionLanguage": null,
"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. |
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
### 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
> **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.
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
```
```text
workspace/
├── SOUL.md # The bot's long-term voice and communication style
├── 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
+207
View File
@@ -0,0 +1,207 @@
# My Tool
Let the agent sense and adjust its own runtime state — like asking a coworker "are you busy? can you switch to a bigger monitor?"
## Why You Need It
Normal tools let the agent operate on the outside world (read/write files, search code). But the agent knows nothing about itself — it doesn't know which model it's running on, how many iterations are left, or how many tokens it has consumed.
My tool fills this gap. With it, the agent can:
- **Know who it is**: What model am I using? Where is my workspace? How many iterations remain?
- **Adapt on the fly**: Complex task? Expand the context window. Simple chat? Switch to a faster model.
- **Remember across turns**: Store notes in your scratchpad that persist into the next conversation turn.
## Configuration
Enabled by default (read-only mode). The agent can check its state but not set it.
```yaml
tools:
my:
enable: true # default: true
allow_set: false # default: false (read-only)
```
To allow the agent to set its configuration (e.g. switch models, adjust parameters), set `tools.my.allow_set: true`.
Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and
rewritten in-place the next time `nanobot onboard` refreshes the config.
All modifications are held in memory only — restart restores defaults.
---
## check — Check "my" current state
Without parameters, returns a key config overview:
```text
my(action="check")
# → max_iterations: 40
# context_window_tokens: 65536
# model: 'anthropic/claude-sonnet-4-20250514'
# workspace: PosixPath('/tmp/workspace')
# provider_retry_mode: 'standard'
# max_tool_result_chars: 16000
# _current_iteration: 3
# _last_usage: {'prompt_tokens': 45000, 'completion_tokens': 8000}
# Note: prompt_tokens is cumulative across all turns, not current context window occupancy.
```
With a key parameter, drill into a specific config:
```text
my(action="check", key="_last_usage.prompt_tokens")
# → How many prompt tokens I've used so far
my(action="check", key="model")
# → What model I'm currently running on
my(action="check", key="web_config.enable")
# → Whether web search is enabled
```
### What you can do with it
| Scenario | How |
|----------|-----|
| "What model are you using?" | `check("model")` |
| "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` |
| "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
| "Where is your working directory?" | `check("workspace")` |
| "Show me your full config" | `check()` |
| "Are there any subagents running?" | `check("subagents")` — shows phase, iteration, elapsed time, tool events |
---
## set — Runtime tuning
Changes take effect immediately, no restart required.
```text
my(action="set", key="max_iterations", value=80)
# → Bump iteration limit from 40 to 80
my(action="set", key="model", value="fast-model")
# → Switch to a faster model
my(action="set", key="context_window_tokens", value=131072)
# → Expand context window for long documents
```
You can also store custom state in your scratchpad:
```text
my(action="set", key="current_project", value="nanobot")
my(action="set", key="user_style_preference", value="concise")
my(action="set", key="task_complexity", value="high")
# → These values persist into the next conversation turn
```
### Protected parameters
These parameters have type and range validation — invalid values are rejected:
| Parameter | Type | Range | Purpose |
|-----------|------|-------|---------|
| `max_iterations` | int | 1100 | Max tool calls per conversation turn |
| `context_window_tokens` | int | 4,0961,000,000 | Context window size |
| `model` | str | non-empty | LLM model to use |
Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
---
## Practical Scenarios
### "This task is complex, I need more room"
```text
Agent: This codebase is large, let me expand my context window to handle it.
→ my(action="set", key="context_window_tokens", value=131072)
```
### "Simple question, don't waste compute"
```text
Agent: This is a straightforward question, let me switch to a faster model.
→ my(action="set", key="model", value="fast-model")
```
### "Remember user preferences across turns"
```text
Turn 1: my(action="set", key="user_prefers_concise", value=True)
Turn 2: my(action="check", key="user_prefers_concise")
# → True (still remembers the user likes concise replies)
```
### "Self-diagnosis"
```text
User: "Why aren't you searching the web?"
Agent: Let me check my web config.
→ my(action="check", key="web_config.enable")
# → False
Agent: Web search is disabled — please set web.enable: true in your config.
```
### "Token budget management"
```text
Agent: Let me check how much budget I have left.
→ my(action="check", key="_last_usage")
# → {"prompt_tokens": 45000, "completion_tokens": 8000}
Agent: I've used ~53k tokens total so far. I'll keep my remaining replies concise.
```
### "Subagent monitoring"
```text
Agent: Let me check on the background tasks.
→ my(action="check", key="subagents")
# → 2 subagent(s):
# [task-1] 'Code review'
# phase: running, iteration: 5, elapsed: 12.3s
# tools: read(✓), grep(✓)
# usage: {'prompt_tokens': 8000, 'completion_tokens': 1200}
# [task-2] 'Write tests'
# phase: pending, iteration: 0, elapsed: 0.2s
# tools: none
Agent: The code review is progressing well. The test task hasn't started yet.
```
---
## Safety Mechanisms
Core design principle: **All modifications live in memory only. Restart restores defaults.** The agent cannot cause persistent damage.
### Off-limits (BLOCKED)
Cannot be checked or modified — fully hidden:
| Category | Attributes | Reason |
|----------|-----------|--------|
| Core infrastructure | `bus`, `provider`, `_running` | Changes would crash the system |
| Tool registry | `tools` | Must not remove its own tools |
| Subsystems | `runner`, `sessions`, `consolidator`, etc. | Affects other users/sessions |
| Sensitive data | `_mcp_servers`, `_pending_queues`, etc. | Contains credentials and message routing |
| Security boundaries | `restrict_to_workspace`, `channels_config` | Bypassing would violate isolation |
| Python internals | `__class__`, `__dict__`, etc. | Prevents sandbox escape |
### Read-only (check only)
Can be checked but not set:
| Category | Attributes | Reason |
|----------|-----------|--------|
| Subagent manager | `subagents` | Observable, but replacing breaks the system |
| Execution config | `exec_config` | Can check sandbox/enable status, cannot change it |
| Web config | `web_config` | Can check enable status, cannot change it |
| Iteration counter | `_current_iteration` | Updated by runner only |
### Sensitive field protection
Sub-fields matching sensitive names (`api_key`, `password`, `secret`, `token`, etc.) are blocked from both check and set, regardless of parent path. This prevents credential leaks via dot-path traversal (e.g. `web_config.search.api_key`).
+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
- Streaming support — receive agent responses token by token
- 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
- Client allow-list via `allowFrom`
- Auto-cleanup of dead connections
@@ -42,7 +42,7 @@ nanobot gateway
You should see:
```
```text
WebSocket server listening on ws://127.0.0.1:8765/
```
@@ -68,7 +68,7 @@ asyncio.run(main())
## Connection URL
```
```text
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
{
"event": "message",
"chat_id": "uuid-v4",
"text": "Hello! How can I help?",
"media": ["/tmp/image.png"],
"reply_to": "msg-id"
@@ -111,6 +112,7 @@ All frames are JSON text. Each message has an `event` field.
```json
{
"event": "delta",
"chat_id": "uuid-v4",
"text": "Hello",
"stream_id": "s1"
}
@@ -121,25 +123,46 @@ All frames are JSON text. Each message has an `event` field.
```json
{
"event": "stream_end",
"chat_id": "uuid-v4",
"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
Send plain text:
**Legacy (default chat):** send a plain string, or a JSON object with a recognized text field:
```json
"Hello nanobot!"
```
Or send a JSON object with a recognized text field:
```json
{"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
@@ -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.
- 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
- **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.
- **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.
- **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")
except PackageNotFoundError:
# 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()
+20 -5
View File
@@ -3,12 +3,13 @@
import base64
import mimetypes
import platform
from importlib.resources import files as pkg_files
from pathlib import Path
from typing import Any
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime, truncate_text
from nanobot.utils.prompt_templates import render_template
@@ -18,6 +19,7 @@ class ContextBuilder:
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
@@ -39,7 +41,7 @@ class ContextBuilder:
parts.append(bootstrap)
memory = self.memory.get_memory_context()
if memory:
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
parts.append(f"# Memory\n\n{memory}")
always_skills = self.skills.get_always_skills()
@@ -48,16 +50,18 @@ class ContextBuilder:
if always_content:
parts.append(f"# Active Skills\n\n{always_content}")
skills_summary = self.skills.build_skills_summary()
skills_summary = self.skills.build_skills_summary(exclude=set(always_skills))
if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
if entries:
capped = entries[-self._MAX_RECENT_HISTORY:]
parts.append("# Recent History\n\n" + "\n".join(
history_text = "\n".join(
f"- [{e['timestamp']}] {e['content']}" for e in capped
))
)
history_text = truncate_text(history_text, self._MAX_HISTORY_CHARS)
parts.append("# Recent History\n\n" + history_text)
return "\n\n---\n\n".join(parts)
@@ -114,6 +118,17 @@ class ContextBuilder:
return "\n\n".join(parts) if parts else ""
@staticmethod
def _is_template_content(content: str, template_path: str) -> bool:
"""Check if *content* is identical to the bundled template (user hasn't customized it)."""
try:
tpl = pkg_files("nanobot") / "templates" / template_path
if tpl.is_file():
return content.strip() == tpl.read_text(encoding="utf-8").strip()
except Exception:
pass
return False
def build_messages(
self,
history: list[dict[str, Any]],
+193 -21
View File
@@ -27,6 +27,7 @@ from nanobot.agent.tools.notebook import NotebookEditTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.search import GlobTool, GrepTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.self import MyTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
@@ -41,7 +42,7 @@ from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
if TYPE_CHECKING:
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, WebToolsConfig
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
from nanobot.cron.service import CronService
@@ -90,6 +91,9 @@ class _LoopHook(AgentHook):
await self._on_stream_end(resuming=resuming)
self._stream_buf = ""
async def before_iteration(self, context: AgentHookContext) -> None:
self._loop._current_iteration = context.iteration
async def before_execute_tools(self, context: AgentHookContext) -> None:
if self._on_progress:
if not self._on_stream:
@@ -156,9 +160,11 @@ class AgentLoop:
hooks: list[AgentHook] | None = None,
unified_session: bool = False,
disabled_skills: list[str] | None = None,
tools_config: ToolsConfig | None = None,
):
from nanobot.config.schema import ExecToolConfig, WebToolsConfig
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
_tc = tools_config or ToolsConfig()
defaults = AgentDefaults()
self.bus = bus
self.channels_config = channels_config
@@ -226,7 +232,7 @@ class AgentLoop:
provider=provider,
model=self.model,
sessions=self.sessions,
context_window_tokens=context_window_tokens,
context_window_tokens=self.context_window_tokens,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
max_completion_tokens=provider.generation.max_tokens,
@@ -242,6 +248,10 @@ class AgentLoop:
model=self.model,
)
self._register_default_tools()
if _tc.my.enable:
self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set))
self._runtime_vars: dict[str, Any] = {}
self._current_iteration: int = 0
self.commands = CommandRouter()
register_builtin_commands(self.commands)
@@ -308,10 +318,16 @@ class AgentLoop:
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."""
for name in ("message", "spawn", "cron"):
# 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"):
if tool := self.tools.get(name):
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
def _strip_think(text: str | None) -> str | None:
@@ -329,6 +345,36 @@ class AgentLoop:
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:
"""Return the session key used for task routing and mid-turn injections."""
if self._unified_session and not msg.session_key_override:
@@ -341,6 +387,7 @@ class AgentLoop:
on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], 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,
channel: str = "cli",
@@ -376,15 +423,18 @@ class AgentLoop:
self._set_runtime_checkpoint(session, payload)
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
"""Non-blocking drain of follow-up messages from the pending queue."""
"""Drain follow-up messages from the pending queue.
When no messages are immediately available but sub-agents
spawned in this dispatch are still running, blocks until at
least one result arrives (or timeout). This keeps the runner
loop alive so subsequent sub-agent completions are consumed
in-order rather than dispatched separately.
"""
if pending_queue is None:
return []
items: list[dict[str, Any]] = []
while len(items) < limit:
try:
pending_msg = pending_queue.get_nowait()
except asyncio.QueueEmpty:
break
def _to_user_message(pending_msg: InboundMessage) -> dict[str, Any]:
content = pending_msg.content
media = pending_msg.media if pending_msg.media else None
if media:
@@ -400,7 +450,36 @@ class AgentLoop:
merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}"
else:
merged = [{"type": "text", "text": runtime_ctx}] + user_content
items.append({"role": "user", "content": merged})
return {"role": "user", "content": merged}
items: list[dict[str, Any]] = []
while len(items) < limit:
try:
items.append(_to_user_message(pending_queue.get_nowait()))
except asyncio.QueueEmpty:
break
# Block if nothing drained but sub-agents spawned in this dispatch
# are still running. Keeps the runner loop alive so subsequent
# completions are injected in-order rather than dispatched separately.
if (not items
and session is not None
and self.subagents.get_running_count_by_session(session.key) > 0):
try:
msg = await asyncio.wait_for(pending_queue.get(), timeout=300)
except asyncio.TimeoutError:
logger.warning(
"Timeout waiting for sub-agent completion in session {}",
session.key,
)
return items
items.append(_to_user_message(msg))
while len(items) < limit:
try:
items.append(_to_user_message(pending_queue.get_nowait()))
except asyncio.QueueEmpty:
break
return items
result = await self.runner.run(AgentRunSpec(
@@ -418,12 +497,18 @@ class AgentLoop:
context_block_limit=self.context_block_limit,
provider_retry_mode=self.provider_retry_mode,
progress_callback=on_progress,
retry_wait_callback=on_retry_wait,
checkpoint_callback=_checkpoint,
injection_callback=_drain_pending,
))
self._last_usage = result.usage
if result.stop_reason == "max_iterations":
logger.warning("Max iterations ({}) reached", self.max_iterations)
# Push final content through stream so streaming channels (e.g. Feishu)
# update the card instead of leaving it empty.
if on_stream and on_stream_end:
await on_stream(result.final_content or "")
await on_stream_end(resuming=False)
elif result.stop_reason == "error":
logger.error("LLM returned error: {}", (result.final_content or "")[:200])
return result.final_content, result.tools_used, result.messages, result.stop_reason, result.had_injections
@@ -455,16 +540,24 @@ class AgentLoop:
raw = msg.content.strip()
if self.commands.is_priority(raw):
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, loop=self)
result = await self.commands.dispatch_priority(ctx)
if result:
await self.bus.publish_outbound(result)
await self._dispatch_command_inline(
msg, msg.session_key, raw,
self.commands.dispatch_priority,
)
continue
effective_key = self._effective_session_key(msg)
# If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn
# injection instead of creating a competing task.
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
if effective_key != msg.session_key:
pending_msg = dataclasses.replace(
@@ -556,6 +649,29 @@ class AgentLoop:
))
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant
# 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
except Exception:
logger.exception("Error processing message for session {}", session_key)
@@ -631,20 +747,36 @@ class AgentLoop:
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"))
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(
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,
current_role=current_role,
)
final_content, _, all_msgs, _, _ = await self._run_agent_loop(
messages, session=session, channel=channel, chat_id=chat_id,
message_id=msg.metadata.get("message_id"),
pending_queue=pending_queue,
)
self._save_turn(session, all_msgs, 1 + len(history))
self._clear_runtime_checkpoint(session)
@@ -680,7 +812,10 @@ class AgentLoop:
if result := await self.commands.dispatch(ctx):
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"))
if message_tool := self.tools.get("message"):
@@ -711,6 +846,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
# agent loop. If the process is killed mid-turn (OOM, SIGKILL, self-
# restart, etc.), the existing runtime_checkpoint preserves the
@@ -729,6 +876,7 @@ class AgentLoop:
on_progress=on_progress or _bus_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
on_retry_wait=_on_retry_wait,
session=session,
channel=msg.channel,
chat_id=msg.chat_id,
@@ -855,6 +1003,30 @@ class AgentLoop:
session.messages.append(entry)
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:
"""Persist the latest in-flight turn state into session metadata."""
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
+202 -52
View File
@@ -6,14 +6,15 @@ import asyncio
import json
import re
import weakref
import tiktoken
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from typing import TYPE_CHECKING, Any, Callable, Iterator
from loguru import logger
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think, truncate_text
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.tools.registry import ToolRegistry
@@ -49,6 +50,7 @@ class MemoryStore:
self.user_file = workspace / "USER.md"
self._cursor_file = self.memory_dir / ".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=[
"SOUL.md", "USER.md", "memory/MEMORY.md",
])
@@ -221,31 +223,77 @@ class MemoryStore:
# -- history.jsonl — append-only, JSONL format ---------------------------
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()
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:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
self._cursor_file.write_text(str(cursor), encoding="utf-8")
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:
"""Read the current cursor counter and return next value."""
"""Read the current cursor counter and return the next value."""
if self._cursor_file.exists():
try:
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
except (ValueError, OSError):
pass
# Fallback: read last line's cursor from the JSONL file.
last = self._read_last_entry()
if last:
return last["cursor"] + 1
return 1
# Fast path: trust the tail when intact. Otherwise scan the whole
# file and take ``max`` — that stays correct even if the monotonic
# invariant was broken by external writes.
last = self._read_last_entry() or {}
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]]:
"""Return history entries with cursor > *since_cursor*."""
return [e for e in self._read_entries() if e["cursor"] > since_cursor]
"""Return history entries with a valid cursor > *since_cursor*."""
return [e for e, c in self._iter_valid_entries() if c > since_cursor]
def compact_history(self) -> None:
"""Drop oldest entries if the file exceeds *max_history_entries*."""
@@ -326,11 +374,13 @@ class MemoryStore:
)
return "\n".join(lines)
def raw_archive(self, messages: list[dict]) -> None:
def raw_archive(self, messages: list[dict], *, max_chars: int | None = None) -> None:
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
formatted = truncate_text(self._format_messages(messages), limit)
self.append_history(
f"[RAW] {len(messages)} messages\n"
f"{self._format_messages(messages)}"
f"{formatted}"
)
logger.warning(
"Memory consolidation degraded: raw-archived {} messages", len(messages)
@@ -343,11 +393,13 @@ class MemoryStore:
# ---------------------------------------------------------------------------
_RAW_ARCHIVE_MAX_CHARS = 16_000 # cap raw_archive entries to avoid bloating history.jsonl
class Consolidator:
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
_MAX_CONSOLIDATION_ROUNDS = 5
_MAX_CHUNK_MESSAGES = 60 # hard cap per consolidation round
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
@@ -400,23 +452,12 @@ class Consolidator:
return last_boundary
def _cap_consolidation_boundary(
def estimate_session_prompt_tokens(
self,
session: Session,
end_idx: int,
) -> int | None:
"""Clamp the chunk size without breaking the user-turn boundary."""
start = session.last_consolidated
if end_idx - start <= self._MAX_CHUNK_MESSAGES:
return end_idx
capped_end = start + self._MAX_CHUNK_MESSAGES
for idx in range(capped_end, start, -1):
if session.messages[idx].get("role") == "user":
return idx
return None
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
*,
session_summary: str | None = None,
) -> tuple[int, str]:
"""Estimate current prompt size for the normal session history view."""
history = session.get_history(max_messages=0)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
@@ -425,6 +466,7 @@ class Consolidator:
current_message="[token-probe]",
channel=channel,
chat_id=chat_id,
session_summary=session_summary,
)
return estimate_prompt_tokens_chain(
self.provider,
@@ -433,6 +475,25 @@ class Consolidator:
self._get_tool_definitions(),
)
@property
def _input_token_budget(self) -> int:
"""Available input token budget for consolidation LLM."""
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
def _truncate_to_token_budget(self, text: str) -> str:
"""Truncate text so it fits within the consolidation LLM's token budget."""
budget = self._input_token_budget
if budget <= 0:
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
try:
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode(text)
if len(tokens) <= budget:
return text
return enc.decode(tokens[:budget]) + "\n... (truncated)"
except Exception:
return truncate_text(text, budget * 4)
async def archive(self, messages: list[dict]) -> str | None:
"""Summarize messages via LLM and append to history.jsonl.
@@ -442,6 +503,7 @@ class Consolidator:
return None
try:
formatted = MemoryStore._format_messages(messages)
formatted = self._truncate_to_token_budget(formatted)
response = await self.provider.chat_with_retry(
model=self.model,
messages=[
@@ -457,6 +519,8 @@ class Consolidator:
tools=None,
tool_choice=None,
)
if response.finish_reason == "error":
raise RuntimeError(f"LLM returned error: {response.content}")
summary = response.content or "[no summary]"
self.store.append_history(summary)
return summary
@@ -465,7 +529,12 @@ class Consolidator:
self.store.raw_archive(messages)
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.
The budget reserves space for completion tokens and a safety buffer
@@ -476,10 +545,13 @@ class Consolidator:
lock = self.get_lock(session.key)
async with lock:
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
budget = self._input_token_budget
target = budget // 2
try:
estimated, source = self.estimate_session_prompt_tokens(session)
estimated, source = self.estimate_session_prompt_tokens(
session,
session_summary=session_summary,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
@@ -497,9 +569,10 @@ class Consolidator:
)
return
last_summary = None
for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
if estimated <= target:
return
break
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
if boundary is None:
@@ -508,21 +581,13 @@ class Consolidator:
session.key,
round_num,
)
return
break
end_idx = boundary[0]
end_idx = self._cap_consolidation_boundary(session, end_idx)
if end_idx is None:
logger.debug(
"Token consolidation: no capped boundary for {} (round {})",
session.key,
round_num,
)
return
chunk = session.messages[session.last_consolidated:end_idx]
if not chunk:
return
break
logger.info(
"Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
@@ -533,18 +598,34 @@ class Consolidator:
source,
len(chunk),
)
if not await self.archive(chunk):
return
summary = await self.archive(chunk)
if summary:
last_summary = summary
else:
break
session.last_consolidated = end_idx
self.sessions.save(session)
try:
estimated, source = self.estimate_session_prompt_tokens(session)
estimated, source = self.estimate_session_prompt_tokens(
session,
session_summary=session_summary,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
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)
# ---------------------------------------------------------------------------
@@ -552,6 +633,13 @@ class Consolidator:
# ---------------------------------------------------------------------------
# Single source of truth for the staleness threshold used in _annotate_with_ages
# *and* in the Phase 1 prompt template (passed as `stale_threshold_days`).
# Keep code and prompt aligned — if you bump this, the LLM's instruction string
# updates automatically.
_STALE_THRESHOLD_DAYS = 14
class Dream:
"""Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
@@ -568,6 +656,7 @@ class Dream:
max_batch_size: int = 20,
max_iterations: int = 10,
max_tool_result_chars: int = 16_000,
annotate_line_ages: bool = True,
):
self.store = store
self.provider = provider
@@ -575,6 +664,10 @@ class Dream:
self.max_batch_size = max_batch_size
self.max_iterations = max_iterations
self.max_tool_result_chars = max_tool_result_chars
# Kill switch for the git-blame-based per-line age annotation in Phase 1.
# Default True keeps the #3212 behavior; set False to feed MEMORY.md raw
# (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
self.annotate_line_ages = annotate_line_ages
self._runner = AgentRunner(provider)
self._tools = self._build_tools()
@@ -632,6 +725,52 @@ class Dream:
# -- main entry ----------------------------------------------------------
def _annotate_with_ages(self, content: str) -> str:
"""Append per-line age suffixes to MEMORY.md content.
Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
suffix like ``← 30d`` indicating days since last modification.
Returns the original content unchanged if git is unavailable,
annotate fails, or the line count doesn't match the age count
(which can happen with an uncommitted working-tree edit — better to
skip annotation than to tag the wrong line).
SOUL.md and USER.md are never annotated.
"""
file_path = "memory/MEMORY.md"
try:
ages = self.store.git.line_ages(file_path)
except Exception:
logger.debug("line_ages failed for {}", file_path)
return content
if not ages:
return content
had_trailing = content.endswith("\n")
lines = content.splitlines()
# If HEAD-blob line count disagrees with the working-tree content we
# received, ages would be assigned to the wrong lines — skip entirely
# and feed the LLM un-annotated content rather than misleading data.
if len(lines) != len(ages):
logger.debug(
"line_ages length mismatch for {} (lines={}, ages={}); skipping annotation",
file_path, len(lines), len(ages),
)
return content
annotated: list[str] = []
for line, age in zip(lines, ages):
if not line.strip():
annotated.append(line)
continue
if age.age_days > _STALE_THRESHOLD_DAYS:
annotated.append(f"{line} \u2190 {age.age_days}d")
else:
annotated.append(line)
result = "\n".join(annotated)
if had_trailing:
result += "\n"
return result
async def run(self) -> bool:
"""Process unprocessed history entries. Returns True if work was done."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
@@ -652,9 +791,14 @@ class Dream:
f"[{e['timestamp']}] {e['content']}" for e in batch
)
# Current file contents
# Current file contents + per-line age annotations (MEMORY.md only)
current_date = datetime.now().strftime("%Y-%m-%d")
current_memory = self.store.read_memory() or "(empty)"
raw_memory = self.store.read_memory() or "(empty)"
current_memory = (
self._annotate_with_ages(raw_memory)
if self.annotate_line_ages
else raw_memory
)
current_soul = self.store.read_soul() or "(empty)"
current_user = self.store.read_user() or "(empty)"
@@ -676,7 +820,11 @@ class Dream:
messages=[
{
"role": "system",
"content": render_template("agent/dream_phase1.md", strip=True),
"content": render_template(
"agent/dream_phase1.md",
strip=True,
stale_threshold_days=_STALE_THRESHOLD_DAYS,
),
},
{"role": "user", "content": phase1_prompt},
],
@@ -759,7 +907,9 @@ class Dream:
# Git auto-commit (only when there are actual changes)
if changelog and self.store.git.is_initialized():
ts = batch[-1]["timestamp"]
sha = self.store.git.auto_commit(f"dream: {ts}, {len(changelog)} change(s)")
summary = f"dream: {ts}, {len(changelog)} change(s)"
commit_msg = f"{summary}\n\n{analysis.strip()}"
sha = self.store.git.auto_commit(commit_msg)
if sha:
logger.info("Dream commit: {}", sha)
+20 -2
View File
@@ -71,6 +71,7 @@ class AgentRunSpec:
context_block_limit: int | None = None
provider_retry_mode: str = "standard"
progress_callback: Any | None = None
retry_wait_callback: Any | None = None
checkpoint_callback: Any | None = None
injection_callback: Any | None = None
@@ -273,7 +274,7 @@ class AgentRunner:
context.tool_calls = list(response.tool_calls)
self._accumulate_usage(usage, raw_usage)
if response.has_tool_calls:
if response.should_execute_tools:
if hook.wants_streaming():
await hook.on_stream_end(context, resuming=True)
@@ -362,6 +363,13 @@ class AgentRunner:
await hook.after_iteration(context)
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)
if response.finish_reason != "error" and is_blank_text(clean):
empty_content_retries += 1
@@ -545,7 +553,7 @@ class AgentRunner:
"tools": tools,
"model": spec.model,
"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:
kwargs["temperature"] = spec.temperature
@@ -932,6 +940,16 @@ class AgentRunner:
if message.get("role") == "user":
kept = kept[i:]
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)
if start:
kept = kept[start:]
+43 -34
View File
@@ -6,6 +6,8 @@ import re
import shutil
from pathlib import Path
import yaml
# Default builtin skills directory (relative to this file)
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
@@ -16,10 +18,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
)
def _escape_xml(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class SkillsLoader:
"""
Loader for agent skills.
@@ -110,39 +108,37 @@ class SkillsLoader:
]
return "\n\n---\n\n".join(parts)
def build_skills_summary(self) -> str:
def build_skills_summary(self, exclude: set[str] | None = None) -> str:
"""
Build a summary of all skills (name, description, path, availability).
This is used for progressive loading - the agent can read the full
skill content using read_file when needed.
Args:
exclude: Set of skill names to omit from the summary.
Returns:
XML-formatted skills summary.
Markdown-formatted skills summary.
"""
all_skills = self.list_skills(filter_unavailable=False)
if not all_skills:
return ""
lines: list[str] = ["<skills>"]
lines: list[str] = []
for entry in all_skills:
skill_name = entry["name"]
if exclude and skill_name in exclude:
continue
meta = self._get_skill_meta(skill_name)
available = self._check_requirements(meta)
lines.extend(
[
f' <skill available="{str(available).lower()}">',
f" <name>{_escape_xml(skill_name)}</name>",
f" <description>{_escape_xml(self._get_skill_description(skill_name))}</description>",
f" <location>{entry['path']}</location>",
]
)
if not available:
desc = self._get_skill_description(skill_name)
if available:
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
else:
missing = self._get_missing_requirements(meta)
if missing:
lines.append(f" <requires>{_escape_xml(missing)}</requires>")
lines.append(" </skill>")
lines.append("</skills>")
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
return "\n".join(lines)
def _get_missing_requirements(self, skill_meta: dict) -> str:
@@ -171,11 +167,19 @@ class SkillsLoader:
return content[match.end():].strip()
return content
def _parse_nanobot_metadata(self, raw: str) -> dict:
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
def _parse_nanobot_metadata(self, raw: object) -> dict:
"""Extract nanobot/openclaw metadata from a frontmatter field.
``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str.
"""
if isinstance(raw, dict):
data = raw
elif isinstance(raw, str):
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
else:
return {}
if not isinstance(data, dict):
return {}
@@ -193,8 +197,8 @@ class SkillsLoader:
def _get_skill_meta(self, name: str) -> dict:
"""Get nanobot metadata for a skill (cached in frontmatter)."""
meta = self.get_skill_metadata(name) or {}
return self._parse_nanobot_metadata(meta.get("metadata", ""))
raw_meta = self.get_skill_metadata(name) or {}
return self._parse_nanobot_metadata(raw_meta.get("metadata"))
def get_always_skills(self) -> list[str]:
"""Get skills marked as always=true that meet requirements."""
@@ -203,7 +207,7 @@ class SkillsLoader:
for entry in self.list_skills(filter_unavailable=True)
if (meta := self.get_skill_metadata(entry["name"]) or {})
and (
self._parse_nanobot_metadata(meta.get("metadata", "")).get("always")
self._parse_nanobot_metadata(meta.get("metadata")).get("always")
or meta.get("always")
)
]
@@ -224,10 +228,15 @@ class SkillsLoader:
match = _STRIP_SKILL_FRONTMATTER.match(content)
if not match:
return None
metadata: dict[str, str] = {}
for line in match.group(1).splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip().strip('"\'')
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return None
if not isinstance(parsed, dict):
return None
# yaml.safe_load returns native types (int, bool, list, etc.);
# keep values as-is so downstream consumers get correct types.
metadata: dict[str, object] = {}
for key, value in parsed.items():
metadata[str(key)] = value
return metadata
+84 -34
View File
@@ -2,7 +2,9 @@
import asyncio
import json
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -23,12 +25,29 @@ from nanobot.config.schema import ExecToolConfig, WebToolsConfig
from nanobot.providers.base import LLMProvider
class _SubagentHook(AgentHook):
"""Logging-only hook for subagent execution."""
@dataclass(slots=True)
class SubagentStatus:
"""Real-time status of a running subagent."""
def __init__(self, task_id: str) -> None:
task_id: str
label: str
task_description: str
started_at: float # time.monotonic()
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
iteration: int = 0
tool_events: list = field(default_factory=list) # [{name, status, detail}, ...]
usage: dict = field(default_factory=dict) # token usage
stop_reason: str | None = None
error: str | None = None
class _SubagentHook(AgentHook):
"""Hook for subagent execution — logs tool calls and updates status."""
def __init__(self, task_id: str, status: SubagentStatus | None = None) -> None:
super().__init__()
self._task_id = task_id
self._status = status
async def before_execute_tools(self, context: AgentHookContext) -> None:
for tool_call in context.tool_calls:
@@ -38,6 +57,15 @@ class _SubagentHook(AgentHook):
self._task_id, tool_call.name, args_str,
)
async def after_iteration(self, context: AgentHookContext) -> None:
if self._status is None:
return
self._status.iteration = context.iteration
self._status.tool_events = list(context.tool_events)
self._status.usage = dict(context.usage)
if context.error:
self._status.error = str(context.error)
class SubagentManager:
"""Manages background subagent execution."""
@@ -54,8 +82,6 @@ class SubagentManager:
restrict_to_workspace: bool = False,
disabled_skills: list[str] | None = None,
):
from nanobot.config.schema import ExecToolConfig
self.provider = provider
self.workspace = workspace
self.bus = bus
@@ -67,6 +93,7 @@ class SubagentManager:
self.disabled_skills = set(disabled_skills or [])
self.runner = AgentRunner(provider)
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
async def spawn(
@@ -80,10 +107,18 @@ class SubagentManager:
"""Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8]
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(
task_id=task_id,
label=display_label,
task_description=task,
started_at=time.monotonic(),
)
self._task_statuses[task_id] = status
bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin)
self._run_subagent(task_id, task, display_label, origin, status)
)
self._running_tasks[task_id] = bg_task
if session_key:
@@ -91,6 +126,7 @@ class SubagentManager:
def _cleanup(_: asyncio.Task) -> None:
self._running_tasks.pop(task_id, None)
self._task_statuses.pop(task_id, None)
if session_key and (ids := self._session_tasks.get(session_key)):
ids.discard(task_id)
if not ids:
@@ -107,10 +143,15 @@ class SubagentManager:
task: str,
label: str,
origin: dict[str, str],
status: SubagentStatus,
) -> None:
"""Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label)
async def _on_checkpoint(payload: dict) -> None:
status.phase = payload.get("phase", status.phase)
status.iteration = payload.get("iteration", status.iteration)
try:
# Build subagent tools (no message tool, no spawn tool)
tools = ToolRegistry()
@@ -129,6 +170,7 @@ class SubagentManager:
restrict_to_workspace=self.restrict_to_workspace,
sandbox=self.exec_config.sandbox,
path_append=self.exec_config.path_append,
allowed_env_keys=self.exec_config.allowed_env_keys,
))
if self.web_config.enable:
tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy))
@@ -145,40 +187,38 @@ class SubagentManager:
model=self.model,
max_iterations=15,
max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id),
hook=_SubagentHook(task_id, status),
max_iterations_message="Task completed but no final response was generated.",
error_message=None,
fail_on_tool_error=True,
checkpoint_callback=_on_checkpoint,
))
if result.stop_reason == "tool_error":
await self._announce_result(
task_id,
label,
task,
self._format_partial_progress(result),
origin,
"error",
)
return
if result.stop_reason == "error":
await self._announce_result(
task_id,
label,
task,
result.error or "Error: subagent execution failed.",
origin,
"error",
)
return
final_result = result.final_content or "Task completed but no final response was generated."
status.phase = "done"
status.stop_reason = result.stop_reason
logger.info("Subagent [{}] completed successfully", task_id)
await self._announce_result(task_id, label, task, final_result, origin, "ok")
if result.stop_reason == "tool_error":
status.tool_events = list(result.tool_events)
await self._announce_result(
task_id, label, task,
self._format_partial_progress(result),
origin, "error",
)
elif result.stop_reason == "error":
await self._announce_result(
task_id, label, task,
result.error or "Error: subagent execution failed.",
origin, "error",
)
else:
final_result = result.final_content or "Task completed but no final response was generated."
logger.info("Subagent [{}] completed successfully", task_id)
await self._announce_result(task_id, label, task, final_result, origin, "ok")
except Exception as e:
error_msg = f"Error: {str(e)}"
status.phase = "error"
status.error = str(e)
logger.error("Subagent [{}] failed: {}", task_id, e)
await self._announce_result(task_id, label, task, error_msg, origin, "error")
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error")
async def _announce_result(
self,
@@ -200,12 +240,22 @@ class SubagentManager:
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(
channel="system",
sender_id="subagent",
chat_id=f"{origin['channel']}:{origin['chat_id']}",
content=announce_content,
session_key_override=override,
metadata={
"injected_event": "subagent_result",
"subagent_task_id": task_id,
},
)
await self.bus.publish_inbound(msg)
+66 -38
View File
@@ -5,54 +5,67 @@ from datetime import datetime
from typing import Any
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.types import CronJob, CronJobState, CronSchedule
@tool_parameters(
tool_parameters_schema(
action=StringSchema("Action to perform", enum=["add", "list", "remove"]),
name=StringSchema(
"Optional short human-readable label for the job "
"(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message."
),
message=StringSchema(
"Instruction for the agent to execute when the job triggers "
"(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)"),
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
tz=StringSchema(
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
"When omitted with cron_expr, the tool's default timezone applies."
),
at=StringSchema(
"ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
"Naive values use the tool's default timezone."
),
deliver=BooleanSchema(
description="Whether to deliver the execution result to the user channel (default true)",
default=True,
),
job_id=StringSchema("Job ID (for remove)"),
required=["action"],
)
_CRON_PARAMETERS = tool_parameters_schema(
action=StringSchema("Action to perform", enum=["add", "list", "remove"]),
name=StringSchema(
"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 "
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
"Not used for action='list' or action='remove'."
),
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
tz=StringSchema(
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
"When omitted with cron_expr, the tool's default timezone applies."
),
at=StringSchema(
"ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
"Naive values use the tool's default timezone."
),
deliver=BooleanSchema(
description="Whether to deliver the execution result to the user channel (default true)",
default=True,
),
job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
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):
"""Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
self._cron = cron_service
self._default_timezone = default_timezone
self._channel = ""
self._chat_id = ""
self._channel: ContextVar[str] = ContextVar("cron_channel", default="")
self._chat_id: ContextVar[str] = ContextVar("cron_chat_id", default="")
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
def set_context(self, channel: str, chat_id: str) -> None:
"""Set the current session context for delivery."""
self._channel = channel
self._chat_id = chat_id
self._channel.set(channel)
self._chat_id.set(chat_id)
def set_cron_context(self, active: bool):
"""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}."
)
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(
self,
action: str,
@@ -128,8 +150,14 @@ class CronTool(Tool):
deliver: bool = True,
) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return (
"Error: cron action='add' requires a non-empty 'message' parameter "
"describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"."
)
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)"
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
@@ -168,8 +196,8 @@ class CronTool(Tool):
schedule=schedule,
message=message,
deliver=deliver,
channel=self._channel,
to=self._chat_id,
channel=channel,
to=chat_id,
delete_after_run=delete_after,
)
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
return None
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
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())
entry = _state.get(p)
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)
except OSError:
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:
+82 -6
View File
@@ -2,6 +2,7 @@
import difflib
import mimetypes
import os
from dataclasses import dataclass
from pathlib import Path
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."""
import re
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
if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw):
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
@@ -123,10 +137,11 @@ class ReadFileTool(_FsTool):
@property
def description(self) -> str:
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. "
"Use offset and limit for large files. "
"Cannot read non-image binary files. "
"Supports PDF, DOCX, XLSX, PPTX documents. "
"Use offset and limit for large text files. "
"Reads exceeding ~128K chars are truncated."
)
@@ -155,6 +170,10 @@ class ReadFileTool(_FsTool):
if fp.suffix.lower() == ".pdf":
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()
if not raw:
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})")
# Read dedup: same path + offset + limit + unchanged mtime → stub
if file_state.is_unchanged(fp, offset=offset, limit=limit):
return f"[File unchanged since last read: {path}]"
# Always check for external modifications before dedup
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:
text_content = raw.decode("utf-8")
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."
# 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()
total = len(all_lines)
@@ -252,6 +309,25 @@ class ReadFileTool(_FsTool):
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
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
+190 -109
View File
@@ -10,6 +10,25 @@ from loguru import logger
from nanobot.agent.tools.base import Tool
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:
"""Return the single non-null branch for nullable unions."""
@@ -99,38 +118,61 @@ class MCPToolWrapper(Tool):
async def execute(self, **kwargs: Any) -> str:
from mcp import types
try:
result = await asyncio.wait_for(
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)
return f"(MCP tool call timed out after {self._tool_timeout}s)"
except asyncio.CancelledError:
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
# Re-raise only if our task was externally cancelled (e.g. /stop).
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
return "(MCP tool call was cancelled)"
except Exception as exc:
logger.exception(
"MCP tool '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP tool call failed: {type(exc).__name__})"
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
for attempt in range(2): # At most 1 retry
try:
result = await asyncio.wait_for(
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
)
return f"(MCP tool call timed out after {self._tool_timeout}s)"
except asyncio.CancelledError:
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
# Re-raise only if our task was externally cancelled (e.g. /stop).
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
return "(MCP tool call was cancelled)"
except Exception as exc:
if _is_transient(exc):
if attempt == 0:
logger.warning(
"MCP tool '{}' hit transient error ({}), retrying once...",
self._name,
type(exc).__name__,
)
await asyncio.sleep(1) # Brief backoff before retry
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:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
# Success — extract result
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
class MCPResourceWrapper(Tool):
@@ -168,40 +210,59 @@ class MCPResourceWrapper(Tool):
async def execute(self, **kwargs: Any) -> str:
from mcp import types
try:
result = await asyncio.wait_for(
self._session.read_resource(self._uri),
timeout=self._resource_timeout,
)
except asyncio.TimeoutError:
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:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
return "(MCP resource read was cancelled)"
except Exception as exc:
logger.exception(
"MCP resource '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP resource read failed: {type(exc).__name__})"
parts: list[str] = []
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]")
for attempt in range(2):
try:
result = await asyncio.wait_for(
self._session.read_resource(self._uri),
timeout=self._resource_timeout,
)
except asyncio.TimeoutError:
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:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
return "(MCP resource read was cancelled)"
except Exception as exc:
if _is_transient(exc):
if attempt == 0:
logger.warning(
"MCP resource '{}' hit transient error ({}), retrying once...",
self._name,
type(exc).__name__,
)
await asyncio.sleep(1)
continue
logger.error(
"MCP resource '{}' failed after retry: {}: {}",
self._name,
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:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
parts: list[str] = []
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):
@@ -254,52 +315,72 @@ class MCPPromptWrapper(Tool):
from mcp import types
from mcp.shared.exceptions import McpError
try:
result = await asyncio.wait_for(
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)
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
except McpError as exc:
logger.error(
"MCP prompt '{}' failed: code={} message={}",
self._name,
exc.error.code,
exc.error.message,
)
return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])"
except Exception as exc:
logger.exception(
"MCP prompt '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP prompt call failed: {type(exc).__name__})"
parts: list[str] = []
for message in result.messages:
content = message.content
# content is a single ContentBlock (not a list) in MCP SDK >= 1.x
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))
for attempt in range(2):
try:
result = await asyncio.wait_for(
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
)
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
except McpError as exc:
logger.error(
"MCP prompt '{}' failed: code={} message={}",
self._name,
exc.error.code,
exc.error.message,
)
return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])"
except Exception as exc:
if _is_transient(exc):
if attempt == 0:
logger.warning(
"MCP prompt '{}' hit transient error ({}), retrying once...",
self._name,
type(exc).__name__,
)
await asyncio.sleep(1)
continue
logger.error(
"MCP prompt '{}' failed after retry: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP prompt call failed after retry: {type(exc).__name__})"
logger.exception(
"MCP prompt '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP prompt call failed: {type(exc).__name__})"
else:
parts.append(str(content))
return "\n".join(parts) or "(no output)"
parts: list[str] = []
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(
+28 -13
View File
@@ -1,5 +1,6 @@
"""Message tool for sending messages to users."""
from contextvars import ContextVar
from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool, tool_parameters
@@ -30,16 +31,19 @@ class MessageTool(Tool):
default_message_id: str | None = None,
):
self._send_callback = send_callback
self._default_channel = default_channel
self._default_chat_id = default_chat_id
self._default_message_id = default_message_id
self._sent_in_turn: bool = False
self._default_channel: ContextVar[str] = ContextVar("message_default_channel", default=default_channel)
self._default_chat_id: ContextVar[str] = ContextVar("message_default_chat_id", default=default_chat_id)
self._default_message_id: ContextVar[str | None] = ContextVar(
"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:
"""Set the current message context."""
self._default_channel = channel
self._default_chat_id = chat_id
self._default_message_id = message_id
self._default_channel.set(channel)
self._default_chat_id.set(chat_id)
self._default_message_id.set(message_id)
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages."""
@@ -49,6 +53,14 @@ class MessageTool(Tool):
"""Reset per-turn send tracking."""
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
def name(self) -> str:
return "message"
@@ -73,16 +85,19 @@ class MessageTool(Tool):
) -> str:
from nanobot.utils.helpers import strip_think
content = strip_think(content)
channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id
default_channel = self._default_channel.get()
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.
# Cross-chat sends must not carry the original message_id, because
# some channels (e.g. Feishu) use it to determine the target
# conversation via their Reply API, which would route the message
# to the wrong chat entirely.
if channel == self._default_channel and chat_id == self._default_chat_id:
message_id = message_id or self._default_message_id
if channel == default_channel and chat_id == default_chat_id:
message_id = message_id or self._default_message_id.get()
else:
message_id = None
@@ -104,7 +119,7 @@ class MessageTool(Tool):
try:
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
media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}"
+10 -2
View File
@@ -14,14 +14,17 @@ class ToolRegistry:
def __init__(self):
self._tools: dict[str, Tool] = {}
self._cached_definitions: list[dict[str, Any]] | None = None
def register(self, tool: Tool) -> None:
"""Register a tool."""
self._tools[tool.name] = tool
self._cached_definitions = None
def unregister(self, name: str) -> None:
"""Unregister a tool by name."""
self._tools.pop(name, None)
self._cached_definitions = None
def get(self, name: str) -> Tool | None:
"""Get a tool by name."""
@@ -46,8 +49,12 @@ class ToolRegistry:
"""Get tool definitions with stable ordering for cache-friendly prompts.
Built-in tools are sorted first as a stable prefix, then MCP tools are
sorted and appended.
sorted and appended. The result is cached until the next
register/unregister call.
"""
if self._cached_definitions is not None:
return self._cached_definitions
definitions = [tool.to_schema() for tool in self._tools.values()]
builtins: list[dict[str, Any]] = []
mcp_tools: list[dict[str, Any]] = []
@@ -60,7 +67,8 @@ class ToolRegistry:
builtins.sort(key=self._schema_name)
mcp_tools.sort(key=self._schema_name)
return builtins + mcp_tools
self._cached_definitions = builtins + mcp_tools
return self._cached_definitions
def prepare_call(
self,
+449
View File
@@ -0,0 +1,449 @@
"""MyTool: runtime state inspection and configuration for the agent loop."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.base import Tool
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
def _has_real_attr(obj: Any, key: str) -> bool:
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
if isinstance(obj, dict):
return key in obj
d = getattr(obj, "__dict__", None)
if d is not None and key in d:
return True
for cls in type(obj).__mro__:
if key in cls.__dict__:
return True
return False
class MyTool(Tool):
"""Check and set the agent loop's runtime configuration."""
BLOCKED = frozenset({
# Core infrastructure
"bus", "provider", "_running", "tools",
# Config management
"_runtime_vars",
# Subsystems
"runner", "sessions", "consolidator",
"dream", "auto_compact", "context", "commands",
# Sensitive runtime state (credentials, message routing, task tracking)
"_mcp_servers", "_mcp_stacks", "_pending_queues",
"_session_locks", "_active_tasks", "_background_tasks",
# Security boundaries (inspect + modify both blocked)
"restrict_to_workspace", "channels_config",
"_concurrency_gate", "_unified_session", "_extra_hooks",
})
READ_ONLY = frozenset({
"subagents", # observable but replacing it would break the system
"_current_iteration", # updated by runner only
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked
})
_DENIED_ATTRS = frozenset({
"__class__", "__dict__", "__bases__", "__subclasses__", "__mro__",
"__init__", "__new__", "__reduce__", "__getstate__", "__setstate__",
"__del__", "__call__", "__getattr__", "__setattr__", "__delattr__",
"__code__", "__globals__", "func_globals", "func_code",
"__wrapped__", "__closure__",
})
# Sub-field names that are sensitive regardless of parent path
_SENSITIVE_NAMES = frozenset({
"api_key", "secret", "password", "token", "credential",
"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]] = {
"max_iterations": {"type": int, "min": 1, "max": 100},
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
"model": {"type": str, "min_len": 1},
}
_MAX_RUNTIME_KEYS = 64
def __init__(self, loop: AgentLoop, modify_allowed: bool = True) -> None:
self._loop = loop
self._modify_allowed = modify_allowed
self._channel = ""
self._chat_id = ""
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
result._loop = self._loop
result._modify_allowed = self._modify_allowed
result._channel = self._channel
result._chat_id = self._chat_id
return result
def set_context(self, channel: str, chat_id: str) -> None:
self._channel = channel
self._chat_id = chat_id
@property
def name(self) -> str:
return "my"
@property
def description(self) -> str:
base = (
"Check and set your own runtime state.\n"
"Actions: check, set.\n"
"- check (no key): full config overview — start here.\n"
"- check (key): drill into a value. Dot-paths allowed "
"(e.g. '_last_usage.prompt_tokens', 'web_config.enable').\n"
"- set (key, value): change config or store notes in your scratchpad. "
"Scratchpad keys persist across turns but not restarts.\n"
"Key values: _current_iteration (current progress), "
"max_iterations - _current_iteration = remaining iterations.\n"
"Note: web_config and exec_config are readable but read-only.\n"
"\n"
"When to use:\n"
"- User asks about your model, settings, or token usage → check that key.\n"
"- A tool fails or behaves unexpectedly → check the related config to diagnose.\n"
"- User asks you to remember a preference for this session → set to store it in your scratchpad.\n"
"- About to start a large task → check context_window_tokens and max_iterations first."
)
if not self._modify_allowed:
base += "\nREAD-ONLY MODE: set is disabled."
else:
base += (
"\nIMPORTANT: Before setting state, predict the potential impact. "
"If the operation could cause crashes or instability "
"(e.g. changing model), warn the user first."
)
return base
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["check", "set"],
"description": "Action to perform",
},
"key": {
"type": "string",
"description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. "
"For check without key, shows all config values.",
},
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."},
},
"required": ["action"],
}
def _audit(self, action: str, detail: str) -> None:
session = f"{self._channel}:{self._chat_id}" if self._channel else "unknown"
logger.info("self.{} | {} | session:{}", action, detail, session)
# ------------------------------------------------------------------
# Path resolution
# ------------------------------------------------------------------
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
parts = path.split(".")
obj = self._loop
for part in parts:
if part in self._DENIED_ATTRS or part.startswith("__"):
return None, f"'{part}' is not accessible"
if part in self.BLOCKED:
return None, f"'{part}' is not accessible"
if part.lower() in self._SENSITIVE_NAMES:
return None, f"'{part}' is not accessible"
try:
if isinstance(obj, dict):
if part in obj:
obj = obj[part]
else:
return None, f"'{part}' not found in dict"
else:
obj = getattr(obj, part)
except (KeyError, AttributeError) as e:
return None, f"'{part}' not found: {e}"
return obj, None
@staticmethod
def _validate_key(key: str | None, label: str = "key") -> str | None:
if not key or not key.strip():
return f"Error: '{label}' cannot be empty or whitespace"
return None
# ------------------------------------------------------------------
# Smart formatting
# ------------------------------------------------------------------
@staticmethod
def _format_status(st: SubagentStatus, indent: str = " ") -> str:
elapsed = time.monotonic() - st.started_at
tool_summary = ", ".join(
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
) or "none"
lines = [
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
f"{indent}tools: {tool_summary}",
f"{indent}usage: {st.usage or 'n/a'}",
]
if st.error:
lines.append(f"{indent}error: {st.error}")
if st.stop_reason:
lines.append(f"{indent}stop_reason: {st.stop_reason}")
return "\n".join(lines)
@staticmethod
def _format_value(val: Any, key: str = "") -> str:
if isinstance(val, SubagentStatus):
header = f"Subagent [{val.task_id}] '{val.label}'"
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
return MyTool._format_value(val._task_statuses, key)
if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus):
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items():
detail = MyTool._format_status(st, " ")
lines.append(f" [{tid}] '{st.label}'\n{detail}")
return "\n".join(lines)
if hasattr(val, "tool_names"):
return f"tools: {len(val.tool_names)} registered — {val.tool_names}"
# Scalar types — repr is fine
if isinstance(val, (str, int, float, bool, type(None))):
r = repr(val)
return f"{key}: {r}" if key else r
# Dict — small: show content; large: show keys for dot-path navigation
if isinstance(val, dict):
ks = list(val.keys())
if not ks:
return f"{key}: {{}}" if key else "{}"
if len(ks) <= 5:
r = repr(val)
if len(r) <= 200:
return f"{key}: {r}" if key else r
preview = ", ".join(str(k) for k in ks[:15])
suffix = ", ..." if len(ks) > 15 else ""
return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}"
# List/tuple — count for large, repr for small
if isinstance(val, (list, tuple)):
if len(val) > 20:
return f"{key}: [{len(val)} items]" if key else f"[{len(val)} items]"
r = repr(val)
return f"{key}: {r}" if key else r
# Complex object — small Pydantic models: show values; others: show field names for navigation
cls_name = type(val).__name__
model_fields = getattr(type(val), "model_fields", None)
if model_fields:
fields = list(model_fields.keys())
if len(fields) <= 8:
# Small config objects: show field=value pairs
pairs = []
for f in fields:
fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f):
continue
if isinstance(fv, (str, int, float, bool, type(None))):
pairs.append(f"{f}={fv!r}")
else:
pairs.append(f"{f}=<{type(fv).__name__}>")
preview = ", ".join(pairs)
return f"{key}: {preview}" if key else preview
else:
fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")]
if fields:
preview = ", ".join(str(f) for f in fields[:20])
suffix = ", ..." if len(fields) > 20 else ""
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
r = repr(val)
return f"{key}: {r}" if key else r
# ------------------------------------------------------------------
# Action dispatch
# ------------------------------------------------------------------
async def execute(
self,
action: str,
key: str | None = None,
value: Any = None,
**_kwargs: Any,
) -> str:
if action in ("inspect", "check"):
return self._inspect(key)
if not self._modify_allowed:
return "Error: set is disabled (tools.my.allow_set is false)"
if action in ("modify", "set"):
return self._modify(key, value)
return f"Unknown action: {action}"
# -- inspect --
def _inspect(self, key: str | None) -> str:
if not key:
return self._inspect_all()
top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"):
return f"Error: '{top}' is not accessible"
obj, err = self._resolve_path(key)
if err:
# "scratchpad" alias for _runtime_vars
if key == "scratchpad":
rv = self._loop._runtime_vars
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
# Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._loop._runtime_vars:
return self._format_value(self._loop._runtime_vars[key], key)
return f"Error: {err}"
# Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._loop, key):
if key in self._loop._runtime_vars:
return self._format_value(self._loop._runtime_vars[key], key)
return f"Error: '{key}' not found"
return self._format_value(obj, key)
def _inspect_all(self) -> str:
loop = self._loop
parts: list[str] = []
# RESTRICTED keys
for k in self.RESTRICTED:
parts.append(self._format_value(getattr(loop, k, None), k))
# Other useful top-level keys shown in description
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
if _has_real_attr(loop, k):
parts.append(self._format_value(getattr(loop, k, None), k))
# Token usage
usage = loop._last_usage
if usage:
parts.append(self._format_value(usage, "_last_usage"))
rv = loop._runtime_vars
if rv:
parts.append(self._format_value(rv, "scratchpad"))
return "\n".join(parts)
# -- modify --
def _modify(self, key: str | None, value: Any) -> str:
if err := self._validate_key(key):
return err
top = key.split(".")[0]
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED {key}")
return f"Error: '{key}' is protected and cannot be modified"
if top in self.READ_ONLY:
self._audit("modify", f"READ_ONLY {key}")
return f"Error: '{key}' is read-only and cannot be modified"
if "." in key:
parent_path, leaf = key.rsplit(".", 1)
if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
self._audit("modify", f"BLOCKED leaf '{leaf}'")
return f"Error: '{leaf}' is not accessible"
if leaf.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
return f"Error: '{leaf}' is not accessible"
parent, err = self._resolve_path(parent_path)
if err:
return f"Error: {err}"
if isinstance(parent, dict):
parent[leaf] = value
else:
setattr(parent, leaf, value)
self._audit("modify", f"{key} = {value!r}")
return f"Set {key} = {value!r}"
if key in self.RESTRICTED:
return self._modify_restricted(key, value)
return self._modify_free(key, value)
def _modify_restricted(self, key: str, value: Any) -> str:
spec = self.RESTRICTED[key]
expected = spec["type"]
if expected is int and isinstance(value, bool):
return f"Error: '{key}' must be {expected.__name__}, got bool"
if not isinstance(value, expected):
try:
value = expected(value)
except (ValueError, TypeError):
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
old = getattr(self._loop, key)
if "min" in spec and value < spec["min"]:
return f"Error: '{key}' must be >= {spec['min']}"
if "max" in spec and value > spec["max"]:
return f"Error: '{key}' must be <= {spec['max']}"
if "min_len" in spec and len(str(value)) < spec["min_len"]:
return f"Error: '{key}' must be at least {spec['min_len']} characters"
setattr(self._loop, key, value)
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_free(self, key: str, value: Any) -> str:
if _has_real_attr(self._loop, key):
old = getattr(self._loop, key)
if isinstance(old, (str, int, float, bool)):
old_t, new_t = type(old), type(value)
if old_t is float and new_t is int:
pass # int → float coercion allowed
elif old_t is not new_t:
self._audit(
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
setattr(self._loop, key, value)
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
if callable(value):
self._audit("modify", f"REJECTED callable {key}")
return "Error: cannot store callable values"
err = self._validate_json_safe(value)
if err:
self._audit("modify", f"REJECTED {key}: {err}")
return f"Error: {err}"
if key not in self._loop._runtime_vars and len(self._loop._runtime_vars) >= self._MAX_RUNTIME_KEYS:
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first."
old = self._loop._runtime_vars.get(key)
self._loop._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
return f"Set scratchpad.{key} = {value!r}"
@classmethod
def _validate_json_safe(cls, value: Any, depth: int = 0) -> str | None:
if depth > 10:
return "value nesting too deep (max 10 levels)"
if isinstance(value, (str, int, float, bool, type(None))):
return None
if isinstance(value, list):
for i, item in enumerate(value):
if err := cls._validate_json_safe(item, depth + 1):
return f"list[{i}] contains {err}"
return None
if isinstance(value, dict):
for k, v in value.items():
if not isinstance(k, str):
return f"dict key must be str, got {type(k).__name__}"
if err := cls._validate_json_safe(v, depth + 1):
return f"dict key '{k}' contains {err}"
return None
return f"unsupported type {type(value).__name__}"
+11 -10
View File
@@ -1,5 +1,6 @@
"""Spawn tool for creating background subagents."""
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
@@ -21,15 +22,15 @@ class SpawnTool(Tool):
def __init__(self, manager: "SubagentManager"):
self._manager = manager
self._origin_channel = "cli"
self._origin_chat_id = "direct"
self._session_key = "cli:direct"
self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli")
self._origin_chat_id: ContextVar[str] = ContextVar("spawn_origin_chat_id", default="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."""
self._origin_channel = channel
self._origin_chat_id = chat_id
self._session_key = f"{channel}:{chat_id}"
self._origin_channel.set(channel)
self._origin_chat_id.set(chat_id)
self._session_key.set(effective_key or f"{channel}:{chat_id}")
@property
def name(self) -> str:
@@ -50,7 +51,7 @@ class SpawnTool(Tool):
return await self._manager.spawn(
task=task,
label=label,
origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id,
session_key=self._session_key,
origin_channel=self._origin_channel.get(),
origin_chat_id=self._origin_chat_id.get(),
session_key=self._session_key.get(),
)
+129 -26
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import base64
import json as _json
import mimetypes
import re
import time
@@ -29,6 +30,7 @@ _DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.DOTALL)
class _FileSizeExceeded(Exception):
"""Raised when an uploaded file exceeds the size limit."""
API_SESSION_KEY = "api:default"
API_CHAT_ID = "default"
@@ -37,6 +39,7 @@ API_CHAT_ID = "default"
# Response helpers
# ---------------------------------------------------------------------------
def _error_json(status: int, message: str, err_type: str = "invalid_request_error") -> web.Response:
return web.json_response(
{"error": {"message": message, "type": err_type, "code": status}},
@@ -69,11 +72,36 @@ def _response_text(value: Any) -> str:
return str(getattr(value, "content") or "")
return str(value)
# ---------------------------------------------------------------------------
# SSE helpers
# ---------------------------------------------------------------------------
def _sse_chunk(delta: str, model: str, chunk_id: str, finish_reason: str | None = None) -> bytes:
"""Format a single OpenAI-compatible SSE chunk."""
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"delta": {"content": delta} if delta else {},
"finish_reason": finish_reason,
}
],
}
return f"data: {_json.dumps(payload)}\n\n".encode()
_SSE_DONE = b"data: [DONE]\n\n"
# ---------------------------------------------------------------------------
# Upload helpers
# ---------------------------------------------------------------------------
def _save_base64_data_url(data_url: str, media_dir: Path) -> str | None:
"""Decode a data:...;base64,... URL and save to disk."""
m = _DATA_URL_RE.match(data_url)
@@ -85,9 +113,7 @@ def _save_base64_data_url(data_url: str, media_dir: Path) -> str | None:
except Exception:
return None
if len(raw) > MAX_FILE_SIZE:
raise _FileSizeExceeded(
f"File exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit"
)
raise _FileSizeExceeded(f"File exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit")
ext = mimetypes.guess_extension(mime_type) or ".bin"
filename = f"{uuid.uuid4().hex[:12]}{ext}"
dest = media_dir / safe_filename(filename)
@@ -121,6 +147,11 @@ def _parse_json_content(body: dict) -> tuple[str, list[str]]:
saved = _save_base64_data_url(url, media_dir)
if saved:
media_paths.append(saved)
elif url:
raise ValueError(
"Remote image URLs are not supported. "
"Use base64 data URLs or upload files via multipart/form-data."
)
text = " ".join(text_parts)
elif isinstance(user_content, str):
text = user_content
@@ -130,12 +161,13 @@ def _parse_json_content(body: dict) -> tuple[str, list[str]]:
return text, media_paths
async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str | None]:
"""Parse multipart/form-data. Returns (text, media_paths, session_id)."""
async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str | None, str | None]:
"""Parse multipart/form-data. Returns (text, media_paths, session_id, model)."""
media_dir = get_media_dir("api")
reader = await request.multipart()
text = ""
session_id = None
model = None
media_paths: list[str] = []
while True:
@@ -146,11 +178,16 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
text = (await part.read()).decode("utf-8")
elif part.name == "session_id":
session_id = (await part.read()).decode("utf-8").strip()
elif part.name == "model":
model = (await part.read()).decode("utf-8").strip()
elif part.name == "files":
raw = await part.read()
if len(raw) > MAX_FILE_SIZE:
raise _FileSizeExceeded(f"File '{part.filename}' exceeds {MAX_FILE_SIZE // (1024*1024)}MB limit")
filename = safe_filename(part.filename or f"{uuid.uuid4().hex[:12]}.bin")
raise _FileSizeExceeded(
f"File '{part.filename}' exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit"
)
base = safe_filename(part.filename or "upload.bin")
filename = f"{uuid.uuid4().hex[:12]}_{base}"
dest = media_dir / filename
dest.write_bytes(raw)
media_paths.append(str(dest))
@@ -158,13 +195,14 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
if not text:
text = "请分析上传的文件"
return text, media_paths, session_id
return text, media_paths, session_id, model
# ---------------------------------------------------------------------------
# Route handlers
# ---------------------------------------------------------------------------
async def handle_chat_completions(request: web.Request) -> web.Response:
"""POST /v1/chat/completions — supports JSON and multipart/form-data."""
content_type = request.content_type or ""
@@ -175,18 +213,17 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
timeout_s: float = request.app.get("request_timeout", 120.0)
model_name: str = request.app.get("model_name", "nanobot")
stream = False
try:
if content_type.startswith("multipart/"):
text, media_paths, session_id = await _parse_multipart(request)
text, media_paths, session_id, requested_model = await _parse_multipart(request)
else:
try:
body = await request.json()
except Exception:
return _error_json(400, "Invalid JSON body")
if body.get("stream", False):
return _error_json(400, "stream=true is not supported yet. Set stream=false or omit it.")
if (requested_model := body.get("model")) and requested_model != model_name:
return _error_json(400, f"Only configured model '{model_name}' is available")
stream = body.get("stream", False)
requested_model = body.get("model")
text, media_paths = _parse_json_content(body)
session_id = body.get("session_id")
except ValueError as e:
@@ -197,12 +234,73 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
logger.exception("Error parsing upload")
return _error_json(413, "File too large or invalid upload")
if requested_model and requested_model != model_name:
return _error_json(400, f"Only configured model '{model_name}' is available")
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
logger.info("API request session_key={} media={} text={}", session_key, len(media_paths), text[:80])
logger.info(
"API request session_key={} media={} text={} stream={}",
session_key, len(media_paths), text[:80], stream,
)
# -- streaming path --
if stream:
resp = web.StreamResponse()
resp.content_type = "text/event-stream"
resp.headers["Cache-Control"] = "no-cache"
resp.headers["Connection"] = "keep-alive"
resp.enable_compression()
await resp.prepare(request)
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
queue: asyncio.Queue[str | None] = asyncio.Queue()
stream_failed = False
async def _on_stream(token: str) -> None:
await queue.put(token)
async def _on_stream_end(*_a: Any, **_kw: Any) -> None:
await queue.put(None)
async def _run() -> None:
nonlocal stream_failed
try:
async with session_lock:
await asyncio.wait_for(
agent_loop.process_direct(
content=text,
media=media_paths if media_paths else None,
session_key=session_key,
channel="api",
chat_id=API_CHAT_ID,
on_stream=_on_stream,
on_stream_end=_on_stream_end,
),
timeout=timeout_s,
)
except Exception:
stream_failed = True
logger.exception("Streaming error for session {}", session_key)
await queue.put(None)
task = asyncio.create_task(_run())
try:
while True:
token = await queue.get()
if token is None:
break
await resp.write(_sse_chunk(token, model_name, chunk_id))
finally:
task.cancel()
if not stream_failed:
await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop"))
await resp.write(_SSE_DONE)
return resp
# -- non-streaming path (original logic) --
_FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE
try:
@@ -252,17 +350,19 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
async def handle_models(request: web.Request) -> web.Response:
"""GET /v1/models"""
model_name = request.app.get("model_name", "nanobot")
return web.json_response({
"object": "list",
"data": [
{
"id": model_name,
"object": "model",
"created": 0,
"owned_by": "nanobot",
}
],
})
return web.json_response(
{
"object": "list",
"data": [
{
"id": model_name,
"object": "model",
"created": 0,
"owned_by": "nanobot",
}
],
}
)
async def handle_health(request: web.Request) -> web.Response:
@@ -274,7 +374,10 @@ async def handle_health(request: web.Request) -> web.Response:
# App factory
# ---------------------------------------------------------------------------
def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0) -> web.Application:
def create_app(
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0
) -> web.Application:
"""Create the aiohttp application.
Args:
+12 -2
View File
@@ -24,6 +24,8 @@ class BaseChannel(ABC):
display_name: str = "Base"
transcription_provider: str = "groq"
transcription_api_key: str = ""
transcription_api_base: str = ""
transcription_language: str | None = None
def __init__(self, config: Any, bus: MessageBus):
"""
@@ -44,10 +46,18 @@ class BaseChannel(ABC):
try:
if self.transcription_provider == "openai":
from nanobot.providers.transcription import OpenAITranscriptionProvider
provider = OpenAITranscriptionProvider(api_key=self.transcription_api_key)
provider = OpenAITranscriptionProvider(
api_key=self.transcription_api_key,
api_base=self.transcription_api_base or None,
language=self.transcription_language or None,
)
else:
from nanobot.providers.transcription import GroqTranscriptionProvider
provider = GroqTranscriptionProvider(api_key=self.transcription_api_key)
provider = GroqTranscriptionProvider(
api_key=self.transcription_api_key,
api_base=self.transcription_api_base or None,
language=self.transcription_language or None,
)
return await provider.transcribe(file_path)
except Exception as e:
logger.warning("{}: audio transcription failed: {}", self.name, e)
+17 -4
View File
@@ -53,6 +53,7 @@ class DiscordConfig(Base):
enabled: bool = False
token: str = ""
allow_from: list[str] = Field(default_factory=list)
allow_channels: list[str] = Field(default_factory=list) # Allowed channel IDs (empty = all)
intents: int = 37377
group_policy: Literal["mention", "open"] = "mention"
read_receipt_emoji: str = "👀"
@@ -134,7 +135,7 @@ if DISCORD_AVAILABLE:
def _register_app_commands(self) -> None:
commands = (
("new", "Start a new conversation", "/new"),
("new", "Stop current task and start a new conversation", "/new"),
("stop", "Stop the current task", "/stop"),
("restart", "Restart the bot", "/restart"),
("status", "Show bot status", "/status"),
@@ -432,8 +433,15 @@ class DiscordChannel(BaseChannel):
raise
async def _handle_discord_message(self, message: discord.Message) -> None:
"""Handle incoming Discord messages from discord.py."""
if message.author.bot:
"""Handle incoming Discord messages from discord.py.
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
sender_id = str(message.author.id)
@@ -450,7 +458,6 @@ class DiscordChannel(BaseChannel):
await self._start_typing(message.channel)
# Add read receipt reaction immediately, working emoji after delay
channel_id = self._channel_key(message.channel)
try:
await message.add_reaction(self.config.read_receipt_emoji)
self._pending_reactions[channel_id] = message
@@ -534,6 +541,12 @@ class DiscordChannel(BaseChannel):
"""Check if inbound Discord message should be processed."""
if not self.is_allowed(sender_id):
return False
# Channel-based filtering: only respond in allowed channels
allow_channels = self.config.allow_channels
if allow_channels:
channel_id = self._channel_key(message.channel)
if channel_id not in allow_channels:
return False
if message.guild is not None and not self._should_respond_in_group(message, content):
return False
return True
+54 -8
View File
@@ -118,6 +118,7 @@ class EmailChannel(BaseChannel):
config = EmailConfig.model_validate(config)
super().__init__(config, bus)
self.config: EmailConfig = config
self._self_addresses = self._collect_self_addresses()
self._last_subject_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
@@ -379,6 +380,12 @@ class EmailChannel(BaseChannel):
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
if not sender:
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 ---
spf_pass, dkim_pass = self._check_authentication_results(parsed)
@@ -388,6 +395,7 @@ class EmailChannel(BaseChannel):
"(no 'spf=pass' in Authentication-Results header)",
sender,
)
self._remember_processed_uid(uid, dedupe, cycle_uids)
continue
if self.config.verify_dkim and not dkim_pass:
logger.warning(
@@ -395,6 +403,7 @@ class EmailChannel(BaseChannel):
"(no 'dkim=pass' in Authentication-Results header)",
sender,
)
self._remember_processed_uid(uid, dedupe, cycle_uids)
continue
subject = self._decode_header_value(parsed.get("Subject", ""))
@@ -446,14 +455,7 @@ class EmailChannel(BaseChannel):
}
)
if uid:
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:])
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
@@ -463,6 +465,50 @@ class EmailChannel(BaseChannel):
except Exception:
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
def _is_stale_imap_error(cls, exc: Exception) -> bool:
message = str(exc).lower()
+147 -35
View File
@@ -308,6 +308,8 @@ class FeishuChannel(BaseChannel):
self._loop: asyncio.AbstractEventLoop | None = None
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
self._bot_open_id: str | None = None
self._background_tasks: set[asyncio.Task] = set()
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
@staticmethod
def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
@@ -549,8 +551,11 @@ class FeishuChannel(BaseChannel):
return None
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
"""
Add a reaction emoji to a message (non-blocking).
"""Add a reaction emoji to a message.
Returns the reaction_id on success, None on failure.
When called via a tracked background task, the returned reaction_id
is stored in ``_reaction_ids`` for later cleanup by ``send_delta``.
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
"""
@@ -594,6 +599,30 @@ class FeishuChannel(BaseChannel):
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
def _on_background_task_done(self, task: asyncio.Task) -> None:
"""Callback: remove from tracking set and log unhandled exceptions."""
self._background_tasks.discard(task)
if task.cancelled():
return
try:
task.result()
except Exception as exc:
logger.warning("Background task failed: {}", exc)
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
"""Callback: store reaction_id after background add-reaction completes."""
if task.cancelled():
return
try:
reaction_id = task.result()
if reaction_id:
self._reaction_ids[message_id] = reaction_id
except Exception:
pass # already logged by _on_background_task_done
# Trim cache to prevent unbounded growth
if len(self._reaction_ids) > 500:
self._reaction_ids.pop(next(iter(self._reaction_ids)))
# Regex to match markdown tables (header + separator + data rows)
_TABLE_RE = re.compile(
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
@@ -1101,17 +1130,23 @@ class FeishuChannel(BaseChannel):
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
return None
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool:
"""Reply to an existing Feishu message using the Reply API (synchronous)."""
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
"""Reply to an existing Feishu message using the Reply API (synchronous).
Args:
reply_in_thread: If True, reply as a thread/topic message
in the Feishu client.
"""
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
try:
body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content)
if reply_in_thread:
body_builder = body_builder.reply_in_thread(True)
request = (
ReplyMessageRequest.builder()
.message_id(parent_message_id)
.request_body(
ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()
)
.request_body(body_builder.build())
.build()
)
response = self._client.im.v1.message.reply(request)
@@ -1166,8 +1201,19 @@ class FeishuChannel(BaseChannel):
logger.error("Error sending Feishu {} message: {}", msg_type, e)
return None
def _create_streaming_card_sync(self, receive_id_type: str, chat_id: str) -> str | None:
"""Create a CardKit streaming card, send it to chat, return card_id."""
def _create_streaming_card_sync(
self,
receive_id_type: str,
chat_id: str,
reply_message_id: str | None = None,
) -> str | None:
"""Create a CardKit streaming card, send it to chat, return card_id.
When *reply_message_id* is provided the card is delivered via the
reply API (with reply_in_thread=True) so it lands inside the
originating thread / topic. Otherwise the plain create-message
API is used.
"""
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
card_json = {
@@ -1196,13 +1242,19 @@ class FeishuChannel(BaseChannel):
return None
card_id = getattr(response.data, "card_id", None)
if card_id:
message_id = self._send_message_sync(
receive_id_type,
chat_id,
"interactive",
json.dumps({"type": "card", "data": {"card_id": card_id}}),
card_content = json.dumps(
{"type": "card", "data": {"card_id": card_id}}, ensure_ascii=False
)
if message_id:
if reply_message_id:
sent = self._reply_message_sync(
reply_message_id, "interactive", card_content,
reply_in_thread=True,
)
else:
sent = self._send_message_sync(
receive_id_type, chat_id, "interactive", card_content,
) is not None
if sent:
return card_id
logger.warning(
"Created streaming card {} but failed to send it to {}", card_id, chat_id
@@ -1292,7 +1344,7 @@ class FeishuChannel(BaseChannel):
_stream_end: Finalize the streaming card.
_tool_hint: Delta is a formatted tool hint (for display only).
message_id: Original message id (used with _stream_end for reaction cleanup).
reaction_id: Reaction id to remove on stream end.
chat_type: "group" or "p2p" controls reply-in-thread for streaming cards.
"""
if not self._client:
return
@@ -1302,10 +1354,13 @@ class FeishuChannel(BaseChannel):
# --- stream end: final update or fallback ---
if meta.get("_stream_end"):
if (message_id := meta.get("message_id")) and (reaction_id := meta.get("reaction_id")):
await self._remove_reaction(message_id, reaction_id)
message_id = meta.get("message_id")
if message_id:
reaction_id = self._reaction_ids.pop(message_id, None)
if reaction_id:
await self._remove_reaction(message_id, reaction_id)
# Add completion emoji if configured
if self.config.done_emoji and message_id:
if self.config.done_emoji:
await self._add_reaction(message_id, self.config.done_emoji)
buf = self._stream_bufs.pop(chat_id, None)
@@ -1343,9 +1398,22 @@ class FeishuChannel(BaseChannel):
{"config": {"wide_screen_mode": True}, "elements": chunk},
ensure_ascii=False,
)
await loop.run_in_executor(
None, self._send_message_sync, rid_type, chat_id, "interactive", card
)
# Fallback: reply via the Reply API for group chats.
# Target message_id — the Feishu API keeps the reply in
# the same topic automatically.
_f_msg = meta.get("message_id")
fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None
if fallback_msg_id:
await loop.run_in_executor(
None, lambda: self._reply_message_sync(
fallback_msg_id, "interactive", card,
reply_in_thread=True,
),
)
else:
await loop.run_in_executor(
None, self._send_message_sync, rid_type, chat_id, "interactive", card
)
return
# --- accumulate delta ---
@@ -1359,8 +1427,16 @@ class FeishuChannel(BaseChannel):
now = time.monotonic()
if buf.card_id is None:
# Send the streaming card as a reply for group chats so it
# lands inside the originating topic/thread. Always target
# message_id (the actual inbound message) — the Feishu Reply
# API keeps the response in the same topic automatically.
is_group = meta.get("chat_type", "group") == "group"
reply_msg_id = meta.get("message_id") if is_group else None
card_id = await loop.run_in_executor(
None, self._create_streaming_card_sync, rid_type, chat_id
None,
self._create_streaming_card_sync,
rid_type, chat_id, reply_msg_id,
)
if card_id:
buf.card_id = card_id
@@ -1404,37 +1480,59 @@ class FeishuChannel(BaseChannel):
return
# No active streaming card — send as a regular
# interactive card with the same 🔧 prefix style.
# Use reply API for group chats so the hint stays in topic.
card = json.dumps(
{"config": {"wide_screen_mode": True}, "elements": [
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
]},
ensure_ascii=False,
)
await loop.run_in_executor(
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
)
_th_msg_id = msg.metadata.get("message_id")
_th_chat_type = msg.metadata.get("chat_type", "group")
if _th_msg_id and _th_chat_type == "group":
await loop.run_in_executor(
None, lambda: self._reply_message_sync(
_th_msg_id, "interactive", card,
reply_in_thread=True,
),
)
else:
await loop.run_in_executor(
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
)
return
# Determine whether the first message should quote the user's message.
# Only the very first send (media or text) in this call uses reply; subsequent
# chunks/media fall back to plain create to avoid redundant quote bubbles.
# Always target message_id — the Feishu Reply API keeps replies in the
# same topic automatically when the target message is inside a topic.
reply_message_id: str | None = None
_msg_id = msg.metadata.get("message_id")
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
reply_message_id = msg.metadata.get("message_id") or None
reply_message_id = _msg_id
# For topic group messages, always reply to keep context in thread
elif msg.metadata.get("thread_id"):
reply_message_id = (
msg.metadata.get("root_id") or msg.metadata.get("message_id") or None
)
reply_message_id = _msg_id
first_send = True # tracks whether the reply has already been used
def _do_send(m_type: str, content: str) -> None:
"""Send via reply (first message) or create (subsequent)."""
"""Send via reply (first message) or create (subsequent).
For group chats the reply API always uses reply_in_thread=True.
The Feishu API automatically keeps replies inside existing
topics reply_in_thread only creates a *new* topic when the
target message is a plain (non-topic) message.
"""
nonlocal first_send
if reply_message_id and first_send:
first_send = False
ok = self._reply_message_sync(reply_message_id, m_type, content)
chat_type = msg.metadata.get("chat_type", "group")
ok = self._reply_message_sync(
reply_message_id, m_type, content,
reply_in_thread=chat_type == "group",
)
if ok:
return
# Fall back to regular send if reply fails
@@ -1543,8 +1641,13 @@ class FeishuChannel(BaseChannel):
logger.debug("Feishu: skipping group message (not mentioned)")
return
# Add reaction
reaction_id = await self._add_reaction(message_id, self.config.react_emoji)
# Add reaction (non-blocking — tracked background task)
task = asyncio.create_task(
self._add_reaction(message_id, self.config.react_emoji)
)
self._background_tasks.add(task)
task.add_done_callback(self._on_background_task_done)
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
# Parse content
content_parts = []
@@ -1624,6 +1727,15 @@ class FeishuChannel(BaseChannel):
if not content and not media_paths:
return
# Build topic-scoped session key for conversation isolation.
# Group chat: each topic gets its own session via root_id (replies
# inside a topic) or message_id (top-level messages start a new topic).
# Private chat: no override — same behavior as Telegram/Slack.
if chat_type == "group":
session_key = f"feishu:{chat_id}:{root_id or message_id}"
else:
session_key = None
# Forward to message bus
reply_to = chat_id if chat_type == "group" else sender_id
await self._handle_message(
@@ -1633,13 +1745,13 @@ class FeishuChannel(BaseChannel):
media=media_paths,
metadata={
"message_id": message_id,
"reaction_id": reaction_id,
"chat_type": chat_type,
"msg_type": msg_type,
"parent_id": parent_id,
"root_id": root_id,
"thread_id": thread_id,
},
session_key=session_key,
)
except Exception as e:
+48 -3
View File
@@ -3,7 +3,8 @@
from __future__ import annotations
import asyncio
from typing import Any
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
@@ -13,6 +14,19 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
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)
_SEND_RETRY_DELAYS = (1, 2, 4)
@@ -27,9 +41,16 @@ class ChannelManager:
- 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.bus = bus
self._session_manager = session_manager
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
@@ -41,6 +62,8 @@ class ChannelManager:
transcription_provider = self.config.channels.transcription_provider
transcription_key = self._resolve_transcription_key(transcription_provider)
transcription_base = self._resolve_transcription_base(transcription_provider)
transcription_language = self.config.channels.transcription_language
for name, cls in discover_all().items():
section = getattr(self.config.channels, name, None)
@@ -54,9 +77,19 @@ class ChannelManager:
if not enabled:
continue
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_api_key = transcription_key
channel.transcription_api_base = transcription_base
channel.transcription_language = transcription_language
self.channels[name] = channel
logger.info("{} channel enabled", cls.display_name)
except Exception as e:
@@ -73,6 +106,15 @@ class ChannelManager:
except AttributeError:
return ""
def _resolve_transcription_base(self, provider: str) -> str:
"""Pick the API base URL for the configured transcription provider."""
try:
if provider == "openai":
return self.config.providers.openai.api_base or ""
return self.config.providers.groq.api_base or ""
except AttributeError:
return ""
def _validate_allow_from(self) -> None:
for name, ch in self.channels.items():
cfg = ch.config
@@ -178,6 +220,9 @@ class ChannelManager:
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
continue
if msg.metadata.get("_retry_wait"):
continue
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
# to reduce API calls and improve streaming latency
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
+569
View File
@@ -0,0 +1,569 @@
"""Microsoft Teams channel MVP using a tiny built-in HTTP webhook server.
Scope:
- DM-focused MVP
- text inbound/outbound
- conversation reference persistence
- sender allowlist support
- optional inbound Bot Framework bearer-token validation
- no attachments/cards/polls yet
"""
from __future__ import annotations
import asyncio
import html
import importlib.util
import json
import re
import threading
import time
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import TYPE_CHECKING, Any
import httpx
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_workspace_path
from nanobot.config.schema import Base
MSTEAMS_AVAILABLE = (
importlib.util.find_spec("jwt") is not None
and importlib.util.find_spec("cryptography") is not None
)
if TYPE_CHECKING:
import jwt
if MSTEAMS_AVAILABLE:
import jwt
class MSTeamsConfig(Base):
"""Microsoft Teams channel configuration."""
enabled: bool = False
app_id: str = ""
app_password: str = ""
tenant_id: str = ""
host: str = "0.0.0.0"
port: int = 3978
path: str = "/api/messages"
allow_from: list[str] = Field(default_factory=list)
reply_in_thread: bool = True
mention_only_response: str = "Hi — what can I help with?"
validate_inbound_auth: bool = True
@dataclass
class ConversationRef:
"""Minimal stored conversation reference for replies."""
service_url: str
conversation_id: str
bot_id: str | None = None
activity_id: str | None = None
conversation_type: str | None = None
tenant_id: str | None = None
updated_at: float | None = None
class MSTeamsChannel(BaseChannel):
"""Microsoft Teams channel (DM-first MVP)."""
name = "msteams"
display_name = "Microsoft Teams"
@classmethod
def default_config(cls) -> dict[str, Any]:
return MSTeamsConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = MSTeamsConfig.model_validate(config)
super().__init__(config, bus)
self.config: MSTeamsConfig = config
self._loop: asyncio.AbstractEventLoop | None = None
self._server: ThreadingHTTPServer | None = None
self._server_thread: threading.Thread | None = None
self._http: httpx.AsyncClient | None = None
self._token: str | None = None
self._token_expires_at: float = 0.0
self._botframework_openid_config_url = (
"https://login.botframework.com/v1/.well-known/openidconfiguration"
)
self._botframework_openid_config: dict[str, Any] | None = None
self._botframework_openid_config_expires_at: float = 0.0
self._botframework_jwks: dict[str, Any] | None = None
self._botframework_jwks_expires_at: float = 0.0
self._refs_path = get_workspace_path() / "state" / "msteams_conversations.json"
self._refs_path.parent.mkdir(parents=True, exist_ok=True)
self._conversation_refs: dict[str, ConversationRef] = self._load_refs()
async def start(self) -> None:
"""Start the Teams webhook listener."""
if not MSTEAMS_AVAILABLE:
logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
return
if not self.config.app_id or not self.config.app_password:
logger.error("MSTeams app_id/app_password not configured")
return
if not self.config.validate_inbound_auth:
logger.warning(
"MSTeams inbound auth validation was explicitly DISABLED in config. "
"Anyone who knows the webhook URL can send messages as any user. "
"Only disable this for local development or controlled testing."
)
self._loop = asyncio.get_running_loop()
self._http = httpx.AsyncClient(timeout=30.0)
self._running = True
channel = self
class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
if self.path != channel.config.path:
self.send_response(404)
self.end_headers()
return
try:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length > 0 else b"{}"
payload = json.loads(raw.decode("utf-8"))
except Exception as e:
logger.warning("MSTeams invalid request body: {}", e)
self.send_response(400)
self.end_headers()
return
auth_header = self.headers.get("Authorization", "")
if channel.config.validate_inbound_auth:
try:
fut = asyncio.run_coroutine_threadsafe(
channel._validate_inbound_auth(auth_header, payload),
channel._loop,
)
fut.result(timeout=15)
except Exception as e:
logger.warning("MSTeams inbound auth validation failed: {}", e)
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"error":"unauthorized"}')
return
try:
fut = asyncio.run_coroutine_threadsafe(
channel._handle_activity(payload),
channel._loop,
)
fut.result(timeout=15)
except Exception as e:
logger.warning("MSTeams activity handling failed: {}", e)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, format: str, *args: Any) -> None:
return
self._server = ThreadingHTTPServer((self.config.host, self.config.port), Handler)
self._server_thread = threading.Thread(
target=self._server.serve_forever,
name="nanobot-msteams",
daemon=True,
)
self._server_thread.start()
logger.info(
"MSTeams webhook listening on http://{}:{}{}",
self.config.host,
self.config.port,
self.config.path,
)
while self._running:
await asyncio.sleep(1)
async def stop(self) -> None:
"""Stop the channel."""
self._running = False
if self._server:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._server_thread and self._server_thread.is_alive():
self._server_thread.join(timeout=2)
self._server_thread = None
if self._http:
await self._http.aclose()
self._http = None
async def send(self, msg: OutboundMessage) -> None:
"""Send a plain text reply into an existing Teams conversation."""
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
ref = self._conversation_refs.get(str(msg.chat_id))
if not ref:
raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}")
token = await self._get_access_token()
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {
"type": "message",
"text": msg.content or " ",
}
if use_thread_reply:
payload["replyToId"] = ref.activity_id
try:
resp = await self._http.post(base_url, headers=headers, json=payload)
resp.raise_for_status()
logger.info("MSTeams message sent to {}", ref.conversation_id)
except Exception as e:
logger.error("MSTeams send failed: {}", e)
raise
async def _handle_activity(self, activity: dict[str, Any]) -> None:
"""Handle inbound Teams/Bot Framework activity."""
if activity.get("type") != "message":
return
conversation = activity.get("conversation") or {}
from_user = activity.get("from") or {}
recipient = activity.get("recipient") or {}
channel_data = activity.get("channelData") or {}
sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
conversation_id = str(conversation.get("id") or "").strip()
service_url = str(activity.get("serviceUrl") or "").strip()
activity_id = str(activity.get("id") or "").strip()
conversation_type = str(conversation.get("conversationType") or "").strip()
if not sender_id or not conversation_id or not service_url:
return
if recipient.get("id") and from_user.get("id") == recipient.get("id"):
return
# DM-only MVP: ignore group/channel traffic for now
if conversation_type and conversation_type not in ("personal", ""):
logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type)
return
text = self._sanitize_inbound_text(activity)
if not text:
text = self.config.mention_only_response.strip()
if not text:
logger.debug("MSTeams ignoring empty message after Teams text sanitization")
return
if not self.is_allowed(sender_id):
logger.warning(
"Access denied for sender {} on channel {}. "
"Add them to allowFrom list in config to grant access.",
sender_id, self.name,
)
return
self._conversation_refs[conversation_id] = ConversationRef(
service_url=service_url,
conversation_id=conversation_id,
bot_id=str(recipient.get("id") or "") or None,
activity_id=activity_id or None,
conversation_type=conversation_type or None,
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
updated_at=time.time(),
)
self._save_refs()
await self._handle_message(
sender_id=sender_id,
chat_id=conversation_id,
content=text,
metadata={
"msteams": {
"activity_id": activity_id,
"conversation_id": conversation_id,
"conversation_type": conversation_type or "personal",
"from_name": from_user.get("name"),
}
},
)
def _sanitize_inbound_text(self, activity: dict[str, Any]) -> str:
"""Extract the user-authored text from a Teams activity."""
text = str(activity.get("text") or "")
text = self._strip_possible_bot_mention(text)
text = self._normalize_html_whitespace(text)
channel_data = activity.get("channelData") or {}
reply_to_id = str(activity.get("replyToId") or "").strip()
normalized_preview = html.unescape(text).replace("&rsquo", "").strip()
normalized_preview = normalized_preview.replace("\xa0", " ")
normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n")
preview_lines = [line.strip() for line in normalized_preview.split("\n")]
while preview_lines and not preview_lines[0]:
preview_lines.pop(0)
first_line = preview_lines[0] if preview_lines else ""
looks_like_quote_wrapper = first_line.lower().startswith("replying to ") or first_line.startswith("Reply wrapper")
if reply_to_id or channel_data.get("messageType") == "reply" or looks_like_quote_wrapper:
text = self._normalize_teams_reply_quote(text)
return text.strip()
def _strip_possible_bot_mention(self, text: str) -> str:
"""Remove simple Teams mention markup from message text."""
cleaned = re.sub(r"<at\b[^>]*>.*?</at>", " ", text, flags=re.IGNORECASE | re.DOTALL)
cleaned = re.sub(r"[^\S\r\n]+", " ", cleaned)
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
return cleaned.strip()
def _normalize_html_whitespace(self, text: str) -> str:
"""Normalize common HTML whitespace/entities from Teams into plain text spacing."""
normalized = html.unescape(text).replace("&rsquo", "")
normalized = normalized.replace("\xa0", " ")
return normalized
def _normalize_teams_reply_quote(self, text: str) -> str:
"""Normalize Teams quoted replies into a compact structured form."""
cleaned = self._normalize_html_whitespace(text).strip()
if not cleaned:
return ""
normalized_newlines = cleaned.replace("\r\n", "\n").replace("\r", "\n")
lines = [line.strip() for line in normalized_newlines.split("\n")]
while lines and not lines[0]:
lines.pop(0)
# Observed native Teams reply wrapper:
# Replying to Bob Smith
# actual reply text
if len(lines) >= 2 and lines[0].lower().startswith("replying to "):
quoted = lines[0][len("replying to ") :].strip(" :")
reply = "\n".join(lines[1:]).strip()
return self._format_reply_with_quote(quoted, reply)
# Observed reply wrapper where the quoted content is surfaced after a
# synthetic "Reply wrapper" header, sometimes with a blank line separating quote
# and reply, and sometimes as a compact line-based fallback shape.
if lines and lines[0].strip().startswith("Reply wrapper"):
body = normalized_newlines.split("\n", 1)[1] if "\n" in normalized_newlines else ""
body = body.lstrip()
parts = re.split(r"\n\s*\n", body, maxsplit=1)
if len(parts) == 2:
quoted = re.sub(r"\s+", " ", parts[0]).strip()
reply = re.sub(r"\s+", " ", parts[1]).strip()
if quoted or reply:
return self._format_reply_with_quote(quoted, reply)
body_lines = [line.strip() for line in body.split("\n") if line.strip()]
if body_lines:
quoted = " ".join(body_lines[:-1]).strip()
reply = body_lines[-1].strip()
if quoted and reply:
return self._format_reply_with_quote(quoted, reply)
# Observed compact fallback where the relay flattens quote and reply into
# a single line after the synthetic Reply wrapper prefix.
compact = re.sub(r"\s+", " ", normalized_newlines).strip()
if compact.startswith("Reply wrapper "):
compact = compact[len("Reply wrapper ") :].strip()
for boundary in (". ", "! ", "? ", ""):
idx = compact.rfind(boundary)
if idx == -1:
continue
quoted = compact[: idx + 1].strip()
reply = compact[idx + len(boundary) :].strip()
if quoted and reply and len(reply) <= 160:
return self._format_reply_with_quote(quoted, reply)
return cleaned
def _format_reply_with_quote(self, quoted: str, reply: str) -> str:
"""Format a reply-with-context message for the model without Teams wrapper noise."""
quoted = quoted.strip()
reply = reply.strip()
if quoted and reply:
return f"User is replying to: {quoted}\nUser reply: {reply}"
if reply:
return reply
return quoted
async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
"""Validate inbound Bot Framework bearer token."""
if not MSTEAMS_AVAILABLE:
raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
if not auth_header.lower().startswith("bearer "):
raise ValueError("missing bearer token")
token = auth_header.split(" ", 1)[1].strip()
if not token:
raise ValueError("empty bearer token")
header = jwt.get_unverified_header(token)
kid = str(header.get("kid") or "").strip()
if not kid:
raise ValueError("missing token kid")
jwks = await self._get_botframework_jwks()
keys = jwks.get("keys") or []
jwk = next((key for key in keys if key.get("kid") == kid), None)
if not jwk:
raise ValueError(f"signing key not found for kid={kid}")
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
claims = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
audience=self.config.app_id,
issuer="https://api.botframework.com",
options={
"require": ["exp", "nbf", "iss", "aud"],
},
)
claim_service_url = str(
claims.get("serviceurl") or claims.get("serviceUrl") or "",
).strip()
activity_service_url = str(activity.get("serviceUrl") or "").strip()
if claim_service_url and activity_service_url and claim_service_url != activity_service_url:
raise ValueError("serviceUrl claim mismatch")
async def _get_botframework_openid_config(self) -> dict[str, Any]:
"""Fetch and cache Bot Framework OpenID configuration."""
now = time.time()
if self._botframework_openid_config and now < self._botframework_openid_config_expires_at:
return self._botframework_openid_config
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
resp = await self._http.get(self._botframework_openid_config_url)
resp.raise_for_status()
self._botframework_openid_config = resp.json()
self._botframework_openid_config_expires_at = now + 3600
return self._botframework_openid_config
async def _get_botframework_jwks(self) -> dict[str, Any]:
"""Fetch and cache Bot Framework JWKS."""
now = time.time()
if self._botframework_jwks and now < self._botframework_jwks_expires_at:
return self._botframework_jwks
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
openid_config = await self._get_botframework_openid_config()
jwks_uri = str(openid_config.get("jwks_uri") or "").strip()
if not jwks_uri:
raise RuntimeError("Bot Framework OpenID config missing jwks_uri")
resp = await self._http.get(jwks_uri)
resp.raise_for_status()
self._botframework_jwks = resp.json()
self._botframework_jwks_expires_at = now + 3600
return self._botframework_jwks
def _load_refs(self) -> dict[str, ConversationRef]:
"""Load stored conversation references."""
if not self._refs_path.exists():
return {}
try:
data = json.loads(self._refs_path.read_text(encoding="utf-8"))
out: dict[str, ConversationRef] = {}
for key, value in data.items():
out[key] = ConversationRef(**value)
return out
except Exception as e:
logger.warning("Failed to load MSTeams conversation refs: {}", e)
return {}
def _save_refs(self) -> None:
"""Persist conversation references."""
try:
stale_keys = [
key
for key, ref in self._conversation_refs.items()
if self._is_stale_or_unsupported_ref(ref)
]
for key in stale_keys:
self._conversation_refs.pop(key, None)
data = {
key: {
"service_url": ref.service_url,
"conversation_id": ref.conversation_id,
"bot_id": ref.bot_id,
"activity_id": ref.activity_id,
"conversation_type": ref.conversation_type,
"tenant_id": ref.tenant_id,
"updated_at": ref.updated_at,
}
for key, ref in self._conversation_refs.items()
}
self._refs_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
except Exception as e:
logger.warning("Failed to save MSTeams conversation refs: {}", e)
def _is_stale_or_unsupported_ref(self, ref: ConversationRef) -> bool:
"""Reject unsupported refs and prune old refs."""
service_url = (ref.service_url or "").strip().lower()
conversation_type = (ref.conversation_type or "").strip().lower()
updated_at = ref.updated_at or 0.0
max_age_seconds = 30 * 24 * 60 * 60
if "webchat.botframework.com" in service_url:
return True
if conversation_type and conversation_type != "personal":
return True
if updated_at and updated_at < time.time() - max_age_seconds:
return True
return False
async def _get_access_token(self) -> str:
"""Fetch an access token for Bot Framework / Azure Bot auth."""
now = time.time()
if self._token and now < self._token_expires_at - 60:
return self._token
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
tenant = (self.config.tenant_id or "").strip() or "botframework.com"
token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
data = {
"grant_type": "client_credentials",
"client_id": self.config.app_id,
"client_secret": self.config.app_password,
"scope": "https://api.botframework.com/.default",
}
resp = await self._http.post(token_url, data=data)
resp.raise_for_status()
payload = resp.json()
self._token = payload["access_token"]
self._token_expires_at = now + int(payload.get("expires_in", 3600))
return self._token
+114 -13
View File
@@ -26,6 +26,11 @@ from nanobot.security.network import validate_url_target
from nanobot.utils.helpers import split_message
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
# safety margin for mid-stream edits (plain text). For _stream_end, we
# 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
@@ -48,6 +53,34 @@ def _strip_md(s: str) -> str:
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:
"""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)
# 3. Headers # Title -> just the title text
text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE)
# 3. Headers # Title -> <b>Title</b> (preserve visual hierarchy)
text = re.sub(r'^#{1,6}\s+(.+)$', r'⟪B⟫\1⟪/B⟫', text, flags=re.MULTILINE)
# 4. Blockquotes > text -> just the text (before HTML escaping)
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
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
for i, code in enumerate(inline_codes):
# Escape HTML in code content
@@ -161,6 +197,9 @@ def _markdown_to_telegram_html(text: str) -> str:
escaped = _escape_telegram_html(code)
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
@@ -561,14 +600,23 @@ class TelegramChannel(BaseChannel):
await self._remove_reaction(chat_id, int(reply_to_message_id))
except ValueError:
pass
chunks = split_message(buf.text, TELEGRAM_MAX_MESSAGE_LEN)
primary_text = chunks[0] if chunks else buf.text
thread_kwargs = {}
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:
html = _markdown_to_telegram_html(primary_text)
await self._call_with_retry(
self._app.bot.edit_message_text,
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:
# 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)
return
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:
await self._call_with_retry(
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
text=primary_text,
text=primary_plain,
)
except Exception as e2:
if self._is_not_modified_error(e2):
@@ -591,10 +641,17 @@ class TelegramChannel(BaseChannel):
else:
logger.warning("Final stream edit failed: {}", e2)
raise # Let ChannelManager handle retry
# If final content exceeds Telegram limit, keep the first chunk in
# the edited stream message and send the rest as follow-up messages.
for extra_chunk in chunks[1:]:
await self._send_text(int_chat_id, extra_chunk)
for extra_html_chunk in extra_html_chunks:
try:
await self._call_with_retry(
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)
return
@@ -614,10 +671,11 @@ class TelegramChannel(BaseChannel):
if message_thread_id := meta.get("message_thread_id"):
thread_kwargs["message_thread_id"] = message_thread_id
if buf.message_id is None:
preview = _strip_md_block(buf.text)
try:
sent = await self._call_with_retry(
self._app.bot.send_message,
chat_id=int_chat_id, text=buf.text,
chat_id=int_chat_id, text=preview,
**thread_kwargs,
)
buf.message_id = sent.message_id
@@ -626,11 +684,16 @@ class TelegramChannel(BaseChannel):
logger.warning("Stream initial send failed: {}", e)
raise # Let ChannelManager handle retry
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:
await self._call_with_retry(
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
text=buf.text,
text=preview,
)
buf.last_edit = now
except Exception as e:
@@ -640,6 +703,44 @@ class TelegramChannel(BaseChannel):
logger.warning("Stream edit failed: {}", e)
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:
"""Handle /start command."""
if not update.message or not update.effective_user:
+462 -43
View File
@@ -7,25 +7,32 @@ import email.utils
import hmac
import http
import json
import mimetypes
import re
import secrets
import ssl
import time
import uuid
from typing import Any, Self
from urllib.parse import parse_qs, urlparse
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from urllib.parse import parse_qs, unquote, urlparse
from loguru import logger
from pydantic import Field, field_validator, model_validator
from websockets.asyncio.server import ServerConnection, serve
from websockets.datastructures import Headers
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.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Base
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
def _strip_trailing_slash(path: str) -> str:
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)
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]]]:
"""Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query)
@@ -156,6 +175,109 @@ def _parse_inbound_payload(raw: str) -> str | None:
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:
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
if not configured_secret:
@@ -176,15 +298,65 @@ class WebSocketChannel(BaseChannel):
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):
config = WebSocketConfig.model_validate(config)
super().__init__(config, bus)
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] = {}
# 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._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
def default_config(cls) -> dict[str, Any]:
@@ -255,6 +427,209 @@ class WebSocketChannel(BaseChannel):
{"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:
supplied = _query_first(query, "token")
static_token = self.config.token.strip()
@@ -286,24 +661,7 @@ class WebSocketChannel(BaseChannel):
connection: ServerConnection,
request: WsRequest,
) -> Any:
got, _ = _parse_request_path(request.path)
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)
return await self._dispatch_http(connection, request)
async def handler(connection: ServerConnection) -> None:
await self._connection_loop(connection)
@@ -353,21 +711,22 @@ class WebSocketChannel(BaseChannel):
logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id))
client_id = client_id[:128]
chat_id = str(uuid.uuid4())
default_chat_id = str(uuid.uuid4())
try:
await connection.send(
json.dumps(
{
"event": "ready",
"chat_id": chat_id,
"chat_id": default_chat_id,
"client_id": client_id,
},
ensure_ascii=False,
)
)
# 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:
if isinstance(raw, bytes):
@@ -376,19 +735,66 @@ class WebSocketChannel(BaseChannel):
except UnicodeDecodeError:
logger.warning("websocket: ignoring non-utf8 binary frame")
continue
envelope = _parse_envelope(raw)
if envelope is not None:
await self._dispatch_envelope(connection, client_id, envelope)
continue
content = _parse_inbound_payload(raw)
if content is None:
continue
await self._handle_message(
sender_id=client_id,
chat_id=chat_id,
chat_id=default_chat_id,
content=content,
metadata={"remote": getattr(connection, "remote_address", None)},
)
except Exception as e:
logger.debug("websocket connection ended: {}", e)
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:
if not self._running:
@@ -402,38 +808,48 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
logger.warning("websocket: server task error during shutdown: {}", e)
self._server_task = None
self._connections.clear()
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._issued_tokens.clear()
self._api_tokens.clear()
async def _safe_send(self, chat_id: str, raw: str, *, label: str = "") -> None:
"""Send a raw frame, cleaning up dead connections on ConnectionClosed."""
connection = self._connections.get(chat_id)
if connection is None:
return
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
try:
await connection.send(raw)
except ConnectionClosed:
self._connections.pop(chat_id, None)
logger.warning("websocket{}connection gone for chat_id={}", label, chat_id)
self._cleanup_connection(connection)
logger.warning("websocket{}connection gone", label)
except Exception as e:
logger.error("websocket{}send failed: {}", label, e)
raise
async def send(self, msg: OutboundMessage) -> None:
connection = self._connections.get(msg.chat_id)
if connection is None:
logger.warning("websocket: no active connection for chat_id={}", msg.chat_id)
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ()))
if not conns:
logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id)
return
payload: dict[str, Any] = {
"event": "message",
"chat_id": msg.chat_id,
"text": msg.content,
}
if msg.media:
payload["media"] = msg.media
if 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)
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(
self,
@@ -441,17 +857,20 @@ class WebSocketChannel(BaseChannel):
delta: str,
metadata: dict[str, Any] | None = None,
) -> None:
if self._connections.get(chat_id) is None:
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
meta = metadata or {}
if meta.get("_stream_end"):
body: dict[str, Any] = {"event": "stream_end"}
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
else:
body = {
"event": "delta",
"chat_id": chat_id,
"text": delta,
}
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
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 ")
+11 -2
View File
@@ -302,13 +302,22 @@ class WecomChannel(BaseChannel):
elif msg_type == "mixed":
# Mixed content contains multiple message items
msg_items = body.get("mixed", {}).get("item", [])
msg_items = body.get("mixed", {}).get("msg_item", [])
for item in msg_items:
item_type = item.get("type", "")
item_type = item.get("msgtype", "")
if item_type == "text":
text = item.get("text", {}).get("content", "")
if text:
content_parts.append(text)
elif item_type == "image":
file_url = item.get("image", {}).get("url", "")
aes_key = item.get("image", {}).get("aeskey", "")
if file_url and aes_key:
file_path = await self._download_and_save_media(file_url, aes_key, "image")
if file_path:
filename = os.path.basename(file_path)
content_parts.append(f"[image: {filename}]")
media_paths.append(file_path)
else:
content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]"))
+65 -12
View File
@@ -145,7 +145,7 @@ def _make_console() -> Console:
def _render_interactive_ansi(render_fn) -> str:
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
ansi_console = Console(
force_terminal=True,
force_terminal=sys.stdout.isatty(),
color_system=console.color_system or "standard",
width=console.width,
)
@@ -593,6 +593,7 @@ def serve(
unified_session=runtime_config.agents.defaults.unified_session,
disabled_skills=runtime_config.agents.defaults.disabled_skills,
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
tools_config=runtime_config.tools,
)
model_name = runtime_config.agents.defaults.model
@@ -635,6 +636,21 @@ def gateway(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""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.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
@@ -643,12 +659,6 @@ def gateway(
from nanobot.heartbeat.service import HeartbeatService
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
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
@@ -687,6 +697,7 @@ def gateway(
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
tools_config=config.tools,
)
# Set cron callback (needs agent)
@@ -715,12 +726,17 @@ def gateway(
cron_token = None
if isinstance(cron_tool, CronTool):
cron_token = cron_tool.set_cron_context(True)
async def _silent(*_args, **_kwargs):
pass
try:
resp = await agent.process_direct(
reminder_note,
session_key=f"cron:{job.id}",
channel=job.payload.channel or "cli",
chat_id=job.payload.to or "direct",
on_progress=_silent,
)
finally:
if isinstance(cron_tool, CronTool) and cron_token is not None:
@@ -729,7 +745,7 @@ def gateway(
response = resp.content if resp else ""
message_tool = agent.tools.get("message")
if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
return response
if job.payload.deliver and job.payload.to and response:
@@ -747,8 +763,9 @@ def gateway(
cron.on_job = on_cron_job
# Create channel manager
channels = ChannelManager(config, bus)
# Create channel manager (forwards SessionManager so the WebSocket channel
# can serve the embedded webui's REST surface).
channels = ChannelManager(config, bus, session_manager=session_manager)
def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
@@ -869,6 +886,7 @@ def gateway(
agent.dream.model = dream_cfg.model_override
agent.dream.max_batch_size = dream_cfg.max_batch_size
agent.dream.max_iterations = dream_cfg.max_iterations
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
from nanobot.cron.types import CronJob, CronPayload
cron.register_system_job(CronJob(
id="dream",
@@ -878,15 +896,43 @@ def gateway(
))
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():
try:
await cron.start()
await heartbeat.start()
await asyncio.gather(
tasks = [
agent.run(),
channels.start_all(),
_health_server(config.gateway.host, port),
)
]
if open_browser_url:
tasks.append(_open_browser_when_ready())
await asyncio.gather(*tasks)
except KeyboardInterrupt:
console.print("\nShutting down...")
except Exception:
@@ -900,6 +946,12 @@ def gateway(
cron.stop()
agent.stop()
await channels.stop_all()
# Flush all cached sessions to durable storage before exit.
# This prevents data loss on filesystems with write-back
# caching (rclone VFS, NFS, FUSE mounts, etc.).
flushed = agent.sessions.flush_all()
if flushed:
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
asyncio.run(run())
@@ -964,6 +1016,7 @@ def agent(
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
tools_config=config.tools,
)
restart_notice = consume_restart_notice_from_env()
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
+113 -10
View File
@@ -4,7 +4,7 @@ import json
import types
from dataclasses import dataclass
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:
import questionary
@@ -202,6 +202,8 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
return FieldTypeInfo(name, None)
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return FieldTypeInfo("model", annotation)
if origin is Literal:
return FieldTypeInfo("literal", list(args))
return FieldTypeInfo("str", None)
@@ -264,7 +266,12 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
if isinstance(value, list):
return ", ".join(str(v) for v in value)
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)
@@ -279,6 +286,63 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
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 ---
@@ -333,7 +397,7 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
).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."""
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":
try:
return int(value)
parsed = int(value)
except ValueError:
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
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":
try:
return float(value)
parsed = float(value)
except ValueError:
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
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":
return [v.strip() for v in value.split(",") if v.strip()]
elif field_type == "dict":
@@ -367,7 +443,7 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any:
def _input_with_existing(
display_name: str, current: Any, field_type: str
display_name: str, current: Any, field_type: str, field_info=None
) -> Any:
"""Handle input with 'keep existing' option for non-empty values."""
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:
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 ---
@@ -568,7 +644,7 @@ def _configure_pydantic_model(
field_name, field_info = fields[field_idx]
current_value = getattr(working_model, field_name, None)
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
if ftype.type_name == "model":
@@ -607,10 +683,19 @@ def _configure_pydantic_model(
continue
# 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":
new_value = _input_bool(field_display, current_value)
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:
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]] = {
"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),
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
}
_SETTINGS_GETTER = {
"Agent Settings": lambda c: c.agents.defaults,
"Channel Common": lambda c: c.channels,
"API Server": lambda c: c.api,
"Gateway": lambda c: c.gateway,
"Tools": lambda c: c.tools,
}
_SETTINGS_SETTER = {
"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),
"Tools": lambda c, v: setattr(c, "tools", v),
}
@@ -915,12 +1006,20 @@ def _show_summary(config: Config) -> None:
# Settings sections
for title, model in [
("Agent Settings", config.agents.defaults),
("Channel Common", config.channels),
("API Server", config.api),
("Gateway", config.gateway),
("Tools", config.tools),
("Channel Common", config.channels),
]:
_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 ---
@@ -984,7 +1083,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
choices=[
"[P] LLM Provider",
"[C] Chat Channel",
"[H] Channel Common",
"[A] Agent Settings",
"[I] API Server",
"[G] Gateway",
"[T] Tools",
"[V] View Configuration Summary",
@@ -1007,7 +1108,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
_MENU_DISPATCH = {
"[P] LLM Provider": lambda: _configure_providers(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"),
"[I] API Server": lambda: _configure_general_settings(config, "API Server"),
"[G] Gateway": lambda: _configure_general_settings(config, "Gateway"),
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
"[V] View Configuration Summary": lambda: _show_summary(config),
+11 -1
View File
@@ -18,7 +18,17 @@ from nanobot import __logo__
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:
+7 -11
View File
@@ -17,15 +17,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session."""
loop = ctx.loop
msg = ctx.msg
tasks = loop._active_tasks.pop(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
total = await loop._cancel_active_tasks(msg.session_key)
content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
@@ -91,14 +83,18 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
context_tokens_estimate=ctx_est,
search_usage_text=search_usage_text,
active_task_count=task_count,
max_completion_tokens=getattr(
getattr(loop.provider, "generation", None), "max_tokens", 8192
),
),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
"""Start a fresh session."""
"""Stop active task and start a fresh session."""
loop = ctx.loop
await loop._cancel_active_tasks(ctx.key)
session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:]
session.clear()
@@ -324,7 +320,7 @@ def build_help_text() -> str:
"""Build canonical help text shared across channels."""
lines = [
"🐈 nanobot commands:",
"/new — Start a new conversation",
"/new — Stop current task and start a new conversation",
"/stop — Stop the current task",
"/restart — Restart the bot",
"/status — Show bot status",
+14
View File
@@ -57,6 +57,20 @@ class CommandRouter:
def is_priority(self, text: str) -> bool:
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:
"""Dispatch a priority command. Called from run() without the lock."""
handler = self._priority.get(ctx.raw.lower())
+15
View File
@@ -117,4 +117,19 @@ def _migrate_config(data: dict) -> dict:
exec_cfg = tools.get("exec", {})
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
# sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow.
if "myEnabled" in tools or "mySet" in tools:
my_cfg = tools.setdefault("my", {})
if "myEnabled" in tools and "enable" not in my_cfg:
my_cfg["enable"] = tools.pop("myEnabled")
else:
tools.pop("myEnabled", None)
if "mySet" in tools and "allowSet" not in my_cfg:
my_cfg["allowSet"] = tools.pop("mySet")
else:
tools.pop("mySet", None)
return data
+20 -6
View File
@@ -29,6 +29,7 @@ class ChannelsConfig(Base):
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
class DreamConfig(Base):
@@ -43,7 +44,12 @@ class DreamConfig(Base):
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
) # Optional Dream-specific model override
max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
max_iterations: int = Field(default=10, ge=1) # Max tool calls per Phase 2
# Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus).
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2
# Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default
# on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly
# to the `← Nd` suffix or you want deterministic, git-independent prompts.
annotate_line_ages: bool = True
def build_schedule(self, timezone: str) -> CronSchedule:
"""Build the runtime schedule, preferring the legacy cron override if present."""
@@ -96,7 +102,7 @@ class AgentsConfig(Base):
class ProviderConfig(Base):
"""LLM provider configuration."""
api_key: str = ""
api_key: str | None = None
api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
@@ -115,10 +121,12 @@ class ProvidersConfig(Base):
dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models
ovms: ProviderConfig = Field(default_factory=ProviderConfig) # OpenVINO Model Server (OVMS)
gemini: ProviderConfig = Field(default_factory=ProviderConfig)
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
minimax_anthropic: ProviderConfig = Field(default_factory=ProviderConfig) # MiniMax Anthropic endpoint (thinking)
mistral: ProviderConfig = Field(default_factory=ProviderConfig)
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
@@ -198,11 +206,19 @@ class MCPServerConfig(Base):
tool_timeout: int = 30 # seconds before a tool call is cancelled
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
class MyToolConfig(Base):
"""Self-inspection tool configuration."""
enable: bool = True # register the `my` tool (agent runtime state inspection)
allow_set: bool = False # let `my` modify loop state (read-only if False)
class ToolsConfig(Base):
"""Tools configuration."""
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
my: MyToolConfig = Field(default_factory=MyToolConfig)
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
@@ -304,17 +320,15 @@ class Config(BaseSettings):
return p.api_key if p else 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
p, name = self._match_provider(model)
if p and 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:
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 None
+6 -1
View File
@@ -104,7 +104,12 @@ class HeartbeatService:
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", ""
args = response.tool_calls[0].arguments
+1
View File
@@ -84,6 +84,7 @@ class Nanobot:
unified_session=defaults.unified_session,
disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes,
tools_config=config.tools,
)
return cls(loop)
+63 -1
View File
@@ -245,9 +245,41 @@ class AnthropicProvider(LLMProvider):
"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
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]] = []
for msg in msgs:
if merged and merged[-1]["role"] == msg["role"]:
@@ -262,6 +294,36 @@ class AnthropicProvider(LLMProvider):
merged[-1]["content"] = prev_c
else:
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
# ------------------------------------------------------------------
+32 -5
View File
@@ -67,6 +67,14 @@ class LLMResponse:
"""Check if response contains tool calls."""
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)
class GenerationSettings:
@@ -77,6 +85,9 @@ class GenerationSettings:
reasoning_effort: str | None = None
_SYNTHETIC_USER_CONTENT = "(conversation continued)"
class LLMProvider(ABC):
"""Base class for LLM providers."""
@@ -97,6 +108,7 @@ class LLMProvider(ABC):
"connection",
"server error",
"temporarily unavailable",
"速率限制",
)
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
@@ -143,6 +155,7 @@ class LLMProvider(ABC):
"temporarily unavailable",
"overloaded",
"concurrency limit",
"速率限制",
)
_SENTINEL = object()
@@ -409,6 +422,17 @@ class LLMProvider(ABC):
recovered["role"] = "user"
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
@staticmethod
@@ -512,9 +536,9 @@ class LLMProvider(ABC):
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Call chat_stream() with retry on transient provider failures."""
if max_tokens is self._SENTINEL:
if max_tokens is self._SENTINEL or max_tokens is None:
max_tokens = self.generation.max_tokens
if temperature is self._SENTINEL:
if temperature is self._SENTINEL or temperature is None:
temperature = self.generation.temperature
if reasoning_effort is self._SENTINEL:
reasoning_effort = self.generation.reasoning_effort
@@ -549,11 +573,14 @@ class LLMProvider(ABC):
Parameters default to ``self.generation`` when not explicitly passed,
so callers no longer need to thread temperature / max_tokens /
reasoning_effort through every layer.
reasoning_effort through every layer. Explicit ``None`` is also
normalized to the provider's generation defaults so that downstream
``_build_kwargs`` never sees ``None`` for ``max_tokens`` / ``temperature``
(which would crash ``max(1, max_tokens)``).
"""
if max_tokens is self._SENTINEL:
if max_tokens is self._SENTINEL or max_tokens is None:
max_tokens = self.generation.max_tokens
if temperature is self._SENTINEL:
if temperature is self._SENTINEL or temperature is None:
temperature = self.generation.temperature
if reasoning_effort is self._SENTINEL:
reasoning_effort = self.generation.reasoning_effort
+81 -8
View File
@@ -9,11 +9,13 @@ import importlib.util
import os
import secrets
import string
import time
import uuid
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
import json_repair
from loguru import logger
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI
@@ -52,6 +54,7 @@ _DEFAULT_OPENROUTER_HEADERS = {
}
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
"kimi-k2.5",
"kimi-k2.6",
"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.
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 "/"
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())
_RESPONSES_FAILURE_THRESHOLD = 3
_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes
def _is_direct_openai_base(api_base: str | None) -> bool:
"""Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways."""
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
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):
"""Unified provider for all OpenAI-compatible APIs.
@@ -189,6 +206,11 @@ class OpenAICompatProvider(LLMProvider):
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:
"""Set environment variables based on provider spec."""
spec = self._spec
@@ -365,17 +387,33 @@ class OpenAICompatProvider(LLMProvider):
kwargs.update(overrides)
break
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
# Normalize reasoning_effort into a semantic form (OpenAI vocab)
# used for internal decisions, and a wire form actually sent out.
# "minimum" is accepted as a DashScope-native alias for "minimal".
semantic_effort: str | None = None
if isinstance(reasoning_effort, str):
semantic_effort = reasoning_effort.lower()
if semantic_effort == "minimum":
semantic_effort = "minimal"
wire_effort = reasoning_effort
if spec and spec.name == "dashscope" and semantic_effort == "minimal":
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
wire_effort = "minimum"
if wire_effort:
kwargs["reasoning_effort"] = wire_effort
# Provider-specific thinking parameters.
# Only sent when reasoning_effort is explicitly configured so that
# the provider default is preserved otherwise.
if spec and reasoning_effort is not None:
thinking_enabled = reasoning_effort.lower() != "minimal"
thinking_enabled = semantic_effort != "minimal"
extra: dict[str, Any] | None = None
if spec.name == "dashscope":
extra = {"enable_thinking": thinking_enabled}
elif spec.name == "minimax":
extra = {"reasoning_split": thinking_enabled}
elif spec.name in (
"volcengine", "volcengine_coding_plan",
"byteplus", "byteplus_coding_plan",
@@ -391,7 +429,7 @@ class OpenAICompatProvider(LLMProvider):
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
# identically to bare names like "kimi-k2.5".
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
thinking_enabled = reasoning_effort.lower() != "minimal"
thinking_enabled = semantic_effort != "minimal"
kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
@@ -414,9 +452,39 @@ class OpenAICompatProvider(LLMProvider):
return False
model_name = (model or self.default_model).lower()
wants = False
if reasoning_effort and reasoning_effort.lower() != "none":
return True
return any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
wants = True
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
def _should_fallback_from_responses_error(e: Exception) -> bool:
@@ -915,10 +983,13 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature,
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:
if not self._should_fallback_from_responses_error(responses_error):
raise
self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
@@ -965,6 +1036,7 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(),
on_content_delta,
)
self._record_responses_success(model, reasoning_effort)
return LLMResponse(
content=content or None,
tool_calls=tool_calls,
@@ -975,6 +1047,7 @@ class OpenAICompatProvider(LLMProvider):
except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error):
raise
self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
+25 -2
View File
@@ -261,7 +261,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
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(
name="moonshot",
keywords=("moonshot", "kimi"),
@@ -269,7 +269,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="Moonshot",
backend="openai_compat",
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
ProviderSpec(
@@ -280,6 +283,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.minimax.io/v1",
),
# MiniMax Anthropic-compatible endpoint: supports thinking mode
ProviderSpec(
name="minimax_anthropic",
keywords=("minimax_anthropic",),
env_key="MINIMAX_API_KEY",
display_name="MiniMax (Anthropic)",
backend="anthropic",
default_api_base="https://api.minimax.io/anthropic",
),
# Mistral AI: OpenAI-compatible API
ProviderSpec(
name="mistral",
@@ -328,6 +340,17 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="11434",
default_api_base="http://localhost:11434/v1",
),
# LM Studio (local, OpenAI-compatible)
ProviderSpec(
name="lm_studio",
keywords=("lm-studio", "lmstudio", "lm_studio"),
env_key="LM_STUDIO_API_KEY",
display_name="LM Studio",
backend="openai_compat",
is_local=True,
detect_by_base_keyword="1234",
default_api_base="http://localhost:1234/v1",
),
# === OpenVINO Model Server (direct, local, OpenAI-compatible at /v3) ===
ProviderSpec(
name="ovms",
+24 -4
View File
@@ -10,9 +10,19 @@ from loguru import logger
class OpenAITranscriptionProvider:
"""Voice transcription provider using OpenAI's Whisper API."""
def __init__(self, api_key: str | None = None):
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
language: str | None = None,
):
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
self.api_url = "https://api.openai.com/v1/audio/transcriptions"
self.api_url = (
api_base
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
or "https://api.openai.com/v1/audio/transcriptions"
)
self.language = language or None
async def transcribe(self, file_path: str | Path) -> str:
if not self.api_key:
@@ -26,6 +36,8 @@ class OpenAITranscriptionProvider:
async with httpx.AsyncClient() as client:
with open(path, "rb") as f:
files = {"file": (path.name, f), "model": (None, "whisper-1")}
if self.language:
files["language"] = (None, self.language)
headers = {"Authorization": f"Bearer {self.api_key}"}
response = await client.post(
self.api_url, headers=headers, files=files, timeout=60.0,
@@ -44,9 +56,15 @@ class GroqTranscriptionProvider:
Groq offers extremely fast transcription with a generous free tier.
"""
def __init__(self, api_key: str | None = None):
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
language: str | None = None,
):
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions"
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
self.language = language or None
async def transcribe(self, file_path: str | Path) -> str:
"""
@@ -74,6 +92,8 @@ class GroqTranscriptionProvider:
"file": (path.name, f),
"model": (None, "whisper-large-v3"),
}
if self.language:
files["language"] = (None, self.language)
headers = {
"Authorization": f"Bearer {self.api_key}",
}
+211 -19
View File
@@ -1,6 +1,7 @@
"""Session management for conversation history."""
import json
import os
import shutil
from dataclasses import dataclass, field
from datetime import datetime
@@ -106,15 +107,18 @@ class SessionManager:
self.legacy_sessions_dir = get_legacy_sessions_dir()
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:
"""Get the file path for a session."""
safe_key = safe_filename(key.replace(":", "_"))
return self.sessions_dir / f"{safe_key}.jsonl"
return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/)."""
safe_key = safe_filename(key.replace(":", "_"))
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
def get_or_create(self, key: str) -> Session:
"""
@@ -184,31 +188,210 @@ class SessionManager:
)
except Exception as 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
def save(self, session: Session) -> None:
"""Save a session to disk."""
path = self._get_session_path(session.key)
try:
messages: list[dict[str, Any]] = []
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:
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")
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
skipped += 1
continue
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, *, fsync: bool = False) -> None:
"""Save a session to disk atomically.
When *fsync* is ``True`` the final file and its parent directory are
explicitly flushed to durable storage. This is intentionally off by
default (the OS page-cache is sufficient for normal operation) but
should be enabled during graceful shutdown so that filesystems with
write-back caching (e.g. rclone VFS, NFS, FUSE mounts) do not lose
the most recent writes.
"""
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")
if fsync:
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
if fsync:
# fsync the directory so the rename is durable.
# On Windows, opening a directory with O_RDONLY raises
# PermissionError — skip the dir sync there (NTFS
# journals metadata synchronously).
try:
fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
except PermissionError:
pass # Windows — directory fsync not supported
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
self._cache[session.key] = session
def flush_all(self) -> int:
"""Re-save every cached session with fsync for durable shutdown.
Returns the number of sessions flushed. Errors on individual
sessions are logged but do not prevent other sessions from being
flushed.
"""
flushed = 0
for key, session in list(self._cache.items()):
try:
self.save(session, fsync=True)
flushed += 1
except Exception:
logger.warning("Failed to flush session {}", key, exc_info=True)
return flushed
def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache."""
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]]:
"""
List all sessions.
@@ -219,6 +402,7 @@ class SessionManager:
sessions = []
for path in self.sessions_dir.glob("*.jsonl"):
fallback_key = path.stem.replace("_", ":", 1)
try:
# Read just the metadata line
with open(path, encoding="utf-8") as f:
@@ -234,6 +418,14 @@ class SessionManager:
"path": str(path)
})
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
return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True)
+72
View File
@@ -0,0 +1,72 @@
---
name: my
description: Check and set the agent's own runtime state (model, iterations, context window, token usage, web config). Use when diagnosing why something doesn't work ("why can't you search the web?", "why did you stop?"), checking resource limits before complex tasks, adapting configuration for long or simple tasks, or remembering user preferences across turns. Also use when the user asks what model you are running, how many tokens you've used, or what your settings are.
always: true
---
# Self-Awareness
## How to use
1. **Identify the situation** from the categories below
2. **Call the my tool** with the appropriate action
3. **If set**, warn the user before changing impactful settings (model, iterations)
4. **For detailed examples**, read [references/examples.md](references/examples.md)
## When to check
<rule>
**Diagnose before explaining.** When something doesn't work, check your state first.
</rule>
<rule>
**Check budget before complex tasks.** Know your limits before committing.
</rule>
<rule>
**Recall across turns.** Store preferences in your scratchpad, read them back later.
</rule>
## When to set
<rule>
**Only set when benefit is clear and user is informed.** Warn before changing model.
</rule>
| Situation | Command |
|-----------|---------|
| Large codebase analysis | `my(action="set", key="context_window_tokens", value=131072)` |
| Repetitive simple tasks | `my(action="set", key="model", value="<fast-model>")` |
| Long multi-step task | `my(action="set", key="max_iterations", value=80)` |
**Tradeoff:** Bias toward stability. Only set when defaults are genuinely insufficient.
## Anti-patterns
<rule>
**Don't check every turn.** Costs a tool call. Use when you need information, not reflexively.
</rule>
<rule>
**Don't store sensitive data.** No API keys, passwords, or tokens in scratchpad.
</rule>
<rule>
**Don't set workspace.** Does not update file tool boundaries — won't work.
</rule>
## Constraints
- All modifications in-memory only — restart resets everything
- Protected params have type/range validation: `max_iterations` (1100), `context_window_tokens` (40961M), `model` (non-empty str)
- If `tools.my.allow_set` is false, check only
## Related tools
| Need | Use | Persists? |
|------|-----|-----------|
| Per-session temp state | `my(action="set", key="...", value=...)` | No |
| Long-term facts | Memory skill (`MEMORY.md`, `USER.md`) | Yes |
| Permanent config change | Edit config file | Yes |
**Rule of thumb:** Tomorrow? Memory. This turn only? My.
+75
View File
@@ -0,0 +1,75 @@
# My Tool — Practical Examples
Concrete scenarios showing when and how to use the my tool effectively.
## Diagnosis
### "Why can't you search the web?"
```
→ my(action="check", key="web_config.enable")
→ False
→ "Web search is disabled. Add web.enable: true to your config to enable it."
```
### "Why did you stop?"
```
→ my(action="check", key="max_iterations")
→ 40
→ my(action="check", key="_last_usage")
→ {"prompt_tokens": 62000, "completion_tokens": 3000}
→ "I hit the iteration limit (40). The task was complex. I can ask the user if they want to increase it."
```
### "What model are you running?"
```
→ my(action="check", key="model")
→ 'anthropic/claude-sonnet-4-20250514'
```
## Adaptive Behavior
### Large codebase analysis
```
→ my(action="check")
→ context_window_tokens: 65536
→ my(action="set", key="context_window_tokens", value=131072)
→ "Set context_window_tokens = 131072 (was 65536)"
→ "I've expanded my context window to handle this large codebase."
```
### Switching to a faster model for repetitive tasks
```
→ my(action="set", key="model", value="anthropic/claude-haiku-4-5-20251001")
→ "Set model = 'anthropic/claude-haiku-4-5-20251001' (was 'anthropic/claude-sonnet-4-20250514')"
→ "Switched to a faster model for these batch tasks."
```
## Cross-Turn Memory
### Remembering user preferences
```
# Turn 1: user says "keep it brief"
→ my(action="set", key="user_style", value="concise")
→ "Set scratchpad.user_style = 'concise'"
# Turn 3: new topic
→ my(action="check", key="user_style")
→ 'concise'
(adjusts response style accordingly)
```
### Tracking project context
```
→ my(action="set", key="active_branch", value="feat/auth")
→ my(action="set", key="test_framework", value="pytest")
→ my(action="set", key="has_docker", value=true)
```
## Budget Awareness
### Token-conscious behavior
```
→ my(action="check", key="_last_usage")
→ {"prompt_tokens": 58000, "completion_tokens": 12000}
→ "I've consumed ~70k tokens. I'll keep my remaining responses focused."
```
+16 -5
View File
@@ -2,8 +2,19 @@
I am nanobot 🐈, a personal AI assistant.
I solve problems by doing, not by describing what I would do.
I keep responses short unless depth is asked for.
I say what I know, flag what I don't, and never fake confidence.
I stay friendly and curious — I'd rather ask a good question than guess wrong.
I treat the user's time as the scarcest resource, and their trust as the most valuable.
## Core Principles
- Solve by doing, not by describing what I would do.
- Keep responses short unless depth is asked for.
- 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).
+17 -7
View File
@@ -1,4 +1,6 @@
Compare conversation history against current memory files. Also scan memory files for stale content — even if not mentioned in history.
You have TWO equally important tasks:
1. Extract new facts from conversation history
2. Deduplicate existing memory files — find and flag redundant, overlapping, or stale content even if NOT mentioned in history
Output one line per finding:
[FILE] atomic fact (not already in memory)
@@ -12,12 +14,20 @@ Rules:
- Corrections: [USER] location is Tokyo, not Osaka
- Capture confirmed approaches the user validated
Staleness — flag for [FILE-REMOVE]:
- Time-sensitive data older than 14 days: weather, daily status, one-time meetings, passed events
- Completed one-time tasks: triage, one-time reviews, finished research, resolved incidents
- Resolved tracking: merged/closed PRs, fixed issues, completed migrations
- Detailed incident info after 14 days — reduce to one-line summary
- Superseded: approaches replaced by newer solutions, deprecated dependencies
Deduplication — scan ALL memory files for these redundancy patterns:
- Same fact stated in multiple places (e.g., "communicates in Chinese" in both USER.md and multiple MEMORY.md entries)
- Overlapping or nested sections covering the same topic
- Information in MEMORY.md that is already captured in USER.md or SOUL.md (MEMORY.md should not duplicate permanent-file content)
- Verbose entries that can be condensed without losing information
For each duplicate found, output [FILE-REMOVE] for the less authoritative copy (prefer keeping facts in their canonical location)
Staleness — MEMORY.md lines may have a ``← Nd`` suffix showing days since last modification:
- SOUL.md and USER.md have no age annotations — they are permanent, only update with corrections
- Age only indicates when content was last touched, not whether it should be removed
- Use content judgment: user habits/preferences/personality traits are permanent regardless of age
- Only prune content that is objectively outdated: passed events, resolved tracking, superseded approaches
- Lines with ``← Nd`` (N>{{ stale_threshold_days }}) deserve closer review but are NOT automatically removable
- When removing: prefer deleting individual items over entire sections
Skill discovery — flag [SKILL] when ALL of these are true:
- A specific, repeatable workflow appeared 2+ times in the conversation history
-12
View File
@@ -1,7 +1,3 @@
# nanobot 🐈
You are nanobot, a helpful AI assistant.
## 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.
{% 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
- Prefer built-in `grep` / `glob` over `exec` for workspace search.
+1 -1
View File
@@ -1,6 +1,6 @@
# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
Unavailable skills need dependencies installed first you can try installing them with apt/brew.
{{ skills_summary }}
+40 -14
View File
@@ -134,18 +134,20 @@ def _extract_xlsx(path: Path) -> str:
"""Extract text from XLSX using openpyxl."""
try:
wb = load_workbook(path, read_only=True, data_only=True)
sheets: list[str] = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
rows: list[str] = []
for row in ws.iter_rows(values_only=True):
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
if row_text.strip():
rows.append(row_text)
if rows:
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
wb.close()
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
try:
sheets: list[str] = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
rows: list[str] = []
for row in ws.iter_rows(values_only=True):
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
if row_text.strip():
rows.append(row_text)
if rows:
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
finally:
wb.close()
except Exception as e:
logger.error("Failed to extract XLSX {}: {}", path, e)
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):
slide_text: list[str] = []
for shape in slide.shapes:
if hasattr(shape, "text") and shape.text:
slide_text.append(shape.text)
_collect_pptx_shape_text(shape, slide_text)
if slide_text:
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
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}]"
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:
"""Extract text from a plain text file."""
try:
+8 -2
View File
@@ -68,8 +68,14 @@ async def evaluate_response(
temperature=0.0,
)
if not llm_response.has_tool_calls:
logger.warning("evaluate_response: no tool call returned, defaulting to notify")
if not llm_response.should_execute_tools:
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
args = llm_response.tool_calls[0].arguments
+85 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import io
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from loguru import logger
@@ -24,6 +25,23 @@ class CommitInfo:
return f"{header}\n(no file changes)"
@dataclass
class LineAge:
"""Age of a single line based on git blame."""
age_days: int # days since last modification
def _compute_line_ages(annotated) -> list[LineAge]:
"""Convert annotate results to per-line ages."""
now = datetime.now(tz=timezone.utc).date()
ages: list[LineAge] = []
for (commit, _tree_entry), _line_bytes in annotated:
dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date()
ages.append(LineAge(age_days=(now - dt).days))
return ages
class GitStore:
"""Git-backed version control for memory files."""
@@ -46,14 +64,35 @@ class GitStore:
if self.is_initialized():
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:
from dulwich import porcelain
porcelain.init(str(self._workspace))
# Write .gitignore
# Write .gitignore (merge with existing if present)
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
# commit has something to track.
@@ -137,6 +176,22 @@ class GitStore:
except Exception:
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:
"""Generate .gitignore content from tracked files."""
dirs: set[str] = set()
@@ -191,6 +246,34 @@ class GitStore:
logger.warning("Git log failed")
return []
def line_ages(self, file_path: str) -> list[LineAge]:
"""Compute the age of each line in a tracked file via git blame.
Returns one LineAge per line, in order.
Returns an empty list if the repo is not initialized, the file is
empty, or annotation fails.
"""
if not self.is_initialized():
return []
target = self._workspace / file_path
if not target.exists() or target.stat().st_size == 0:
return []
try:
from dulwich import porcelain
annotated = porcelain.annotate(str(self._workspace), file_path)
except Exception:
logger.warning("Git line_ages annotate failed for {}", file_path)
return []
if not annotated:
return []
return _compute_line_ages(annotated)
def diff_commits(self, sha1: str, sha2: str) -> str:
"""Show diff between two commits."""
if not self.is_initialized():
+67 -13
View File
@@ -15,12 +15,48 @@ from loguru import logger
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"^\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"^\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()
@@ -37,7 +73,9 @@ def detect_image_mime(data: bytes) -> str | 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."""
b64 = base64.b64encode(raw).decode()
return [
@@ -83,6 +121,7 @@ _TOOL_RESULTS_DIR = ".nanobot/tool-results"
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
_TOOL_RESULT_MAX_BUCKETS = 32
def safe_filename(name: str) -> str:
"""Replace unsafe path characters with underscores."""
return _UNSAFE_CHARS.sub("_", name).strip()
@@ -258,9 +297,9 @@ def split_message(content: str, max_len: int = 2000) -> list[str]:
break
cut = content[:max_len]
# Try to break at newline first, then space, then hard break
pos = cut.rfind('\n')
pos = cut.rfind("\n")
if pos <= 0:
pos = cut.rfind(' ')
pos = cut.rfind(" ")
if pos <= 0:
pos = max_len
chunks.append(content[:pos])
@@ -401,9 +440,10 @@ def build_status_content(
context_tokens_estimate: int,
search_usage_text: str | None = None,
active_task_count: int = 0,
max_completion_tokens: int = 8192,
) -> str:
"""Build a human-readable runtime status snapshot.
Args:
search_usage_text: Optional pre-formatted web search usage string
(produced by SearchUsageInfo.format()). When provided
@@ -419,8 +459,14 @@ def build_status_content(
last_out = last_usage.get("completion_tokens", 0)
cached = last_usage.get("cached_tokens", 0)
ctx_total = max(context_window_tokens, 0)
ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0
ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate)
# Budget mirrors Consolidator formula: ctx_window - max_completion - _SAFETY_BUFFER
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_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"
token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out"
if cached and last_in:
@@ -429,19 +475,20 @@ def build_status_content(
f"\U0001f408 nanobot v{version}",
f"\U0001f9e0 Model: {model}",
token_line,
f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)",
f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}% of input budget)",
f"\U0001f4ac Session: {session_msg_count} messages",
f"\u23f1 Uptime: {uptime}",
f"\u26a1 Tasks: {active_task_count} active",
]
if 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]:
"""Sync bundled templates to workspace. Only creates missing files."""
from importlib.resources import files as pkg_files
try:
tpl = pkg_files("nanobot") / "templates"
except Exception:
@@ -467,15 +514,22 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
if added and not silent:
from rich.console import Console
for name in added:
Console().print(f" [dim]Created {name}[/dim]")
# Initialize git for memory version control
try:
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()
except Exception:
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

+14 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
version = "0.1.5.post1"
version = "0.1.5.post2"
description = "A lightweight personal AI assistant framework"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
@@ -16,6 +16,10 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
license-files = [
"LICENSE",
"THIRD_PARTY_NOTICES.md",
]
dependencies = [
"typer>=0.20.0,<1.0.0",
@@ -40,7 +44,7 @@ dependencies = [
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.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",
"questionary>=2.0.0,<3.0.0",
"mcp>=1.26.0,<2.0.0",
@@ -50,6 +54,7 @@ dependencies = [
"tiktoken>=0.12.0,<1.0.0",
"jinja2>=3.1.0,<4.0.0",
"dulwich>=0.22.0,<1.0.0",
"pyyaml>=6.0,<7.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
@@ -68,9 +73,13 @@ weixin = [
"qrcode[pil]>=8.0",
"pycryptodome>=3.20.0",
]
msteams = [
"PyJWT>=2.0,<3.0",
"cryptography>=41.0",
]
matrix = [
"matrix-nio[e2e]>=0.25.2",
"matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'",
"mistune>=3.0.0,<4.0.0",
"nh3>=0.2.17,<1.0.0",
]
@@ -125,6 +134,8 @@ include = [
"bridge/",
"README.md",
"LICENSE",
"THIRD_PARTY_NOTICES.md",
"pyproject.toml",
]
[tool.ruff]
View File
+118 -13
View File
@@ -4,7 +4,7 @@ import pytest
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from nanobot.agent.memory import Consolidator, MemoryStore
from nanobot.agent.memory import Consolidator, MemoryStore, _RAW_ARCHIVE_MAX_CHARS
@pytest.fixture
@@ -65,6 +65,46 @@ class TestConsolidatorSummarize:
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:
async def test_prompt_below_threshold_does_not_consolidate(self, consolidator):
"""No consolidation when tokens are within budget."""
@@ -77,8 +117,8 @@ class TestConsolidatorTokenBudget:
await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_called()
async def test_chunk_cap_preserves_user_turn_boundary(self, consolidator):
"""Chunk cap should rewind to the last user boundary within the cap."""
async def test_large_chunk_archived_without_cap(self, consolidator):
"""Without chunk cap, the full range from pick_consolidation_boundary is archived."""
consolidator._SAFETY_BUFFER = 0
session = MagicMock()
session.last_consolidated = 0
@@ -93,19 +133,19 @@ class TestConsolidatorTokenBudget:
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
# Use real pick_consolidation_boundary — it will find boundary at idx=50
# (user message at 50, token budget met)
consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session)
archived_chunk = consolidator.archive.await_args.args[0]
assert len(archived_chunk) == 50
# pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
assert archived_chunk[0]["content"] == "m0"
assert archived_chunk[-1]["content"] == "m49"
assert session.last_consolidated == 50
assert session.last_consolidated > 0
async def test_chunk_cap_skips_when_no_user_boundary_within_cap(self, consolidator):
"""If the cap would cut mid-turn, consolidation should skip that round."""
async def test_boundary_respected_when_no_intermediate_user_turn(self, consolidator):
"""When boundary points past a long tool chain, the full chunk is archived."""
consolidator._SAFETY_BUFFER = 0
session = MagicMock()
session.last_consolidated = 0
@@ -117,11 +157,76 @@ class TestConsolidatorTokenBudget:
}
for i in range(70)
]
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(1200, "tiktoken"))
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_awaited()
assert session.last_consolidated == 0
consolidator.archive.assert_awaited_once()
# pick_consolidation_boundary finds the only boundary at idx=61
assert session.last_consolidated == 61
class TestRawArchiveTruncation:
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""
def test_raw_archive_truncates_large_content(self, store):
"""Large messages should be truncated to _RAW_ARCHIVE_MAX_CHARS."""
big = "x" * 50_000
messages = [{"role": "user", "content": big}]
store.raw_archive(messages)
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert len(entries[0]["content"]) < 50_000
assert "[RAW]" in entries[0]["content"]
def test_raw_archive_preserves_small_content(self, store):
"""Small messages should not be truncated."""
messages = [{"role": "user", "content": "hello"}]
store.raw_archive(messages)
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "hello" in entries[0]["content"]
def test_raw_archive_custom_max_chars(self, store):
"""max_chars parameter should override default limit."""
messages = [{"role": "user", "content": "a" * 200}]
store.raw_archive(messages, max_chars=100)
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries[0]["content"]) < 200
class TestArchiveTruncation:
"""archive() must truncate formatted text before sending to consolidation LLM."""
async def test_archive_truncates_large_formatted_text(self, consolidator, mock_provider, store):
"""Large formatted text should be truncated to token budget before LLM call."""
# context_window_tokens=1000, max_completion_tokens=100, _SAFETY_BUFFER=1024
# budget = 1000 - 100 - 1024 = -124 → fallback via truncate_text(budget*4)
big_messages = [{"role": "user", "content": "x" * 100_000}]
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary of large input.", finish_reason="stop"
)
await consolidator.archive(big_messages)
call_args = mock_provider.chat_with_retry.call_args
user_content = call_args.kwargs["messages"][1]["content"]
# Should be significantly shorter than 100K
assert len(user_content) < 50_000
async def test_archive_truncates_with_small_token_budget(self, consolidator, mock_provider, store):
"""Small context window: truncation uses actual tokenizer count."""
consolidator.context_window_tokens = 500
big_messages = [{"role": "user", "content": "word " * 50_000}]
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary.", finish_reason="stop"
)
await consolidator.archive(big_messages)
sent_messages = mock_provider.chat_with_retry.call_args.kwargs["messages"]
user_content = sent_messages[1]["content"]
# budget = 500 - 100 - 1024 = negative, fallback char-based
# Should be truncated
assert len(user_content) < 250_000
+77 -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:
"""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)
sync_workspace_templates(workspace, silent=True)
builder = ContextBuilder(workspace)
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 "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:
"""Telegram channel should get messaging-app format hint."""
workspace = _make_workspace(tmp_path)
@@ -219,3 +242,55 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
for left, right in zip(messages, messages[1:]):
assert not (left.get("role") == right.get("role") == "assistant")
def test_always_skills_excluded_from_skills_index(tmp_path) -> None:
"""Always skills should appear in Active Skills but NOT in the skills index."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
# memory skill should be in Active Skills section
assert "# Active Skills" in prompt
assert "### Skill: memory" in prompt
# memory skill should NOT appear in the skills index
skills_section = prompt.split("# Skills\n", 1)
if len(skills_section) > 1:
index_text = skills_section[1].split("\n\n---")[0]
assert "**memory**" not in index_text
def test_template_memory_md_is_skipped(tmp_path) -> None:
"""MEMORY.md matching the bundled template should not inject the Memory section."""
workspace = _make_workspace(tmp_path)
from nanobot.utils.helpers import sync_workspace_templates
sync_workspace_templates(workspace, silent=True)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
# The "# Memory\n\n## Long-term Memory" block is produced only by
# build_system_prompt() when MEMORY.md is injected. The memory skill
# also contains "# Memory" but is followed by "## Structure", not
# "## Long-term Memory".
assert "# Memory\n\n## Long-term Memory" not in prompt
assert "This file is automatically updated by nanobot" not in prompt
def test_customized_memory_md_is_injected(tmp_path) -> None:
"""A Dream-populated MEMORY.md should be injected normally."""
workspace = _make_workspace(tmp_path)
from nanobot.utils.helpers import sync_workspace_templates
sync_workspace_templates(workspace, silent=True)
(workspace / "memory" / "MEMORY.md").write_text(
"# Long-term Memory\n\nUser prefers dark mode.\n", encoding="utf-8"
)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
assert "# Memory\n\n## Long-term Memory" in prompt
assert "User prefers dark mode" in prompt
+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]}"
)
+134 -1
View File
@@ -2,11 +2,12 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from nanobot.agent.memory import Dream, MemoryStore
from nanobot.agent.runner import AgentRunResult
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.utils.gitstore import LineAge
@pytest.fixture
@@ -123,3 +124,135 @@ class TestDreamRun:
assert "Successfully wrote" in result
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store):
"""Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
# Init git so line_ages works
store.git.init()
store.git.auto_commit("initial memory state")
await dream.run()
# The MEMORY.md section should not crash and should contain the memory content
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store):
"""SOUL.md and USER.md should never have age annotations — they are permanent."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
store.git.init()
store.git.auto_commit("initial state")
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
# The ← suffix should only appear in MEMORY.md section
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0]
user_section = user_msg.split("## Current USER.md")[1]
# SOUL and USER should not contain age arrows
assert "\u2190" not in soul_section
assert "\u2190" not in user_section
async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store):
"""Phase 1 should work fine even if git is not initialized (no age annotations)."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
# Should still succeed — just without age annotations
mock_provider.chat_with_retry.assert_called_once()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "## Current MEMORY.md" in user_msg
async def test_phase1_prompt_carries_age_suffix_for_stale_lines(
self, dream, mock_provider, mock_runner, store,
):
"""End-to-end: ages >14d must appear verbatim in the LLM prompt, ages ≤14d must not."""
# MEMORY.md fixture has 2 non-blank lines ("# Memory" and "- Project X active").
# Inject four ages to cover threshold boundaries: >14 suffix, ==14 no suffix, <14 no suffix.
store.write_memory("# Memory\n- Project X active\n- fresh item\n- edge case line")
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
fake_ages = [
LineAge(age_days=30), # "# Memory" → should get ← 30d
LineAge(age_days=20), # "- Project X..." → should get ← 20d
LineAge(age_days=14), # "- fresh item" → ==14, threshold is strictly >14, no suffix
LineAge(age_days=5), # "- edge case..." → no suffix
]
with patch.object(store.git, "line_ages", return_value=fake_ages):
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
assert "\u2190 30d" in memory_section
assert "\u2190 20d" in memory_section
assert "\u2190 14d" not in memory_section
assert "\u2190 5d" not in memory_section
async def test_phase1_skips_annotation_when_disabled(
self, dream, mock_provider, mock_runner, store,
):
"""`annotate_line_ages=False` must bypass the git lookup entirely and keep MEMORY.md raw."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
dream.annotate_line_ages = False
# line_ages must be bypassed entirely — verify with a spy rather than a
# raising side_effect, because _annotate_with_ages catches Exception
# (which swallows AssertionError) and would hide an accidental call.
with patch.object(store.git, "line_ages") as mock_line_ages:
await dream.run()
mock_line_ages.assert_not_called()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
assert "\u2190" not in user_msg
async def test_phase1_skips_annotation_on_line_ages_length_mismatch(
self, dream, mock_provider, mock_runner, store,
):
"""If ages length != lines length (dirty working tree), skip annotation instead of mis-tagging."""
# MEMORY.md has 2 non-blank lines but we hand back only 1 age → mismatch.
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
with patch.object(store.git, "line_ages", return_value=[LineAge(age_days=999)]):
await dream.run()
call_args = mock_provider.chat_with_retry.call_args
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
# No age arrow at all — we refused to annotate rather than tag the wrong line.
assert "\u2190" not in memory_section
async def test_phase1_prompt_uses_threshold_from_template_var(
self, dream, mock_provider, mock_runner, store,
):
"""System prompt should reference the stale-threshold constant, not a hardcoded 14."""
store.append_history("some event")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
system_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][0]["content"]
# The template renders with stale_threshold_days=14 → LLM must see "N>14"
assert "N>14" in system_msg
+59 -3
View File
@@ -102,7 +102,7 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
loop.sessions.save(session)
call_count = [0]
def mock_estimate(_session):
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
@@ -139,7 +139,7 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
call_count = [0]
def mock_estimate(_session):
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
@@ -156,6 +156,61 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
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
async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> None:
"""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=[])
loop.provider.chat_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.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)
call_count = [0]
def mock_estimate(_session):
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
return (1000 if call_count[0] <= 1 else 80, "test")
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._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
+56 -8
View File
@@ -1,8 +1,7 @@
"""Tests for the restructured MemoryStore — pure file I/O layer."""
from datetime import datetime
import json
from pathlib import Path
from datetime import datetime
import pytest
@@ -65,6 +64,34 @@ class TestHistoryWithCursor:
cursor = store.append_history("event 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):
store.append_history("event 1")
store.append_history("event 2")
@@ -79,6 +106,29 @@ class TestHistoryWithCursor:
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 2
def test_read_unprocessed_skips_entries_without_cursor(self, store):
"""Regression: entries missing the cursor key should be silently skipped."""
store.history_file.write_text(
'{"timestamp": "2026-04-01 10:00", "content": "no cursor"}\n'
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "valid"}\n'
'{"cursor": 3, "timestamp": "2026-04-01 10:02", "content": "also valid"}\n',
encoding="utf-8",
)
entries = store.read_unprocessed_history(since_cursor=0)
assert [e["cursor"] for e in entries] == [2, 3]
def test_next_cursor_falls_back_when_last_entry_has_no_cursor(self, store):
"""Regression: _next_cursor should not KeyError on entries without cursor."""
store.history_file.write_text(
'{"timestamp": "2026-04-01 10:01", "content": "no cursor"}\n',
encoding="utf-8",
)
# Delete .cursor file so _next_cursor falls back to reading JSONL
store._cursor_file.unlink(missing_ok=True)
# Last entry has no cursor — should safely return 1, not KeyError
cursor = store.append_history("new event")
assert cursor == 1
def test_compact_history_drops_oldest(self, tmp_path):
store = MemoryStore(tmp_path, max_history_entries=2)
store.append_history("event 1")
@@ -111,7 +161,8 @@ class TestLegacyHistoryMigration:
"""JSONL entries with cursor=1 are correctly parsed and returned."""
store.history_file.write_text(
'{"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)
assert len(entries) == 1
assert entries[0]["cursor"] == 1
@@ -195,8 +246,7 @@ class TestLegacyHistoryMigration:
memory_dir.mkdir()
legacy_file = memory_dir / "HISTORY.md"
legacy_content = (
"[2026-03-252026-04-02] Multi-day summary.\n"
"[2026-03-26/27] Cross-day summary.\n"
"[2026-03-252026-04-02] Multi-day summary.\n[2026-03-26/27] Cross-day summary.\n"
)
legacy_file.write_text(legacy_content, encoding="utf-8")
@@ -254,9 +304,7 @@ class TestLegacyHistoryMigration:
memory_dir = tmp_path / "memory"
memory_dir.mkdir()
legacy_file = memory_dir / "HISTORY.md"
legacy_file.write_bytes(
b"[2026-04-01 10:00] Broken \xff data still needs migration.\n\n"
)
legacy_file.write_bytes(b"[2026-04-01 10:00] Broken \xff data still needs migration.\n\n")
store = MemoryStore(tmp_path)
+467
View File
@@ -22,6 +22,9 @@ from nanobot.cli.onboard import (
_format_value,
_get_field_display_name,
_get_field_type_info,
_get_constraint_hint,
_input_text,
_validate_field_constraint,
run_onboard,
)
from nanobot.config.schema import Config
@@ -207,6 +210,25 @@ class TestGetFieldTypeInfo:
assert type_name == "str"
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:
"""Tests for _get_field_display_name human-readable name generation."""
@@ -493,3 +515,448 @@ class TestRunOnboardExitBehavior:
assert result.should_save is False
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
+180 -7
View File
@@ -643,10 +643,11 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
trimmed = runner._snip_history(spec, messages)
assert trimmed == [
{"role": "system", "content": "system"},
{"role": "assistant", "content": "after tool"},
]
# After the fix, the user message is recovered so the sequence is valid
# for providers that require system → user (e.g. GLM error 1214).
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
@@ -1073,7 +1074,7 @@ async def test_runner_tool_error_sets_final_content():
@pytest.mark.asyncio
async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, monkeypatch):
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.bus.queue import MessageBus
bus = MessageBus()
@@ -1096,7 +1097,8 @@ async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, mon
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
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._announce_result.assert_awaited_once()
args = mgr._announce_result.await_args.args
@@ -2786,4 +2788,175 @@ async def test_injection_cycle_cap_on_error_path():
assert result.had_injections is True
# Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks
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
+87
View File
@@ -310,3 +310,90 @@ def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None
always = loader.get_always_skills()
assert "alpha" not in always
assert "beta" in always
# -- multiline description tests (YAML folded > and literal |) -----------------
def test_build_skills_summary_folded_description(tmp_path: Path) -> None:
"""description: > (YAML folded scalar) should be parsed correctly."""
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
skill_dir = ws_skills / "pdf"
skill_dir.mkdir(parents=True)
skill_path = skill_dir / "SKILL.md"
skill_path.write_text(
"---\n"
"name: pdf\n"
"description: >\n"
" Use this skill when visual quality and design identity matter for a PDF.\n"
" CREATE (generate from scratch): \"make a PDF\".\n"
"---\n\n# PDF Skill\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
summary = loader.build_skills_summary()
assert "pdf" in summary
assert "visual quality" in summary
def test_build_skills_summary_literal_description(tmp_path: Path) -> None:
"""description: | (YAML literal scalar) should be parsed correctly."""
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
skill_dir = ws_skills / "multi"
skill_dir.mkdir(parents=True)
skill_path = skill_dir / "SKILL.md"
skill_path.write_text(
"---\n"
"name: multi\n"
"description: |\n"
" Line one of description.\n"
" Line two of description.\n"
"---\n\n# Multi\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
meta = loader.get_skill_metadata("multi")
assert meta is not None
desc = meta.get("description")
assert isinstance(desc, str)
assert "Line one" in desc
assert "Line two" in desc
def test_get_skill_metadata_handles_yaml_types(tmp_path: Path) -> None:
"""yaml.safe_load returns native types; always should be True, not 'true'."""
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
skill_dir = ws_skills / "typed"
skill_dir.mkdir(parents=True)
payload = json.dumps({"nanobot": {"requires": {"bins": ["gh"]}, "always": True}}, separators=(",", ":"))
skill_path = skill_dir / "SKILL.md"
skill_path.write_text(
"---\n"
"name: typed\n"
f"metadata: {payload}\n"
"always: true\n"
"---\n\n# Typed\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
meta = loader.get_skill_metadata("typed")
assert meta is not None
# YAML parsed 'true' to Python True
assert meta.get("always") is True
# metadata is a parsed dict, not a JSON string
assert isinstance(meta.get("metadata"), dict)
+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"
+103 -5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -269,7 +270,9 @@ class TestSubagentCancellation:
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
from nanobot.agent.subagent import SubagentStatus
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)
assistant_messages = [
msg for msg in captured_second_call
@@ -308,7 +311,9 @@ class TestSubagentCancellation:
mgr.runner.run = AsyncMock(side_effect=fake_run)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
from nanobot.agent.subagent import SubagentStatus
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()
mgr._announce_result.assert_awaited_once()
@@ -344,7 +349,9 @@ class TestSubagentCancellation:
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
from nanobot.agent.subagent import SubagentStatus
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._announce_result.assert_awaited_once()
args = mgr._announce_result.await_args.args
@@ -356,7 +363,7 @@ class TestSubagentCancellation:
@pytest.mark.asyncio
async def test_cancel_by_session_cancels_running_subagent_tool(self, monkeypatch, tmp_path):
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -389,7 +396,10 @@ class TestSubagentCancellation:
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
task = asyncio.create_task(
mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
mgr._run_subagent(
"sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"},
SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()),
)
)
mgr._running_tasks["sub-1"] = task
mgr._session_tasks["test:c1"] = {"sub-1"}
@@ -402,3 +412,91 @@ class TestSubagentCancellation:
assert cancelled.is_set()
assert task.cancelled()
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(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
)
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
@@ -274,6 +275,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
)
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
@@ -395,7 +397,10 @@ class TestConsolidationUnaffectedByUnifiedSession:
await consolidator.maybe_consolidate_by_tokens(session)
# 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)
consolidator.archive.assert_not_called()
View File
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
"""Tests for subagent tool registration and wiring."""
import asyncio
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
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()
@pytest.mark.asyncio
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
"""_drain_pending should block when no messages are available but sub-agents are still running."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import Session
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
session = Session(key="test:drain-block")
injection_callback = None
# Capture the injection_callback that _run_agent_loop creates
original_run = loop.runner.run
async def fake_runner_run(spec):
nonlocal injection_callback
injection_callback = spec.injection_callback
# Simulate: first call to injection_callback should block because
# sub-agents are running and no messages are in the queue yet.
# We'll resolve this from a concurrent task.
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
messages=[],
usage={},
had_injections=False,
tools_used=[],
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
# Register a running sub-agent in the SubagentManager for this session
async def _hang_forever():
await asyncio.Event().wait()
hang_task = asyncio.create_task(_hang_forever())
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-drain-1")
loop.subagents._running_tasks["sub-drain-1"] = hang_task
# Run _run_agent_loop — this defines the _drain_pending closure
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
session=session,
channel="test",
chat_id="c1",
pending_queue=pending_queue,
)
assert injection_callback is not None
# Now test the callback directly
# With sub-agents running and an empty queue, it should block
drain_task = asyncio.create_task(injection_callback())
# Give it a moment to enter the blocking wait
await asyncio.sleep(0.05)
# Should still be running (blocked on pending_queue.get())
assert not drain_task.done(), "drain should block while sub-agents are running"
# Now put a message in the queue (simulating sub-agent completion)
await pending_queue.put(InboundMessage(
sender_id="subagent",
channel="test",
chat_id="c1",
content="Sub-agent result",
media=None,
metadata={},
))
# Should unblock and return results
results = await asyncio.wait_for(drain_task, timeout=2.0)
assert len(results) >= 1
assert results[0]["role"] == "user"
assert "Sub-agent result" in str(results[0]["content"])
# Cleanup
hang_task.cancel()
try:
await hang_task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_drain_pending_no_block_when_no_subagents(tmp_path):
"""_drain_pending should not block when no sub-agents are running."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
pending_queue: asyncio.Queue = asyncio.Queue()
injection_callback = None
async def fake_runner_run(spec):
nonlocal injection_callback
injection_callback = spec.injection_callback
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
messages=[],
usage={},
had_injections=False,
tools_used=[],
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
session=None,
channel="test",
chat_id="c1",
pending_queue=pending_queue,
)
assert injection_callback is not None
# With no sub-agents and empty queue, should return immediately
results = await asyncio.wait_for(injection_callback(), timeout=1.0)
assert results == []
@pytest.mark.asyncio
async def test_drain_pending_timeout(tmp_path):
"""_drain_pending should return empty after timeout when sub-agents hang."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import Session
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
pending_queue: asyncio.Queue = asyncio.Queue()
session = Session(key="test:drain-timeout")
injection_callback = None
async def fake_runner_run(spec):
nonlocal injection_callback
injection_callback = spec.injection_callback
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
messages=[],
usage={},
had_injections=False,
tools_used=[],
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
# Register a "running" sub-agent that will never complete
async def _hang_forever():
await asyncio.Event().wait()
hang_task = asyncio.create_task(_hang_forever())
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-timeout-1")
loop.subagents._running_tasks["sub-timeout-1"] = hang_task
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
session=session,
channel="test",
chat_id="c1",
pending_queue=pending_queue,
)
assert injection_callback is not None
# Patch the timeout to be very short for testing
with patch("nanobot.agent.loop.asyncio.wait_for") as mock_wait:
mock_wait.side_effect = asyncio.TimeoutError
results = await injection_callback()
assert results == []
# Cleanup
hang_task.cancel()
try:
await hang_task
except asyncio.CancelledError:
pass

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