Compare commits

...
Author SHA1 Message Date
chengyongru 9e8ec15223 fix(agent): route direct subagent results in-turn 2026-07-01 16:03:51 +08:00
chengyongru c9534ef6f9 docs(agent): guide mapreduce subagent outputs 2026-07-01 13:47:24 +08:00
chengyongruandXubin Ren a6a489e0fa refactor: tighten session recency cleanup
maintainer edit: remove defensive branches that normal session storage cannot produce and keep the idle-expiry helper direct.
2026-06-30 23:38:32 +08:00
chengyongruandXubin Ren 840ba5af33 fix: simplify session recency activity tracking
maintainer edit: remove the _last_compacted_at maintenance state, gate idle compaction on whether a session still has a removable tail, and sort WebUI sessions by the latest visible transcript activity.
2026-06-30 23:38:32 +08:00
chengyongruandXubin Ren 3403b87641 fix(webui): keep idle compaction out of session recency 2026-06-30 23:38:32 +08:00
hamb1yandXubin Ren bfbae5a7b3 fix(cli): refresh oauth provider default models 2026-06-30 23:02:42 +08:00
hamb1yandXubin Ren 58cce14a07 fix(cli): allow oauth login to set main provider 2026-06-30 23:02:42 +08:00
Xubin Ren f9b02496c8 fix(mcp): redact URL paths in logs 2026-06-30 22:43:12 +08:00
Xubin Ren bfc2a74e4f fix(mcp): preserve IPv6 brackets when redacting URLs 2026-06-30 22:43:12 +08:00
xiaweiwei67-stackandXubin Ren 780093d037 fix(mcp): redact credentials from URLs before logging
MCP server URLs can carry secrets in userinfo
(`https://user:token@host/sse`) or a query string (`?token=...`). A few
connect/validate paths logged the raw `cfg.url` / `request.url`, so those
secrets could land in log files that are often shared or aggregated.

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

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

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

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

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

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

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

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

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

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

Refs #4136

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add the same _seen_tc_ids dedup pattern used in _parse_chunks to the
_parse method so both paths handle duplicate IDs consistently.
2026-06-27 16:47:48 +08:00
r4sk1nandXubin Ren 47dcc61e9b test(agent): fix flaky test_keeps_n_most_recent by ensuring sequential mtimes 2026-06-27 16:29:31 +08:00
axelray-devandXubin Ren 2bf111f456 fix(exec): remove ad-hoc shell comment stripping from _guard_command
- Removes match_text regex that stripped # comments before pattern matching
(broke on quoted # inside strings)
- allow_patterns now run re.fullmatch against the full lowercased command
- deny_patterns search the original lowercased command
- Replaces comment-stripping test with comment-tail bypass regression
(touch canary # echo allowlisted must be blocked)
- Adds Re-bin regression for quoted hash + blocked command
(echo "#" followed by blocked command must be caught)
- All 10 tests pass

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-27 11:11:46 +08:00
axelray-devandXubin Ren aa6c1bf300 fix(exec): prevent allowPatterns bypass via chained commands and shell comments 2026-06-27 11:11:46 +08:00
chengyongruandXubin Ren 5281e67222 fix(docker): repair whatsapp image build 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren dbb53109f4 build(docker): remove node from runtime image 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren b015515f30 docs(security): remove whatsapp bridge wording 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren be88e14424 docs(security): trim whatsapp migration note 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren 9e490ef473 docs(readme): restore changelog wording 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren bfb4246659 fix(docker): limit whatsapp bridge removal 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren fbf96a3502 fix(whatsapp): add bridge migration compatibility 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren 2a9e288dfe refactor(whatsapp): replace bridge with neonize 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren 3460ca3cb9 fix(agent): gate microcompaction on context pressure
Extract model-facing context governance from AgentRunner.

Only compact in-flight tool results when the model request is over budget, keep compacted IDs stable within a turn, and allow the newest result to be compacted as a last resort when it is the remaining source of overflow.
2026-06-27 11:04:41 +08:00
chengyongruandXubin Ren 9b45fc1172 fix(session): remove message time replay prefixes 2026-06-27 11:04:36 +08:00
chengyongruandXubin Ren 8656549129 test: cover exec login default public path
maintainer edit: add a regression test for the public ExecTool.execute path so omitted login stays non-login by default, and update the Unix environment docstring to match the new explicit login behavior.
2026-06-27 11:04:31 +08:00
axelray-devandXubin Ren 4c1f127549 test: update login-shell assertion for new default=False 2026-06-27 11:04:31 +08:00
axelray-devandXubin Ren 13c951aa41 fix: change exec login-shell default from true to false (#4518)
The exec tool defaults login=True for bash/zsh, which causes the shell
to source ~/.bash_profile and similar startup files. This reintroduces
secrets from shell startup files into the exec environment, even though
_build_env() intentionally starts with a curated environment.

Change the default to login=False in both _prepare_command() and _spawn(),
and update the schema default accordingly.
2026-06-27 11:04:31 +08:00
chengyongruandXubin Ren cd1fb61eb5 ci: relax webui install lock check 2026-06-27 11:04:11 +08:00
chengyongruandXubin Ren e79cb816e3 ci: pin bun for webui job 2026-06-27 11:04:11 +08:00
chengyongruandXubin Ren 64901be67f test: harden webui and gateway checks 2026-06-27 11:04:11 +08:00
chengyongruandXubin Ren 9ce9d2235a docs: clarify heartbeat versus cron delivery 2026-06-27 11:04:08 +08:00
Xubin Ren 06d5495b60 docs: document subagent tool error behavior 2026-06-25 22:53:36 +08:00
axelray-devandXubin Ren 851a0ff50c fix: make subagent fail_on_tool_error configurable (#4198)
Add fail_on_tool_error to AgentDefaults and wire it through
AgentLoop -> SubagentManager -> AgentRunSpec.

Previously hardcoded to True in SubagentManager._run_subagent.
Now configurable via config.json with default True for backward
compatibility. When set to False, subagents can retry on minor
tool errors instead of immediately failing.

Changes:
- nanobot/config/schema.py: add fail_on_tool_error field (default True)
- nanobot/agent/subagent.py: accept and forward fail_on_tool_error
- nanobot/agent/loop.py: pass config through to SubagentManager
- tests/agent/test_subagent.py: add regression test

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-25 22:53:36 +08:00
Xubin Ren 3596ccf828 docs: explain custom provider thinking style 2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren d1ae73a8a8 fix: add clear error message for invalid thinking_style values
Widen thinking_style from Literal to str | None and add a
@field_validator that produces a helpful error message listing
valid options when an invalid value is provided.

Addresses the review feedback on #4482.
2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren c661012754 fix: coalesce None thinking_style to empty string in provider creation
ProviderConfig.thinking_style defaults to None (Optional field), but
create_dynamic_spec expects a string. Coalesce None to "" at all call
sites (factory.py, settings_api.py) and fix the test assertion to
expect None from the config default.
2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren 0e19ea3062 fix: validate thinking_style against known values at config load time 2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren ceae6d7b61 fix: allow custom provider to configure thinking style (#4429) 2026-06-25 22:53:15 +08:00
Xubin Ren 34f776b48b test(cli): lock disabled dream cursor advancement 2026-06-25 22:53:10 +08:00
axelray-devandXubin Ren f7b027a295 fix: only advance dream cursor when behind latest (#4242)
Address review: avoid overwriting cursor on every restart when Dream
is disabled. Now only advances if current cursor is behind the latest
position, so repeated restarts don't permanently skip entries.
2026-06-25 22:53:10 +08:00
axelray-devandXubin Ren 6c880a6691 fix: advance dream cursor when Dream is disabled to prevent prompt bloat (#4242)
When dream.enabled is false, the Dream cron job never runs, so the
dream cursor (.dream_cursor) stays at its initial value (0). This
causes read_recent_history_for_prompt() to treat every history entry
as unprocessed, injecting the full chat history into every system
prompt and growing without bound.

Fix: fast-forward the dream cursor to the latest history entry at
gateway startup when Dream is disabled.
2026-06-25 22:53:10 +08:00
Xubin Ren 4636c78100 test(webui): cover xiaomi mimo wav recording path 2026-06-25 22:52:55 +08:00
zpljd258andXubin Ren 28c8c89a42 fix(webui): convert WebM to WAV for Xiaomi MiMo ASR transcription
MiMo ASR (mimo-v2.5-asr) only accepts audio/wav, audio/mp3, and
audio/mpeg formats. Web browsers record in WebM/Opus by default,
causing the API to reject the payload with a transcription error.

This change adds a frontend WebM→WAV converter using the Web Audio API
(DecodeAudioData + PCM encoding) that activates only when the
configured transcription provider is 'xiaomi_mimo'. Other providers
are unaffected — they continue to receive the original browser format.

Tested and confirmed working on WebUI.
2026-06-25 22:52:55 +08:00
7899857201 refactor(cli): simplify onboard search-provider dispatch
- Extract _set_field_from_choices for the shared pick-and-set tail used by
  both the LLM and search provider handlers.
- Replace the single-entry _TYPED_FIELD_HANDLERS registry with a direct
  isinstance check in _resolve_field_handler (robust to renames, no
  class-name strings).
- Drop a redundant str() in the search default computation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:52:50 +08:00
9354b80a6e fix(cli): show search engines (incl. Keenable) in onboard wizard
The onboard wizard dispatched field handlers by bare field name, so
WebSearchConfig.provider was hijacked by the LLM-provider handler and
showed LLM providers instead of search engines. Keenable was also never
wired into the CLI wizard when it landed in the WebUI.

- Add a single source of truth for selectable search providers
  (SEARCH_PROVIDER_OPTIONS in web.py); WebUI settings now import it.
- Add a WebSearchConfig-aware search-provider picker to the wizard and
  resolve handlers by (model type, field name) so the LLM and search
  provider fields no longer collide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:52:50 +08:00
Max HsuandXubin Ren 638af123ba fix(dingtalk): preserve richText formatting and set HTTP client timeout
richText messages only kept rich_text_list items whose type was
"text", so bold/italic/inlineCode/pre segments were silently dropped;
a message made entirely of formatted segments produced empty content
and fell through to the "unsupported message type: richText" warning.
Now any item carrying text is kept (matching the SDK's own
get_text_list, which keys off the "text" field) and its type is mapped
to Markdown so the formatting survives into the agent's context.

Text and downloadCode within a rich-text item are handled
independently (the SDK treats them as separate via get_text_list /
get_image_list), so an item carrying both a caption and an attachment
no longer drops the file.

The shared httpx.AsyncClient was created without a timeout, so all
requests (including large file/image downloads) used httpx's 5s
default and hit ConnectTimeout/ReadTimeout on uploads. Set an explicit
httpx.Timeout (connect=10s, read/write=30s).

Adds regression tests for formatted-segment preservation, the
all-formatted no-drop case, the text+downloadCode item, and the client
timeout configuration.

Closes #4497
2026-06-25 22:52:45 +08:00
michaelxerandXubin Ren 42aa37cfc0 docs: update enabledTools docs and schema comment to reflect resource/prompt gating
The enabledTools gate now also controls MCP resource and prompt
registration (not just tools). Update the configuration docs and
schema field comment to document this behavioral change.

Refs: #4435, #4436
2026-06-25 16:10:37 +08:00
michaelxerandXubin Ren 03302c751f log info when resources/prompts skipped due to enabledTools gate
Address chengyongru review: bump skip message from silent to logger.info
so operators get a visible trace when resources/prompts are not registered.
2026-06-25 16:10:37 +08:00
michaelxerandXubin Ren 246ea8ef61 fix(tools): gate MCP resource and prompt registration behind enabledTools
The enabledTools allowlist was only enforced for MCP tools returned by
session.list_tools(). Resources and prompts from session.list_resources()
and session.list_prompts() were registered unconditionally, allowing a
deny-all or restrictive enabledTools config to leak resource and prompt
capabilities to the model.

Now resources and prompts are only registered when allow_all_tools is
true (default ["*"] wildcard). Any explicit tool restriction — including
enabledTools: [] (deny-all) or a list of specific tool names — also
blocks resource and prompt registration from that server.

Fixes #4435
2026-06-25 16:10:37 +08:00
chengyongruandXubin Ren f60b3c7920 docs: explain Telegram rich messages opt-in
maintainer edit: document that richMessages defaults to false, when to enable it, and why Telegram Web users should leave it disabled.
2026-06-25 16:10:33 +08:00
chengyongruandXubin Ren e92899607a fix: make Telegram rich messages opt in
maintainer edit: Telegram Web cannot render sendRichMessage payloads, so keep the rich path available only for explicit opt-in instead of enabling it by default.
2026-06-25 16:10:33 +08:00
axelray-devandXubin Ren c930aa3713 fix: add rich_messages config to disable sendRichMessage for Telegram Web (#4488) 2026-06-25 16:10:33 +08:00
chengyongruandXubin Ren 4378944459 test: speed up test suite 2026-06-25 16:10:28 +08:00
chengyongruandXubin Ren 123384975e fix(webui): restore code block copy fallback 2026-06-25 16:10:23 +08:00
chengyongruandXubin Ren 943191f0c0 fix(webui): keep multi-file apply_patch edits 2026-06-24 20:04:39 +08:00
Xubin Ren c915e98c15 test: cover archived heartbeat target selection 2026-06-24 15:45:49 +08:00
Heng Wei BinandXubin Ren de4009efbd fix: exclude archived keys in heartbeat & fallback missing session timestamps 2026-06-24 15:45:49 +08:00
hyoukadevandXubin Ren 9c6eaf0bed test: deduplicate proxy value and construct tool via constructor
- Use a local variable for the proxy URL instead of hardcoding it twice
- Pass proxy through the WebSearchTool constructor instead of mutating
  after instantiation (matches real usage path)
- Add assertion that timeout is still forwarded correctly
- Use generic mock data instead of test-specific strings
2026-06-24 15:45:44 +08:00
hyoukadevandXubin Ren c66a0217d2 fix(web): pass proxy to DDGS client
The DuckDuckGo search provider instantiated DDGS(timeout=10) without
passing the configured proxy, making web_search unusable in environments
that require a proxy (e.g. behind GFW). DDGS supports a proxy parameter
and the proxy value is already available as self.proxy — it was simply
not forwarded.

Add a test verifying the proxy kwarg is forwarded to DDGS.
2026-06-24 15:45:44 +08:00
yorkhellenandXubin Ren 319791cd10 fix(config): preserve dream cron when saving config 2026-06-24 15:45:39 +08:00
chengyongruandXubin Ren a584ffe92d refactor: trim thinking tag helper setup
maintainer edit: remove unused self-closing tag derivation and duplicate reasoning partial cleanup after ponytail review.
2026-06-24 15:45:34 +08:00
chengyongruandXubin Ren 1e22932313 refactor: centralize thinking tag patterns
maintainer edit: derive thinking tag regexes and streaming partial prefixes from one tag list so future aliases only need one entry while preserving legacy self-closing think/thought behavior.
2026-06-24 15:45:34 +08:00
chengyongruandXubin Ren 98dd883ce8 fix: buffer split reasoning wrapper deltas
maintainer edit: native reasoning streams can split <thinking> wrapper tags across chunks. Buffer the stream and emit only cleaned incremental reasoning so raw partial tags do not reach WebUI.
2026-06-24 15:45:34 +08:00
ZhouandXubin Ren 35bd1be109 fix: ignore non-string reasoning wrappers 2026-06-24 15:45:34 +08:00
ZhouandXubin Ren 596bf5398c test: cover empty thinking marker streaming 2026-06-24 15:45:34 +08:00
ZhouandXubin Ren 523bb928bf fix: normalize thinking tags in reasoning output 2026-06-24 15:45:34 +08:00
Xubin Ren f9afc9389b fix(providers): apply Kimi Coding default headers 2026-06-24 10:43:16 +08:00
chengyongruandXubin Ren 9d6c606cc9 docs: document kimi coding provider
Maintainer edit: add the provider reference row and a pasteable cookbook recipe so users know to select kimi_coding and set the required User-Agent header.
2026-06-24 10:43:16 +08:00
NanoBotandXubin Ren 44817b75c6 feat(provider): add kimi_coding provider for Kimi Coding Plan
Add a dedicated provider entry for the Kimi Coding Plan endpoint
(api.kimi.com/coding) using the Anthropic Messages API transport.

- Register kimi_coding with backend=anthropic
- Use KIMI_CODING_API_KEY env key to avoid clashing with MOONSHOT_API_KEY
- Default api_base set to https://api.kimi.com/coding/v1 so that
  AnthropicProvider._normalize_base_url() + the SDK produce the correct
  /coding/v1/messages request path
- Keywords include kimi-coding, kimi_coding and kimi-for-coding

Closes HKUDS/nanobot#4463
2026-06-24 10:43:16 +08:00
chengyongruandXubin Ren c55f7ec5bb style: trim pairing sender-id comments
maintainer edit: remove redundant explanatory comments from the focused sender-id normalization tests and store change without changing behavior.
2026-06-24 10:29:58 +08:00
w.antarandXubin Ren d7f868b832 fix(pairing): also coerce sender_id in approve_code() 2026-06-24 10:29:58 +08:00
w.antarandXubin Ren d481d5fb1a fix(pairing): normalize sender IDs to str in the pairing store 2026-06-24 10:29:58 +08:00
chengyongruandXubin Ren bc1df49201 fix(gateway): handle lifecycle edge cases 2026-06-24 10:29:08 +08:00
chengyongruandXubin Ren 7826f8f89c docs: document runtime environment variables 2026-06-24 10:28:15 +08:00
chengyongruandXubin Ren b14f82f408 fix(webui): prevent iOS Safari composer zoom 2026-06-24 10:26:14 +08:00
chengyongruandXubin Ren 6d989de336 docs: document OpenCode provider setup
Maintainer edit: document OpenCode Zen and Go configuration, keep their registry entries with gateway providers, and add focused provider registration tests.
2026-06-24 10:25:12 +08:00
zpljd258andXubin Ren ddad6c5a7c feat(providers): add OpenCode Zen and OpenCode Go providers 2026-06-24 10:25:12 +08:00
David JimenezandXubin Ren 4b1decdb95 chore: bump to node 24 2026-06-24 10:23:07 +08:00
chengyongruandXubin Ren 23dc253f89 refactor: simplify Anthropic tool id remapping
maintainer edit: remove duplicate counter state and use the seen id set directly when choosing duplicate suffixes.
2026-06-24 10:21:48 +08:00
chengyongruandXubin Ren 0e9861558a fix: keep duplicate id repair in Anthropic provider
maintainer edit: move duplicate tool_use history repair out of AgentRunner and into Anthropic message conversion, reusing the OpenAI-compatible queue-mapping approach locally without broadening the shared runner path.
2026-06-24 10:21:48 +08:00
chengyongruandXubin Ren 853aecdb97 fix: preserve duplicate-id tool calls
maintainer edit: remap duplicate tool_use/tool_call ids instead of dropping later calls, so Anthropic-compatible providers that reuse ids for distinct parallel tool calls keep all requested work while still sending unique ids.
2026-06-24 10:21:48 +08:00
Teddy YanandXubin Ren 6b8e832ba5 fix: address PR review comments - rename _dedup to _dedupe and fix ID storage consistency 2026-06-24 10:21:48 +08:00
6689e2d377 fix(providers): dedupe tool_use ids to prevent Anthropic 400s
Anthropic rejects any request where two tool_use blocks share an id
("messages.N.content.M: tool_use ids must be unique"). A mis-assembled
stream could surface the same tool_use block twice in one assistant turn;
the runner persisted it verbatim, so the malformed message was re-sent on
every subsequent turn and permanently bricked the session — the agent
silently stopped replying.

Fix at two layers:
- AnthropicProvider._parse_response: drop duplicate tool_use ids (keep
  first) as the response enters nanobot, so corruption is never persisted.
- AgentRunner._dedup_tool_calls: a new context-governance pass that dedupes
  assistant tool_calls and tool results by id before each send, healing any
  history that was already corrupted.

Add regression tests covering both the dedup and the no-op fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:21:48 +08:00
axelray-devandXubin Ren 160cec2396 fix: skip sendRichMessage when streaming preview exists (#4470)
When _stream_end fires and a streaming preview already exists, the
sendRichMessage path deletes the preview and sends a fresh message.
This causes line break loss and visible flickering. Gate the rich
path on not buf.message_id so existing previews use the legacy
edit_message_text path instead.
2026-06-24 10:19:14 +08:00
Xubin Ren d2da6df14e docs: align release news dates 2026-06-23 09:50:44 +08:00
Xubin Ren 701ae5563d docs: add v0.2.2 release news 2026-06-23 09:47:23 +08:00
Xubin Ren e2e75c913f docs: add June 20 news entry 2026-06-22 23:55:10 +08:00
Xubin Ren 87f3e08ff7 docs: remove unreleased 0.2.2 news entry 2026-06-22 23:43:15 +08:00
Xubin Ren 951fd73c8e fix(webui): keep new chat heading on one line 2026-06-22 23:30:29 +08:00
Xubin Ren 90703002c9 fix(gateway): restore tty signal mode for ctrl-c 2026-06-22 23:20:45 +08:00
Xubin Ren e624943bac fix(gateway): tolerate cancelled channel tasks during shutdown 2026-06-22 23:20:45 +08:00
Xubin Ren f80a78d5a8 fix(webui): preserve fork replies during history refresh 2026-06-22 22:00:07 +08:00
Xubin Ren 747104c9cc fix(gateway): make foreground shutdown responsive 2026-06-22 22:00:07 +08:00
Xubin Ren a9d1fdcee8 fix(webui): follow active turn output after send 2026-06-22 21:12:34 +08:00
Xubin Ren 83c29292d3 chore(release): prepare v0.2.2 2026-06-22 20:53:28 +08:00
Xubin Ren 7170761e47 fix(webui): anchor sent prompts during active turns 2026-06-22 20:43:27 +08:00
Xubin Ren fbaa85117b fix: close MCP stdio transports from agent task 2026-06-22 18:59:50 +08:00
Xubin Ren 6efef2700a docs: align my tool context window examples 2026-06-22 18:33:49 +08:00
chengyongruandXubin Ren 0db9fbe250 chore: default context window to 200k 2026-06-22 18:33:49 +08:00
chengyongruandXubin Ren 991422a328 refactor: simplify CLI Apps route await 2026-06-22 17:14:13 +08:00
chengyongruandXubin Ren a67285e6a2 fix: use async CLI Apps catalog refresh
Replace the manual thread-based catalog refresh with an asyncio task and async HTTP catalog fetches so the Settings route stays within the async WebUI model.
2026-06-22 17:14:13 +08:00
chengyongruandXubin Ren dd2cb4ca91 fix: refresh optional CLI Apps catalogs
maintainer edit: CLI Apps settings now treats optional catalog caches as refresh candidates without blocking the initial payload, and pending polling stops when refresh is throttled instead of running indefinitely.
2026-06-22 17:14:13 +08:00
chengyongruandXubin Ren 2216405821 fix: avoid stuck Apps loading during catalog refresh
maintainer edit: Empty cache-only CLI Apps payloads should still update the UI while the background catalog refresh is pending, otherwise cold catalog failures leave Settings stuck on the spinner.
2026-06-22 17:14:13 +08:00
chengyongruandXubin Ren 1cd5a0e029 fix: keep refreshable Codex OAuth configured
maintainer edit: Settings reads local Codex token storage to avoid refresh work, so expired access tokens with refresh credentials still need to count as configured until real provider use refreshes them.
2026-06-22 17:14:13 +08:00
chengyongruandXubin Ren b8abe5542c Avoid blocking settings on CLI Apps catalog refresh 2026-06-22 17:14:13 +08:00
chengyongruandXubin Ren 7b153c5aa9 Avoid refreshing Codex token in settings 2026-06-22 17:14:13 +08:00
Xubin Ren e3c9aff41e feat(gateway): add background and service controls 2026-06-22 14:48:49 +08:00
Xubin Ren 351aabd512 fix: polish onboard wizard keyboard navigation 2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 9b52202f4a fix: make quick start rollback on websocket failure
maintainer edit: stage Quick Start edits on a draft config so declining WebSocket/password setup cannot leave saveable provider defaults behind. Align beginner WebUI docs with the password-protected setup.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 9713fe2a3b fix: avoid initial webui password error 2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren fca1f2ad02 fix: secure quick start webui setup
Add Quick Start endpoint choices for subscription plan providers, require explicit WebSocket confirmation, and require a WebUI password when enabling the WebSocket channel. Update docs to route Quick Start users through the WebUI instead of agent -m.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren e5294002ed fix: broaden quick start provider setup
Drive Quick Start provider choices from the provider registry instead of a short allowlist. Clean up parenthetical wizard labels and keep the beginner docs in sync.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren eb14720381 fix: ask for quick start model id
Remove automatic Quick Start model discovery. Users now explicitly enter the model ID after choosing the provider and API key, and incomplete Quick Start input does not leave partial provider config behind.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren d14b692368 refactor: simplify quick start onboarding
Cleanup-only simplification: remove a one-entry dispatch table, avoid unused provider tuple unpacking, and strip the selected model once before storing it.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 8e9f09f829 fix: keep onboard cli defaults unchanged
Remove the PR changes that made nanobot onboard default to the wizard, added --defaults, added non-TTY fallback behavior, and changed the Docker smoke command. Keep Quick Start available through nanobot onboard --wizard.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren da225fc24e fix: fetch openai quick start models
Use OpenAI's SDK default base URL for Quick Start model discovery without writing that default into the saved provider config.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 71631f9ab0 fix: ask provider in quick start
Maintainer edit: replace Quick Start key/base detection with an explicit provider-first flow. Users choose the provider that issued the API key, paste the key, and only custom OpenAI-compatible setups ask for a base URL.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 2319b660e6 fix: make quick start provider neutral
Maintainer edit: restart the Quick Start flow around an API-key-first path without recommending OpenRouter or DeepSeek. Detect unique key prefixes locally, fall back to a user-provided base URL, and only fetch models from that approved URL.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren ed3a8f64d8 fix: use deepseek for quick start onboarding
Maintainer edit: restart the Quick Start default around a mainland-friendly provider instead of making OpenRouter the first-run dependency. Update wizard copy, default preset, focused tests, and beginner docs to use DeepSeek with the current deepseek-v4-flash model.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 0e9b136315 fix: streamline quick start onboarding flow
Maintainer edit: continue the wizard simplification pass by making Quick Start save after the API key path, hiding save/summary actions until they are needed, removing failed-key side effects, and aligning beginner docs with the local WebUI path.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren fa5f7f5b88 fix: make onboarding api-key first
Maintainer edit: collapse the first-run wizard to an API-key-only Quick Start and move lower-frequency provider, model, channel, gateway, and tool settings behind Advanced Settings.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 1dbec3da50 fix: simplify beginner quick start
Maintainer edit: reduce the default onboarding path to a recommended local WebUI setup that only asks for an OpenRouter key, while keeping the detailed provider/channel flow available for advanced setup.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 84143d31b2 refactor: reuse onboard config refresh path
Simplify pass: share the existing-config refresh flow between non-interactive defaults and declined overwrite prompts without changing behavior.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 3d773d9054 fix: preserve config during non-interactive defaults onboarding
Maintainer edit: make explicit --defaults refresh existing configs without prompting when no TTY is available, preserving user values for CI and Docker runs.
2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 9c7d1c9507 Use channel login in onboarding wizard 2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren db68fc1c09 Use provider registry in quick start 2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren f9f5a19910 Route WebUI quick start through channel config 2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren 0097488eed Keep quick start channels opt-in 2026-06-22 13:04:05 +08:00
chengyongruandXubin Ren fc7971b3b6 Improve onboard wizard setup flow 2026-06-22 13:04:05 +08:00
Xubin Ren 9db3dc5e32 docs(readme): update news through 2026-06-20 2026-06-21 17:14:53 +08:00
Xubin Ren dbf3c4b245 feat(sdk): expand Python runtime controls 2026-06-21 16:55:23 +08:00
Xubin Ren f4cc001410 refactor(sdk): pass run hooks explicitly 2026-06-21 15:59:28 +08:00
Xubin Ren b6a9a9728a test(sdk): cover concurrent run hook isolation 2026-06-21 15:59:28 +08:00
michaelxerandXubin Ren a19725bc57 docs: add note about ephemeral guard and per-call hooks contextvar
Document that SDK callers always go through run() → process_direct(ephemeral=False),
so per_call hooks are intentionally excluded from ephemeral turns per chengyongru
review feedback.
2026-06-21 15:59:28 +08:00
michaelxerandXubin Ren 345ef80571 fix: update facade tests for contextvar hooks + fix ephemeral guard
The test_run_populates_tools_used_across_iterations,
test_run_populates_final_messages, and
test_run_user_hooks_still_fire_alongside_capture tests were reading
bot._loop._extra_hooks directly in their fake_process_direct mocks,
but the contextvar change moved per-call hooks to _per_call_hooks.

Also fixes the ephemeral guard: per_call hooks should not be used in
ephemeral mode (the original code incorrectly applied them regardless).
2026-06-21 15:59:28 +08:00
michaelxerandXubin Ren 0bb1b0b3c2 fix(sdk): use contextvars for per-call hooks to prevent concurrent run() race
Nanobot.run() previously mutated the shared self._loop._extra_hooks
attribute under a try/finally. When two run() calls with different
session_keys execute concurrently, they overwrite each other's hook
lists — the second call saves the first call's hooks as 'prev', and
the finally block restores stale state.

Use a contextvars.ContextVar instead, which is per-task in asyncio.
This gives each concurrent run() call its own hook list without
changing any function signatures. Falls back to self._extra_hooks
when no per-call hooks are set (non-SDK usage).
2026-06-21 15:59:28 +08:00
Xubin Ren fede2ecfc2 test(websocket): expect optional Keenable search key 2026-06-21 15:22:13 +08:00
Xubin Ren d30f3d466d fix(webui): allow optional Keenable search key 2026-06-21 15:22:13 +08:00
5feb6f485f refactor(web): use module constant for Keenable search URL
Match the _BOCHA_SEARCH_API_URL / _VOLCENGINE_SEARCH_API_URL convention
instead of hardcoding the URL inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:22:13 +08:00
74daa81a1b feat(web): allow Keenable search without an API key
Keenable's public endpoint serves the free tier (1000 req/hour) without
auth. Route to /v1/search/public with the X-Keenable-Title header when no
key is configured, instead of falling back to DuckDuckGo; keep the
authenticated /v1/search path when an apiKey or KEENABLE_API_KEY is set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:22:13 +08:00
yu-xin-candXubin Ren a5768a4ebe fix(tools): reject unknown builtin parameters 2026-06-21 15:22:09 +08:00
Xubin Ren 85036bacb6 test(telegram): cover rich message fallback latch 2026-06-21 15:22:05 +08:00
NanoBotandXubin Ren a8c65b50ca fix: narrow rich capability error detection to prevent false latch
- Remove overly broad 'not found' match that could trigger on transient
  errors like 'chat not found' or 'message to reply not found'
- Keep only 'method not found' and 'unknown method' which indicate
  the server genuinely doesn't support sendRichMessage
- Demote timeout log from warning to debug since it falls back to
  legacy path successfully
2026-06-21 15:22:05 +08:00
NanoBotandXubin Ren e9494c1dce feat(telegram): add Bot API 10.1 sendRichMessage support
- Try sendRichMessage for all non-blockquote messages (tables, task lists,
  math, collapsible, etc.) with graceful fallback to legacy HTML
- Fix payload format to use top-level 'markdown' field per Bot API 10.1 spec
- Add rich path in send_delta() stream end (delete preview + send rich)
- Latch off permanently if server returns capability error
- Removes _needs_rich_rendering() gate and 4 regex patterns
2026-06-21 15:22:05 +08:00
Xubin Ren 98916a31e3 perf(tokens): document tool schema cache assumption 2026-06-21 13:06:34 +08:00
yu-xin-candXubin Ren 7275a81ec7 perf(tokens): cache tool schema estimates 2026-06-21 13:06:34 +08:00
Xubin Ren a81c5e704a fix(memory): reject negative history cursors 2026-06-21 13:05:57 +08:00
Stellar鱼andXubin Ren be058c0922 fix(memory): keep history cursor monotonic 2026-06-21 13:05:57 +08:00
franciscomaestreandXubin Ren 99e158c062 feat(whatsapp): seed LID->phone mappings on startup
WhatsApp can deliver a sender LID instead of a phone number. The channel
already learns the LID->phone mapping at runtime, but only after a message
that carries both values, so the first message from a contact can't be
resolved to a phone number.

Seed the mapping on startup from two sources:

- reverse mapping files the bridge persists in the auth directory
  (lid-mapping-<lid>_reverse.json), resolved via get_runtime_subdir so it
  respects a custom runtime dir
- a new optional channels.whatsapp.lidMappings config dict for static
  mappings (takes precedence over the on-disk files)

Malformed/empty mapping files are ignored rather than failing startup.
Adds tests for both sources, precedence, malformed files and the
no-auth-dir case, plus docs for the new config field.
2026-06-21 13:05:06 +08:00
Xubin Ren e81968a32b test: isolate MCP timeout regression from DNS 2026-06-20 19:12:36 +08:00
Xubin Ren 0eae7ceb1a test: use safe URL for MCP timeout regression 2026-06-19 21:07:51 +08:00
Xubin Ren bc8f42c639 test(mcp): cover streamable HTTP timeout 2026-06-19 21:07:51 +08:00
Luc NguyenandXubin Ren d7abf39169 fix: set httpx timeout for streamableHttp transport to prevent event loop blocking
timeout=None causes the httpx client to wait indefinitely when connecting
to MCP servers via streamableHttp transport, blocking the entire event loop.
This prevents Telegram polling and other async operations from running.

Set timeout=httpx.Timeout(30.0, connect=10.0) to match the pattern used
in the SSE transport's httpx_client_factory.
2026-06-19 21:07:51 +08:00
yorkhellenandXubin Ren 33638417be fix(session): delete_session also removes legacy path files to prevent history revival
SessionManager._load() migrates sessions from the legacy directory
(~/.nanobot/sessions/) to the workspace path, but delete_session only
checked the workspace path. A user deleting a session could therefore
see its history come back the next time the session was loaded.

- delete_session now attempts to unlink both paths
- returns True if at least one file was removed
- added regression tests: legacy-only, both-paths, and no-revival
2026-06-19 21:07:47 +08:00
sbyininandXubin Ren 6da56f3574 Fix OpenAI reference image home expansion test on Windows 2026-06-19 17:16:09 +08:00
sbyininandXubin Ren 44f7bbae50 fix openai image reference home paths 2026-06-19 17:16:09 +08:00
sbyininandXubin Ren adb737b614 fix openai image reference edits 2026-06-19 17:16:09 +08:00
chengyongruandXubin Ren dd4d410ce2 refactor(feishu): simplify table extraction
Maintainer edit: remove speculative recursive cell handling and keep table extraction to the documented columns and row values.
2026-06-19 17:16:04 +08:00
chengyongruandXubin Ren 215379ac63 fix(feishu): extract table card rows
Maintainer edit: parse Feishu card table columns and rows so forwarded table cards do not fall back to [interactive], and add parser regression coverage.
2026-06-19 17:16:04 +08:00
Jiajun XieandXubin Ren bc59fe8719 fix(feishu): support reading WebSocket rendered card content
Feishu cards arriving via WebSocket have a different structure than
expected: elements are nested lists with tag:text/text fields instead
of flat lists with tag:markdown/content. Also extract user_dsl for
richer card data and support body.elements (schema 2.0).
2026-06-19 17:16:04 +08:00
Xubin Ren 58a14d18dd test: tighten image placeholder assertions 2026-06-19 14:59:52 +08:00
Xubin Ren de5e216583 test: fix image strip test formatting 2026-06-19 14:59:52 +08:00
michaelxerandXubin Ren 7917466f4e fix(tests): update image placeholder assertions for new non-descriptive text
The _strip_image_content methods now use a fixed non-descriptive
placeholder instead of path-derived text. Update the 3 existing
test_provider_retry assertions to match the new placeholder format.
2026-06-19 14:59:52 +08:00
michaelxerandXubin Ren 9ed3905a23 fix(providers): use non-descriptive placeholder when stripping images
The image-strip fallback (triggered when a model errors on image input)
replaced image_url blocks with [image: <path>] or [image omitted]. Both
read like a live, available image to the LLM, causing it to:

1. hallucinate about image contents it never received
2. attempt read_file on the leaked server path
3. expose internal file paths to the model

Replace with an explicit '[Image not delivered to model — do not describe
or reference it]' placeholder that tells the LLM the image was stripped.

Fixes #4345
2026-06-19 14:59:52 +08:00
Xubin Ren bbd7bbd7f5 fix(mcp): avoid relying on progress notification root shape 2026-06-19 14:59:48 +08:00
yu-xin-candXubin Ren f9511049c4 fix(mcp): ignore malformed progress notifications 2026-06-19 14:59:48 +08:00
nanobot-contributorandXubin Ren c2c47f7a03 fix(fallback): treat empty API choices as fallbackable error
When the primary model (e.g. DeepSeek during peak hours) returns an empty
choices response with HTTP 200, the error carries no status code or
structured error metadata. The existing _FALLBACK_ERROR_TOKENS had no
matching token, so _should_fallback() returned False and fallback models
were never tried.

Changes:
- Add 'empty' token to _FALLBACK_ERROR_TOKENS so 'Error: API returned
  empty choices.' text matches the fallback path
- Set error_kind='empty' in openai_compat_provider when returning
  the empty-choices error, making the classification explicit
- Add test coverage for both text-only and error_kind matching paths

Fixes: glebov reported primary never falls back when DeepSeek returns
       empty responses
2026-06-19 14:59:44 +08:00
Xubin Ren d36117de7a feat(webui): support Firecrawl keyless MCP preset 2026-06-19 01:19:20 +08:00
chengyongruandXubin Ren 2d86094fc7 ci: skip docs-only changes 2026-06-18 22:47:43 +08:00
chengyongruandXubin Ren 8ca3f42a3b docs: note feishu login URL fallback
maintainer edit: document that Feishu login prints a URL when optional terminal QR rendering is unavailable.
2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren 71d0593b06 fix: render feishu login with rich
maintainer edit: replace plain progress prints with Rich output for the interactive Feishu QR login flow.
2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren 04777bf6f1 fix: polish feishu login prompts
maintainer edit: make the Feishu QR login output read like a user flow instead of development logs.
2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren 08a5f4cbbb fix: keep feishu login URL unmodified
maintainer edit: remove nonessential tracking parameters from the Feishu QR login URL after the author confirmed the flow works without them.
2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren 5dfdd4f892 fix: handle feishu login network errors
maintainer edit: keep QR login network failures on the expected failure path instead of crashing the channels login command.
2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren a56cd9710b fix: simplify feishu QR login 2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren 99846da6f3 fix: trim feishu login path output
maintainer edit: remove leftover config-path printing from Feishu channel login while keeping the generic success message.
2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren cd51654bf1 fix: keep channel login generic
maintainer edit: remove the Feishu-specific config-file guard from the shared CLI login command. Feishu now follows the Weixin pattern where channel.login owns its setup and persistence.
2026-06-18 22:38:06 +08:00
chengyongruandXubin Ren 3d386f7b7e fix: stabilize feishu login setup
maintainer edit: fix the lint failure, report the active config path, fail fast when the registration response lacks a login URL, and cover the new Feishu login writeback path.
2026-06-18 22:38:06 +08:00
593ab4a788 feat(feishu): add QR scan-to-create bot CLI login feishu command
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:38:06 +08:00
Xubin Ren a1a627839c docs: include keenable in search provider list 2026-06-18 00:08:52 +08:00
d7280da17c fix(web): require API key for Keenable, fall back to DuckDuckGo
Manual testing against the live API showed the Keenable REST endpoint
(/v1/search) returns 401 without a key — the keyless "free tier" applies
only to the CLI, not the HTTP API. Treat Keenable like every other
key-based provider: fall back to DuckDuckGo when no key is configured,
and drop the now-inaccurate free-tier wording from the docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:08:52 +08:00
092b07c7aa feat(web): use shared user-agent and send X-Keenable-Title
Drop the bespoke nanobot/<version> User-Agent in favor of self.user_agent
for consistency with every other search provider, and add an
X-Keenable-Title: nanobot header so Keenable can attribute traffic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:08:52 +08:00
83cb296cdc style(test): match existing single-line result-dict style
Keep the Keenable search test consistent with the surrounding mocks
(test_tavily/test_brave) rather than introducing a multi-line outlier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:08:52 +08:00
fa3e902ee8 feat(web): register Keenable provider in WebUI, docs, and tests
Bring the Keenable search provider in line with the established
multi-file provider pattern so it surfaces everywhere the others do:

- settings_api.py: register in the web-search provider options so it
  appears in the WebUI settings dropdown (credential: api_key, optional).
- WebUI provider-brand: add keenable brand entry + brand test.
- docs/configuration.md: provider table row + config example.
- Harden _search_keenable: honor config.timeout and return explicit
  messages on HTTP status errors (429 / other), matching peer providers.
- Add env-key and HTTP-error tests; add the websocket settings whitelist
  assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:08:52 +08:00
4f6e5e9cb8 feat(web): send honest nanobot UA for Keenable
Keenable is a first-party API, so identify as nanobot/<version> instead
of the shared spoofed-browser User-Agent used by scraping providers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:08:52 +08:00
6fcf65a8e3 chore(web): drop redundant comments from Keenable provider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:08:52 +08:00
a630a78941 feat(web): add Keenable search provider
Add Keenable (https://keenable.ai) as a web_search backend, modeled on
the existing httpx-based providers. Unlike key-gated providers, Keenable
has a no-login free tier, so it resolves to itself even without an API
key instead of falling back to DuckDuckGo; the X-API-Key header is only
sent when a key is configured (config api_key or KEENABLE_API_KEY env).

Maps result snippet (falling back to description) into the shared
content field. Covered by tests for keyed/anonymous search and the
no-fallback concurrency behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:08:52 +08:00
d5f5eb43e5 feat(providers): first-class Mistral support
Mistral's API constrains reasoning_effort to "high"/"none", rejects the
kwarg entirely for Magistral (reasoning is implicit), returns assistant
content as a mixed array of {type:"thinking",...}/{type:"text",...}
blocks, and 400s on the reasoning_content key in history.

- Remap user-supplied reasoning_effort (low/medium/minimal) onto Mistral's
  two-tier vocabulary; strip the kwarg for Magistral models
- Lift thinking blocks into reasoning_content for both batch and streaming
  responses; pass only text through on_content_delta callbacks
- Drop reasoning_content from outbound history when the spec asks for it
- Expose per-preset reasoning_effort_values so the UI can render the
  provider-specific option set

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 00:08:47 +08:00
comadrejaandXubin Ren 51bd3337ef feat(bridge): send read receipts (blue ticks) for incoming messages
Mark each incoming WhatsApp message as read via sock.readMessages()
right after the startup-timestamp filter. Wrapped in try/catch so a
failed receipt never blocks message processing.
2026-06-18 00:08:42 +08:00
chengyongruandXubin Ren 3f41605ddc refactor: simplify exact-file allowlist check
maintainer edit: make the exact-file allowlist check express the invariant directly: the resolved target must still equal the requested logical path before the logical path can match the allowlist.
2026-06-18 00:03:26 +08:00
chengyongruandXubin Ren 515e418eb4 fix: block exact-file allowlist link escapes
maintainer edit: compare exact-file allowlist entries using logical paths and require the resolved target to stay at that canonical path, so symlinks or junctions cannot redirect canonical memory files to an external write target.
2026-06-18 00:03:26 +08:00
chengyongruandXubin Ren 42ce294665 fix: preserve internal tool write scopes
Maintainer edit: keep capability-specific allowed_dir boundaries active even when the outer workspace scope is full access, and cover Dream plus ordinary full-access filesystem behavior.
2026-06-18 00:03:26 +08:00
chengyongruandXubin Ren 51f2dae855 refactor: trim unused filesystem allowlist state
maintainer edit: simplify the exact-file allowlist follow-up by removing unused read-side state and keeping the exact path helper private to workspace_policy.
2026-06-18 00:03:26 +08:00
chengyongruandXubin Ren 15f218e918 fix: enforce exact Dream memory file writes
maintainer edit: Dream write tools used file paths as directory roots, so a missing canonical memory file could be treated as a parent directory. Add exact-file allowlist support and keep skills/ as the only Dream write directory.
2026-06-18 00:03:26 +08:00
chengyongruandXubin Ren 732992df4f Clarify filesystem workspace write policy 2026-06-18 00:03:26 +08:00
chengyongruandXubin Ren fc635377bc fix: avoid replaying older long turns
Treat the current live user message as the replay boundary for normal user turns, while keeping user-turn extension for history and consolidation paths that need it. Add regression coverage for the user-triggered long tool-turn case.
2026-06-18 00:03:22 +08:00
Xubin Ren 09962895fb fix(session): preserve user turns in replay history 2026-06-18 00:03:22 +08:00
chengyongruandXubin Ren 472c67722e fix(webui): correct activity duration display 2026-06-18 00:03:18 +08:00
chengyongruandXubin Ren b46aac0554 refactor: trim model preset tool handling
maintainer edit: remove duplicate documentation and inline one-use formatting so the PR keeps the same behavior with less code.
2026-06-18 00:03:13 +08:00
chengyongruandXubin Ren ef6da0a94e Fix my tool model preset switching 2026-06-18 00:03:13 +08:00
Xubin Ren bdf21c932b fix(anthropic): avoid sanitized tool id collisions 2026-06-18 00:03:08 +08:00
comadrejaandXubin Ren 4d7c2074e6 fix(anthropic): sanitize tool_use/tool_result IDs to API pattern
The Anthropic Messages API rejects tool IDs that don't match
`^[a-zA-Z0-9_-]+$` with a 400 error. Tool IDs originating from other
providers or restored multi-turn sessions can contain invalid
characters (pipes, dots). Add a deterministic _sanitize_tool_id() and
apply it to both the tool_use id and the matching tool_result
tool_use_id so the pair stays consistent.
2026-06-18 00:03:08 +08:00
michaelxerandXubin Ren 0023f6d998 fix(providers): remove custom cloud httpx client, let SDK handle proxy defaults
chengyongru reviewed #4367 and identified that the cloud  branch
created a bare httpx.AsyncClient that lacked the SDK's default settings
(follow_redirects, connection pool limits). Since the SDK's
DefaultAsyncHttpxClient already has trust_env=True and proper defaults,
the simplest fix is to let http_client stay None for cloud endpoints.

Also updated the test to match the new behavior (http_client is None).
2026-06-18 00:03:03 +08:00
michaelxerandXubin Ren 72b8fc806f fix(providers): disable proxy for local endpoints, respect env proxy for cloud
When the host has HTTP_PROXY / HTTPS_PROXY / ALL_PROXY set, httpx routes
all traffic through the proxy — including requests to localhost or LAN
addresses that the proxy typically cannot reach.  This breaks local model
servers (Ollama, llama.cpp, vLLM) silently.

- Local endpoints: pass transport=httpx.AsyncHTTPTransport(proxy=None)
  so proxy env vars are ignored for local traffic.
- Cloud endpoints: pass trust_env=True so corporate/VPN proxies work
  without explicit configuration.

Fixes #4366
2026-06-18 00:03:03 +08:00
HaisamandXubin Ren bfe5b64022 fix: allow git commands in workspace subdirectories
The shell safety guard in _guard_command() checked extracted absolute
paths only against cwd_path (the command's working directory).  When
cwd was a subdirectory like ~/.nanobot/workspace/obsidian_notes, any
absolute path referencing the broader workspace (e.g. a sibling
directory or the workspace root itself) was incorrectly blocked with
"path outside working dir".

Fix: pass the workspace_root from _prepare_command() into
_guard_command() and check absolute paths against it as a fallback
when they are outside cwd_path.  Paths truly outside the workspace
root are still blocked.

Added tests:
- test_exec_allows_workspace_paths_from_subdirectory
- test_exec_blocks_outside_paths_from_subdirectory
2026-06-18 00:02:58 +08:00
chengyongruandXubin Ren 4219911423 fix: recover failed Feishu streaming updates
Feishu CardKit content updates can fail without raising, leaving a blank Generating card while the final streamed response skips normal send. Reopen streaming mode and retry once, close blank cards when the first update fails, and fall back to a regular card when final updates still fail.
2026-06-18 00:02:54 +08:00
chengyongruandXubin Ren 8ecc2d69c4 fix: log primary model error before fallback 2026-06-18 00:02:49 +08:00
chengyongruandXubin Ren 0d4af68e63 fix: silence unroutable cli progress noise 2026-06-18 00:02:45 +08:00
Xubin RenandGitHub c29601d303 Merge PR #4330: feat(webui): add automation management view
feat(webui): add automation management view
2026-06-17 01:55:39 +08:00
Xubin Ren 9ce40969ce style(webui): add subtle automation filter tones 2026-06-17 01:49:24 +08:00
HaisamandXubin Ren 5847470b65 docs: use pipe pattern for curl installer commands
Replace `sh -c "$(curl ...)"` with `curl ... | sh` across all
documentation. The subshell pattern breaks when users embed the
command inside other scripts (e.g. Dockerfiles using here-docs),
because the outer shell eagerly expands `$(curl ...)` before writing
the script to disk, mangling the installer contents.

The pipe pattern avoids this problem and is friendlier to further
scripting. For commands that pass arguments (--dry-run, --dev),
`sh -s --` is used to forward them through stdin.
2026-06-17 00:59:36 +08:00
chengyongruandXubin Ren 43eb658a0b fix installer for externally managed Python 2026-06-17 00:48:13 +08:00
chengyongruandXubin Ren 4c5e340186 feat(memory): enable idle auto compact by default 2026-06-17 00:48:06 +08:00
chengyongruandXubin Ren dcf76117ab fix(command): explain empty dream runs 2026-06-17 00:47:59 +08:00
chengyongruandXubin Ren dcb33cf919 refactor: simplify token truncation loop
Maintainer edit: keep the strict token-budget behavior while removing the duplicate pre-loop result construction in the shared truncation helper.
2026-06-17 00:47:52 +08:00
chengyongruandXubin Ren 072921893f fix: reuse token truncation helper
Maintainer edit: make token truncation include the suffix within the budget and route the consolidator through the shared helper so recent-history and archive truncation keep the same semantics.
2026-06-17 00:47:52 +08:00
w.antarandXubin Ren 21d9072190 fix(tests): update recent history truncation to use token limits 2026-06-17 00:47:52 +08:00
w.antarandXubin Ren 973a5ee507 fix(context): cap recent-history digest by tokens, not characters
The recent-history section injected into the system prompt was capped by character count (_MAX_HISTORY_CHARS = 32_000). Characters are a poor proxy for tokens: ~32k chars of English is ~8k tokens, but the same char count of CJK text or code can be far more, so the cap could let the section blow well past its intended size on non English/code-heavy histories.

Add a reusable truncate_text_to_tokens() helper (reusing the tiktoken
 cl100k_base encoder already used elsewhere, with a char-based fallback) and
  switch the digest cap to a token budget (_MAX_HISTORY_TOKENS = 8_000),
  matching the previous English-text size while holding regardless of
  content.
2026-06-17 00:47:52 +08:00
yu-xin-candXubin Ren 846410f936 fix(providers): validate stream idle timeout config 2026-06-17 00:47:44 +08:00
Xubin Ren 7bec0f6e01 fix(api): honor skip-user persist through save boundary 2026-06-16 21:31:26 +08:00
04cbandXubin Ren d75f80437c fix(api): don't re-persist user turn on empty-response retry (#4079)
The non-streaming retry called process_direct again with the same
content, persisting a duplicate user turn. Pass persist_user_message=False
so the retry recovers a response without re-recording the user message.
2026-06-16 21:31:26 +08:00
chengyongru c6ea5aecff docs: add webui user guide 2026-06-16 19:52:16 +08:00
Xubin Ren 25a55fe1c7 fix(providers): enable thinking for Kimi K2.7 models 2026-06-16 17:44:22 +08:00
comadrejaandXubin Ren 4262375c19 chore: ignore bridge/node_modules
The .gitignore already excludes desktop/ and webui/ node_modules but
not the bridge's. Add bridge/node_modules/ so the compiled bridge deps
are never accidentally committed.
2026-06-16 17:32:02 +08:00
Heng Wei BinandXubin Ren 5573a9d78e fix(webui): override wsUrl with local LAN IP when on dev server port 5173 2026-06-16 17:31:32 +08:00
chengyongru 2b741ad4e5 fix(webui): align automation settings layout
Maintainer edit: reuse the same standalone SettingsView shell as Apps and Skills for Automations while keeping the automation queue height constrained.
2026-06-16 16:31:24 +08:00
chengyongru e9f982785e fix(webui): remove automation background layer
Maintainer edit: keep the automation workspace on the page background and remove the extra card-like backdrop that made the layout show square edges around the panels.
2026-06-16 16:24:35 +08:00
chengyongru 0263dbd1c3 chore: trim automation ui cleanup 2026-06-16 15:10:51 +08:00
chengyongru 3357dcc05f docs: drop README change from automation PR 2026-06-16 14:37:30 +08:00
chengyongru b24b5f19fc fix(cron): always require bound automation sessions 2026-06-16 14:16:12 +08:00
chengyongru 6239114c46 fix(cron): prevent unbound automation execution 2026-06-16 14:07:08 +08:00
chengyongru 04545b95d9 fix(webui): refine automation styling and delete confirmation 2026-06-16 11:41:05 +08:00
chengyongruandXubin Ren 27d869d3cc fix(agent): refresh goal continuation context 2026-06-16 11:19:24 +08:00
chengyongru 201d442a85 fix(webui): soften automation manager styling 2026-06-16 11:07:48 +08:00
chengyongru 04387bf9e3 fix(webui): keep automation workspace within viewport 2026-06-16 10:08:50 +08:00
chengyongru 23e12b84ff fix(webui): clarify automation search placeholder 2026-06-16 09:48:53 +08:00
chengyongru f56a73b2d3 fix(webui): align automation toolbar controls 2026-06-16 00:59:42 +08:00
chengyongru 8f1fe7337c fix(webui): tighten automation toolbar layout 2026-06-16 00:42:25 +08:00
chengyongru e6957de622 fix(webui): drop legacy automation setup state 2026-06-16 00:26:06 +08:00
chengyongru 87aaf8991a fix(webui): simplify automation details 2026-06-15 22:52:26 +08:00
chengyongru 3aa90e539c fix(webui): make automation history diagnostic 2026-06-15 21:54:09 +08:00
chengyongru acf408ced2 fix(webui): collapse automation run history by default 2026-06-15 21:03:48 +08:00
chengyongru 85a3ff1372 fix(webui): smooth automation detail overflow 2026-06-15 20:29:18 +08:00
chengyongruandXubin Ren 3ce0cd972e fix(session): keep auto compact suffix on user turn 2026-06-15 19:03:37 +08:00
chengyongru 03c79817ac test(websocket): expect session refresh after turn end 2026-06-15 18:25:00 +08:00
chengyongru 153f2d9529 fix(webui): move automations after skills 2026-06-15 18:12:54 +08:00
chengyongru 848378d0db Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	webui/src/components/settings/SettingsView.tsx
2026-06-15 18:11:22 +08:00
chengyongru 828759d1b6 fix(webui): hide settings kicker on automations 2026-06-15 18:08:44 +08:00
chengyongru 167a53dc45 fix(webui): sort sessions by transcript activity 2026-06-15 17:44:26 +08:00
chengyongru cbb4c0bad2 fix(webui): polish automation layout and session updates 2026-06-15 17:30:06 +08:00
chengyongru d7e73609d3 fix(webui): redesign automation management layout 2026-06-15 16:35:15 +08:00
chengyongru 0439ecb802 fix(webui): refine automation row layout 2026-06-15 15:41:35 +08:00
chengyongru 04496e9e28 fix(webui): improve automation edit message field 2026-06-15 15:22:01 +08:00
chengyongru 140c4fb49f fix(webui): clarify automation anomaly labels 2026-06-15 15:17:38 +08:00
chengyongru f8bf6aea51 fix(webui): encode automation update values 2026-06-15 15:14:24 +08:00
9814a3b9fe fix(api): forward real LLM usage in /v1/chat/completions response (#4310)
* fix(api): forward real LLM usage in /v1/chat/completions response

_chat_completion_response() hardcoded prompt_tokens/completion_tokens
to zero.  Now reads agent_loop._last_usage (set by process_direct
after every LLM call) and forwards the actual prompt/completion counts.

Streaming path is unchanged; usage is only surfaced in non-streaming
responses for now.

Fixes #4309

* fix: use defensive getattr for _last_usage and add it to all test mock agents

- Use getattr(agent_loop, '_last_usage', None) in server.py for safety
- Add _last_usage = {} to mock agents in test_api_attachment.py and test_api_stream.py
- Prevents AttributeError/500 when mock agents don't have the attribute

* fix(api): preserve provider total usage

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-06-15 15:13:11 +08:00
Stellar鱼andGitHub f85101f017 fix(memory): ignore malformed history entries (#4315) 2026-06-15 15:13:07 +08:00
Stellar鱼andGitHub a54e56c69e fix(runner): ignore empty injected payloads (#4337) 2026-06-15 15:13:03 +08:00
chengyongruandXubin Ren fbbb09e9a5 fix: use config base for file tools
Maintainer edit: after #4314 moved shared tool config models to nanobot.config_base, keep FileToolsConfig on the new dependency boundary so the PR passes the architecture guard.
2026-06-15 02:55:18 +08:00
chengyongruandXubin Ren 44ce220af6 fix: preserve file tool toggle for subagents
Maintainer edit: subagents rebuilt their scoped ToolsConfig without carrying tools.file, which re-enabled built-in file tools after the parent agent disabled them. Preserve the file config and add loader/subagent coverage for the disabled path.
2026-06-15 02:55:18 +08:00
Nir AdlerandXubin Ren fee21332d3 Remove redundant comment in FileToolsConfig 2026-06-15 02:55:18 +08:00
Nir AdlerandXubin Ren df0f9f4d5c tools: add tools.file.enable to toggle built-in filesystem tools (default true) 2026-06-15 02:55:18 +08:00
chengyongruandXubin Ren f31a9c4cb0 fix: reduce mobile gap above composer 2026-06-15 02:54:37 +08:00
chengyongruandXubin Ren 4232e8547d fix: keep thread pinned during keyboard resize 2026-06-15 02:54:37 +08:00
chengyongruandXubin Ren c5a735549a fix: scroll thread to bottom on composer focus 2026-06-15 02:54:37 +08:00
chengyongruandXubin Ren 052e7f1559 fix: keep mobile composer above soft keyboard 2026-06-15 02:54:37 +08:00
chengyongruandXubin Ren 946ed6690a fix(webui): improve mobile responsiveness 2026-06-15 02:54:37 +08:00
chengyongru 5892c6913b fix(webui): allow content-only automation edits
Avoid resubmitting unchanged schedules from the automation edit dialog so completed one-time automations can still have their content updated. Treat unchanged schedules as already-valid on the backend while preserving validation for actual schedule changes.
2026-06-15 00:59:23 +08:00
chengyongru 8b42d0760e fix(webui): harden automation management API
Maintainer edit: redact external channel chat identifiers from the WebUI automation payload and reject malformed or unschedulable automation updates before they mutate cron jobs.
2026-06-14 22:19:28 +08:00
Xubin Ren f30ef9f28e docs(readme): add themed cover image 2026-06-14 19:22:47 +08:00
chengyongru 747f0a08c7 fix(webui): improve automation management 2026-06-14 18:24:59 +08:00
chengyongruandXubin Ren b226a95588 fix(webui): localize update check copy 2026-06-14 17:59:31 +08:00
Xubin Ren 1b17aa1632 docs(readme): link Kimi partner banner 2026-06-14 14:52:18 +08:00
fde5654ded docs: add Kimi and MiniMax partner links (#4295)
* docs(config): add Kimi affiliate link

* docs(readme): add Kimi collaborator banner

* docs(readme): add MiniMax collaborator link

---------

Co-authored-by: xumingyuan <xumingyuan@msh.team>
Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-06-14 14:43:36 +08:00
qcypggsandXubin Ren cb2620c877 Fix Codex image SSE handling 2026-06-14 12:52:40 +08:00
axelray-devandXubin Ren ad89cbb24f test(providers): use real Fable model ID claude-fable-5 in tests
Replace placeholder claude-fable-1 with the actual API model ID.
2026-06-14 12:52:07 +08:00
axelray-devandXubin Ren 29d7186853 fix(providers): widen omit_temperature to cover opus-4-8 and fable
The temperature suppression was hardcoded to only match opus-4-7. Newer
Anthropic models (opus-4-8, fable) also reject the parameter with a 400.

Normalize model_name to lowercase before matching so mixed-case configs
do not fall through.  Add tests for opus-4-8 and fable across adaptive,
enabled, and no-thinking paths, plus a negative test confirming ordinary
models still send temperature.

Fixes #4333
2026-06-14 12:52:07 +08:00
04cbandXubin Ren e36c43c9e5 fix(cli): use configured bot_icon for agent interactive banner (#4262) 2026-06-14 12:51:43 +08:00
chengyongru 6cf1f8e164 fix(webui): trim automation creation prompt 2026-06-14 02:10:16 +08:00
chengyongru 5f2f694034 fix(webui): reduce automation dashboard copy 2026-06-14 01:03:53 +08:00
chengyongru 17e3183598 fix(webui): simplify automation source display 2026-06-14 00:32:53 +08:00
chengyongru 5b10102629 fix(webui): avoid fake automation origins 2026-06-14 00:04:15 +08:00
chengyongru 43830b7162 fix(webui): localize automation runtime labels 2026-06-13 23:06:28 +08:00
chengyongru e08462ca30 feat(webui): add automation management view 2026-06-13 23:06:28 +08:00
chengyongruandXubin Ren be6419b289 refactor(memory): clarify idle compact archive inputs
maintainer edit: rename the idle compact archive inputs so the code distinguishes messages being removed from messages being summarized. This keeps the #4264 behavior unchanged while making the retained-suffix summary rule easier to read.
2026-06-13 22:24:30 +08:00
tangtaizhong666andXubin Ren 0863e6e5ab fix(memory): summarize full session tail during idle compaction (#4264)
Idle compaction summarized only the dropped prefix, excluding the recent
suffix it retains. On a finished conversation a late user correction or
final result lands in that kept suffix, so it never reached the persisted
summary and history kept the stale pre-correction conclusion — which, for
idle sessions that are rarely resumed, is never fixed.

Summarize over the full unconsolidated tail instead, while still removing
(and raw-dumping on LLM failure) only the dropped messages. Adds an opt-in
summary_context argument to Consolidator.archive so the summarization
window and the archived set can differ without affecting other callers.
2026-06-13 22:24:30 +08:00
chengyongruandXubin Ren 04ed7554c0 Simplify WebUI startup loading fix 2026-06-13 21:59:36 +08:00
chengyongruandXubin Ren af0e3441d7 Fix WebUI startup blocking on slow gateway routes 2026-06-13 21:59:36 +08:00
chengyongruandXubin Ren 3a221d74cf Break tool config schema import cycle 2026-06-13 21:59:07 +08:00
Xubin Ren 7ff8e02eaf chore(repo): remove desktop app from core repo 2026-06-13 00:14:21 +08:00
Xubin RenandGitHub dac4e39bcf Merge PR #4299: feat(cron): bind scheduled automations to sessions
feat(cron): bind scheduled automations to sessions
2026-06-13 00:07:55 +08:00
Xubin RenandGitHub 1b3b322674 Merge PR #4226: feat(bridge): WhatsApp forwarded message detection, startup guard, and contact handling
feat(bridge): WhatsApp forwarded message detection, startup guard, and contact handling
2026-06-13 00:05:58 +08:00
chengyongruandXubin Ren d72d0102d9 style: trim orphan toolcall comments
maintainer edit: keep the invariant comments in production code but remove repeated issue background from tests.
2026-06-13 00:05:03 +08:00
chengyongruandXubin Ren 33e6da14d8 fix: drop tool results missing call ids
maintainer edit: treat tool messages without tool_call_id as orphaned during session persistence so malformed results cannot survive into history.
2026-06-13 00:05:03 +08:00
tangtaizhong666andXubin Ren eb25df9b49 fix(session): never persist tool results without a declared tool call 2026-06-13 00:05:03 +08:00
tangtaizhong666andXubin Ren 2ebf7e2eef fix(session): keep placeholder for tool results filtered to empty 2026-06-13 00:05:03 +08:00
tangtaizhong666andXubin Ren ac5e84d453 fix(session): anchor save boundary to prompt prefix size (#4006)
build_messages merges the current message into a same-role history tail,
shrinking the prompt prefix to 1 + history_count. The save boundary
assumed a standalone current message and skipped one message too many,
cutting the first new-turn assistant message (with its tool_calls) from
persistence while keeping its tool results - producing orphaned tool
results in session history.
2026-06-13 00:05:03 +08:00
tangtaizhong666andXubin Ren df832a37e9 test(session): reproduce orphaned tool results from save-boundary overshoot (#4006) 2026-06-13 00:05:03 +08:00
chengyongruandXubin Ren 30640e9e00 docs: clarify model prefix provider resolution 2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren c282012607 fix: resolve auto custom provider settings state
maintainer edit: use the resolved provider row when WebUI settings evaluates auto-selected providers, so named custom providers follow their apiBase-based configured state instead of the legacy has_api_key fallback.
2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren af9f9ebfd7 fix: reject custom provider alias conflicts
maintainer edit: reject arbitrary custom provider keys that normalize to built-in provider names so runtime and WebUI settings cannot disagree about whether a provider is dynamic or built in.
2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren b2d00a4ce0 fix: strip dynamic custom provider route prefixes
maintainer edit: preserve provider-prefix CLI routing for named custom providers by stripping only the matched dynamic route prefix before sending the model id to OpenAI-compatible endpoints. This keeps ordinary namespaced model ids intact when the provider is selected explicitly.
2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren 09d24e6c25 fix: validate named custom provider endpoints 2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren a9308eb8e2 docs: clarify custom provider protocol support
maintainer edit: spell out that arbitrary named custom providers use the OpenAI-compatible request format only, and point Anthropic-compatible proxies to the built-in anthropic provider with apiBase.
2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren 69d66e0d6a docs: document named custom providers
maintainer edit: explain how to configure arbitrary OpenAI-compatible provider names, including multiple endpoints, model presets, and troubleshooting guidance.
2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren 57ced7930d refactor: simplify dynamic provider settings tests
maintainer edit: keep the WebUI dynamic-provider behavior unchanged while reducing repeated test setup and tightening the small dynamic-provider helper.
2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren 37ae655fa6 fix: expose dynamic custom providers in WebUI settings
maintainer edit: WebUI settings still treated non-registry custom providers as unknown, so users could not select them in model configurations or fetch their model list. Reuse dynamic provider specs for settings payloads, model-list requests, and provider updates.
2026-06-13 00:04:13 +08:00
chengyongruandXubin Ren 68c6844c0b fix: preserve dynamic custom provider semantics
maintainer edit: treat arbitrary custom provider names as direct OpenAI-compatible providers, validate their api_type consistently, and avoid Pydantic instance-field warnings in fallback routing.
2026-06-13 00:04:13 +08:00
wangjingguang002andXubin Ren e9e1489cee feat: support multiple custom OpenAI-compatible providers
This change allows users to define arbitrary custom providers in config:

providers:
  my_provider:
    api_base: ...
    api_key: sk-xxx

Usage:
  nanobot /my_provider/gpt-4 hello
  nanobot --provider my_provider hello

Changes:
- ProvidersConfig: add extra=allow to accept arbitrary fields
- _match_provider: check for custom provider by prefix and by fallback
- registry: add create_dynamic_spec() for dynamic provider specs
2026-06-13 00:04:13 +08:00
chengyongru e1ff0f37d9 fix: preserve WhatsApp forwarded metadata
maintainer edit: carry the bridge isForwarded flag into channel metadata so forwarded voice messages remain distinguishable after transcription.
2026-06-12 18:21:47 +08:00
chengyongru 32d8a1dd7b fix: hide internal cron prompts from webui 2026-06-12 18:17:28 +08:00
chengyongru 0505a4fb2a Merge origin/main into whatsapp bridge improvements 2026-06-12 18:16:24 +08:00
chengyongru a50b3ac0f2 fix: harden cron session automation flows 2026-06-12 18:13:25 +08:00
chengyongru b5f9d51b5b chore: drop unrelated chat apps docs change 2026-06-12 17:07:40 +08:00
chengyongru 2248527971 fix: show cron bindings before deleting sessions 2026-06-12 17:02:29 +08:00
chengyongru 8335554894 refactor: migrate legacy cron payloads to bound sessions 2026-06-12 16:51:20 +08:00
chengyongru af8192dc38 refactor: move bound cron execution out of gateway 2026-06-12 15:50:36 +08:00
chengyongru 5ae907bc2f refactor: store cron origin delivery context 2026-06-12 15:07:25 +08:00
chengyongru b232a52794 fix: tighten cron session deletion UX 2026-06-12 14:51:02 +08:00
chengyongru c4b64a4caf refactor: preserve origin session routing for cron 2026-06-12 14:21:09 +08:00
chengyongru bc18142650 chore: drop cron design note from pr 2026-06-12 14:07:55 +08:00
chengyongru 80524e9e88 refactor: bind cron jobs to origin sessions 2026-06-12 14:00:53 +08:00
chengyongru 271b3651d7 refactor: use cron turn naming internally 2026-06-12 11:57:35 +08:00
chengyongru d9d481bc15 refactor: centralize cron session metadata keys 2026-06-12 11:43:23 +08:00
chengyongru 0e3a57b371 docs: clarify cron session ownership 2026-06-12 11:12:26 +08:00
chengyongru 0ff8cd0cb3 fix: honor unified session for webui automations 2026-06-12 10:19:12 +08:00
chengyongru 1ad9d77bc7 fix: avoid completed cron tail pending state 2026-06-12 00:54:32 +08:00
chengyongru e46a99ced9 fix: bind webui cron jobs to visible session 2026-06-12 00:28:12 +08:00
2d9260cb9f feat(slack): add groupRequireMention for allowlist channels
Slack's groupPolicy could either restrict to specific channels
("allowlist") or require an @mention ("mention"), but not both: in
allowlist mode the bot replied to every message in approved channels.

Add a groupRequireMention flag so that, when groupPolicy is "allowlist",
the bot only responds in channels listed in groupAllowFrom AND only when
@mentioned. Mirrors Signal's group.requireMention. No effect for the
"mention"/"open" policies, so existing configs are unchanged.

Extract the mention check into _is_mention and reuse it from both the
mention and allowlist branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 00:23:27 +08:00
chengyongru 29f1473940 fix: keep session automations bound-only 2026-06-12 00:15:57 +08:00
chengyongru 369237f6a8 fix: allow slower webui chat creation 2026-06-11 23:53:51 +08:00
chengyongru 8dac6b2889 fix: show websocket cron jobs in automations 2026-06-11 23:53:45 +08:00
chengyongru 3725b42e0e fix: use shared bound cron predicate
maintainer edit: make gateway execution, WebUI automation listing, and delete protection agree on the new bound cron shape. Legacy delivery payloads that carry sessionKey are excluded from the WebUI-bound automation surface.
2026-06-11 23:09:21 +08:00
chengyongru b4b6c04657 fix: preserve legacy cron delivery payloads
maintainer edit: keep existing cron jobs with legacy delivery fields on the legacy execution path, even when they already carry a sessionKey. This preserves deliver=false behavior and channel-specific routing metadata for upgraded jobs.
2026-06-11 22:26:06 +08:00
chengyongru f82ab9f192 fix: record cancelled cron runs
maintainer edit: treat job-level CancelledError as a failed cron run so bound automation cancellations update run history and do not break subsequent scheduling.
2026-06-11 22:01:23 +08:00
chengyongru a326ba40f4 feat(cron): bind scheduled automations to sessions 2026-06-11 19:48:07 +08:00
chengyongruandXubin Ren ffae1dca6d fix: keep Telegram streamed code blocks balanced
Maintainer edit: split final streamed Telegram markdown before rendering to HTML so long fenced code blocks do not produce unbalanced <pre><code> chunks while still respecting Telegram's rendered HTML limit.
2026-06-11 13:52:19 +08:00
axelray-devandXubin Ren a5a816abaf fix(telegram): move fenced-code-block splitting into Telegram-specific helper
Move the fenced-code-block-aware splitting logic out of the shared
split_message helper (used by Signal, Slack, Discord, Weixin, etc.)
and into a Telegram-specific _split_telegram_markdown function.

The shared split_message remains a plain-text chunker. The Telegram
channel now uses _split_telegram_markdown for its raw Markdown paths
that feed _markdown_to_telegram_html, preventing broken HTML rendering
when splits fall inside fenced code blocks.

Also fixes a regression where content beginning with whitespace before
a fence could emit a whitespace-only chunk.

Addresses review feedback on #4257.
2026-06-11 13:52:19 +08:00
axelray-devandXubin Ren 131446fa61 fix(utils): make split_message fenced-code-block-aware
When split_message splits a long message, it now checks whether the
split point falls inside a fenced code block. If so, it either moves
the split to before the opening fence or closes/reopens the fence
across chunks, preventing broken HTML rendering.

Addresses #4250
2026-06-11 13:52:19 +08:00
Xubin Ren b8a4ceb30c test(webui): cover siliconflow transcription settings 2026-06-10 23:05:12 +08:00
moranandXubin Ren 9ed638ad70 feat(transcription): add SiliconFlow as transcription provider
- Register SiliconFlow in transcription registry with default model
  FunAudioLLM/SenseVoiceSmall and alias 'silicon'
- Reuse existing OpenAITranscriptionProvider adapter (Whisper-compatible)
- Add generic key/base resolution: fallback to registry env_key and
  default_api_base when provider config is absent
- Add tests for registry entry, alias, adapter, default model, and
  config resolution with env var fallback
2026-06-10 23:05:12 +08:00
Xubin RenandGitHub ddbd7ca39e Merge PR #4278: feat(webui): segment transcript storage
feat(webui): segment transcript storage
2026-06-10 21:02:10 +08:00
Xubin Ren e1e643de2a refactor(webui): keep sidebar index out of session manager 2026-06-10 20:45:29 +08:00
Xubin Ren 1f5ecf36ca fix(webui): align chat action menu hover inset 2026-06-10 20:30:32 +08:00
Xubin Ren 999552b998 perf(webui): index session list metadata 2026-06-10 20:02:22 +08:00
Xubin Ren 603feef3aa Merge remote-tracking branch 'origin/main' into codex/webui-segmented-transcript-store 2026-06-10 19:11:37 +08:00
Xubin Ren e168bb2754 feat(webui): segment transcript storage 2026-06-10 18:28:55 +08:00
Jiajun XieandXubin Ren 4255656089 refactor(webui): replace real-time polling with click-to-check version updates
- Remove background PyPI polling loop and WebSocket broadcast
- Remove UpdateBanner from ThreadHeader (keep main page clean)
- Add on-demand version check endpoint (GET /api/settings/version-check)
- Add 'About' section in Settings > Overview with check-for-updates button
- Design: no auto-fetch, user initiates check explicitly via button click
2026-06-10 18:11:06 +08:00
chengyongruandXubin Ren c00371c761 docs: clarify streamed timeout fallback behavior
maintainer edit: update fallback docs and provider docstring to describe the new stream-stall timeout recovery exception.
2026-06-10 18:10:44 +08:00
chengyongruandXubin Ren bc4bb508a1 fix: continue recovered streams in a new segment
maintainer edit: streamed timeout recovery was returning the retried response internally while the channel still treated the final outbound as already streamed. End the current stream segment before retry/fallback recovery so subsequent deltas are delivered in a new segment.
2026-06-10 18:10:44 +08:00
aiguozhi123456andXubin Ren 2c5a4e0703 fix(providers): allow retry and fallback on stream stalled timeout
When a stream stalls mid-response, both the retry layer and
FallbackProvider blocked recovery because content had already been
emitted via on_content_delta. This left users with truncated replies
and no automatic recovery.

For error_kind="timeout" specifically:
- _run_with_retry now suppresses delta callbacks and retries the same
  model instead of returning immediately
- FallbackProvider now allows failover to a different model with
  delta callbacks suppressed

Non-timeout errors retain the original "skip retry/failover after
streamed content" behavior to avoid duplicate output.
2026-06-10 18:10:44 +08:00
chengyongruandXubin Ren dadb35af49 feat(exec): add path prepend config 2026-06-10 18:09:57 +08:00
chengyongruandXubin Ren 8c30dc5a57 Preserve session key when archiving new sessions 2026-06-10 18:09:45 +08:00
chengyongruandXubin Ren bfc6febddc Scope prompt recent history by session
Fixes #4259
2026-06-10 18:09:45 +08:00
chengyongruandXubin Ren aee656eb9f Fail fast on invalid config files 2026-06-10 18:09:36 +08:00
chengyongruandXubin Ren 5d7f2e60c2 fix(feishu): lazy-load lark sdk during gateway startup 2026-06-10 18:09:27 +08:00
Xubin Ren 7186039be1 fix(websocket): limit final stream text to inline endings 2026-06-10 15:52:39 +08:00
4dd5b62f11 fix(websocket): always send text in stream_end when stream had content
The channel manager coalesces consecutive _stream_delta messages and
forwards a single merged message with _stream_end=True. In that path
no individual delta events ever reach the WebUI client, so the
stream_end frame is the only carrier of the text. The previous guard
only attached text when media-URL rewriting changed the string, which
silently dropped entire turns of plain-text output whenever the
agent generated tokens faster than the queue drained.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 15:52:39 +08:00
MoranandXubin Ren 9c492143b4 search: add Bocha web search provider 2026-06-10 15:51:15 +08:00
primit1v0andXubin Ren ce887772e9 fix(sandbox): set HOME inside bwrap 2026-06-10 15:50:53 +08:00
Xubin Ren 62a35c21b8 fix(asr): normalize StepFun transcription endpoint 2026-06-10 15:50:38 +08:00
moranandXubin Ren 7930058348 feat(asr): add StepFun ASR SSE transcription provider
- Add StepFunTranscriptionProvider class in nanobot/providers/transcription.py
- New _post_stepfun_asr_with_retry() function handling SSE stream parsing
  (transcript.text.delta → transcript.text.done event sequence)
- Register 'stepfun' in transcription_registry.py with default model stepaudio-2.5-asr
- Reuse existing stepfun provider config (apiBase can point to Plan endpoint)
- Add 17 tests covering SSE parsing, retry contract, empty-text edge case, and registry integration
- Update docs/configuration.md with stepfun ASR documentation

StepFun ASR uses a dedicated SSE endpoint (/v1/audio/asr/sse) rather
than the chat-completions or Whisper multipart formats used by other
providers. Users on Step Plan can set apiBase to the Plan endpoint.
2026-06-10 15:50:38 +08:00
erikmackinnonandXubin Ren 31bfec58d0 Add Exa web search provider 2026-06-10 15:02:07 +08:00
chengyongruandXubin Ren 5d91d59cf7 fix(agent): finalize max-iteration turns without tools 2026-06-10 14:47:20 +08:00
chengyongruandXubin Ren 99f7f371fa fix: cover o1 max-completion token fallback
Maintainer edit: keep the GPT-5/o-series fallback on slug-boundary matching so unrelated model names are not caught by substring checks, and include o1 alongside o3/o4 because it is also an o-series chat model.
2026-06-10 14:47:10 +08:00
04cbandXubin Ren a779e7c29e fix(providers): use max_completion_tokens for gpt-5/o-series on flagless specs (#4261) 2026-06-10 14:47:10 +08:00
yu-xin-candXubin Ren fd9fc38f41 fix(tools): keep apply_patch additions line-separated 2026-06-10 14:47:01 +08:00
Xubin Ren 1b5f5b94d5 fix(webui): use tabler fork icon 2026-06-10 04:26:06 +08:00
Xubin Ren ea791f605c fix(webui): restore fork action icon 2026-06-10 04:26:06 +08:00
Xubin Ren fd947a1fd8 fix(webui): normalize action tooltips 2026-06-10 04:26:06 +08:00
Xubin Ren 1432094bb5 refactor(webui): isolate fork websocket handler 2026-06-10 04:26:06 +08:00
Xubin Ren 916525f94a refactor(webui): shrink fork implementation 2026-06-10 04:26:06 +08:00
Xubin Ren 1f926e3769 refactor(webui): isolate chat fork creation 2026-06-10 04:26:06 +08:00
Xubin Ren 26a58282d4 feat(webui): show forked history boundary 2026-06-10 04:26:06 +08:00
Xubin Ren 73d4b1cb2f feat(webui): persist fork boundary metadata 2026-06-10 04:26:06 +08:00
Bayern4ever-dotandXubin Ren 03bca4c0a9 feat(webui): add assistant reply fork-from-here 2026-06-10 04:26:06 +08:00
chengyongruandGitHub 4a58b83acc docs: make onboarding friendlier for beginners (#4177)
* docs: make onboarding friendlier for beginners

* docs: build clearer documentation paths

Maintainer edit: turn the onboarding follow-up into a layered docs structure for first-time setup, provider selection, troubleshooting, CLI reference, and source-level architecture. This keeps quick start focused while giving advanced users precise reference paths.

* docs: render architecture flow with mermaid

Maintainer edit: replace the ASCII architecture sketch with a GitHub-rendered Mermaid flowchart so the core runtime path is easier to scan in the PR and README docs.

* docs: recommend model presets for model config

Maintainer edit: make named modelPresets the primary model configuration path and expand fallback preset examples so string fallbacks are clearly preset names, not raw model IDs.

* docs: document api base urls and langfuse setup

Maintainer edit: explain when users need apiBase/base URL in quick start and provider docs, and add Langfuse tracing setup with troubleshooting links.

* docs: use python module pip consistently

Maintainer edit: keep install commands tied to the active Python interpreter by using python -m pip in the Azure optional dependency notes too.

* docs: add non-technical getting started path

Maintainer edit: add a wizard-first guide for users without terminal or JSON background, including a text TUI menu example and links from the main docs entrypoints.

* docs: avoid hard-wrapped prose in user docs

Maintainer edit: unwrap ordinary prose across user-facing documentation while preserving markdown structure, code blocks, tables, lists, and prompt/template files.

* docs: keep desktop list continuations nested

Maintainer edit: preserve list nesting after unwrapping prose in the desktop WebUI sync guide.

* docs: add one-command installer

Maintainer edit: add auditable macOS/Linux and Windows install scripts that install nanobot-ai and start the onboarding wizard, then document the commands in the main onboarding entrypoints.

* docs: add installer dry run mode

Maintainer edit: add --dry-run to the one-command installer scripts so users can preview Python detection, install source, pip command, and wizard behavior without changing their environment.

* docs: clean installer error output

Maintainer edit: make PowerShell installer failures print a concise Error: message instead of Write-Error call-site details.

* docs: add provider setup cookbook

Maintainer edit: add pasteable provider recipes for common hosted, local, fallback, runtime switching, and Langfuse setups, then link the cookbook from onboarding and troubleshooting entrypoints.

* docs: address review feedback

* docs: clarify reader paths

* docs: explain terminal basics for beginners

* docs: clarify wizard navigation

* docs: avoid duplicate onboarding steps

* docs: add setup status check

* docs: explain status output

* docs: remove provider recommendation wording

* docs: explain status diagnostics

* docs: reduce hard-wrapped guidance

* docs: migrate config examples to presets

* docs: clarify python command fallbacks

* docs: improve installer failure recovery

* docs: expand install troubleshooting

* docs: cover installer download failures

* docs: put stable install paths first

* docs: add bundled webui quick path

* docs: clarify provider-neutral setup

* docs: clarify gateway setup for chat surfaces

* docs: improve docs navigation paths

* docs: add configuration quick jump

* docs: clarify provider secret variables

* chore: request PR review acknowledgement

Empty commit: please read the PR review comments and reply on the PR to confirm that you have received them.

This commit intentionally changes no files; it exists only to notify the remote Codex run so it can end its active goal.

* docs: add README start here guide

* docs: avoid provider recommendation wording

* docs: guide next steps after first reply

* docs: explain merging JSON snippets

* docs: add CLI command chooser

* docs: add configuration task map

* docs: add deployment readiness guide

* docs: simplify WebUI entry paths

* docs: add provider recipe chooser

* docs: fix provider factual references

Update OpenRouter and LongCat model examples, align Bedrock guidance, and make fallback snippets schema-valid.

Also correct group policy wording and image-generation provider lists to match the current code.

* fix: keep PowerShell installer from closing caller shell

* docs: mention self-guided configuration
2026-06-10 00:36:22 +08:00
chengyongruandXubin Ren 56ce18167e docs: clarify email post-action expunge fallback
maintainer edit: clarify that postActionExpunge only allows the broad EXPUNGE fallback when UID-scoped expunge is unavailable or fails.
2026-06-09 14:50:59 +08:00
Flávio Veloso SoaresandXubin Ren 0580c186c1 test(email): update tests for postActionExpunge option 2026-06-09 14:50:59 +08:00
Flávio Veloso SoaresandXubin Ren 6de8d7f52e feat(email): add postActionExpunge option to gate broad IMAP expunge 2026-06-09 14:50:59 +08:00
Flávio Veloso SoaresandXubin Ren 1d683f0f18 style(email): fix import order via ruff 2026-06-09 14:50:59 +08:00
Flávio Veloso SoaresandXubin Ren b96ed1b7c6 docs(email): clarify _fetch_new_messages return docstring 2026-06-09 14:50:59 +08:00
Flávio Veloso SoaresandXubin Ren 4369eb20fc feat(email): support IMAP MOVE and UID expunge fallbacks 2026-06-09 14:50:59 +08:00
Flávio Veloso SoaresandXubin Ren ec5460d23e feat(email): add configurable post-action handling 2026-06-09 14:50:59 +08:00
Flávio Veloso SoaresandXubin Ren 85ab55aeee refactor(email): extract IMAP session helper 2026-06-09 14:50:59 +08:00
chengyongruandXubin Ren 5bd4a83e85 fix(webui): render TeX math delimiters 2026-06-09 14:50:49 +08:00
chengyongruandGitHub 0a396aa6e2 Improve tool call validation strictness (#4190)
* Improve tool call validation strictness

Reject near-miss tool names without executing suggested tools. Require object-shaped tool parameters while preserving only lossless JSON wire-shape normalization.

* Tighten tool call argument validation

* Simplify tool argument validation tests

* Improve tool name suggestions

* Simplify tool suggestion helpers

* Limit tool suggestions to canonical matches

* Allow repair only for tool history replay

* Clarify non-object tool argument errors

* Inline replay tool argument normalization

* Track only successful tool executions

* Reject JSON null tool arguments
2026-06-09 14:50:40 +08:00
comadrejaandXubin Ren f3eb2aa08b feat(transcription): add AssemblyAI as transcription provider
Add AssemblyAI as a third transcription provider option alongside
OpenAI and Groq. AssemblyAI offers better accuracy for certain
audio types (distant voices, noisy environments) and serves as a
reliable fallback when other providers struggle.

Changes:
- Add AssemblyAITranscriptionProvider class in providers/transcription.py
- Add 'assemblyai' option in base channel's transcribe_audio()
- Per-channel configuration via transcriptionProvider in config

Usage:
  Set transcriptionProvider: 'assemblyai' and provide an AssemblyAI
  API key via transcriptionApiKey in the channel config.
2026-06-09 05:33:18 +08:00
Xubin Ren f183b37542 test(webui): cover Xiaomi MIMO provider alias 2026-06-09 04:29:09 +08:00
c20ecc52d7 feat(transcription): add Xiaomi MiMo ASR provider (mimo-v2.5-asr)
Add support for Xiaomi MiMo ASR as a third transcription backend alongside
Groq and OpenAI Whisper. Xiaomi ASR uses the /v1/chat/completions endpoint
with base64-encoded audio input, rather than the standard Whisper multipart
upload format.

Co-Authored-By:连 <lian@tangping.homes>
2026-06-09 04:29:09 +08:00
Xubin Ren 552ec18a3c test(webui): cover OpenRouter provider brand 2026-06-09 04:01:37 +08:00
0eb3010e40 feat(transcription): configurable STT model + OpenRouter provider
Add a `transcriptionModel` channel setting and an OpenRouter transcription
backend so voice messages can be transcribed through OpenRouter's
speech-to-text endpoint (e.g. nvidia/parakeet-tdt-0.6b-v3, openai/whisper-1),
alongside the existing Groq/OpenAI Whisper providers.

- schema: add channels.transcriptionModel (None = provider default)
- providers/transcription: extract a shared POST/retry skeleton; add a
  JSON+base64 OpenRouterTranscriptionProvider; make the STT model a
  constructor param on all providers instead of hardcoding it
- channels: route transcriptionProvider="openrouter" and thread the model
  through the manager to each channel
- docs + tests

Only dedicated STT models work on OpenRouter's transcription endpoint;
chat LLMs (e.g. google/gemini-3.5-flash) are rejected there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:01:37 +08:00
axelray-devandXubin Ren 28f3a20d64 feat(providers): add extra_query config for OpenAI-compatible providers
Adds ProviderConfig.extra_query, threaded into AsyncOpenAI(default_query)
so that Azure-style gateways requiring query params like api-version can
be configured without URL hacks.

Also updates provider_signature to track extra_query changes so per-turn
refresh rebuilds the provider when the value changes.

Addresses the extra_query portion of #4204. The max_completion_tokens
model-awareness enhancement is intentionally left separate.
2026-06-09 03:18:14 +08:00
Xubin RenandGitHub 9c81280300 feat(transcription): add shared voice input support (#4232)
* feat(webui): add voice transcription input

* feat(webui): render ANSI output in code blocks

* refactor(webui): isolate voice recorder logic

* refactor(transcription): keep websocket ingress thin

* refactor(transcription): resolve channel audio settings on demand

* style(webui): neutralize voice waveform color

* feat(webui): add voice input tooltip

* feat(webui): add voice input keyboard shortcut

* fix(webui): distinguish voice shortcut platforms

* fix(webui): place voice button after model selector

* refactor(webui): share voice hold recording helpers

* fix(desktop): allow microphone voice input

* fix(webui): stabilize token usage month labels

* feat(webui): show voice input on settings overview

* fix(webui): label voice capability as recognition

* fix(webui): align capability overview status

* refactor(webui): isolate transcription socket handling

* fix(webui): soften silent voice waveform

* refactor(audio): clarify transcription service location

* docs(transcription): clarify audio and provider boundaries

* fix(exec): reduce session output polling flake
2026-06-09 01:08:49 +08:00
chengyongruandXubin Ren 06d454a225 test: cover MCP redirect guard wiring
Maintainer edit: make the unsafe redirect regression go through connect_mcp_servers so both SSE and streamable HTTP prove that the request hook is attached to the MCP clients before redirects are followed.
2026-06-08 16:03:57 +08:00
chengyongruandXubin Ren a73924f77e docs: document MCP SSRF allowlist behavior
Maintainer edit: explain that HTTP/SSE MCP now uses the shared SSRF guard before connecting and before following redirects, so local or private HTTP MCP endpoints require an explicit tools.ssrfWhitelist entry.
2026-06-08 16:03:57 +08:00
Stellar鱼andXubin Ren ed0aeb1ea9 fix(mcp): reject unsafe HTTP URLs before probe 2026-06-08 16:03:57 +08:00
chengyongruandXubin Ren 6e6470daa0 docs: remove nightly branch guidance 2026-06-08 16:03:24 +08:00
chengyongruandXubin Ren 8fe0149c65 refactor(webui): simplify token usage heatmap 2026-06-08 16:02:12 +08:00
chengyongruandXubin Ren 7510918610 fix(webui): align token usage heatmap 2026-06-08 16:02:12 +08:00
chengyongruandXubin Ren 631fdb4a46 test: cover empty reasoning_content history preservation
maintainer edit: add SDK-object and tool-call history regressions so the empty-string reasoning_content fix is covered across both parse branches and the sanitized request path.
2026-06-08 01:08:27 +08:00
michaelxerandXubin Ren 05de864f5b fix: preserve empty-string reasoning_content instead of coercing to None
Custom providers (e.g. DeepSeek) may return reasoning_content as an
empty string "" to explicitly indicate no reasoning occurred. The
previous truthiness checks (, ) treated "" as falsy
and converted it to None, which caused the field to be dropped from
the message history entirely. Providers that require reasoning_content
on all assistant messages then rejected subsequent requests.

Replace truthiness checks with identity checks () so that
empty-string reasoning_content is preserved as-is. The streaming path
is unchanged since an empty join genuinely means no chunks received.

Fixes #4105
2026-06-08 01:08:27 +08:00
4f5f965f09 fix(whatsapp): handle LID group mentions (#2663)
Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-06-07 18:02:39 +08:00
comadreja eec59c05de feat(bridge): WhatsApp forwarded message detection, startup guard, and contact handling
Three improvements to the WhatsApp Baileys bridge:

1. Forwarded message detection: Extracts contextInfo.isForwarded from
   all message types (text, image, video, audio, document) and passes
   it as isForwarded in the InboundMessage. Allows the agent to
   distinguish forwarded content from direct messages — useful for
   different handling (e.g., transcribe-only vs execute as instruction).

2. Startup timestamp guard: Records the timestamp when the bridge
   starts and drops any messages with messageTimestamp older than
   startup time. Prevents replaying message history on reconnect,
   which caused duplicate processing and stale command execution.

3. Contact message handling: Adds support for contactMessage and
   contactsArrayMessage types, extracting displayName and vcard
   data instead of silently dropping shared contacts.

Changes:
- Add isForwarded field to InboundMessage interface
- Add startupTimestamp guard in message processing loop
- Add contactMessage/contactsArrayMessage extraction
- Extract contextInfo.isForwarded from all media message types
2026-06-06 12:25:24 -05:00
ab9f49970d feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da12.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
2026-06-06 19:49:33 +08:00
Xubin Ren a1b9577224 test(image): cover dropping null OpenAI image params 2026-06-06 19:35:46 +08:00
04cbandXubin Ren a4cf0f9514 fix(providers): allow dropping default OpenAI image params via null extraBody (#4167) 2026-06-06 19:35:46 +08:00
Xubin Ren 73353785a0 docs(sdk): document Nanobot teardown 2026-06-06 15:35:28 +08:00
axelray-devandXubin Ren 57fa37dcfe fix(sdk): close MCP connections from Nanobot facade
The SDK opened MCP connections through AgentLoop.process_direct but
never called close_mcp, leaving stdio MCP generators to be finalized
during asyncio shutdown from a different task, producing a RuntimeError
about exiting a cancel scope in a different task.

Add aclose() that delegates to AgentLoop.close_mcp (which already
drains background tasks and closes MCP stacks), plus __aenter__ and
__aexit__ so the SDK works as an async context manager.

Fixes #4211
2026-06-06 15:35:28 +08:00
Xubin Ren 6a0368b32f fix(telegram): route /skill command 2026-06-05 18:48:51 +08:00
Xubin Ren 935a37182d docs(command): document /skill command 2026-06-05 18:48:51 +08:00
EndeavourYuanandXubin Ren 6b6be20f32 feat(command): add /skill slash command to list enabled skills
- Register /skill in BUILTIN_COMMAND_SPECS with wrench icon
- Add cmd_skill handler that lists skill names and descriptions
- Disabled skills are excluded from the output
- Add 6 tests covering empty list, names/descriptions, disabled
  filtering, fallback description, markdown output, and router
  registration
2026-06-05 18:48:51 +08:00
chengyongruandXubin Ren 710d00a179 fix(webui): persist user messages for refresh 2026-06-05 16:13:51 +08:00
chengyongruandXubin Ren 3da68ac7fe Fix pairing for Weixin and Telegram DMs 2026-06-05 16:13:31 +08:00
chengyongruandXubin Ren d435cb0b21 fix: harden custom image provider compatibility
Maintainer edit: preserve provider-specific size hints for custom image generation endpoints while keeping the default 1K mapping compatible. Clarify the custom provider contract in docs and cover response_format/size overrides in tests.
2026-06-05 15:56:03 +08:00
chengyongruandXubin Ren ae17a79bdf fix: harden custom image generation config
Maintainer edit: require providers.custom.apiBase before making custom image requests and allow unauthenticated local endpoints by omitting Authorization when no apiKey is configured.
2026-06-05 15:56:03 +08:00
axelray-devandXubin Ren 748b28da01 feat(image): support custom image generation provider
Addresses #4132.

Add CustomImageGenerationClient for any OpenAI-compatible image generation
API (POST {apiBase}/images/generations). Uses the existing providers.custom
config slot. No schema changes required.

Tests: 54 passed, ruff clean.
Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-05 15:56:03 +08:00
chengyongruandXubin Ren c574b028c1 fix(feishu): allow punctuation after mention placeholders
maintainer edit: Keep the shared-prefix guard for Feishu numbered mention keys while still resolving placeholders followed by punctuation, matching the previous user-visible mention behavior.
2026-06-05 15:55:53 +08:00
Xubin Ren 894811db8b fix(feishu): strip leading bot mention before commands 2026-06-05 15:55:53 +08:00
Kunal KarmakarandXubin Ren fa423dffbc Remove check from the test 2026-06-05 01:17:34 +08:00
Kunal KarmakarandXubin Ren 9fdc6f892a Fix test 2026-06-05 01:17:34 +08:00
Kunal KarmakarandXubin Ren c849ff6eec Address PR review comments 2026-06-05 01:17:34 +08:00
Kunal KarmakarandXubin Ren ba3fa38e97 Add support for Azure AAD based Auth 2026-06-05 01:17:34 +08:00
chengyongruandXubin Ren 39454534d4 fix: isolate run-level hook snapshots 2026-06-05 01:09:45 +08:00
chengyongruandXubin Ren 8933da1ec5 fix: harden run-level hook lifecycle
maintainer edit: keep cancellation out of on_error so shutdown paths do not look like run failures, and let the SDK capture hook use the authoritative after_run snapshot.
2026-06-05 01:09:45 +08:00
chengyongruandXubin Ren 2ea226055e feat: add run-level agent hook lifecycle 2026-06-05 01:09:45 +08:00
chengyongruandXubin Ren c77ca16d91 fix: preserve uv pip update reinstall semantics
Maintainer edit: the uv fallback for CLI app updates now keeps the force-reinstall behavior from the python -m pip path by using uv pip install --reinstall, with unit coverage for the generated argv.
2026-06-04 19:41:51 +08:00
axelray-devandXubin Ren c2e9064b35 fix: remove unsupported -y flag from uv pip uninstall fallback
uv pip uninstall does not support the -y (assume-yes) flag. Remove it
from the uv fallback argv while keeping it for the python -m pip
uninstall path.

Reported-by: chengyongru
2026-06-04 19:41:51 +08:00
axelray-devandXubin Ren 6d827efb0e test: explicitly stub _pip_available in pip-path tests
CI's uv-managed Python does not have pip importable, so the runtime
falls back to uv pip. Four tests that verify the python -m pip path
were failing because _pip_available() returned False in CI.

Monkeypatch _pip_available to True in tests that intentionally verify
the pip code path, so they pass regardless of the CI Python
environment.
2026-06-04 19:41:51 +08:00
axelray-devandXubin Ren a37e58a29e fix(cli): fall back to uv pip when pip is unavailable
When nanobot is installed via uv tool install, sys.executable points to
a Python that does not have pip available as a module. _pip_install_argv
and _pip_uninstall_argv always used [sys.executable, -m, pip, ...]
which fails in that environment.

Add _pip_available() helper that checks importlib.util.find_spec('pip').
When pip is not available and uv is on PATH, fall back to:
  uv pip install --python <sys.executable> ...
  uv pip uninstall --python <sys.executable> -y ...
If neither pip nor uv is available, raise CliAppError.

Fixes #4158
2026-06-04 19:41:51 +08:00
chengyongruandXubin Ren 24e56fcf07 test: improve deterministic unit test coverage 2026-06-04 19:41:32 +08:00
Xubin Ren 87bd56468c fix(webui): show platform-specific new chat shortcut 2026-06-04 14:01:21 +08:00
chengyongruandXubin Ren 54d8d3010b fix: close search when starting new chats
maintainer edit: Close the session search dialog when the global new-chat shortcut navigates to the blank chat route, and expose the new shortcut through the sidebar button title so the shortcut is discoverable.
2026-06-04 14:01:21 +08:00
axelray-devandXubin Ren 4275678b43 feat(webui): add new chat keyboard shortcut
Add Cmd/Ctrl+Shift+O shortcut to start a new chat, matching the
convention used by ChatGPT, Claude.ai, and Gemini.

Addresses #4178

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-04 14:01:21 +08:00
chengyongruandXubin Ren d0eba7cd9d fix: cover MCP reconnect edge cases
maintainer edit: handle prompt sessions that report Connection closed outside McpError, and match reconnect registration prefixes with the same sanitization used by MCP wrapper names.
2026-06-04 10:43:09 +08:00
chengyongruandXubin Ren e9145b7acd fix(mcp): reconnect terminated sessions 2026-06-04 10:43:09 +08:00
yorkhellenandXubin Ren 7c3808327f fix(qq): send pairing codes for unauthorized C2C users 2026-06-04 10:42:51 +08:00
chengyongruandXubin Ren facdc41a16 fix: restore top-level import order 2026-06-03 16:57:29 +08:00
chengyongruandXubin Ren 3b46386887 test(email): cover progress message suppression
Maintainer edit: add a regression test for the email channel fix so progress/tool-event messages return before SMTP is opened instead of sending empty emails.
2026-06-03 15:01:47 +08:00
Nicolas BlondiauandXubin Ren cbf1ede179 fix(email): skip progress messages to prevent empty emails after tool calls 2026-06-03 15:01:47 +08:00
chengyongruandXubin Ren 13178f3eaa fix(session): reject non-integer consolidated offsets
maintainer edit: corrupt session metadata can contain JSON strings, nulls, floats, or booleans. Reset non-integer offsets before range checks so recovery keeps valid messages visible instead of falling back to an empty session.
2026-06-03 15:01:29 +08:00
04cbandXubin Ren 0307ee6b73 fix(session): reset out-of-range last_consolidated to recover hidden history (#4066) 2026-06-03 15:01:29 +08:00
d1a94dae8a refactor(dream): replace two-phase Dream class with simple cron + process_direct (#3990)
* refactor(dream): replace two-phase Dream class with simple cron + process_direct

- Remove the heavyweight Dream class (AgentRunner-based two-phase system)
  from nanobot/agent/memory.py
- Delete dream_phase1.md and dream_phase2.md templates
- New dream.md template serves as the consolidation prompt
- Cron callback uses agent.process_direct(prompt, session_key=\"dream\")
  instead of agent.dream.run()
- Always performs git auto_commit after execution
- /dream command updated to use process_direct + git commit
- DreamConfig kept for backward compatibility; deprecated fields
  (model_override, max_batch_size, max_iterations, annotate_line_ages)
  are ignored but accepted in config
- interval_h remains configurable via agents.defaults.dream.interval_h
- Update tests and webui settings to match new architecture

* feat(loop): add ephemeral mode to process_direct, skip history writes for Dream

When ephemeral=True, _state_save skips enforce_file_cap (which calls
raw_archive -> append_history) and consolidator.maybe_consolidate_by_tokens.
This prevents Dream sessions from creating a positive feedback loop where
they process their own output. The session IS still saved to disk.

* fix(loop): skip extra hooks for ephemeral sessions (Dream)

* feat(dream): per-run timestamped sessions with rotation for WebUI

* test(config): restore DreamConfig schedule and alias tests

* fix(dream): include LLM response summary in git auto-commit message

The old two-phase Dream class included the Phase 1 analysis in the git
commit message body. The new single-phase version lost this. Restore it
by extracting resp.content from the process_direct return value and
appending it to the commit message in both the cron handler and the
/dream command.

* fix(test): accept ephemeral kwarg in test_openai_api fake_process

* refactor(dream): merge dream_session.py into MemoryStore

The standalone dream_session.py module only contained three small helpers
that all revolve around MemoryStore concerns (session keys, commit messages,
file pruning). Fold them into MemoryStore as @staticmethod to reduce
indirection and avoid a 35-line module with no independent reason to exist.

* fix(test): address code review — patch correct instance, use actual function

- Fix test_ephemeral_skips_raw_archive to patch loop.context.memory
  instead of the fixture's separate MemoryStore instance
- Fix TestDreamCommitMessage to call MemoryStore.build_dream_commit_message
  instead of reimplementing the logic inline
- Move Dream helpers in memory.py above the Consolidator section comment
  to avoid misleading visual boundary

* fix(dream): gate cursor advancement and restrict tools

maintainer edit: Dream now processes backlog from the oldest unprocessed entries, only advances the cursor after a completed ephemeral run, and uses a restricted file-only tool registry for background consolidation.

* fix(dream): skip idle compact for dream sessions

Dream runs use internal dream:* sessions that are pruned by Dream retention. Exclude them from AutoCompact scheduling, archive execution, and summary injection so idle-session compaction cannot truncate Dream transcripts.

* fix(dream): keep batched history isolated

* feat(dream): tag archived memory for single-phase Dream

---------

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-06-02 22:46:47 +08:00
chengyongruandXubin Ren b2ae5d936f fix(email): bound outbound attachment handling
maintainer edit: apply the existing email attachment count and size limits to outbound media, and include visible fallback notes when an attachment cannot be sent.
2026-06-02 21:17:31 +08:00
PringlasandXubin Ren 82a3fd03b1 test(email): cover agent-initiated file attachments in outbound messages 2026-06-02 21:17:31 +08:00
PringlasandXubin Ren 25bb053206 feat(email): attach media files to outbound SMTP messages 2026-06-02 21:17:31 +08:00
chengyongruandXubin Ren 456ed77e79 fix(webui): bound startup fetch waits 2026-06-02 18:47:34 +08:00
chengyongruandXubin Ren 2a98360105 refactor: split WebUI gateway dependencies
Maintainer edit for PR 4115: rebase onto origin/main and split gateway HTTP routing from token, media, and workspace services so WebSocketChannel depends on explicit gateway services instead of GatewayHTTPHandler internals.

Preserve file edit channel capabilities and restore tools.restrict_to_workspace wiring through ChannelManager.
2026-06-02 17:14:38 +08:00
chengyongruandXubin Ren 2420826e05 fix: handler token issue also checks static token as fallback 2026-06-02 17:14:38 +08:00
chengyongruandXubin Ren 0acf7cd373 refactor: remove gateway-specific kwargs from WebSocketChannel 2026-06-02 17:14:38 +08:00
chengyongruandXubin Ren 1252550649 refactor: ChannelManager creates and injects GatewayHTTPHandler 2026-06-02 17:14:38 +08:00
chengyongruandXubin Ren e5eb08e3e5 refactor: WebSocketChannel accepts injected http_handler, update all tests 2026-06-02 17:14:38 +08:00
chengyongruandXubin Ren 22673c2a27 refactor: update import paths after ws_http move to webui/ 2026-06-02 17:14:38 +08:00
chengyongruandXubin Ren ca139c7031 refactor: move ws_http.py from channels/ to webui/ 2026-06-02 17:14:38 +08:00
chengyongruandXubin Ren 1a585288b2 refactor: extract GatewayHTTPHandler from WebSocketChannel
Extract all HTTP route handling (bootstrap, sessions, settings, media,
commands, sidebar state, static serving, token management) into a new
GatewayHTTPHandler class in nanobot/channels/ws_http.py.

WebSocketChannel is reduced from 1907 to 1372 lines (-28%), retaining
only WebSocket connection management and message dispatch.

No behavior change. 3730 tests pass, 0 failures.

Shared HTTP utility functions (path parsing, response builders, auth
helpers) now live in ws_http.py with websocket.py importing from there,
avoiding circular dependencies.

Backwards-compat property aliases on WebSocketChannel ensure existing
tests continue to work without modification.
2026-06-02 17:14:38 +08:00
JasperandXubin Ren 92fe40a690 fix(runner): prevent read_file offload loop 2026-06-02 17:06:37 +08:00
Xubin Ren f382133bb4 refactor(webui): move media replay helpers out of websocket channel 2026-06-02 16:18:57 +08:00
Xubin Ren 7aa5e620be chore(webui): remove useless timezone assignment 2026-06-02 16:18:57 +08:00
Xubin Ren 8bc4a80035 fix(webui): suppress restart handshake noise 2026-06-02 16:18:57 +08:00
Xubin Ren 21c60b0c97 fix(webui): resign replayed assistant media 2026-06-02 16:18:57 +08:00
Xubin Ren a371907809 fix(webui): keep tool activity in one thought block 2026-06-02 16:18:57 +08:00
Xubin Ren fd61203be4 feat(webui): bucket dense prompt rails 2026-06-02 16:18:57 +08:00
Xubin Ren 1af2bc513f feat(webui): add prompt rail navigation 2026-06-02 16:18:57 +08:00
Xubin Ren e8d4aff5be fix(webui): polish links and thought timing 2026-06-02 16:18:57 +08:00
chengyongruandXubin Ren d5692bf94c fix(napcat): harden async handlers and action errors
maintainer edit: track background handler tasks, surface failed OneBot actions, reject image redirects, and add focused unit coverage for group routing and edge cases.
2026-06-02 14:10:10 +08:00
LZDQandXubin Ren 0c3063b78c Fix deadlock: get_group_member_info blocks receive loop 2026-06-02 14:10:10 +08:00
LZDQandXubin Ren b1a3053ceb Channel napcat by Claude 2026-06-02 14:10:10 +08:00
04cbandXubin Ren ac226d66f9 fix(memory): serialize cursor allocation in append_history (#4081) 2026-06-02 14:09:01 +08:00
chengyongruandXubin Ren 3e98a03188 fix: support fallback copy for webui replies 2026-06-02 14:08:55 +08:00
chengyongruandXubin Ren 1886d22352 fix webui refresh location routing 2026-06-02 14:08:47 +08:00
chengyongruandXubin Ren b2cabb2bd8 fix(webui): keep project heading singular
maintainer edit: render the Projects divider only before the first project group so Chats can sort between projects without duplicating the heading. Add middle and last ordering regression coverage.
2026-06-02 14:08:45 +08:00
chengyongruandXubin Ren a70871679c fix(webui): sort Chats group among projects by recency
In project-based sidebar grouping, the "Chats" section (non-project
conversations) was always appended at the end regardless of its most
recent updated_at. This meant the newest conversation could appear
below older project groups.

Move Chats group insertion before the global sort, compute its
updatedAt from its most recently updated session, and sort all groups
together by updatedAt descending.
2026-06-02 14:08:45 +08:00
Xubin Ren edf34d857a search: add Volcengine web search provider 2026-06-02 13:55:12 +08:00
chengyongruandXubin Ren 851150fcd8 docs: document DingTalk group user isolation 2026-06-01 23:01:19 +08:00
李明振andXubin Ren da0aafcfbd feat(dingtalk): add group_user_isolation to separate sessions per user in group chats
Add a new config option group_user_isolation (default: false) to the
DingTalk channel. When enabled, each user in a group chat gets their own
session while bot replies are still routed to the shared group chat.
2026-06-01 23:01:19 +08:00
chengyongruandXubin Ren 0042f68f94 fix: close websocket turns after errors 2026-06-01 23:00:53 +08:00
chengyongruandXubin Ren ebc8c9faf9 chore: restore existing import order 2026-06-01 23:00:53 +08:00
chengyongruandXubin Ren d1b0fb6676 docs: clarify progress bus responsibility 2026-06-01 23:00:53 +08:00
chengyongruandXubin Ren f78700fe69 refactor: move runtime event publishing out of loop 2026-06-01 23:00:53 +08:00
chengyongruandXubin Ren 81370565e0 refactor: subscribe to runtime event types 2026-06-01 23:00:53 +08:00
chengyongruandXubin Ren 2f0e638bd1 refactor: route file edit progress via channel capability 2026-06-01 23:00:53 +08:00
chengyongruandXubin Ren 8129c16b7d fix: tolerate missing runtime event state in direct loop tests 2026-06-01 23:00:53 +08:00
chengyongruandXubin Ren 628b250e9a refactor: decouple webui runtime state via events 2026-06-01 23:00:53 +08:00
Xubin Ren 0c6ce80aeb docs: update README with release notes for v0.2.1, highlighting new features and improvements 2026-06-01 17:14:06 +08:00
Xubin Ren f309982bb0 chore(release): update version to 0.2.1 2026-06-01 16:51:24 +08:00
chengyongruandXubin Ren 0e37024114 fix(session): archive actual idle compact drops 2026-06-01 16:07:08 +08:00
yorkhellenandXubin Ren baffd6ef92 fix(session): correct last_consolidated tracking in non-contiguous retention
The previous fix made retain_recent_legal_suffix return the actual dropped
message list, but already_consolidated was still computed with
min(before_last_consolidated, len(dropped)), which assumes dropped messages
are always a prefix. In the else branch (tail has no user messages), dropped
may include messages from after the consolidated prefix, causing
already_consolidated to skip too many and leaving tail messages neither
retained nor raw-archived.

Fix by having retain_recent_legal_suffix return (dropped,
already_consolidated_count) where already_consolidated_count is computed
against original message indices. Also fix last_consolidated update to count
how many retained messages were inside the old consolidated prefix.
2026-06-01 16:07:08 +08:00
yorkhellenandXubin Ren 72fb642ef7 fix(session): prevent duplicate archive and message loss in enforce_file_cap
When retain_recent_legal_suffix hits the else branch (tail has no user
messages), it takes a non-contiguous slice from the middle of the session.
enforce_file_cap incorrectly assumed dropped messages were always a prefix
(before[:dropped_count]), causing user messages to be both archived and
retained, and some messages to silently disappear.

Fix by having retain_recent_legal_suffix return the actual dropped message
list using identity-based diff, so enforce_file_cap no longer needs to
guess which messages were removed.
2026-06-01 16:07:08 +08:00
JasperandXubin Ren b886b4a566 docs: add AGENTS.md for Codex 2026-06-01 16:06:51 +08:00
Xubin Ren a4bd4befd4 Fix thought activity ordering 2026-06-01 16:05:42 +08:00
Xubin Ren 9ecd25bca1 docs: update nanobot_webui.png for improved visuals 2026-06-01 06:07:10 +08:00
Xubin Ren 503fc83ce2 docs: rename README cover image 2026-06-01 05:47:14 +08:00
Xubin Ren 806176f161 docs: update GitHub README image 2026-06-01 05:41:23 +08:00
Xubin Ren 081482b20f docs: refresh README opening positioning 2026-06-01 05:29:11 +08:00
Xubin Ren ff80998423 docs: tighten README positioning bullets 2026-06-01 05:26:18 +08:00
Xubin Ren b60e507010 docs: sharpen README positioning 2026-06-01 05:19:14 +08:00
Xubin Ren 76e857269d docs: update README news through May 30 2026-06-01 05:14:53 +08:00
Xubin Ren be2e0172d1 fix(agent): extend sustained goal iteration budget 2026-06-01 04:00:15 +08:00
Xubin Ren cba9ff1f57 fix(webui): simplify rendered source links 2026-06-01 00:00:37 +08:00
Xubin Ren 33a13b701b feat(webui): render source links with favicons 2026-06-01 00:00:37 +08:00
Xubin Ren 34386fe676 fix(webui): stabilize streaming output and settings i18n 2026-06-01 00:00:37 +08:00
Xubin Ren 31722120b7 feat(webui): polish native host experience 2026-06-01 00:00:37 +08:00
Xubin Ren 15c6abc991 test(webui): assert code block language fallback 2026-05-31 15:42:40 +08:00
Flinn-XandXubin Ren bdb3a2ded7 fix(webui): handle undefined language in code blocks
When fenced code blocks have no language specifier, react-syntax-highlighter
receives undefined for the language prop, causing a white screen crash.

- CodeBlock.tsx: fallback to 'text' when language is undefined
- MarkdownTextRenderer.tsx: defensive fallback at fence rendering site
- Added test cases for both components

Fixes #4116
2026-05-31 15:42:40 +08:00
hamb1yandXubin Ren a3241c33ba Require auth for WebSocket token issuance 2026-05-31 15:15:54 +08:00
chengyongruandXubin Ren 15c2bd25b3 refactor(heartbeat): remove Completed section and tighten section gating
- Remove ## Completed section from HEARTBEAT.md template; completed
  tasks should be deleted, not accumulated
- Change in_active_section from tri-state (None/True/False) to bool
  (True/False) so stray text before any ## heading no longer triggers
  heartbeat
- Add test cases for stray pre-heading text and ## Notes section
- Update docs/chat-commands.md to reference ## Active Tasks
2026-05-31 15:15:37 +08:00
Xubin Ren 2671c8fe55 fix(heartbeat): ignore completed-only heartbeat entries 2026-05-31 15:15:37 +08:00
04cbandXubin Ren e3df310309 fix(heartbeat): skip when HEARTBEAT.md has no tasks and fail closed on delivery (#4111) 2026-05-31 15:15:37 +08:00
Xubin Ren 2b4c984e9a fix(matrix): align SAS verification message flow 2026-05-31 01:00:14 +08:00
mytechdreamandXubin Ren 68712fc489 fix(matrix): handle SAS device verification 2026-05-31 01:00:14 +08:00
Xubin Ren 0cc58a80a4 test(agent): cover process_direct session locking 2026-05-30 23:45:37 +08:00
04cbandXubin Ren e29c9c3906 fix(agent): acquire per-session lock in process_direct (#4080) 2026-05-30 23:45:37 +08:00
Xubin RenandGitHub 3dcf511c84 feat(webui): refine output timeline and model controls (#4108)
* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
2026-05-30 23:45:26 +08:00
chengyongruandXubin Ren b2e43955e3 fix: add regression tests for bare-dict coercion, update stale comment 2026-05-30 15:35:04 +08:00
chengyongruandXubin Ren 98be0de919 fix(test): increase yield_time_ms in test_write_stdin_can_close_stdin for Windows CI stability 2026-05-30 15:35:04 +08:00
04cbandXubin Ren 13ab092cea feat(dream): add enabled toggle to skip Dream job registration (#3885) 2026-05-30 15:35:04 +08:00
04cbandXubin Ren 5fe57f8afa fix(providers): coerce typeless Anthropic content blocks to text (#3993) 2026-05-30 15:35:04 +08:00
chengyongruandXubin Ren 288146315e fix(security): normalize IPv6-mapped IPv4 in loopback check, add tests
- Apply _normalize_addr in _is_allowed_loopback_target so
  ::ffff:127.0.0.1 is correctly identified as loopback
- Add test for contains_internal_url with IPv6-mapped addresses
- Add test for whitelist + IPv6-mapped CGNAT interaction
2026-05-30 15:34:49 +08:00
yorkhellenandXubin Ren 13dec9d2c2 fix(security): normalize IPv6-mapped IPv4 addresses in SSRF checks
::ffff:127.0.0.1 and ::ffff:169.254.169.254 are IPv6Address objects
that match neither the IPv4 blocklists (127.0.0.0/8, 169.254.0.0/16)
nor the IPv6 ones (::1/128), allowing SSRF bypass via DNS responses
that return IPv6-mapped IPv4 addresses.

Add _normalize_addr() to convert ipv4_mapped IPv6 addresses to their
IPv4 form before blocklist/allowlist matching.
2026-05-30 15:34:49 +08:00
Xubin Ren 1d4000560d fix(matrix): reject boolean media sizes 2026-05-30 15:34:19 +08:00
hinotoi-agentandXubin Ren 4dd89f4c46 fix(matrix): bound inbound media downloads 2026-05-30 15:34:19 +08:00
chengyongruandXubin Ren 7c86223643 fix(exec): bypass cmd.exe for multi-line python -c commands on Windows
On Windows, cmd.exe /c treats newlines as command separators, silently
dropping code after the first line in `python -c "..."` commands. This
causes multi-line inline Python to produce no output with exit code 0.

Detect multi-line `python -c` commands on Windows, parse them into exec
args via `_split_python_c_args`, and use `create_subprocess_exec` to
bypass cmd.exe entirely. Same principle as Codex's Rust `Command::args()`.

Applied to both the direct execution path and the session spawn path.
Added unit tests for the parser and the exec-vs-shell branching logic.
2026-05-30 01:02:40 +08:00
Xubin Ren 8e421eb976 refactor(webui): clarify websocket routing 2026-05-29 17:26:58 +08:00
Xubin Ren 9ed5643d93 refactor(webui): isolate signed media serving 2026-05-29 17:26:58 +08:00
Xubin Ren 4a0035ef8f fix(webui): support video byte ranges 2026-05-29 17:26:58 +08:00
Xubin Ren a71e6a0ae8 fix(webui): persist markdown video previews 2026-05-29 17:26:58 +08:00
Xubin Ren 57563b671f fix(apps): recover stale npm installs 2026-05-29 17:26:58 +08:00
Xubin Ren d7bc1bcfb5 fix(apps): use registry logos 2026-05-29 17:26:58 +08:00
Xubin Ren c1357e86de feat(apps): add extension registry source 2026-05-29 17:26:58 +08:00
Xubin Ren 232df45126 fix(msteams): trust official Teams service hosts 2026-05-29 16:46:46 +08:00
hinotoi-agentandXubin Ren 5734c17ee0 fix(msteams): trust service URLs before replies 2026-05-29 16:46:46 +08:00
04cbandXubin Ren 9d3fe7c34b fix(providers): surface clear arrearage warning on quota/billing errors (#3006) 2026-05-29 15:31:17 +08:00
chengyongruandXubin Ren 672fabe5be refactor(agent): move document media logic out of AgentLoop into document.py
Extract is_image_file() and reference_non_image_attachments() from
AgentLoop private static methods into nanobot/utils/document.py where
they belong alongside extract_documents(). Simplify config lookup by
removing dead isinstance(dict) branch.
2026-05-29 15:31:03 +08:00
hanyuanlingandXubin Ren ec4f9e9857 Add document extraction channel toggle 2026-05-29 15:31:03 +08:00
Xubin Ren 404b68cdd4 feat(webui): add context window setting 2026-05-29 13:09:08 +08:00
Xubin RenandGitHub 3a420136bb feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
2026-05-29 03:42:53 +08:00
chengyongruandXubin Ren 84428136e6 test: harden timing-fragile test and add cross-tool ContextVar isolation test
Replace asyncio.sleep(0.05) with an asyncio.Event + patched Lock.acquire
to guarantee the waiting task has reached the lock before asserting.  Add
a test confirming LongTaskTool and CompleteGoalTool ContextVars are
isolated, and document the design intent in _GoalToolsMixin.
2026-05-28 22:54:46 +08:00
hamb1yandXubin Ren 0df60416ba fix(agent): address session and streaming concurrency bugs 2026-05-28 22:54:46 +08:00
chengyongruandXubin Ren 1a4ae8994d fix(tests): update monkeypatch path for evaluate_response
The import was moved to module top in nanobot/cli/commands.py,
so tests must patch nanobot.cli.commands.evaluate_response instead
of nanobot.utils.evaluator.evaluate_response.
2026-05-28 20:20:28 +08:00
chengyongruandXubin Ren fe2af64e04 refactor(heartbeat): migrate heartbeat service to cron-based auto-registration
Remove standalone nanobot/heartbeat/ service and replace it with an
auto-registered system cron job on gateway startup. Key behaviors preserved:

- HeartbeatConfig (enabled, interval_s, keep_recent_messages) remains in
  GatewayConfig for backward compatibility.
- On startup, if enabled, a system cron job "heartbeat" is registered with
  schedule derived from interval_s.
- HEARTBEAT.md is checked on each tick; empty/template-identical files skip
  to avoid wasting LLM calls.
- Post-run evaluate_response and session history truncation
  (keep_recent_messages) are retained.
- Delivery target selection, deliverable filtering, and preamble guidance
  are preserved.

Files removed:
- nanobot/heartbeat/__init__.py
- nanobot/heartbeat/service.py
- tests/heartbeat/*
- tests/agent/test_heartbeat_service.py

Templates and docs updated to reflect cron-based usage.
2026-05-28 20:20:28 +08:00
hamb1yandXubin Ren 7d09f1cd9e Add Discord model slash command 2026-05-28 15:48:50 +08:00
yeounhyeokandXubin Ren ac8bef76f6 fix(provider): honor NANOBOT_STREAM_IDLE_TIMEOUT_S in Codex provider
Every other streaming provider (anthropic, bedrock, openai_compat,
litellm) reads NANOBOT_STREAM_IDLE_TIMEOUT_S with a 90s default. The
Codex provider hardcoded 60s in _request_codex, so it could not be
tuned the same way and aborted streams sooner than its peers.

Read the same env var with the same default and pass it as the httpx
client timeout. The variable name and int parsing match anthropic /
openai_compat / bedrock verbatim.

#4009 normalized the error response when the timeout fires; this PR
fixes the timeout knob itself.
2026-05-28 02:17:15 +08:00
Xubin RenandGitHub 1cfc3ef165 docs(contribution): update maintainers information 2026-05-27 18:16:52 +08:00
EunHyunsuandXubin Ren 18567daaa0 Handle blank Codex transport errors 2026-05-27 03:01:32 +08:00
Xubin Ren 9b9b48f1ea chore(webui): restore rollup libc selectors 2026-05-26 17:12:13 +08:00
Stellar鱼andXubin Ren 1eddc129a1 chore: enable WebUI ESLint 2026-05-26 17:12:13 +08:00
outlook84andXubin Ren a4a2c55120 feat(telegram): add webhook support and ordered message queue
Introduce webhook mode for the Telegram channel and implement a session-based message reordering mechanism.

    Key changes:
    - Update `python-telegram-bot` dependency to include the `webhooks` extra.
    - Add `TelegramConfig` fields for webhook configuration, with validation rules for public HTTPS URLs and Telegram's secret token.
    - Implement `_enqueue_ordered_update` and `_drain_ordered_updates` in `TelegramChannel` to stage incoming messages and commands behind a short per-session reorder
  window, ensuring sequential delivery based on message and update IDs.
    - Configure `start_webhook` in `TelegramChannel.start()` when webhook mode is enabled.
    - Add unit tests for webhook config validations, webhook startup, and message reordering.
    - Document webhook configuration and reverse proxy details in `docs/chat-apps.md`.
2026-05-26 16:14:51 +08:00
A.G. BocsardiandXubin Ren 172ec4d4c4 fix(web): update Kagi search API integration
Use Kagi's documented v1 Search API shape from the OpenAPI spec: POST /search, Bearer auth, JSON query payload, and data.search results.
2026-05-26 12:27:01 +08:00
Xubin Ren 4f14f980d9 fix(agent): keep sustained goal continuation independent 2026-05-26 00:53:38 +08:00
chengyongruandXubin Ren 7bbd9c7103 fix(agent): prevent runner from exiting while sustained goal is active
`long_task` registers a sustained objective, but `AgentRunner` would
still exit with `stop_reason="completed"` when the LLM produced a final
text response without calling `complete_goal`. This defeated the purpose
of sustained goals.

Add `goal_active_predicate` and `goal_continue_message` to `AgentRunSpec`.
When the predicate returns `True` at the natural completion checkpoint,
inject a continuation message via the existing `_try_drain_injections`
machinery, forcing the runner to continue looping.

Also extract the default continuation prompt to
`nanobot/utils/runtime.py` alongside the existing recovery-message
builders.
2026-05-26 00:53:38 +08:00
Xubin RenandGitHub 418cb23da2 feat(apps): unify CLI apps and MCP (#3991)
* refactor(cli): load bundled apps from catalog

* feat(plugins): unify CLI and MCP settings

* feat(plugins): add settings category filter

* style(plugins): refine settings catalog

* refactor(cli): load nanobot apps from repo catalog

* feat(store): add capability store entry

* feat(apps): rename capability store

* fix(apps): verify clean app removal

* fix(apps): keep main sidebar on apps view

* feat(apps): add shared app manifest protocol

* fix(apps): dismiss app status message

* refactor(apps): move CLI adapter under apps

* refactor(apps): drop legacy cli apps package
2026-05-25 20:07:02 +08:00
moranandXubin Ren 179acfe104 feat(providers): add Step Plan support
Document how to use StepFun's Step Plan subscription endpoint with the
existing `stepfun` provider by overriding `apiBase`, following the same
pattern as the `zhipu` provider's coding plan documentation.

- **Base URL**: `https://api.stepfun.com/step_plan/v1` (dedicated endpoint)
- **API Key**: same `STEPFUN_API_KEY` as the regular `stepfun` provider
- **Models**: `step-3.5-flash`, `step-3.5-flash-2603`, `step-router-v1`

Changes:
- `docs/configuration.md` — provider tip, and config example showing
  `apiBase` override on the existing `stepfun` provider

Test: 488/488 provider tests passed.
2026-05-25 18:57:36 +08:00
FelixandXubin Ren cfabc29f74 fix(agent): propagate maxConcurrentSubagents config to SubagentManager
The maxConcurrentSubagents field in AgentDefaults was never wired
through AgentLoop.from_config() → AgentLoop.__init__() →
SubagentManager.__init__(), causing it to always fall back to the
hardcoded default of 1 regardless of the user's config.
2026-05-25 16:35:57 +08:00
outlook84andXubin Ren 92f2ff3a33 test: Add test to ensure responses API is used regardless of circuit breaker state 2026-05-25 01:23:36 +08:00
outlook84andXubin Ren c433d60681 feat: Enhance OpenAI provider configuration with extraBody support and apiType validation 2026-05-25 01:23:36 +08:00
outlook84andXubin Ren d472595417 feat: Add OpenAI API type configuration and update provider settings 2026-05-25 01:23:36 +08:00
Xubin Ren 92915ea424 feat(webui): improve slash command actions 2026-05-24 21:24:54 +08:00
Yuxin LouandXubin Ren 3f0098839e fix(provider): preserve OpenAI-compatible tool call ids 2026-05-24 20:53:14 +08:00
Xubin Ren c4e2fcaf0c fix(webui): preserve activity duration on replay 2026-05-24 19:43:20 +08:00
Xubin Ren 8fedee276b fix(webui): auto-collapse completed activity 2026-05-24 19:43:20 +08:00
Xubin Ren 547f81e4aa fix(webui): baseline-align activity diff counts 2026-05-24 19:43:20 +08:00
Xubin Ren 00a6e720dc fix(webui): align inline file references with text 2026-05-24 19:43:20 +08:00
Xubin Ren 6ea7a6a2ac refactor(webui): prune unused legacy components 2026-05-24 19:43:20 +08:00
Xubin Ren 704ac558f6 feat(mcp): add preset setup and capability mentions 2026-05-24 19:43:20 +08:00
Xubin Ren 8be258212e fix(webui): handle final stream image rewrites 2026-05-24 19:43:20 +08:00
Xubin Ren c9ff64fc0f fix(webui): render local CLI image artifacts 2026-05-24 19:43:20 +08:00
Xubin Ren 9efdce276f fix(cli): refresh installed apps after settings changes 2026-05-24 19:43:20 +08:00
04cbandXubin Ren 7a6cc657db feat(spawn): allow per-subagent sampling temperature (#3969) 2026-05-24 13:54:37 +08:00
Xubin Ren ec99232208 docs: fix Xiaomi MiMo token plan env key 2026-05-23 22:56:24 +08:00
honjiaxuanandXubin Ren 43a1784c5f docs: use xiaomi_mimo provider for MiMo token plan
Replace standalone 'Token Plan' section with general Xiaomi MiMo
section using the built-in xiaomi_mimo provider. Token plan becomes
a note within the section, since it's just an apiBase override.

Key changes:
- Use xiaomi_mimo provider (auto-matches via 'mimo' keyword in model name)
- Drop redundant provider field (auto-detected)
- Add token plan tip to provider tips block
- Restructure as general Xiaomi MiMo section with token plan as note
2026-05-23 22:56:24 +08:00
Xubin Ren 3d3ef586e7 docs(config): clarify exec timeout and transcription apiBase 2026-05-23 17:32:59 +08:00
04cbandXubin Ren ef2ef4f789 fix(transcription): normalize chat-style apiBase to audio endpoint (#3637) 2026-05-23 17:32:59 +08:00
04cbandXubin Ren 5b71f61f55 fix(exec): uncap config exec timeout; 0 means no limit (#3595) 2026-05-23 17:32:59 +08:00
Xubin Ren 5937236f9d test(image-generation): tighten zhipu provider coverage 2026-05-23 17:06:36 +08:00
Hermes AgentandXubin Ren 192d2af19d fix(zhipu): raise error on reference images and ensure client cleanup in finally 2026-05-23 17:06:36 +08:00
Jiajun XieandXubin Ren 3e6f9907fe feat: Add Zhipu (智谱) image generation provider 2026-05-23 17:06:36 +08:00
Xubin Ren c0d4f012c8 test(cli): cover CLI Apps on Windows CI 2026-05-23 00:47:28 +08:00
Xubin Ren e2d00ffc8f feat: add CLI Apps settings MVP 2026-05-23 00:33:31 +08:00
Xubin Ren a5a956d9af fix(webui): preserve localized chat show-more copy 2026-05-23 00:01:52 +08:00
Stellar鱼andXubin Ren 8c5acea3b0 chore: fill remaining webui locale keys 2026-05-23 00:01:52 +08:00
Xubin Ren 545294c62c fix(web): keep safe fetch preflight streaming 2026-05-22 23:10:13 +08:00
hinotoi-agentandXubin Ren 25d00b1ea4 fix(web): support redirect handling in fake responses 2026-05-22 23:10:13 +08:00
hinotoi-agentandXubin Ren ff173045fe fix(web): validate redirect targets before fetching 2026-05-22 23:10:13 +08:00
yu-xin-candXubin Ren b1140f6aee chore: fill zh-TW and ja locale keys 2026-05-22 22:38:34 +08:00
Xubin RenandGitHub 782d761b81 Merge PR #3929: Unify image provider HTTP handling and document Gemini image base URLs
Unify image provider HTTP handling and document Gemini image base URLs
2026-05-22 22:31:27 +08:00
Xubin Ren c1073f2986 fix(image-generation): keep image presence helper stable 2026-05-22 22:19:32 +08:00
Xubin Ren 143224e25a Merge remote-tracking branch 'origin/main' into codex/review-pr-3929 2026-05-22 22:15:46 +08:00
Yuxin LouandXubin Ren 055c9be359 fix: dedupe Responses replay item ids
Ensure converted Responses API input items use unique replay ids when restoring assistant messages and function calls. This prevents Codex from rejecting resumed conversations with duplicate rs_* item ids while preserving call_id-based tool result linkage.
2026-05-22 22:14:07 +08:00
Xubin RenandGitHub ddfe5c3bdf Merge PR #3946: Add Ollama image generation support
Add Ollama image generation support
2026-05-22 22:06:28 +08:00
Xubin Ren f5534bcaa0 Merge origin/main into fix-ollama-image-generation 2026-05-22 21:15:42 +08:00
Xubin Ren 8c0b2c1a29 fix(image-generation): clamp OpenAI sizes by model family 2026-05-22 17:42:01 +08:00
ZegWeandXubin Ren ffd85a8611 fix image generation provider settings 2026-05-22 17:42:01 +08:00
ZegWeandXubin Ren 65dff4f3a5 fix(providers): preserve codex text deltas 2026-05-22 17:42:01 +08:00
3483141ed7 feat(providers): add OpenAI and OpenAI Codex image generation providers
Add two new image generation providers:

- `openai` — uses the standalone OpenAI Images API
  (`/v1/images/generations`) with an API key. Supports DALL-E
  and gpt-image-* models, with automatic parameter adjustment
  (gpt-image models don't accept response_format or n).

- `openai_codex` — uses the Codex Responses API with the
  `image_generation` tool, authenticated via OAuth subscription
  token. The same mechanism ChatGPT uses internally.

Also remove the API key pre-check in ImageGenerationTool so
providers that handle their own auth fallback (like Codex OAuth)
can work without a configured key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:42:01 +08:00
Xubin Ren b0d3069621 fix(apply-patch): tighten edits-only boundaries 2026-05-22 17:25:45 +08:00
chengyongruandXubin Ren 3d9f50a0cc refactor(apply_patch): remove deprecated patch mode, keep edits-only
Drop the legacy unified-diff patch parameter and all related parsing/
generation logic (_parse_patch, _generate_patch, _apply_hunks, etc.).
The tool now accepts only the structured `edits` array, eliminating the
intermediate diff-string round-trip.

Also update file_edit_events tracking and tests to work exclusively
with edits.

Benchmark (zhipu glm-5.1, edits mode): 15/15 cases passed.
2026-05-22 17:25:45 +08:00
Xubin Ren effc1efd92 fix(webui): avoid misleading file edit counters 2026-05-22 13:58:09 +08:00
A.G. BocsardiandXubin Ren 9b2f452b6e fix: drop redundant reasoning_effort for Kimi thinking models
Moonshot's API rejects requests that carry both 'reasoning_effort'
(top-level kwarg) and 'thinking' (extra_body) at the same time.
After the unified thinking-style injection loop injects the native
'thinking' param for kimi models, pop 'reasoning_effort' from kwargs
since it is redundant and causes a 400 error.

Uses _model_slug() + _KIMI_THINKING_MODELS lookup to stay consistent
with the refactored code (the old _is_kimi_thinking_model helper was
removed in 4f895e63).

Existing kimi tests updated to assert 'reasoning_effort' is absent.
Xiaomi MiMo models are unaffected — their API accepts both params.

Closes #3939
2026-05-22 03:36:28 +08:00
Xubin Ren d660573b18 feat(webui): improve sidebar performance 2026-05-22 03:35:20 +08:00
Xubin Ren cb7daa77db feat(webui): refine collapsible sidebar 2026-05-22 00:34:42 +08:00
Xubin Ren 8281cd1946 test(providers): cover Novita gateway fallback 2026-05-21 16:16:32 +08:00
Alex-wuhuandXubin Ren e5476573f4 test(providers): align Novita provider coverage 2026-05-21 16:16:32 +08:00
Alex-wuhuandXubin Ren 0d1d23b5fb feat: add Novita AI provider 2026-05-21 16:16:32 +08:00
Xubin Ren 835bab5f5a fix(exec): stabilize Windows shell tests 2026-05-21 16:10:09 +08:00
Xubin RenandGitHub ccbc0bb6e3 Merge PR #3923: feat(tools): optimize coding workflows
feat(tools): optimize coding workflows
2026-05-21 15:55:13 +08:00
Xubin Ren 722b760eae feat(webui): stream apply patch edit progress 2026-05-21 15:44:01 +08:00
Xubin Ren 23d5148a57 fix(provider): dedupe repeated tool ids in history 2026-05-21 15:33:49 +08:00
Xubin Ren d29fcaf5d1 refactor(agent): internalize tool contract prompt 2026-05-21 15:21:39 +08:00
Haisam Abbas 84603f4cf2 Add Ollama image generation support 2026-05-21 12:06:08 +05:00
Xubin Ren 581faa34f7 Merge remote-tracking branch 'origin/main' into codex/coding-tooling-optimization 2026-05-21 14:44:56 +08:00
Xubin Ren 7e3af8c38b docs(tools): add general tool workflow contract 2026-05-21 14:44:34 +08:00
Haisam AbbasandXubin Ren e645fbcb34 fix shell guard url path detection 2026-05-21 14:42:11 +08:00
Xubin Ren 4f895e6307 refactor(providers): centralize gateway reasoning control 2026-05-21 14:41:50 +08:00
olgagagaandXubin Ren 0cd2f626c0 fix(providers): inject OpenRouter reasoning.effort for thinking models
Follow-up to #3851: that PR added `extra_body.thinking={type: disabled}`
for MiMo via OpenRouter, but OR doesn't forward provider-specific
thinking shapes to upstream — it strips unknown extra_body fields and
uses its own unified `reasoning` parameter. So MiMo via OR kept
thinking despite the injection (reproduced by @ClearPlume on #3851
with identical kwargs but provider switched from openrouter → xiaomi_mimo).

For known thinking-capable models (Kimi, MiMo) routed via the
openrouter spec, also inject `extra_body.reasoning = {effort: <effort>}`
in OR's documented enum ("none"|"minimal"|"low"|"medium"|"high"|"xhigh").
OR translates this to the upstream model's native shape.

Existing tests updated to expect both fields on the OR path. The direct
xiaomi_mimo and moonshot paths are unchanged (the new branch is gated
on spec.name == "openrouter"). Flash and non-MiMo models on OR continue
to receive no injection.
2026-05-21 14:41:50 +08:00
Xubin Ren 44ef697aac docs(tools): clarify coding tool guidance 2026-05-21 14:28:39 +08:00
chengyongruandXubin Ren e2b51fa5dc fix(weixin): prevent silent message drops from poll exceptions and expired tokens
- Remove suppress(Exception) from poll loop and message processing; add
  logger.exception so inbound errors are visible.
- Check both ret and errcode on send to avoid silent drops when iLink
  returns ret != 0 with errcode == 0.
- Proactively refresh context_token via getconfig before sending if the
  cached token is older than 60s. This prevents message loss on long
  agent turns and cron pushes without relying on complex retry logic.

Refs: openclaw/openclaw#61174, NousResearch/hermes-agent#21011
2026-05-21 13:41:05 +08:00
Xubin Ren 7e122d6e49 chore(tools): merge main and resolve conflicts 2026-05-21 12:53:42 +08:00
hanyuanlingandXubin Ren de0a8f5e41 fix(webui): keep new chat during session refresh 2026-05-21 12:42:56 +08:00
Xubin Ren 3d3ebf1110 test(provider): cover duplicate streaming tool call ids 2026-05-21 12:28:24 +08:00
chengyongruandXubin Ren 77ec55bf8e fix(provider): deduplicate streaming tool_call_ids for parallel calls 2026-05-21 12:28:24 +08:00
Xubin Ren 8141df0d3f fix(tools): stabilize session output test 2026-05-21 01:32:27 +08:00
Xubin Ren 5f0ba05de5 feat(tools): tighten patch and session workflows 2026-05-21 01:25:20 +08:00
chengyongruandXubin Ren 886e7e43d5 fix(signal): bypass base is_allowed for policy-approved messages
Override _handle_message to publish directly to the bus for messages
that have already passed _check_inbound_policy. The denied DM pairing
path calls super()._handle_message() to issue pairing codes via the
base class. This avoids cross-policy leakage where e.g. group open
policy would cause is_allowed to incorrectly allow denied DM senders.

Also includes:
- SSE: strip one optional leading space after 'data:' per spec
- Convert 20+ f-string log calls to loguru lazy formatting
- Add end-to-end tests for DM/group routing through the full chain
- Add cross-policy test (dm allowlist + group open) for pairing
- Add Signal channel documentation to docs/chat-apps.md
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren b3d0d24a52 fix(signal): consult pairing store in is_allowed
BaseChannel.is_allowed ORs is_approved (the pairing store) into the
allow decision; the signal override dropped that step and only looked
at config.allow_from. With the new DM-pairing flow in place, an
approved-via-pairing sender's next message would have failed the
allow check and triggered another pairing code in a loop.

OR in a normalized check against the pairing store: walk each part of
the pipe-joined sender_id through _normalize_signal_id and call
is_approved for each variant, so an approval stored under one form
(phone with/without "+", UUID/ACI) still matches when the next inbound
uses a different form. Mirrors how slack.py:643 handles it.

Also tightens the empty-allowlist warning to only fire when nothing
else granted access, since pairing-store hits are now a valid path.

Not part of the original review, but Comments 2 and 3 turn this latent
gap into a broken round-trip — included so the pairing UX actually
works.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 82dfe8c1f7 fix(signal): join multi-line SSE data with newline per spec
Per the SSE spec, multiple data: lines within a single event must be
joined with \n before parsing. signal-cli emits single-line JSON so
this was latent, but the joining was wrong.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren dc33247671 fix(signal): route denied DMs through _handle_message for pairing code
Previously _check_inbound_policy returned (False, chat_id) for DMs
that failed the allowlist and the caller dropped them — so unapproved
DM senders never saw a pairing code. Mirror Slack: when the policy
gate denies a DM but dm.enabled is true, still call
_handle_message(content="", is_dm=True) so BaseChannel can issue the
pairing reply. Group denials stay a hard drop.

Combined with the previous is_dm forwarding, unapproved DM senders
now receive a pairing code through the standard flow.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren d376ec129d fix(signal): pass is_dm to _handle_message so DM pairing flow runs
BaseChannel._handle_message uses is_dm to decide whether to issue a
pairing code when is_allowed rejects the sender. Without it the base
class treats every denied message as a group message and silently
drops it. Forward is_dm=not is_group_message so unapproved DM users
get a pairing code through the standard flow.

This change only takes effect once denied DMs actually reach
_handle_message (next commit); on its own it is a no-op since the
policy gate still short-circuits before this call.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren d653f23aba fix(signal): raise on signal-cli error response so send is retriable
_send_http_request collapses every exception path into a {"error": ...}
dict, so the if "error" in response branch inside send() is the only
place where send failures surface. Logging-only there meant the
ChannelManager retry mechanism never fired. Raise RuntimeError so the
base-class retry path is exercised; the outer try/except already
re-raises into the caller.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 96767ca179 Cleanup 2026-05-21 01:00:36 +08:00
b300ea495f fix(signal): normalize composite sender_ids in is_allowed too
The base BaseChannel.is_allowed() does a literal ``sender_id in allow_from``
check, but Signal's sender_id is a pipe-joined composite of phone/UUID
parts. After splitting an allowlist entry like ``+phone|uuid`` into two
separate entries, the per-DM gate accepted it but the base gate still
denied because the composite sender string wasn't literally in the list.

Override is_allowed on SignalChannel to delegate to
_sender_matches_allowlist, which already splits both sides on ``|`` and
normalizes each part. _sender_matches_allowlist itself now also splits
allowlist entries on ``|`` so legacy composite entries keep working too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
632f41e418 test(signal): cover markdown adjacency, nesting, and malformed input
The existing markdown suite was strong on UTF-16 offsets and chunk
redistribution but had no coverage for nested or adjacent styles, no test
that an unmatched opener round-trips as plain text, and no test for the
blockquote/inline-code interaction. Add six cases including the
documented contiguous-BOLD output for `# **wrap** me`, which Signal
renders as one visual span.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
9c486b90d5 test(signal): consolidate channel-capture setup into one factory
Two test classes (TestHandleDataMessageDM, TestHandleDataMessageGroup)
plus three TestCommandHandling tests each repeated the same handful of
lines: build a channel, mock _handle_message to record kwargs, replace
_start_typing with a no-op, paper over the assignment with type: ignore.

Hoist the pattern into _make_channel_with_capture and call it from all
five sites. Drops 30+ lines of duplication and 7 type: ignore comments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
590ac99c8a test(signal): cover SSE receive loop and the empty-phone start guard
Previously the SSE loop and the empty-phone-number short-circuit in start()
had zero coverage. Both now have tests: a fake httpx stream feeds canned
SSE lines, exercising the valid-frame, invalid-JSON, non-200, and
no-http-client paths; start() with an empty phone number is asserted to
return without entering the HTTP loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
7733a7840e refactor(signal): split _handle_data_message into policy and assembly helpers
The receive-path handler was ~165 lines deep into nested DM/group policy
checks, buffer mutations, mention stripping, attachment downloads, and
final bus forwarding. Pull the policy gate out into _check_inbound_policy
(returns (allow, chat_id), still appends to the group buffer once allowed)
and the text+media construction into _assemble_inbound_content. The
top-level method now reads as orchestration only.

Add TestCheckInboundPolicy that exercises the helper directly across the
DM/group policy permutations, including the buffer side effect, so the
new seam is locked in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
83aed43682 feat(signal): make signal-cli attachments directory configurable
The inbound attachment loop hardcoded ~/.local/share/signal-cli/attachments
as the source path. That is the daemon's default on Linux but not on macOS
or Windows, and breaks if the daemon was launched with XDG_DATA_HOME set.

Add SignalConfig.attachments_dir as an optional override. When unset the
behavior is unchanged; when set the value is run through Path.expanduser()
so ~ is honored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
ad7c1ac381 refactor(signal): wrap top-level receive handler with _safe_handle
Replace the inline try/except at the end of _handle_receive_notification
with a small async context manager that swallows the exception, logs
self.logger.error with the offending payload's repr (bounded to 200 chars),
and attaches the traceback via logger.opt(exception=True).

The previous log line only carried `e`, so diagnosing a bad envelope from
production logs required correlating timestamps. The wrapper is generic so
future receive/dispatch sites can adopt it; for now only this site uses it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
882d4139d7 fix(signal): normalize identifiers when matching DM allowlist
The DM allowlist check split sender_id on '|' and looked for raw membership
in the allow_from list. Senders carry their phone number with a leading
'+' but admins routinely write allowlist entries without it (or vice
versa), and UUID/ACI matches were case-sensitive. Both forms now flow
through _normalize_signal_id, so an entry like 19995550001 matches a
sender +19995550001 and a UUID matches case-insensitively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
ca72f6b6c9 refactor(signal): hygiene cleanups around constants, typing, and config
- Hoist the cell-strip patterns to module level so they match the rest of
  the module's regex style and aren't reparsed on every call.
- Type the markdown transform callback and the mention id walker so the
  inline Callable signature is no longer an untyped Any.
- Add _HTTP_TIMEOUT_SECONDS alongside the other class-level tunables.
- Reject group_message_buffer_size <= 0 in a Pydantic field_validator
  rather than silently disabling the buffer at write time.
- Mark SignalConfig.allow_from as a computed_field so it shows up in
  model_dump() instead of being invisible to serialization.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
96eb3b7194 fix(signal): redistribute textStyle ranges across split message chunks
split_message can break a long Signal payload into multiple JSON-RPC sends,
but the previous code attached the full textStyle list only to chunk 0.
Style ranges in later chunks were dropped, and ranges whose offsets pointed
past chunk 0's end were sent as invalid metadata against chunk 0.

Add _partition_styles, which rebases each range against the chunk it lives
in (in UTF-16 code units, matching the markdown converter) and splits
boundary-spanning ranges across the chunks they touch. Whitespace trimmed
by split_message's lstrip is skipped so offsets stay aligned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
8f6b7611a2 fix(signal): emit textStyle offsets in UTF-16 code units
Signal's BodyRange (via signal-cli's textStyle) interprets start/length as
UTF-16 code units, but the Phase-3 assembly used Python's len(), which counts
code points. A single non-BMP character (e.g. an emoji) earlier in a message
shifted every subsequent styled span left by one unit, dropping the last
letter of bold/italic words.

Track a running UTF-16 offset in the assembly loop and add regression tests
covering emojis, supplementary CJK, ZWJ sequences, and a multi-section
message that mirrors the reported failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 1a6fe093e7 fix(signal): drop duplicate self in unconfigured-account log call
Addresses review feedback on HKUDS/nanobot#3852: self.self.logger.error
would crash if the phone_number guard ever fired.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 8ec1025193 feat(signal): add Signal channel support
Integrates signal-cli daemon via HTTP JSON-RPC as a nanobot channel.
Supports DMs and group chats with open/allowlist access policies,
markdown→Signal text style conversion, typing indicators, attachment
handling, group message context buffering, and automatic reconnect
with exponential backoff.

Includes unit tests for channel lifecycle, message routing, mention
detection, markdown conversion, and message splitting.

Originally based on https://github.com/HKUDS/nanobot/pull/601.
2026-05-21 01:00:36 +08:00
Xubin Ren 480ca28a2d feat(tools): improve coding workflow recovery 2026-05-21 00:58:05 +08:00
Xubin Ren 3e154bb5cf fix(tools): align exec platform test doubles 2026-05-20 23:42:55 +08:00
Xubin Ren 6851fa57a6 feat(tools): optimize coding workflows 2026-05-20 23:08:21 +08:00
chengyongruandXubin Ren 09a692be6f docs(readme): add multi-language doc site links
Link nanobot.wiki documentation in 10 languages from README header:
English, 简体中文, 繁體中文, Español, Français, Bahasa Indonesia,
日本語, 한국어, Русский, Tiếng Việt.
2026-05-20 22:37:11 +08:00
Haisam Abbas 3f789bd9f9 Revert "fix shell guard url path detection"
This reverts commit 65cecc01fb.
2026-05-20 17:21:34 +05:00
Haisam Abbas 65cecc01fb fix shell guard url path detection 2026-05-20 17:16:53 +05:00
Haisam Abbas a7b34422f3 fix Gemini image base and provider docs 2026-05-20 14:06:55 +05:00
Haisam Abbas 72f999f8f7 refactor image provider HTTP handling 2026-05-20 13:56:43 +05:00
Haisam Abbas e6587a8d8e Fix image mime detection for MiniMax 2026-05-20 12:18:18 +05:00
Xubin Ren eae51333ad fix(providers): point Skywork at APIFree agent endpoint 2026-05-20 12:33:03 +08:00
moranandXubin Ren 6194a9b919 docs(configuration): fix APIFree formatting — merge wrapped description into single line 2026-05-20 12:33:03 +08:00
moranandXubin Ren 61ae869610 feat(providers): add APIFree support
Add APIFree as a built-in OpenAI-compatible provider. APIFree offers
agent-optimised models such as skywork-ai/skyclaw-v1 through an
OpenAI-compatible API at https://api.apifree.ai/agent/v1.

Changes:
- Register apifree provider in the provider registry
- Add config schema field
- Add documentation with configuration example
- Add provider tests, websocket channel tests, and webui tests
- Add provider icon in settings UI
2026-05-20 12:33:03 +08:00
Xubin Ren 3eebe08dba fix(exec): detach stdin for shell commands 2026-05-20 12:07:17 +08:00
Xubin Ren 38a5f09f02 refactor: preserve cold-start lazy boundaries 2026-05-20 12:02:23 +08:00
chengyongruandXubin Ren af9f8d54b8 perf: optimize gateway cold start from ~6.9s to ~460ms (#3918)
Channel lazy load: discover_enabled() only imports enabled channel
modules instead of all 18 modules with heavy SDKs (telegram, discord,
slack, etc). discover_all() now delegates to discover_enabled().

Lazy OpenAI client: defer AsyncOpenAI() + httpx construction to
_ensure_client() with asyncio.Lock double-checked locking. openai
and httpx imports moved from module-level into _ensure_client().

Minor: lazy Nanobot/RunResult and CronService exports via __getattr__.

Benchmark: 6910ms → 460ms (-93.3%)
2026-05-20 12:02:23 +08:00
Xubin Ren 1391aa3d57 fix(tests): make settings workspace path portable 2026-05-20 02:20:44 +08:00
Xubin Ren e00220bdb6 feat(providers): add Skywork provider support 2026-05-20 02:20:44 +08:00
moranandXubin Ren 4dccee56a7 docs: translate StepPlan section from Chinese to English 2026-05-20 00:08:38 +08:00
moranandXubin Ren 2d302a006e feat(image-generation): add StepFun provider support and StepPlan docs
- Add StepFunImageGenerationClient with step-image-edit-2 / step-1x-medium support
- Map aspect ratios to StepFun size strings (WxH order)
- Add style_reference for step-1x-medium reference-image generation
- Register in image gen provider registry (auto-discovered by nanobot.py)
- Add 7 unit tests: payload, default size, explicit size, style_reference (1x/non-1x), missing key, no-images
- Add StepFun section to docs/image-generation.md with provider config
- Add StepPlan (订阅制) subsection with apiBase override example
2026-05-20 00:08:38 +08:00
Xubin RenandGitHub 3f321179eb Merge PR #3894: fix(webui): accept end/error phases in tool trace rendering
fix(webui): accept end/error phases in tool trace rendering
2026-05-19 23:29:16 +08:00
Xubin Ren cda1de863e Merge remote-tracking branch 'origin/main' into codex/review-pr-3894
# Conflicts:
#	tests/utils/test_webui_transcript.py
2026-05-19 23:19:33 +08:00
Xubin RenandGitHub 57d5276da1 feat(webui): upgrade settings and sidebar controls (#3906)
* feat(settings): expand settings api payload

* feat(webui): build app-style settings center

* feat(webui): add centered chat search dialog

* fix(webui): shorten chat search label

* fix(webui): center dialog entrance animation

* fix(webui): simplify chat search results

* fix(webui): tighten mobile settings navigation

* feat(webui): persist sidebar state

* feat(webui): add sidebar organization controls

* refactor(webui): organize backend helpers

* refactor(webui): remove utils compatibility shims

* refactor(session): move shared webui helpers out of webui package

* feat(webui): add image generation settings

* style(webui): refine settings overview layout

* fix(webui): localize settings zh-CN copy

* style(webui): add settings status indicators

* feat(webui): show sidebar run indicators

* fix(webui): persist sidebar run indicators

* fix(webui): highlight settings pending status

* fix(webui): align settings test with provider update

* fix(utils): preserve legacy webui helper imports
2026-05-19 22:42:38 +08:00
Xubin RenandGitHub 30fc05c746 Merge PR #3912: docs(atomic_chat): surface local provider setup in README
docs: surface local provider setup in README
2026-05-19 22:27:27 +08:00
Xubin Ren 15dba8d080 Polish local provider docs 2026-05-19 22:15:09 +08:00
Xubin Ren a45884c0d3 Merge remote-tracking branch 'origin/main' into codex/review-pr-3912 2026-05-19 22:14:01 +08:00
Xubin Ren 6a8a17a380 Refine local setup README entry 2026-05-19 22:11:10 +08:00
yanalialiukandGitHub 705abff7a3 Document local setup for NanoBot with Atomic Chat
Added instructions for running NanoBot locally using Atomic Chat.
2026-05-19 14:49:04 +03:00
Xubin Ren 44b7bba9bd fix(image-generation): align media delivery and mime handling 2026-05-19 15:35:19 +08:00
chengyongruandXubin Ren d7a73093a8 refactor: remove dead image media attachment code
- Remove generated_image_paths_from_messages() and _extract_text_payload() from artifacts.py (no runtime callers)
- Remove session_attachments.py entirely (merge_turn_media_into_last_assistant and stage_media_paths_for_session_replay had no runtime callers)
- Remove test_session_media_persist.py and the orphaned test in test_artifacts.py
2026-05-19 15:35:19 +08:00
chengyongruandXubin Ren 59548b0a04 docs(image-generation): collapse redundant Quick Setup examples
Keep one minimal OpenRouter example and link to Provider Notes
for AIHubMix, MiniMax, and Gemini configuration.
2026-05-19 15:35:19 +08:00
chengyongruandXubin Ren fc1c8ea770 fix(image-generation): let LLM deliver images via message tool instead of runtime media attachment
The runtime media-attachment mechanism was broken for streaming channels
(e.g. WebSocket): the _streamed flag caused _send_once to skip the final
OutboundMessage that carried generated media, so images were never delivered.

Rather than adding complex coordination between streaming and media delivery,
delegate image delivery to the LLM: after generate_image returns artifact
paths, the next_step prompt now instructs the LLM to call the message tool
with the paths in the media parameter. This works uniformly across all
channels, streaming or not.

Remove generated_media from TurnContext, _assemble_outbound, and _state_save.
Update prompts in identity.md, SKILL.md, message tool description, and
artifacts.py to reflect the new flow.
2026-05-19 15:35:19 +08:00
chengyongruandXubin Ren 99e4d25d4c docs(image-generation): add MiniMax to docs and skill
Updates docs/image-generation.md and skills/image-generation/SKILL.md to
include MiniMax configuration examples, supported aspect ratios, and
troubleshooting references. Also updates the supported provider list to
include minimax alongside openrouter, aihubmix, and gemini.
2026-05-19 15:35:19 +08:00
chengyongruandXubin Ren c588d56a77 refactor(image-generation): introduce provider registry to eliminate manual wiring
Adds ImageGenerationProvider ABC with shared __init__, _http_post(), and
_require_images(). Introduces _IMAGE_GEN_PROVIDERS registry with
register/get/image_gen_provider_configs() helpers.

Four existing providers (OpenRouter, AIHubMix, Gemini, MiniMax) now inherit
from the base class and self-register. Adding a new provider only requires
writing one class + one registration line.

Eliminates if/else chains in the tool dispatch and hardcoded provider config
dicts in commands.py (3 sites) and nanobot.py (1 site). Fixes the agent CLI
command missing image_generation_provider_configs entirely.

Also simplifies test monkeypatch targets to patch the registry lookup.
2026-05-19 15:35:19 +08:00
7367741ac1 feat(image-generation): add Gemini provider support
Adds GeminiImageGenerationClient covering both Imagen 4 (:predict) and
Gemini Flash (:generateContent), wires the gemini ProviderConfig through
the SDK, API server, and gateway entry points, and updates the
image-generation docs and skill. Errors from the Gemini endpoints are
logged and surface with the HTTP status and parsed message instead of an
empty string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:35:19 +08:00
yaotutuandXubin Ren 4e0d872588 feat: add MiniMax image generation provider support
Add MiniMaxImageGenerationClient with support for:
- Text-to-image generation via MiniMax image-01 model
- Reference image support (subject_reference)
- Aspect ratio selection
- Proper error handling aligned with existing providers

Wire up MiniMax provider config in ImageGenerationTool, gateway,
serve, and Nanobot class.
2026-05-19 15:35:19 +08:00
Xubin Ren 0a5606b409 fix webui tool trace dedupe 2026-05-19 13:12:19 +08:00
Xubin Ren 7411afa0e7 fix(webui): sync remark-breaks lockfile 2026-05-18 22:47:33 +08:00
Xubin Ren c4293a7835 feat(providers): add Ant Ling support 2026-05-18 22:13:52 +08:00
Xubin Ren 40c1d83b32 fix(ci): update live file edit test expectations 2026-05-18 22:01:33 +08:00
Xubin Ren 0537cc1682 feat(webui): render live file edit activity 2026-05-18 22:01:33 +08:00
Xubin Ren 7e2dbdef7d feat(webui): stream live file edit events 2026-05-18 22:01:33 +08:00
Wayne HengandSisyphus c4794b82a9 fix(webui): accept end/error phases in backend transcript replay
Match the frontend fix: tool_trace_lines_from_events now processes end and error phases with call_id deduplication so transcript replay shows tool calls correctly.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 17:56:44 +08:00
Wayne HengandSisyphus d7122a13d3 fix(webui): accept end/error phases in tool trace rendering
Tool call events only displayed at phase=start, but progress_hook sends end/error phases after agent execution. Accept all three phases with call_id deduplication to prevent duplicate rendering.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 17:55:28 +08:00
chengyongruandXubin Ren d4ade8f680 feat(cli): add Model Preset wizard to onboard
Extract the [M] Model Presets interactive CRUD screen from PR #3696
and adapt it to the current main branch schema (fallback_models
instead of fallback_presets). Adds preset cache, field handlers for
model_preset/provider/fallback_models, and 9 new tests.
2026-05-18 15:13:41 +08:00
chengyongruandXubin Ren 28d0f8560e fix(webui): preserve single newlines in markdown rendering
Add remark-breaks plugin so that single newlines in assistant messages
(such as /help output) render as line breaks instead of being collapsed
into a single paragraph by standard markdown behavior.
2026-05-18 15:12:27 +08:00
Xubin RenandGitHub ba38f90832 Merge PR #3877: feat(webui+agent): optimize streaming, activity rendering, and runtime sync
feat(webui+agent): optimize streaming, activity rendering, and runtime sync
2026-05-18 02:04:36 +08:00
Xubin Ren eb3aed359f Refine file edit progress gating 2026-05-18 01:59:55 +08:00
Xubin Ren 4445fcc8b9 refactor(cli): localize reasoning buffer state 2026-05-18 01:34:08 +08:00
liyazhouandXubin Ren b67205f5aa fix(cli): buffer reasoning tokens to avoid one-token-per-line display 2026-05-18 01:34:08 +08:00
Xubin Ren de8761f25a fix(test): add gateway llm runtime fake 2026-05-18 01:19:45 +08:00
Xubin Ren 8708ccea86 Merge branch 'main' of https://github.com/HKUDS/nanobot into codex/webui-performance 2026-05-18 01:18:28 +08:00
Xubin Ren eb0ff3ad1d fix(memory): refresh session before empty guard 2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren c58a360b25 fix(test): seed get_or_create mock for session-refresh guard compatibility 2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren 5bb94edc99 refactor(autocompact): delegate _archive to Consolidator.compact_idle_session
Replace AutoCompact._archive() direct session mutation with delegation
to Consolidator.compact_idle_session(). Remove _split_unconsolidated()
method since that logic now lives inside compact_idle_session.

All session mutation for idle compaction now goes through the
Consolidator's lock, eliminating the race condition between
background token consolidation and idle TTL compaction.

Changes:
- autocompact.py: rewrite _archive() to call compact_idle_session,
  remove _split_unconsolidated(), clean up unused imports
- test_autocompact_unit.py: replace TestArchive/TestSplitUnconsolidated
  with TestArchiveDelegates that verifies delegation behavior
- test_auto_compact.py: convert all consolidator.archive mocks to
  consolidator.compact_idle_session mocks via _make_fake_compact helper
2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren 888d54790d fix(memory): add session-refresh guard to maybe_consolidate_by_tokens
When background consolidation runs with a stale session reference (captured
before AutoCompact replaced the session via compact_idle_session), it could
operate on outdated data. Now, after acquiring the per-session lock, the
method refreshes its session reference from SessionManager.get_or_create().
If the session was replaced, it swaps in the fresh reference before doing
any consolidation work.

This prevents a race where AutoCompact truncates an idle session while a
background maybe_consolidate_by_tokens call is in flight with the old
session object.
2026-05-18 01:16:47 +08:00
chengyongruandXubin Ren 48d35bd2d9 feat(consolidator): add compact_idle_session method with lock-protected truncation
Add Consolidator.compact_idle_session(session_key, max_suffix=8) that
performs hard-truncation of idle sessions under the per-session
consolidation lock. This is the single lock-protected path for AutoCompact
to use instead of modifying session state directly, fixing the race
condition between AutoCompact and Consolidator.

Behavior:
- Acquires per-session consolidation lock
- Invalidates cache and reloads fresh from disk
- Splits unconsolidated tail into archive prefix and retained suffix
- Archives prefix via LLM (with raw_archive fallback on failure)
- Persists _last_summary in session metadata on success
- Returns summary text, None on LLM failure, or '' if nothing to archive

Tests: 6 new tests covering prefix archival, empty session timestamp
refresh, (nothing) summary exclusion, LLM failure fallback,
last_consolidated offset, and lock acquisition verification.
2026-05-18 01:16:47 +08:00
Xubin Ren fce1550814 fix(webui): refresh bootstrap token before expiry 2026-05-18 00:53:36 +08:00
voidborne-dandXubin Ren bf8a6e35fd docs(deployment): match docker run gateway example to docker-compose.yml (refs #3873)
The `docker run` example for `gateway` in `docs/deployment.md` had drifted from
the canonical configuration in `docker-compose.yml`:

- It omitted the security flags that `docker-compose.yml` already declares
  (`cap_drop: ALL` + `cap_add: SYS_ADMIN` + unconfined apparmor/seccomp).
  These are required whenever `tools.exec.sandbox: "bwrap"` is enabled, because
  bwrap needs CAP_SYS_ADMIN for user namespaces; without them bwrap exits with
  `clone3: Operation not permitted` and exec tools silently fail.
- It omitted `-p 8765:8765`, even though both the bundled `docker-compose.yml`
  and `Dockerfile` (`EXPOSE 18790 8765`) already expose the WebSocket channel
  / WebUI port; users following the docs would get a reachable gateway health
  endpoint but an unreachable WebUI.

This change keeps the two paths in sync so anyone reading deployment.md and
using `docker run` directly gets the same security posture and port surface
as the Compose path.

Also adds a short `!IMPORTANT` note documenting that `gateway.host` and
`channels.websocket.host` default to `127.0.0.1` (set in
`nanobot/config/schema.py:GatewayConfig`). Docker `-p` cannot forward to the
container's loopback interface, so the user must set both binds to `0.0.0.0`
in `config.json` for the published ports to actually be reachable. This is
the symptom reported as items 2 + 3 of #3873; items 1 + 4 of that issue are
already resolved on `main` (`Dockerfile` line 49 already exposes both ports,
and README.md lines 218-220 already reflect that the WebUI ships in the wheel).

Docs only, no code changes.

Signed-off-by: voidborne-d <258577966+voidborne-d@users.noreply.github.com>
2026-05-18 00:45:49 +08:00
Xubin Ren f017e209da docs(configuration): align Docker env-file example 2026-05-18 00:45:34 +08:00
olgagagaandXubin Ren 5a34504b76 docs(configuration): expand "Environment Variables for Secrets" section
- Note that any string field supports ${VAR_NAME} and resolved values are
  never written back to disk.
- Document the failure mode for unset variables.
- Add MCP (stdio env + HTTP headers) and web-search examples.
- Add Docker, direnv, and secret-manager (1Password / pass / Bitwarden)
  delivery patterns alongside the existing systemd example.
- Replace plaintext apiKey values in tools.web.search examples (Brave,
  Tavily, Jina, Kagi, Olostep) with ${PROVIDER_API_KEY} placeholders so
  the docs stop modelling the anti-pattern.
- Cross-link from the Security section.

Refs: HKUDS/nanobot#2172
2026-05-18 00:45:34 +08:00
Xubin Ren af26ed0041 fix(heartbeat): remove unused runtime import 2026-05-18 00:40:31 +08:00
Xubin Ren 112f40ad67 fix(agent): refresh llm runtime for background tasks 2026-05-18 00:35:12 +08:00
Xubin Ren 2f323e24c1 fix(webui): polish session titles and status 2026-05-17 23:52:50 +08:00
Xubin Ren 361f31c0e4 fix(webui): use portal file reference tooltips 2026-05-17 23:52:29 +08:00
Xubin Ren 945f208d38 feat(webui): render file edit activity 2026-05-17 23:52:14 +08:00
Xubin Ren c8bb04a8fe feat(webui): persist agent activity events 2026-05-17 23:51:52 +08:00
Xubin Ren 4b5de66c58 Polish WebUI streaming and provider settings 2026-05-17 17:41:33 +08:00
Xubin Ren 9340567f2d Fix duplicate reasoning display 2026-05-17 17:11:38 +08:00
Xubin Ren e5be4dac7a Optimize WebUI streaming and long history rendering
Batch stream deltas, window long transcripts, lazy-load syntax highlighting, and refine activity/composer interactions.

Add title refresh retries plus tests for streaming, windowing, code blocks, and live activity behavior.
2026-05-17 17:04:57 +08:00
Xubin Ren 175b58e259 fix(docker): document bundled webui port 2026-05-17 15:51:04 +08:00
huanglei.214andXubin Ren 3bf8de047a fix docker build 2026-05-17 15:51:04 +08:00
chengyongruandXubin Ren 400f822601 fix(providers): recognize Chinese rate-limit marker '访问量过大' as transient error 2026-05-17 14:25:20 +08:00
Xubin Ren 9fb9d7afcb docs: update README with v0.2.0 release details, including new features and improvements 2026-05-16 15:22:32 +00:00
Xubin Ren c018c3fb6a chore(release): bundle webui into wheel and prep 0.2.0 2026-05-16 13:38:11 +00:00
olgagagaandXubin Ren 0ca0fe2221 fix(providers): wire MiMo thinking control on gateway providers (#3845)
The xiaomi_mimo ProviderSpec carries thinking_style="thinking_type", but
gateway providers (OpenRouter etc.) route MiMo under their own spec
which has no thinking_style. As a result, `reasoning_effort="none"` was
silently ignored: `{"thinking": {"type": "disabled"}}` was never
injected and responses still contained reasoning_content.

Mirror the Kimi pattern that already handles the same problem: add an
explicit _MIMO_THINKING_MODELS allowlist (mimo-v2.5-pro, mimo-v2.5,
mimo-v2-pro, mimo-v2-omni — per Xiaomi docs), an _is_mimo_thinking_model
helper that strips publisher prefixes ("xiaomi/mimo-v2.5-pro" matches),
and a sibling branch in _build_kwargs that injects the thinking payload
by model name. mimo-v2-flash is intentionally excluded — it has no
thinking mode.

Also include MiMo in the explicit_thinking predicate so the
reasoning_content backfill (#3554, #3584) covers the gateway path
consistently with the direct path.

Tests cover the gateway disable/enable signals, bare-slug fallback,
flash exclusion, and a non-MiMo sanity check.
2026-05-16 20:46:34 +08:00
chengyongruandXubin Ren 8a819dda1e fix(agent): remove duplicate runtime context injection in mid-turn drain
_drain_pending injected a full runtime context block (including goal
state) into every injected user message, but the initial message already
carries runtime context via build_messages(). This caused goal state to
appear multiple times in the LLM context window within a single turn,
wasting tokens (up to 4000 chars per duplicate).

Now _drain_pending only passes the raw user content without runtime
context. The initial turn message remains the sole carrier.
2026-05-16 20:46:08 +08:00
chengyongruandXubin Ren 45eacc3a98 docs: update CLAUDE.md to reflect current codebase state
- Update channels list: add WeCom, DingTalk, Email, MoChat, MS Teams
- Update providers: add Bedrock, Codex, Responses API, image generation, transcription
- Update tools: add long_task/sustained goals, image generation, sandbox backends
- Update session: add goal_state.py for sustained goal tracking
- Add missing subsystems: API Server, Command Router, Heartbeat, Pairing, Skills, Security
2026-05-16 20:45:52 +08:00
Xubin Ren 387724c355 test(agent): add tests to ensure goal state does not leak across sessions 2026-05-16 11:14:56 +00:00
ykstartandXubin Ren f97b960433 fix(exec): refine format command deny pattern to allow URL parameters
The previous regex r"(?:^|[;&|]\s*)format\b" incorrectly blocked
commands containing URL parameters like &format=json. Added negative
lookahead (?!=) so format= (URL param key=value) is allowed while
standalone format commands (e.g. ;format, &format, |format) remain
blocked. Added test cases for both blocking and allowing scenarios.
2026-05-16 18:52:42 +08:00
Xubin Ren e87c07c368 fix(agent): prevent outer wall-clock timeout for streaming requests 2026-05-16 10:12:57 +00:00
Xubin Ren 06a1bef9fe fix(goal): reduce pre-long_task overthinking 2026-05-16 09:57:44 +00:00
e804f2fddb fix(agent): align LLM wall timeout with sustained goals for main + subagents
Centralize runner_wall_llm_timeout_s in session goal_state metadata helpers so
spawned subagents inherit the same policy as AgentLoop without coupling to
long_task. Pass optional resolver into SubagentManager and add tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 16:33:49 +08:00
Xubin Ren cf09a8d691 refactor(webui): disable React StrictMode and enhance Markdown rendering 2026-05-16 08:33:15 +00:00
Xubin Ren 2144af7cd0 fix(agent): disable LLM wall-clock timeout during sustained goals 2026-05-16 05:27:40 +00:00
Xubin Ren 90632469f6 fix(webui): rename goal-related terminology and enhance UI components 2026-05-16 04:42:58 +00:00
olgagagaandXubin Ren e14c0310ad docs(contributing): warn that ruff format predates the codebase
The Development Setup block instructs new contributors to run
`ruff format nanobot/`, but the tree predates the formatter and many
lines exceed the configured 100-char limit (E501 is ignored). Running
the command as documented produces an ~80-file unrelated diff that
buries real changes. Document this and recommend formatting only the
files actually touched.
2026-05-16 12:25:28 +08:00
Xubin Ren 2e31002e6e refactor(long_task): streamline goal instructions and enhance documentation 2026-05-16 04:25:09 +00:00
Xubin Ren 897eedaaa7 chore(ci): update Python version in CI workflow to focus on supported runtimes 3.13 and 3.14 2026-05-16 04:15:58 +00:00
yanalialiukandXubin Ren 18072856ec feat: add Atomic Chat as OpenAI-compatible local provider
Register atomic_chat in the provider registry with default base URL
http://localhost:1337/v1, schema field, docs, and config tests.
2026-05-16 12:14:33 +08:00
Xubin Ren 9ccef018c2 feat(telegram): add new slash commands and update regex for command handling 2026-05-15 17:55:52 +00:00
0f96ab7e70 fix(webui): drop App markdown warmup; keep preloadMarkdownText export
Startup no longer triggers preloadMarkdownText (#3746). Restore the named
export so MessageBubble can still warm the lazy markdown chunk when the
reasoning panel opens (compatible with current main).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 01:42:42 +08:00
yorkhellenandXubin Ren 52a9300d9e fix(webui): remove eager markdown preload
Remove the eager preloading of markdown/code-highlighting chunk at startup.
The markdown renderer will now only be loaded when actually needed to render content.
2026-05-16 01:42:42 +08:00
Xubin Ren 0a25f696ab chore(docs): refine README entry for 2026-05-08 to clarify inline chat image feature 2026-05-15 17:35:56 +00:00
Xubin Ren 4fbabb5474 chore(docs): update README with recent news entries and earlier updates for clarity 2026-05-15 17:35:28 +00:00
Xubin Ren 937c8e6931 chore(docs): update README with recent news entries and earlier updates 2026-05-15 17:32:16 +00:00
Xubin Ren 858b6610c3 fix(config): reduce max_tokens and context_window_tokens in schema 2026-05-15 17:19:47 +00:00
1c2ea1aad2 feat(goal): /goal command & long-running tasks (long_task)
* feat(long-task): add LongTaskTool for multi-step agent tasks

Implements a meta-ReAct loop where long-running tasks are broken into
sequential subagent steps, each starting fresh with the original goal
and progress from the previous step. This prevents context drift when
agents work on complex, multi-step tasks.

- Extract build_tool_registry() from SubagentManager for reuse
- Add run_step() for synchronous subagent execution (no bus announcement)
- Add HandoffTool and CompleteTool as signal mechanisms via shared dict
- Add LongTaskTool orchestrator with simplified prompt (8 iterations/step)
- Register LongTaskTool in main agent loop
- Add _extract_handoff_from_messages fallback for robustness

* fix(long-task): add debug logging for step-level observability

* feat(long-task): major overhaul with structured handoffs, validation, and observability

- Structured HandoffState: HandoffTool now accepts files_created,
  files_modified, next_step_hint, and verification fields instead of
  a plain string. Progress is passed between steps as structured data.

- Completion validation round: After complete() is called, a dedicated
  validator step runs to verify the claim against the original goal.
  If validation fails, the task continues rather than returning
  a false completion.

- Dynamic prompt system: 3 Jinja2 templates (step_start, step_middle,
  step_final) selected based on step number. Final steps get tighter
  budget and stronger "wrap up" guidance.

- Automatic file change tracking: Extracts write_file/edit_file events
  from tool_events and injects them into the next step's context if
  the subagent forgot to report them explicitly.

- Budget tracking & adaptive strategy: Cumulative token usage is tracked
  across steps. Per-step tool budget drops from 8 to 4 in the last
  two steps to force handoff/completion.

- Crash retry with graceful degradation: A step that crashes is retried
  once. Persistent crashes terminate the task and return partial progress.

- Full observability hooks for future WebUI integration:
  - set_hooks() with on_step_start, on_step_complete, on_handoff,
    on_validation_started, on_validation_passed, on_validation_failed,
    on_task_complete, on_task_error, and catch-all on_event.
  - Readable state properties: current_step, total_steps, status,
    last_handoff, cumulative_usage, goal.
  - inject_correction() allows external code to send user corrections
    that are injected into the next step's prompt.

- run_step() accepts optional max_iterations for dynamic budget control.

All 27 long-task tests and 11 subagent tests pass.

* test(long-task): add boundary tests and fix race conditions

- Add 7 edge-case tests: validation crash resilience, hook exception safety, mid-run correction injection, FIFO correction ordering, explicit file changes overriding auto-detection, final budget for max_steps=1, and dynamic budget switching boundaries

- Fix assertion in test_long_task_completes_after_multiple_handoffs to match exact prompt format

- Remove asyncio timing hack from test_state_exposure

- Add asyncio.sleep(0) yield in test_inject_correction_during_execution to prevent race between signal injection and step continuation

- All 34 tests passing

* fix(long-task): address code review findings

- Declare _scopes = {"core"} explicitly to prevent recursive nesting in subagent scope
- Document fragile coupling in _extract_file_changes: path extraction depends on
  write_file/edit_file detail format; add debug log for unexpected formats
- Align final-template threshold (max_steps - 2) with budget switch threshold
- Eliminate hasattr(self, "_state") in _reset_state by initializing in __init__

* fix(long-task): honor final signal and file tracking

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(long-task): improve prompt structure and agent contract

- Expand LongTaskTool.description to instruct parent agent on goal
  construction, return value semantics, and how to handle results.
- Expand CompleteTool.description to emphasize that the summary IS the
  final answer returned to the parent agent.
- Prefix validated return value with an explicit "final answer" directive
  to stop parent agent from re-running work.
- Redesign step_start.md: Step 1 is now explicitly for exploration,
  planning, and skeleton-building. complete() is discouraged.
- Remove bulky payload debug logging from _emit(); add targeted
  info/warning/error logs at key state transitions instead.
- Add signal_type to HandoffState for cleaner signal detection.

* test(long-task): expect wrapped completion message after validation

Align assertions with LongTaskTool final return shape on main.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): turn timing strip, latency, and session-switch restore

- Agent loop: publish goal_status run/idle for WebSocket turns; attach
  wall-clock latency_ms on turn_end and persisted assistant metadata.
- WebSocket channel: forward goal_status and latency fields to clients.
- NanobotClient: track goal_status started_at per chat without requiring
  onChat; useNanobotStream restores run strip when returning to a chat.
- Thread UI: composer/shell viewport hooks for run duration and latency;
  format helpers and i18n strings.
- MessageBubble: drop trailing StreamCursor (layout artifact vs block markdown).
- Builtin / tests: model command coverage, websocket and loop tests.

Covers multi-session UX and round-trip timing visibility for the WebUI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep message-tool file attachments after canonical history hydrate

- MessageTool records per-turn media paths delivered to the active chat.
- nanobot.utils.session_attachments stages out-of-media-root files and
  merges into the last assistant message before save (loop stays a thin call).
- WebUI MediaCell: use a signed URL as a real download link when present.

Fixes attachments flashing then vanishing on turn_end when paths lived
outside get_media_dir (e.g. workspace files).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): agent activity cluster, stable keys, LTR sheen labels

- Group reasoning and tool traces in AgentActivityCluster with i18n summaries
- Stabilize React list keys for activity clusters (first message id anchor)
- Replace background-clip shimmer with overlay sheen for streaming labels
- ThreadMessages/MessageList integration and locale strings

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): render assistant reasoning with Markdown + deferred stream

- Use MarkdownText for ReasoningBubble body (same GFM/KaTeX path as replies)
- Apply muted/italic prose tokens so thinking stays visually subordinate
- useDeferredValue while reasoningStreaming to ease parser work during deltas
- Preload markdown chunk when trace opens; add regression test with preloaded renderer

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): default-collapse agent activity cluster while Working

Outer fold no longer auto-expands during isTurnStreaming; user opens to see traces.
Header sheen and live summary unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(long_task): cumulative run history, file union, and prompt tuning

Inject cross-step summaries and merged file paths into middle/final step
templates so chains do not lose early context. Strip the last run-history
block when it duplicates Previous Progress to save tokens. Add optional
cumulative_prompt_max_chars and cumulative_step_body_max_chars parameters
with clamped defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): session switch keeps in-flight thread and replays buffered WS

Save the prior chat message list to the per-chat cache in a layout effect
when chatId changes (before stale writes could corrupt another chat).
Skip one post-switch layout cache tick so we do not snapshot the wrong tab.

Buffer inbound events per chat_id when no onChat subscriber is registered
(e.g. user focused another session) and drain on resubscribe up to a cap,
so streaming deltas are not lost while off-tab.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): snap thread scroll to bottom on session open (no smooth glide)

Use scroll-behavior auto on the viewport, instant programmatic scroll when
following new messages and on scrollToBottomSignal. Keep smooth only for
the explicit scroll-to-bottom button.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): respect manual scroll-up after opening a session

Track when the user leaves the bottom with a ref and skip ResizeObserver
and deferred bottom snaps until they return or the conversation is reset.
Remove the time-based force-bottom window that overrode atBottom.

Multi-frame scrollToBottom honours the same guard unless force (scroll button).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Publish long_task UI snapshots on outbound metadata

- Add OUTBOUND_META_AGENT_UI (_agent_ui) for channel-agnostic structured state
- LongTaskTool publishes {kind: long_task, data: snapshot} on the bus with _progress
- WebSocket send forwards metadata as agent_ui for WebUI clients
- Tests for bus payload, WS frame, and progress assertions
- Fix loop progress tests: ignore _goal_status in streaming final filter and
  avoid brittle outbound[-1] ordering after goal status idle messages

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: WebUI long_task activity card and resilient history merge

Add optional ui_summary to the long_task tool for one-line UI labels. Stream
long_task agent_ui into a dedicated message row with timeline, markdown peek,
and a right sheet for details. Merge canonical history after turn_end while
re-inserting long_task rows before the final assistant reply. Collapse
duplicate task_start/step_start steps in the timeline and extend i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: align long_task with thread_goal and drop orchestrator UI

- Persist sustained objectives via session metadata (long_task / complete_goal); no subagent wiring or tool-driven agent_ui payloads.\n- Remove WebUI long-task activity UI, types, and translations; history merge preserves trace replay only, with legacy long_task rows normalized to traces.\n- Drop long_task prompt templates and get_long_task_run_dir; add webui thread disk helper for gateway persistence tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(agent): thread goal runtime context, tools, and skill

- Add thread_goal_state helper and mirror active objectives into Runtime Context
- Wire loop/context/memory/events as needed for goal metadata in turns
- Expand long_task / complete_goal semantics (pivot/cancel/honest recap)
- Add always-on thread-goal SKILL.md; align /goal command prompt
- Tests for context builder and thread goal state
- Remove unused webui ChatPane component

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(thread-goal): add websocket snapshot helper and publish goal updates from long_task

Introduce thread_goal_ws_blob for bounded JSON snapshots, attach snapshots to
websocket turn_end metadata in AgentLoop, and let long_task fan-out dedicated
thread_goal frames on the websocket channel after persisting session metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(channels): websocket thread_goal frames, turn_end replay, and session API scrub for subagent inject

Emit thread_goal events and optional thread_goal on turn_end; scrub persisted
subagent announce blobs on GET /api/sessions/.../messages and shorten session
list previews so WebUI does not surface full Task/Summarize scaffolding.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): merge ephemeral traces per user turn when reconciling canonical history

Preserve disk/live trace rows inside the matching user–assistant segment instead
of stacking every trace before the final assistant reply (fixes inflated tool
counts after refresh or session switch).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): show assistant reply copy only on the last slice before the next user turn

Avoid duplicate copy affordances on intermediate assistant bubbles that precede
more agent activity in the same turn (tools or further assistant text).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): thread_goal stream plumbing, composer goal strip, sky glow, and client-side subagent scrub projection

Track thread_goal and turn_goal snapshots in NanobotClient, hydrate React state
from thread_goal frames and turn_end, surface objective/elapsed in the composer,
add breathing sky halo CSS while goals are active, mirror server scrub logic on
history hydration and webui_thread snapshots, and extend tests/client mocks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(channels): add Slack Socket Mode connect timeout with actionable timeout errors

Abort hung websockets.connect handshakes after a bounded wait, log REST-vs-WSS
guidance, surface RuntimeError to channel startup, and log successful WSS setup.

Co-authored-by: Cursor <cursoragent@cursor.com>

* webui: expand thread goal in composer bottom sheet

Add ChevronUp control on the run/goal strip that opens a bottom Sheet
with full ui_summary and objective. Inline preview logic in RunElapsedStrip,
add i18n strings across locales, and a composer unit test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): widen dedupeToolCallsForUi input for session API typing

fetchSessionMessages types tool_calls as unknown; accept unknown so tsc
build passes when passing message.tool_calls through.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(agent): extract WebSocket turn run status to webui_turn_helpers

* refactor(skills): rename thread-goal to long-task and document idempotent goals

* feat(skills): rename sustained-goal skill to long-goal and tighten long_task guidance

* chore: remove unused subagent/context/router helpers

* feat(session): rename sustained goal to goal_state and align WS/WebUI

- Move helpers from agent/thread_goal_state to session/goal_state:
  GOAL_STATE_KEY, goal_state_runtime_lines, goal_state_ws_blob, parse_goal_state.
- Session metadata now uses "goal_state"; still read legacy "thread_goal";
  long_task writes drop the legacy key after save.
- WebSocket: event/field goal_state, _goal_state_sync; turn_end carries goal_state;
  accept legacy _thread_goal_sync/thread_goal inbound metadata for dispatch.
- WebUI: GoalStateWsPayload, goalState hook/client props, i18n keys goalState*.
- Runtime Context copy uses "Goal (active):" instead of "Thread goal".

* feat(agent): stream Anthropic thinking deltas and fix stream idle timeout

* refactor(webui): transcript jsonl as sole timeline source

* fix(agent): reject mismatched WS message chat_id and stream reasoning deltas

* feat(webui): hydrate sustained goal and run timer after websocket subscribe

* chore(webui,websocket): remove unused fetch helpers and legacy thread_goal WS paths

* Raise default max_tokens and context window in agent schema.

Align AgentDefaults and ModelPresetConfig with typical Claude-scale usage
(32k completion budget, 256k context window) and update migration tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(gateway): bootstrap prefers in-memory model; clarify websocket naming

* fix(websocket): websocket _handle_message passes is_dm; refresh /status test expectations

---------

Co-authored-by: chengyongru <2755839590@qq.com>
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 01:14:11 +08:00
hanyuanlingandXubin Ren 2d17a095dc fix(codex): stabilize prompt cache key 2026-05-16 00:13:10 +08:00
hanyuanlingandXubin Ren b2ac609bb5 fix(web): back off Brave search rate limits 2026-05-16 00:12:50 +08:00
chengyongruandXubin Ren 0f3677c0d8 perf(agent): append runtime context after user content for cache stability
Runtime context (time, channel, sender) changes every turn, so placing
it before user content invalidated the prompt-cache prefix. Appending it
after user content keeps the prefix stable and improves KV cache hit
rates. The stripping logic in _save_turn was simplified from 16 lines
to 6 as a side benefit.
2026-05-15 23:06:37 +08:00
hinotoi-agentandXubin Ren 164614ccf2 fix(message): share workspace path resolver 2026-05-15 17:19:20 +08:00
hinotoi-agentandXubin Ren 57d7847dc8 fix(message): confine local media attachments 2026-05-15 17:19:20 +08:00
chengyongruandXubin Ren afbaea870b style: fix extra blank line in search.py 2026-05-15 17:19:00 +08:00
chengyongruandXubin Ren f9cb0f22bd docs: remove glob tool references from templates and skills
Update identity.md, TOOLS.md, skills README, and skill-creator
SKILL.md to remove mentions of the removed glob tool. Grep's
glob parameter remains documented where relevant.
2026-05-15 17:19:00 +08:00
chengyongruandXubin Ren fe90edd71f refactor(tools): remove GlobTool
GlobTool is redundant — GrepTool already supports glob-based file
filtering via its `glob` parameter, making a standalone glob-only
tool unnecessary. Removing it simplifies the tool surface and reduces
LLM confusion between glob and grep.
2026-05-15 17:19:00 +08:00
Vicky TamandXubin Ren 45d999ae70 fix: clear media_paths after successful voice transcription\
\
  After transcribing a WhatsApp voice message, the .ogg file path          \
  remains in media_paths and gets appended as a [file: ...] tag.           \
  The LLM sees this tag and responds that it cannot process audio,          \
  even though the transcription already succeeded.
2026-05-15 15:47:27 +08:00
Jiajun XieandXubin Ren 6a25d8042d fix(shell): support UNC paths in Windows path extraction
- Update regex in _extract_absolute_paths to match both drive paths (C:\...) and UNC paths (\server\share)
- Add comprehensive test cases for UNC paths, mixed paths, and edge cases
2026-05-15 15:47:15 +08:00
chengyongruandXubin Ren 2d64aa7dd8 docs(pairing): consolidate access control docs — MECE allowFrom + pairing 2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 8aff3d6151 docs(pairing): add user-friendly pairing documentation 2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren cab4bdbf33 simplify(pairing): unify allow_list lookup in BaseChannel.is_allowed()
Merge the three-branch dict lookup (allow_from key check, allowFrom
fallback, getattr) into a single `or` chain. Same semantics, less
branching.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren ada11b38c4 simplify(pairing): deduplicate Slack pairing code — delegate to BaseChannel
Slack hand-rolled the same generate_code + format_pairing_reply + send
sequence already in BaseChannel._handle_message. Replace with
delegation to _handle_message(is_dm=True), matching Feishu's pattern.
Removes 3 unused imports (generate_code, format_pairing_reply,
PAIRING_CODE_META_KEY) from slack.py.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 22a0df0c53 simplify(pairing): address review findings — constants, TOCTOU, nesting
- Remove TOCTOU exists() check in _load(); rely on FileNotFoundError
- Define PAIRING_CODE_META_KEY and PAIRING_COMMAND_META_KEY constants
  in nanobot.pairing, replacing magic strings across base.py, slack.py,
  and builtin.py
- Flatten nested revoke logic in handle_pairing_command()
- Trim redundant docstring/comment noise in is_allowed() and generate_code()
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren b9522e0a4d refactor(pairing): remove redundant CLI commands
CLI pairing commands (list/approve/deny/revoke) are fully replaceable by
`nanobot agent -m "/pairing ..."`, which routes through the same
CommandRouter and handle_pairing_command() backend. Removing them
cuts 86 lines of duplicate surface area without losing any functionality.

- Remove pairing_app and its 4 subcommands from cli/commands.py
- Update format_pairing_reply() to drop the "Via CLI" line
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 88ff64be48 feat(pairing): allow omitted allowFrom — pairing-only mode by default
Previously _validate_allow_from raised SystemExit when allowFrom was
missing, forcing every channel to declare an explicit allowlist.
With the pairing feature this is no longer necessary: a channel with
no allowFrom simply operates in pairing-only mode, letting users
approve senders via /pairing approve <code> from the WebUI or CLI.

- Replace SystemExit with an info log in _validate_allow_from
- Add test_validate_allow_from_allows_missing_allow_from
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 199a1bb8fa docs(pairing): address reviewer comments — comments, error msg, __all__ test
- Clarify SystemExit message for missing/null allowFrom (manager.py)
- Document why Feishu passes content="" for unauthorized DMs
- Document exact-match semantics in BaseChannel.is_allowed()
- Document negligible collision probability in generate_code()
- Add test_all_exports_are_importable for nanobot.pairing.__all__
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren ac9a2d0c25 test(pairing): cover _PENDING_USER_TURN_KEY cleanup and None allow_from
- Assert pending_user_turn is cleared from session metadata after
  shortcut commands (e.g. /help) in test_auto_compact.py.
- Add test for None allow_from / allowFrom values in
  test_base_channel.py to prevent TypeError regressions.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren eab35af9f3 fix(review): apply PR #3774 review fixes
- Clear pending_user_turn after shortcut command persistence
- Guard is_allowed against None allow_from values
- Update pairing help text for two-arg revoke
- Reuse format_expiry in CLI pairing list
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren b68e9fa21e fix(pairing): persist shortcut commands and avoid Feishu side effects
- AgentLoop._state_command now persists user message and assistant
  response for shortcut commands (e.g. /pairing) so WebUI history
  hydration after _turn_end no longer shows an empty chat.  /new is
  excluded because it intentionally clears the session.

- Feishu _on_message sends pairing codes for unauthorized DMs before
  any media side effects (reactions, downloads, transcription).
  Group chat unauthorized senders are still silently ignored early.

- Update test_feishu_reply to assert the new DM pairing behavior.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 589792f41e feat(pairing): friendlier pairing reply with slash command hint
Update format_pairing_reply() to be more conversational and explicitly
mention both ways an owner can approve:
- In-chat: /pairing approve <code>
- CLI: nanobot pairing approve <code>
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f9d404618b refactor(pairing): move /pairing from BaseChannel to CommandRouter
/pairing is now a first-class built-in command dispatched through
CommandRouter, just like /status, /model, /dream, etc.

Benefits:
- WebUI automatically shows /pairing in the slash command palette
  (because builtin_command_palette() feeds /api/commands).
- All channels (Telegram, Discord, WebSocket, etc.) use the same
  dispatch path for /pairing; no more channel-level interception.
- The command still only works for already-authorised users because
  is_allowed() gates message ingestion before the bus.

Changes:
- Add handle_pairing_command() to nanobot.pairing.store — pure
  function callable from CLI, CommandRouter, and tests.
- Add cmd_pairing to nanobot.command.builtin and register in
  BUILTIN_COMMAND_SPECS + register_builtin_commands().
- Remove BaseChannel._handle_pairing_command() and the /pairing
  interception logic from _handle_message().
- Clean up unused pairing imports from base.py.
- Add unit tests for handle_pairing_command and cmd_pairing dispatch.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f3cae85bb1 fix(feishu): propagate is_dm and remove early is_allowed check
Feishu was doing its own is_allowed check before _handle_message
without considering is_dm, so unrecognised p2p senders were silently
ignored instead of receiving a pairing code.

- Remove the early self.is_allowed() return so BaseChannel can handle
permission checks and pairing uniformly.
- Pass is_dm=chat_type == "p2p" to _handle_message so DM pairing
works for Feishu/Lark private chats.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f47b8f0819 fix(websocket): do not trigger pairing on authenticated WS connections
WebSocket already authenticates clients at handshake time via token
or issued-token validation. Setting is_dm=True caused unrecognised
clients to receive a pairing code after they had already passed
token auth, which is nonsensical for a browser-tab client.

Treat WebSocket as non-DM so pairing is never offered; access control
remains at the WS handshake level (allow_from + token gate).
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 9bc86ee825 refactor(pairing): apply simplify review fixes
- Extract format_pairing_reply() and format_expiry() to eliminate
duplication between BaseChannel and SlackChannel.
- Use _write_text_atomic() from helpers.py instead of hand-rolled
fsync logic in pairing store.
- Convert approved lists to in-memory sets for O(1) lookup.
- Remove collision retry loop (8-char entropy is sufficient).
- Fix /pairing command parsing to split prefix exactly.
- Remove unused import time from base.py.
- Fix tests to pass subcommand_text, not full /pairing string.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f8e7e50759 code-review fixes: fsync, entropy, is_dm propagation, tests
- Add os.fsync with Windows-compatible directory flush in pairing store
- Increase pairing code length from 6 -> 8 characters for higher entropy
- Remove SystemExit on empty allowFrom; empty list now defers to pairing
- Update is_allowed docstring to document pairing fallback semantics
- Propagate is_dm to Matrix (direct rooms) and Slack (im channels)
- Slack _is_allowed now checks pairing store for DM allowlist mode
- Fix /pairing revoke to accept optional channel argument
- Move inline import time to module top-level
- Add WebSocket comment explaining is_dm=True assumption
- Add comprehensive tests for store and BaseChannel pairing integration
- Fix existing tests that expected empty allowFrom to hard-exit

Refs #3774
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 4c4a9ae590 feat(pairing): chat-native DM sender approval
Replace the file-editing onboarding workflow with a chat-native pairing flow:

- New pairing store (nanobot/pairing/store.py) persists approved senders
  and pending codes in ~/.nanobot/pairing.json.
- DM messages from unknown senders receive a short pairing code instead of
  silent denial. Group chats remain silently ignored.
- Existing allowFrom semantics are fully preserved; approved pairing users
  are merged at runtime so no config migration is needed.
- nanobot pairing list/approve/deny/revoke CLI commands for bootstrap and
  emergency management.
- /pairing slash commands intercepted in-channel so owners can approve
  senders without leaving the chat.
- is_dm flag added to BaseChannel._handle_message; Telegram, Discord and
  WebSocket updated to pass it.

Closes #3768
2026-05-15 15:46:44 +08:00
hinotoi-agentandXubin Ren c10ec6094e fix(feishu): simplify media filename sanitization 2026-05-15 15:44:52 +08:00
hinotoi-agentandXubin Ren 39db5c4846 fix(feishu): confine downloaded media filenames 2026-05-15 15:44:52 +08:00
chengyongruandXubin Ren 26665823e3 fix(agent): persist shortcut commands without polluting LLM context
Shortcut commands (e.g. /help, /pairing) skip BUILD and SAVE states,
so their turns were never persisted to the session.  This caused WebUI
chats to appear empty after _turn_end because history hydration reads
from the session file.

Fix by persisting the user message and assistant response inside
_state_command, but tag them with _command=True so Session.get_history
filters them out of LLM context.  /new is excluded because it
intentionally clears the session.

- AgentLoop._persist_user_message_early now accepts **kwargs so
  _state_command can pass _command=True for the user turn.
- Session.get_history skips messages with _command=True.
2026-05-14 23:51:58 +08:00
chengyongruandXubin Ren 8b724d510e fix(feishu): register no-op handlers for bot member events
Register handlers for im.chat.member.bot.added_v1 and
im.chat.member.bot.deleted_v1 to silence "processor not found"
errors that appear when any bot is added to or removed from a group.

Closes #3772
2026-05-14 23:10:16 +08:00
Xubin Ren 5d7f3f2751 fix(webui): stabilize live thread rendering and navigation 2026-05-13 16:39:07 +00:00
chengyongruandXubin Ren 6a4ed255de fix(mcp): probe HTTP port before connecting to prevent event-loop crash
When an MCP server configured as streamableHttp or SSE is unreachable,
streamable_http_client's anyio task group cleanup raises RuntimeError /
ExceptionGroup that escapes the caller's try/except and crashes the
event loop with "Unhandled exception in event loop".

Fix: add a lightweight TCP probe (_probe_http_url) before entering the
MCP SDK transport. If the port is closed, the server is skipped with a
warning instead of crashing. stdio transport is not probed (local
process).

Closes #3739
2026-05-13 23:39:07 +08:00
Xubin RenandGitHub 921fe259f4 Merge PR #3756: feat(runner): model failover with fallback_models
feat(runner): model failover with fallback_models
2026-05-13 23:38:14 +08:00
Xubin RenandCursor 5efd67919b feat(runner): support fallback candidates
Resolve fallbackModels as preset references or explicit inline provider configs so failover uses complete model settings without exposing fallback logic to the agent loop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 15:34:03 +00:00
Xubin Ren 43db848db0 Revert "feat(runner): support structured fallback models"
This reverts commit 02b059a616.
2026-05-13 14:11:08 +00:00
Xubin RenandCursor 02b059a616 feat(runner): support structured fallback models
Bind fallback model chains to the active model configuration so defaults and presets do not inherit or merge fallback behavior implicitly. Require explicit fallback providers while preserving per-fallback generation overrides and context-window safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 13:57:30 +00:00
Xubin Ren eaa8ebd5d3 Merge remote-tracking branch 'origin/main' into pr-3756 2026-05-13 13:12:56 +00:00
Xubin Ren fb508a302a feat(webui): refresh session titles from live updates 2026-05-13 13:10:21 +00:00
chengyongru 913b0774d8 feat(runner): add model failover with fallback_models
When the primary model returns a non-transient error and no content
has been streamed yet, the runner now tries each model listed in the
active preset's fallback_models in order.  Each fallback model may
reside on a different provider — a temporary provider instance is
created on-the-fly via make_provider(config, model=...).

Key design:
- Failover is request-scoped (does not affect subagents/dream/consolidator)
- Provider is restored via try/finally after each fallback attempt
- Skipped when content was already streamed to avoid duplicate output
- Recursive failover prevented by clearing fallback_models on fallback spec
- Circuit breaker trips open after 3 consecutive primary failures (60s cooldown)
- Cross-provider routing: fallback model prefix (e.g. groq/) determines provider

Fixes: cross-provider fallback was broken because the factory passed the
original preset (with provider forced to primary's provider) when creating
fallback providers.  Now uses provider="auto" so the model string prefix
correctly routes to the right provider.

Also fixes: log messages now distinguish between primary-failed,
previous-fallback-failed, and circuit-open scenarios.

closes: https://github.com/HKUDS/nanobot/issues/3376
2026-05-13 17:30:49 +08:00
Xubin RenandGitHub 79e528119c Merge PR #3655: feat(reason): display model reasoning content during streaming
feat(reason): display model reasoning content during streaming
2026-05-13 17:19:30 +08:00
Xubin RenandCursor 567e95dee6 fix(cli): stop spinner before resumed answer deltas
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 09:18:59 +00:00
Xubin RenandCursor 53831e1611 fix(cli): clear thinking spinner before trace output
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 09:15:53 +00:00
Xubin RenandCursor 3fab736262 fix(cli): keep trace output under assistant header
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 09:13:16 +00:00
Xubin RenandCursor 9d50f1b933 feat: polish trace delivery and slash menu UX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 08:47:34 +00:00
Xubin RenandCursor 321c565ec4 fix(webui): normalize thinking trace row box model
Thinking and Used tools are both auxiliary rows, but Thinking still carried
an internal mb-2 even when it was standalone. That made collapsed Thinking
rows visually taller than tool trace rows despite the shared thread spacing.

Only add the extra bottom margin when a Thinking bubble has answer content
below it in the same assistant message. Standalone Thinking rows now share
the same outer box model as Used tools. Tests lock both standalone and
answer-backed cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 08:12:44 +00:00
Xubin RenandCursor 82ba63e148 fix(webui): compact spacing between auxiliary trace rows
Thinking and Used tools are both auxiliary trace rows, but the thread list
was applying the same large gap used between full chat turns. That made
alternating Thinking / Used tools sequences look uneven and too airy.

Move row spacing from a fixed flex gap to per-row margins: full chat turns
keep mt-5, while consecutive auxiliary rows use mt-2. Add coverage for
Thinking -> Used tools -> Thinking spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 08:05:34 +00:00
Xubin RenandCursor c7ec5d3b75 fix(webui): align thinking and tool trace affordances
Tool trace groups are supporting details, so default them to collapsed.
Match the Thinking bubble's expanded body to the tool trace affordance by
using the same grouped header and animated fade/slide body treatment.

Update MessageBubble tests to assert tool traces start collapsed and expand
on click.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 07:58:24 +00:00
Xubin RenandCursor 521aaa5ecf fix(webui): split reasoning at tool trace boundaries
Live rendering merged reasoning chunks by scanning backward to the latest
assistant row. That fixed late reasoning, but the scan skipped trace rows,
so reasoning after a tool call crossed the Used tools block and attached to
the previous assistant iteration. Refresh looked correct because persisted
history reconstructs assistant/tool boundaries.

Treat trace rows as hard phase boundaries, just like user messages. A
reasoning_delta after Used tools now starts a fresh assistant placeholder,
so live rendering matches replay: Thinking -> Used tools -> Thinking ->
Used tools / answer.

Add a regression for reasoning_delta -> reasoning_end -> tool_hint ->
reasoning_delta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 07:49:44 +00:00
Xubin RenandCursor 278affc25e fix(webui): hydrate reasoning and tool traces from history
Live reasoning/tool frames were rendering correctly, but refreshing WebUI
replayed only role/content/media from `/api/sessions/:key/messages`.
Assistant `reasoning_content` / `thinking_blocks` and `tool_calls` were
already persisted by the backend and returned by the history endpoint, but
useSessionHistory discarded them.

Hydrate persisted assistant reasoning into `UIMessage.reasoning` and
reconstruct assistant tool calls as `kind: "trace"` rows so the replayed
thread keeps the same Thinking bubble and Used tools block as the live
stream. Tool result rows remain hidden from the conversation view to avoid
replaying raw tool output as chat text.

Adds regression coverage for both persisted reasoning and historical tool
call trace hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 07:33:52 +00:00
Xubin RenandCursor 0033a8a185 fix(webui): keep reasoning scoped to the current user turn
The post-hoc reasoning fix allowed late reasoning frames to attach back to
the nearest assistant message, but the scan crossed a newer user message.
That made the next turn's Thinking bubble render above the previous
assistant reply.

Treat the latest user message as a hard boundary: reasoning after it must
start a new assistant placeholder and can no longer attach to earlier
assistant turns. Add a regression covering previous assistant -> new user
-> reasoning_delta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 07:28:54 +00:00
Xubin RenandCursor 9829cf66d2 fix(webui): keep late reasoning attached above the answer
Some providers only surface structured `reasoning_content` after answer
text has already streamed. The WebUI was treating those late
`reasoning_delta` frames as a fresh assistant placeholder, so the
Thinking bubble rendered below the already-visible answer.

Attach late reasoning back to the active assistant turn instead. The
bubble still renders above the message content, preserving the expected
Thinking -> answer order even when the provider protocol delivers the
reasoning post-hoc. Added a regression test for answer-first followed by
reasoning_delta/reasoning_end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 07:20:36 +00:00
Xubin RenandCursor 458b4ba235 feat(reasoning): stream reasoning content as a first-class channel
Reasoning now flows as its own stream — symmetric to the answer's
``delta`` / ``stream_end`` pair — instead of being shipped as one
oversized progress message. This lets WebUI render a live "Thinking…"
bubble that updates in place, then auto-collapses when the stream
closes. Other channels remain plugin no-ops by default.

## Protocol

New metadata: ``_reasoning_delta`` (chunk) and ``_reasoning_end``
(close marker). ChannelManager routes both to the dedicated plugin
hooks below; the legacy one-shot ``_reasoning`` is kept for back-compat
and BaseChannel expands it into a single delta + end pair so plugins
only ever implement the streaming primitives.

WebSocket emits two new events:

- ``reasoning_delta`` (event, chat_id, text, optional stream_id)
- ``reasoning_end`` (event, chat_id, optional stream_id)

## BaseChannel surface

- ``send_reasoning_delta(chat_id, delta, metadata)`` — no-op default
- ``send_reasoning_end(chat_id, metadata)`` — no-op default
- ``send_reasoning(msg)`` — back-compat wrapper, base impl forwards
  to the streaming primitives

A channel adds reasoning support by overriding the two streaming
primitives. Telegram / Slack / Discord / Feishu / WeChat / Matrix keep
the base no-ops until their bubble UIs are adapted; reasoning silently
drops at dispatch, never as a stray text message.

## AgentHook

Adds ``emit_reasoning_end`` to the hook lifecycle. ``_LoopHook`` tracks
whether a reasoning segment is open and closes it on:

- the first answer delta arriving (so the UI locks the bubble before
  the answer renders below),
- ``on_stream_end``,
- one-shot ``reasoning_content`` / ``thinking_blocks`` after a single
  non-streaming response.

## WebUI

- ``UIMessage.reasoning`` is now a single accumulated string with a
  companion ``reasoningStreaming`` flag.
- ``useNanobotStream`` consumes ``reasoning_delta`` / ``reasoning_end``;
  legacy ``kind: "reasoning"`` is auto-translated to a delta + end.
- New ``ReasoningBubble``: shimmer header + auto-expanded while
  streaming, collapses to a clickable "Thinking" pill once closed,
  respects ``prefers-reduced-motion``.
- Answer deltas adopt the reasoning placeholder so the bubble and the
  answer share one assistant row.

## Tests

- ``tests/channels/test_channel_manager_reasoning.py`` — manager routes
  delta + end, drops on channel opt-out, expands one-shot back-compat.
- ``tests/channels/test_websocket_channel.py`` — new ``reasoning_delta``
  / ``reasoning_end`` frames, empty-chunk safety, no-subscriber safety,
  back-compat expansion.
- ``tests/agent/test_runner_reasoning.py`` — runner closes the segment
  on streaming answer start and after one-shot reasoning.
- WebUI ``useNanobotStream`` + ``message-bubble`` cover the new
  protocol and the shimmer styling.

## Docs

``docs/configuration.md`` and ``docs/websocket.md`` document the new
events and the plugin contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 07:13:43 +00:00
Xubin RenandCursor a6b059d379 refactor(reasoning): make channel plugins own reasoning rendering
Reasoning was being shipped to every channel as a generic progress
message with a `_reasoning: true` flag. Two problems with that:

1. Channels without a low-emphasis UI primitive (Telegram, Slack,
   Discord, Feishu...) would dump raw model thoughts as ordinary
   replies, polluting the conversation.
2. The agent loop double-gated by inspecting `channels_config`, which
   coupled the loop to display policy.

Treat reasoning as its own plugin action — `BaseChannel.send_reasoning`
defaults to a documented no-op; channels that have a fitting affordance
override. ChannelManager routes `_reasoning` outbounds to that method
only when the channel opts in via `show_reasoning` (camelCase alias
`showReasoning` mirrors `sendProgress`). Plugins that don't override
silently drop reasoning — "no fit, no leak" is the contract.

Reference implementation lands for WebSocket / WebUI: a new
`kind: "reasoning"` frame, parked on the active assistant bubble as a
collapsible `Thinking` group above the answer. CLI keeps its existing
direct path (it doesn't go through the bus). `ChannelsConfig.show_reasoning`
flips to `true` by default — only adapted channels surface anything,
others stay quiet.

Loop net diff is -3 lines: the `channels_config.show_reasoning` check
moves out, leaving emit_reasoning a one-liner that publishes and trusts
the channel to decide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 06:27:53 +00:00
Xubin RenandCursor 01fa362c03 Merge origin/main into feat/show-reasoning
Resolves conflicts after main landed the state-machine turn refactor
and the test_runner.py 9-file split:

- nanobot/agent/loop.py: take main's `_state_build`/`_persist_user_message_early`
  flow; restore the `reasoning: bool` parameter on `_build_bus_progress_callback`
  so the loop hook can mark progress as reasoning-channel without coupling to
  the answer stream.
- nanobot/cli/stream.py: keep main's configurable `bot_name`/`bot_icon` header
  while preserving the PR's `transient=True` Live + `self._console` routing
  + `_renderable()` final-render path that fixed TUI duplication.
- tests/agent/test_runner.py was deleted on main and split into 9 focused
  files; relocated all 6 reasoning tests into a new `test_runner_reasoning.py`
  matching the new layout, deduplicated the per-test `ReasoningHook` boilerplate
  through a shared `_RecordingHook` helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 05:07:14 +00:00
chengyongruandXubin Ren 99cc6ee808 test(agent): expand coverage and refactor test structure
- Add 42 tests for ContextBuilder (context.py: 0→42 tests)
- Add 37 tests for SubagentManager lifecycle (subagent.py: 2→37 tests)
- Add 42 unit tests for AutoCompact in isolation
- Split monolithic test_runner.py (3313 lines) into 9 focused files:
  test_runner_core, test_runner_hooks, test_runner_errors,
  test_runner_safety, test_runner_persistence, test_runner_governance,
  test_runner_tool_execution, test_runner_injections,
  test_loop_runner_integration
- Add 3 config passthrough tests (temperature/max_tokens/reasoning_effort)
- Fix fragile patch.object(__init__) in test_stop_preserves_context
- Create shared conftest.py with make_provider/make_loop factories

Total: 934 tests passing, 0 regressions
2026-05-13 12:49:17 +08:00
Xubin RenandCursor 352aaf0627 refactor(reasoning): unify reasoning extraction across providers
Reasoning surfacing was split across three branches in runner.py plus
two separate streaming buffers (loop hook and runner progress stream),
with three independent display-side gates in the CLI. This collapsed
the policy into one source of truth and fixed two real bugs:

- Structured `reasoning_content` was suppressed whenever the answer was
  streamed, because the runner gated emission on `streamed_content`.
  Providers don't stream `reasoning_content`; it only arrives on the
  final response, so the answer stream and the reasoning channel are
  independent. Added `streamed_reasoning` to `AgentHookContext` to track
  the right bit.
- `channels.showReasoning` was subordinated to `sendProgress`. They are
  orthogonal — turning off progress streaming shouldn't silence
  reasoning. Reworked the CLI gates accordingly.

Single-helper consolidation:

- `extract_reasoning(reasoning_content, thinking_blocks, content)`
  returns `(reasoning_text, cleaned_content)` with a defined fallback
  order: dedicated field → Anthropic thinking_blocks → inline
  `<think>`/`<thought>` tags. Models that expose none of these
  short-circuit to `(None, content)` — zero overhead.
- `IncrementalThinkExtractor` replaces the ad-hoc `emit_incremental_think`
  function and its hand-rolled "emitted cursor" state in both the loop
  hook and the runner progress stream.

Also documented the new `showReasoning` channel option in
docs/configuration.md and noted its independence from sendProgress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 17:14:19 +00:00
彭星杰andXubin Ren 00597fccd6 fix(webui): default to new chat on load and preserve scroll on settings return
- Remove auto-selection of the most recent session on initial load,
  so the app opens to a blank new-chat page instead of the last session.
- Preserve active session state when navigating to/from settings:
  keep ThreadShell mounted (hidden via CSS) so scroll position, message
  cache, and streaming state are not lost.
- Update onBackToChat to return to blank page when no session was active
  instead of falling back to the most recent session.
- Update related test expectations to match the new navigation behavior.
2026-05-12 23:13:11 +08:00
Flinn XieandSisyphus 3a851f8f8d feat(reasoning): add inline think tag extraction and Anthropic thinking_blocks support
Add extract_think() and emit_incremental_think() helpers to extract thinking content from inline <think> and <thought> tags in the content field. This handles models served via Ollama, self-hosted vLLM, or other compatible endpoints that embed reasoning as inline tags instead of using the dedicated reasoning_content API field.

Also adds Anthropic thinking_blocks support for extended thinking via the thinking content blocks array.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 23:02:59 +08:00
chengyongruandXubin Ren 9e15925cf4 refactor(agent): remove ask_user tool
The ask_user tool used AskUserInterrupt(BaseException) for mid-turn
blocking, creating heavy coupling across runner, loop, and session
management. The model now asks questions naturally in response text,
the turn ends normally, and the user's next message starts a new turn
with session history providing continuity.

Removed:
- nanobot/agent/tools/ask.py (tool, interrupt, helpers)
- tests/agent/test_ask_user.py
- webui/src/components/thread/AskUserPrompt.tsx
- AskUserInterrupt handling in runner.py
- Dual-path message building in loop.py
- Pending ask detection via history scanning
- button_prompt/buttons emission in WebSocket channel
- ask_user references in Slack channel docstrings

Preserved (MessageTool uses these independently):
- OutboundMessage.buttons field
- Channel button rendering (Telegram, Slack, WebSocket)
2026-05-12 22:48:26 +08:00
07f9ab580a fix(provider): preserve Bedrock tool config for history
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:59:01 +08:00
chengyongruandXubin Ren ef268f47d2 chore: remove dead code identified by vulture + coverage cross-validation
Remove unused code confirmed dead via vulture scan, grep verification,
and coverage analysis:

- _get_bridge_dir (cli/commands.py): 82-line function with zero callers
- add_assistant_message (agent/context.py): method body never executed,
  also removed now-unused build_assistant_message import
- _tool_parameters_schema (agent/tools/base.py): redundant copy of schema
  already exposed via the `parameters` property
- MSTEAMS_REF_TTL_S (channels/msteams.py): unused constant (production
  uses config.ref_ttl_days directly); inlined in test
- MESSAGE_TYPE_USER (channels/weixin.py): unused constant
2026-05-12 20:52:48 +08:00
35f64cd828 docs(config): document model presets
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
079b37aac5 test(config): cover legacy model defaults without presets
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
13eede5803 refactor(agent): inject runtime model publisher
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
6554c1f832 refactor(agent): move preset helpers out of loop
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
e6103d9312 fix(agent): separate preset snapshots from config reload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
8fcb24bb7c refactor(agent): trim model preset runtime wiring
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
70b8daaee6 fix(command): show default as current model preset
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
c9b84c7b11 fix(config): reserve implicit default model preset
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
1d14c2ba40 fix(config): accept modelPresets root alias
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
bcc4b97183 fix(webui): broadcast runtime model updates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
c92345bbb1 fix(webui): sync model badge after preset switch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
b61c6304c3 fix(config): reconcile presets with settings reload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
c450d6fd3f fix(config): make model preset switching atomic
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:06:22 +08:00
chengyongruandXubin Ren 6f78267c82 feat(config): add ModelPresetConfig and runtime preset switching
- Add `ModelPresetConfig` schema for named model presets
- Add `model_presets` dict to `Config` and `model_preset` field to `AgentDefaults`
- Add `resolve_preset()` to return effective model params from preset or defaults
- Add `@model_validator` to reject unknown preset names
- Update `_match_provider()` to use resolved preset model/provider
- Update `make_provider()` and `provider_signature()` to use `resolve_preset()`
- Add `model_preset` property to `AgentLoop` for atomic runtime switching
- Update `AgentLoop.from_config()` to inject a runtime `default` preset
- Wire self-tool to inspect/clear preset state
- Update CLI display strings to show active preset
2026-05-12 20:06:22 +08:00
1175420339 test(feishu): cover topic isolation alias
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 11:51:25 +08:00
yorkhellenandXubin Ren a32be99ddc test(feishu): add config and helper tests for topic_isolation 2026-05-12 11:51:25 +08:00
yorkhellenandXubin Ren 03b357b12d feat(feishu): add topic_isolation config switch 2026-05-12 11:51:25 +08:00
fd6887c274 test(providers): cover VolcEngine token parameter
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 11:35:52 +08:00
dd4def25fa fix(providers): set supports_max_completion_tokens for VolcEngine providers
VolcEngine's OpenAI-compatible gateway rejects requests when both
max_tokens and max_completion_tokens are present (the latter added
by openai-python SDK v2.x serialization). Set the flag so nanobot
sends max_completion_tokens instead of max_tokens for volcengine,
volcengine_coding_plan, and by extension byteplus variants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:35:52 +08:00
23312d683e fix(tools): isolate plugin runtime state
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 11:28:20 +08:00
chengyongruandXubin Ren 043f0e67f7 feat(tools): introduce plugin-based tool discovery and runtime context protocol
This commit implements a progressive refactoring of the tool system to support
plugin discovery, scoped loading, and protocol-driven runtime context injection.

Key changes:
- Add Tool ABC metadata (tool_name, _scopes) and ToolContext dataclass for
dependency injection.
- Introduce ToolLoader with pkgutil-based builtin discovery and
entry_points-based third-party plugin loading.
- Add scope filtering (core/subagent/memory) so different contexts load
appropriate tool sets.
- Introduce ContextAware protocol and RequestContext dataclass to replace
hardcoded per-tool context injection in AgentLoop.
- Add RuntimeState / MutableRuntimeState protocols to decouple MyTool from
AgentLoop.
- Migrate all built-in tools to declare scopes and implement create()/enabled()
hooks.
- Migrate MessageTool, SpawnTool, CronTool, and MyTool to ContextAware.
- Refactor AgentLoop to use ToolLoader and protocol-driven context injection.
- Refactor SubagentManager to use ToolLoader(scope="subagent") with per-run
FileStates isolation.
- Register all built-in tools via pyproject.toml entry_points.
- Add comprehensive tests for loader scopes, entry_points, ContextAware,
subagent tools, and runtime state sync.
2026-05-12 11:28:20 +08:00
04cbandXubin Ren bd0ba745dd fix(wecom): preserve real filename from SDK when payload omits name (#3737) 2026-05-12 10:27:32 +08:00
6d07aa6059 test(webui): cover randomUUID entry shim fallback
Add a focused regression test for the non-secure-context WebUI entry shim so missing crypto.randomUUID no longer depends on manual verification.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 15:39:05 +08:00
5ea2c37325 fix(webui): shim crypto.randomUUID for non-secure contexts
`crypto.randomUUID` only exists in secure contexts (HTTPS or localhost).
Over LAN HTTP it is undefined, so `ChatPane`'s welcome-message flush and
streaming-message handlers crash mid-render with `TypeError`, unmounting
the React tree and leaving the user a blank page.

Install a Math.random-backed v4-ish fallback at app entry, gated on the
feature being missing. This mirrors the shim already used in the test
setup and covers all six call sites (`ChatPane.tsx`, `useNanobotStream.ts`)
without touching them. These IDs are client-side message keys with no
security role, so non-cryptographic randomness is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:39:05 +08:00
chengyongruandXubin Ren 49f85f5c23 docs(schema,config): clarify reasoning_effort semantics for MiMo thinking mode
- Update AgentDefaults.reasoning_effort comment to document "none"
  (disable) and None (preserve provider default).
- Add configuration.md tip explaining MiMo thinking mode behavior.
2026-05-11 14:38:28 +08:00
Alfredo ArenasandXubin Ren c6b7a9524c fix(providers): wire MiMo to thinking_type to allow disabling reasoning (#3585)
The hosted Xiaomi MiMo API accepts {"thinking": {"type": "enabled"|"disabled"}}
to toggle reasoning, which is exactly the shape produced by the existing
thinking_type style. The xiaomi_mimo ProviderSpec just needed to opt in.

Before this fix, setting reasoning_effort="none" had no effect on MiMo
because no thinking_style was configured, so the disable signal never
reached the server. Default-on models (mimo-v2.5-pro and friends) kept
reasoning regardless of user configuration.

Source: https://platform.xiaomimimo.com/docs/en-US/api/chat/openai-api

Co-authored with Claude Opus 4.7. Strategy and review via Claude Desktop,
implementation via Claude Code.
2026-05-11 14:38:28 +08:00
Alfredo ArenasandXubin Ren 271b674bf1 feat(cli): pass bot_name/bot_icon from config to StreamRenderer (#3650)
Both StreamRenderer instantiations in the agent command (single-message
mode and interactive mode) now read bot_name and bot_icon from
config.agents.defaults and forward them to the renderer.

This is the wiring step that makes the schema fields actually take
effect at runtime. With safe defaults of "nanobot" and "🐈", existing
users see no change.
2026-05-11 11:50:18 +08:00
Alfredo ArenasandXubin Ren 86693f5422 feat(cli): make stream renderer use bot_name and bot_icon (#3650)
Threads bot_name/bot_icon through ThinkingSpinner and StreamRenderer
with safe defaults that preserve current behavior.

- ThinkingSpinner uses bot_name in its status text
- StreamRenderer header is "<icon> <name>" when icon is set,
  or just "<name>" when icon is empty
- Removes the now-unused __logo__ import (the cat emoji is the
  default value of bot_icon, not a hardcoded constant)
2026-05-11 11:50:18 +08:00
Alfredo ArenasandXubin Ren fcf9d110dd feat(schema): add bot_name and bot_icon to AgentDefaults (#3650)
Two new fields with safe defaults that preserve current branding:
- bot_name: str = "nanobot"
- bot_icon: str = "🐈"

Empty string for bot_icon is allowed and lets users opt out of the
leading icon. camelCase keys (botName, botIcon) bind via the existing
to_camel alias generator.
2026-05-11 11:50:18 +08:00
Alfredo ArenasandXubin Ren dfb013659a test(cli): add tests for configurable bot identity (#3650)
Six tests covering:
- AgentDefaults preserves 'nanobot' and the cat icon by default
- camelCase config keys (botName/botIcon) bind to the new fields
- Empty bot_icon is accepted (opt-out of the leading icon)
- ThinkingSpinner uses bot_name in its status text
- StreamRenderer header combines icon and name when icon is set
- StreamRenderer header is just the name when icon is empty
2026-05-11 11:50:18 +08:00
barreler126andXubin Ren 046d0831ef feat: add NVIDIA NIM provider support 2026-05-11 01:25:44 +08:00
chengyongruandXubin Ren a6e993df25 fix(agent): move archived summary into system prompt for KV cache stability
- Append [Archived Context Summary] to system prompt instead of injecting
  it into the user message runtime context, improving KV cache reuse across
  turns and avoiding consecutive same-role messages.
- _last_summary persists in metadata (no pop) for restart survival;
  summary is re-injected every turn via the stable system prompt.
- Remove dynamic "Inactive for X minutes" from _format_summary — use
  static last_active timestamp instead to preserve KV cache stability.
- Pass session_summary through build_messages() so both normal and
  ask_user paths receive the archived summary in the system prompt.
- estimate_session_prompt_tokens now reads _last_summary from metadata
  to include the summary in token budget estimation.
- Remove obsolete session_summary parameter from
  maybe_consolidate_by_tokens and estimate_session_prompt_tokens
  call sites in loop.py (summary flows through build_messages instead).
- Ensure /new (session.clear()) clears _last_summary from metadata.
2026-05-11 01:25:15 +08:00
Flinn XieandClaude Opus 4.7 3a27af0018 feat(cli): display model reasoning content during streaming
Add show_reasoning config (default: False) to display model
thinking/reasoning content in the TUI during streaming.  Reasoning
is emitted via a new emit_reasoning hook on AgentHook, gated by the
channels config.  Display uses ✻ prefix with dim italic styling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 01:02:49 +08:00
Flinn XieandClaude Opus 4.7 d630ac90d1 fix(cli): prevent TUI content duplication via transient Live and renderer routing
Route progress output through the Live's render hook to fix cursor
misalignment that caused content duplication.  The root cause was that
progress/reasoning output used a separate Console instance, bypassing
Rich Live's process_renderables hook.  Also fixes pre-existing issue
where multiple headers printed per agent turn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 01:02:49 +08:00
chengyongruandXubin Ren 73a8d8a875 fix(utils): remove unreachable dead code in find_legal_message_start
The for loop at line 168 never executes because start is assigned
i + 1 immediately before slicing messages[start : i + 1], which
is always an empty list. Remove the dead code.

Fixes #3716
2026-05-09 18:53:13 +08:00
chengyongruandXubin Ren de13e72e15 refactor(loop): log turn completion with state count 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 728d837e4e refactor(loop): add turn_id for trace correlation
- TurnContext now carries a turn_id (session_key:time_ns)
- All state transition debug logs include [turn_id] prefix
- RuntimeError messages also include turn_id for observability
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 5327f5e1a0 refactor(loop): event-driven state transitions + trace logging
- State handlers now return event strings ('ok', 'dispatch', 'shortcut')
- Driver loop uses _TRANSITIONS lookup table: (state, event) -> next_state
- State graph is centralized and visible at a glance
- Added StateTraceEntry to record per-state timing and events
- Driver loop logs state duration + event at debug level
- Exception paths are traced with error field for observability
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 6ef1b2c842 refactor(loop): address code review nits
- Fix _assemble_outbound on_stream type annotation (Callable[[str], Awaitable[None]] | None)
- Use last_msg consistently in _state_save instead of re-indexing
- Remove dead  fallback in _state_respond (guaranteed non-None by _state_save)
- Change pending_summary type from Any to str | None
- Make session optional in TurnContext to avoid redundant fetch
- Add defensive dispatch with RuntimeError for missing handlers
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 8a6b769219 refactor(loop): fix line length in state handlers 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 02443ca208 refactor(loop): convert _process_message to functional state machine
- Extract TurnState enum and TurnContext dataclass
- Extract state handlers: _state_restore, _state_compact, _state_command,
  _state_build, _state_run, _state_save, _state_respond
- Extract _process_system_message for system message short-circuit
- Driver loop uses getattr dispatch over explicit state transitions
- Preserve all existing behavior (794 tests passing)
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 9fb9f53147 refactor(loop): add TurnState and TurnContext 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 88cf8db164 refactor(loop): extract _assemble_outbound 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 0124c94d19 refactor(loop): extract _build_initial_messages 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren ce52070fcf refactor(loop): extract _persist_user_message_early 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren d2cb8ac17f refactor(loop): extract _build_retry_wait_callback 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren b2fb776a68 refactor(loop): extract _build_bus_progress_callback 2026-05-09 17:15:23 +08:00
Xubin RenandCursor 4f1faea90c ci: optimize Test Suite workflow (safe subset)
Re-applies the safe portion of c01f8599 after the revert in 2e8e674e.
Drops the uv cache which broke last time because uv.lock is gitignored
in this repo, and keeps lint as a step inside the test job (matching
the pre-c01f8599 layout).

What's added (all metadata-only, no external dependencies):
- concurrency: cancel superseded runs on the same ref
- permissions: tighten GITHUB_TOKEN to contents: read
- timeout-minutes: 20 to bound runaway jobs
- fail-fast: false so all matrix combinations surface failures
- matrix conditional: PRs run Linux x {3.11, 3.14} for fast feedback;
  push to main/nightly still runs the full 2-OS x 4-Python matrix

What's intentionally NOT added (each removed for a reason):
- uv cache: depends on uv.lock which is gitignored
- separate lint job: kept inline as a step, matches original
- workflow_dispatch / paths-ignore: scope creep, not needed now

All jobs continue to run on standard GitHub-hosted runners
(ubuntu-latest, windows-latest), keeping CI within the free tier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 08:27:46 +00:00
Xubin RenandCursor 2e8e674e38 revert(ci): restore original Test Suite workflow
The optimized workflow in c01f8599 set astral-sh/setup-uv@v4 with
cache-dependency-glob: "uv.lock", but uv.lock is gitignored in this
repo, so the hosted runner's checkout never contains it and the
Install uv step fails with:

  Error: No file matched to [uv.lock], make sure you have
  checked out the target repository

Reverting the workflow to the pre-c01f8599 version to unbreak CI.

The "Modifying CI Workflows" section added to CONTRIBUTING.md in the
same commit is left in place; it documents general guidance and is
independent of this specific implementation choice.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 08:22:09 +00:00
Xubin RenandCursor c01f85995f ci: optimize Test Suite workflow and document free-tier rule
Workflow changes (.github/workflows/ci.yml):
- Add concurrency to cancel superseded runs on the same ref
- Enable uv dependency caching keyed on uv.lock
- Split lint into a dedicated job; gate test on lint via needs
- Split matrix: PRs run Linux x {3.11, 3.14} for fast feedback;
  push to main/nightly still runs the full 2-OS x 4-Python matrix
- Add fail-fast: false so all platforms surface failures together
- Add timeouts (lint: 5m, test: 20m) to bound runaway jobs
- Tighten GITHUB_TOKEN to contents: read

Docs (CONTRIBUTING.md):
- Add a short "Modifying CI Workflows" section so contributors know
  to stay within standard runners / no metered storage / no paid
  actions before touching .github/workflows/

All jobs continue to run on standard GitHub-hosted runners
(ubuntu-latest, windows-latest), keeping CI within the free tier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 08:15:27 +00:00
chengyongruandXubin Ren ff6b014a07 refactor: allow model/context_window_tokens override in from_config()
- Pop model and context_window_tokens from extra kwargs before
  forwarding to __init__, allowing callers like _run_gateway to
  pass snapshot-derived values instead of config defaults
- _run_gateway now explicitly passes model/context_window_tokens
  from provider_snapshot to preserve pre-refactor behavior
2026-05-09 15:30:48 +08:00
chengyongruandXubin Ren 733b34d685 refactor: address code review feedback on AgentLoop.from_config()
- Accept optional `provider` kwarg in from_config() to avoid double
  instantiation in _run_gateway (which already builds provider_snapshot)
- Restore try/except ValueError wrappers in serve() and agent() for
  clean error messages on provider creation failure
- Update test: _FakeAgentLoop captures provider from kwargs, restore
  strong assertion (seen["provider"] is provider)
2026-05-09 15:30:48 +08:00
chengyongruandXubin Ren 3202f58c41 refactor: introduce AgentLoop.from_config() to centralize loop assembly
Extract duplicated bus/provider/loop initialization from CLI commands
(serve, _run_gateway, agent) and Nanobot facade into a single
AgentLoop.from_config() classmethod.

- Remove _make_provider() from cli/commands.py and nanobot.py
- Remove inline provider creation in all three CLI entry points
- AgentLoop.from_config() creates MessageBus, calls make_provider(),
  and assembles AgentLoop with all standard config-derived parameters
- Supports **extra overrides for callers that need custom args
  (e.g. cron_service, session_manager, provider_snapshot_loader)
- Update tests to mock make_provider at nanobot.providers.factory
  and add from_config classmethod to _FakeAgentLoop fixtures

This is PR 1/4 of the model-preset feature decomposition.
2026-05-09 15:30:48 +08:00
Xubin Ren 9252f4d826 Revert "fix(agent): persist _last_summary across restarts with used sentinel"
This reverts commit e5a1416a37.
2026-05-09 15:00:54 +08:00
chengyongruandXubin Ren e5a1416a37 fix(agent): persist _last_summary across restarts with used sentinel
The previous implementation popped _last_summary from session.metadata
after injecting it into the prompt, then saved the session. This caused
the summary to be permanently lost after a process restart, making the
AI forget archived context and appear to ignore memory or reference
non-existent previous messages.

Replace the destructive pop with a _last_summary_used sentinel:
- _last_summary stays in metadata for restart survival
- _last_summary_used prevents duplicate injection within the same turn
- Clear the sentinel whenever a new summary is generated

Updates tests to match the new persistence behavior.
2026-05-09 14:58:38 +08:00
56eee06736 feat(webui): add BYOK web search settings
Let WebUI users configure the single web search provider credential from BYOK while keeping saved secrets masked and hot-reloaded for new searches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 14:52:48 +08:00
7c1aa5ae31 docs: refine AI contributor guidance
Clarify nanobot's preference for small core changes, reviewable PR boundaries, and careful handling of prompt/context surfaces so AI contributors preserve the project's maintenance philosophy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 14:00:32 +08:00
chengyongruandXubin Ren 6eef3d0f15 docs: add CLAUDE.md and .agent/ guides for AI contributors
Add CLAUDE.md at the repository root to orient future Claude Code
instances, and split detailed constraints into .agent/:

- .agent/design.md    — architectural constraints (core small, duplication
  over abstraction, minimal changes, explicit over magical)
- .agent/security.md  — workspace/SSRF/shell sandbox boundaries
- .agent/gotchas.md   — config ${VAR}, Windows compat, templates,
  heartbeat virtual tool call, atomic writes, ruff format warning,
  skills extension point

Also updates .gitignore to not ignore .agent/.
2026-05-09 14:00:32 +08:00
Eugene ChaeandXubin Ren 4d7bf5bb8a fix(cli): handle retry-wait messages in interactive mode 2026-05-09 13:50:39 +08:00
Xubin Ren 3231aaf9ee fix(image): prevent duplicate delivery and replay artifacts 2026-05-09 05:45:13 +00:00
Vilius VystartasandXubin Ren 4d168c571c fix: replace raise with logger.error + return fail in exception handlers
The previous version changed return fail/pass to raise, which broke
graceful degradation — tests expect upload/content failures to be
caught and handled, not propagated.

Now logs errors with exc_info=True while preserving existing control
flow (return fail for upload/content send, stop typing for stream).
2026-05-09 01:04:20 +08:00
Vilius VystartasandXubin Ren 31c45fe798 fix: raise instead of swallowing on outbound-message path errors
Per reviewer request (chengyongru): raise exceptions on the outbound
message path so ChannelManager can trigger retry logic, matching the
pattern from commit 98c2f7cc (Weixin channel cleanup).

Changes:
- _resolve_server_upload_limit_bytes: warning → error (non-fatal config)
- _upload_and_send_attachment media upload: raise instead of swallow
- _upload_and_send_attachment room send: raise instead of swallow
- send_delta stream edit: error + raise after cleanup
- weixin _load_state: warning → error (non-fatal state load)
2026-05-09 01:04:20 +08:00
Vilius VystartasandXubin Ren ba1e5036f5 fix: log errors in silent exception handlers (matrix + weixin channels)
The Matrix channel had 4 bare except blocks that silently swallowed
transport errors with no logging — stream send/edit failures, media
upload failures, server config fetch failures, and room content send
failures. The Weixin channel had 1 silent state-load failure.

This mirrors commit 98c2f7cc ('fix(weixin): raise exceptions instead
of silently dropping messages') for the Matrix channel and adds a
warning for the remaining silent catch in Weixin's _load_state.

All failures now log at warning level with exc_info=True so operators
can diagnose intermittent Matrix/Weixin transport issues.
2026-05-09 01:04:20 +08:00
yorkhellenandXubin Ren 843e96f09d fix(feishu): send all messages to topic when in thread 2026-05-09 01:03:57 +08:00
chengyongruandXubin Ren 908f1246d8 fix(cli): sanitize surrogate code points before entering message bus
On Windows, prompt_toolkit produces lone surrogate code points (e.g.
🐈) for emoji input. These propagate through the message bus
and crash at json.dumps() / file write time because surrogates cannot
be encoded as UTF-8.

Extract _sanitize_surrogates() that round-trips through UTF-16 to
reconstruct paired surrogates into real characters (e.g. 🐈🐈), replacing unpaired surrogates with U+FFFD. Apply it at the CLI
input path and reuse in SafeFileHistory.
2026-05-09 01:03:34 +08:00
bbdf1db30d fix(webui): render generated images as rounded previews
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 23:48:01 +08:00
151c3d5ad0 fix(webui): restore chat selection after settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 23:48:01 +08:00
2cc32ca07c feat(webui): redesign settings and BYOK configuration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 23:48:01 +08:00
Xubin RenandCursor 451d740849 fix(webui): polish delete dialog and sidebar toggles
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 13:28:34 +00:00
cbd5b06075 fix(memory): align replay overflow with history trimming
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 20:37:03 +08:00
24daf9a51c test(memory): accept replay window in consolidation assertion
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 20:37:03 +08:00
91ade9eaac fix(memory): consolidate history hidden by replay window
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 20:37:03 +08:00
2c830ca817 test(weixin): stabilize typing keepalive assertion
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 20:06:23 +08:00
e936ed48bd feat: add image generation tool and WebUI mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 20:06:23 +08:00
chengyongruandXubin Ren 3a2f47d720 fix(onboard): allow empty strings and falsy values in input fields
Fixes two related input-handling bugs in the onboard wizard:

1. _input_text treated "" as None, preventing users from clearing
   optional string fields or entering empty strings intentionally.

2. _input_model_with_autocomplete used `if value else None`, which
   discarded falsy values such as empty strings or 0.

To support clearing optional string fields, add _is_str_or_none() and
normalize empty strings to None inside _configure_pydantic_model only
when the field annotation is `str | None`. Required str fields keep
"" as a valid value.

Also included:
- Remember last selected item in provider/channel/model menus for
  better UX when configuring multiple items.
- Rename _SIMPLE_TYPES and _MENU_DISPATCH to lowercase to follow
  Python naming conventions (they are local variables, not constants).
- Remove unused imports in test file.

Extracted from PR #3358.
2026-05-08 13:21:51 +08:00
zhonghongweiandXubin Ren 6a3069514c fix(api): remove enable_compression to restore real SSE streaming
The HTTP compression buffer in aiohttp held all SSE chunks until
the stream ended, making streaming appear batched instead of
incremental. SSE payloads are small and frequent, so compression
provides negligible benefit while breaking real-time delivery.
2026-05-07 22:03:27 +08:00
chengyongruandXubin Ren 536c456e5e fix(channels): restore bound logger in discord and websocket
PR introduced module-level logger in static methods, which drops
the channel context bound by BaseChannel.__init__. Revert to
self._channel.logger / self.logger to preserve log labels.

Also remove @staticmethod since these methods legitimately need
instance access (F821 was the real issue, not the logger source).
2026-05-07 13:07:22 +08:00
yorkhellenandXubin Ren a2f5de6838 refactor: fix import order for logger in discord.py 2026-05-07 13:07:22 +08:00
yorkhellenandXubin Ren 10a0bb0fb3 refactor: use module-level logger in static methods 2026-05-07 13:07:22 +08:00
yorkhellenandXubin Ren 4773589685 fix: F821 undefined name errors in channels 2026-05-07 13:07:22 +08:00
yorkhellenandXubin Ren 4a4e0af0ba ci: Enable full ruff -F (all F rules) checks 2026-05-07 13:07:22 +08:00
chengyongruandXubin Ren 9a8c4da0c4 refactor(logging): preserve tracebacks in remaining except blocks
Follow-up to PR #3651:

- Replace logger.error with logger.exception inside except blocks
  so stack traces are no longer lost:
  - providers/transcription.py (5 occurrences)
  - agent/tools/mcp.py (1 occurrence)

- Replace stdlib logging.getLogger with loguru logger in
  providers/openai_compat_provider.py for consistency.
2026-05-07 13:06:59 +08:00
44a341335a fix(dream): restore cursor with memory state
Track the Dream cursor in memory versioning so restores do not skip history after rolling back Dream commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 01:06:05 +08:00
ac18a8baad feat(webui): add localized slash commands
Add a session-scoped slash command palette sourced from backend command metadata, and keep welcome-page quick actions localized across all WebUI languages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:20:28 +08:00
chengyongruandXubin Ren 49c07aa45a style: address code review feedback
- Consistent "WeChat" prefix in context_token error message
- Use object() instead of httpx.AsyncClient() in new tests to avoid
  resource leak warnings
2026-05-06 23:52:50 +08:00
chengyongruandXubin Ren 98c2f7cc27 fix(weixin): raise exceptions instead of silently dropping messages
_send_text() swallowed API errors (non-zero errcode) with just a
warning log, and send() had three silent return paths (no client,
session paused, no context_token). Neither triggered ChannelManager's
retry logic, causing persistent message loss until a new inbound
message refreshed the context_token.

Now all failure paths raise RuntimeError, matching BaseChannel's
contract and enabling proper retry behavior.
2026-05-06 23:52:50 +08:00
chengyongruandXubin Ren 4efd904ccc fix(webui): require token_issue_secret for LAN access with frontend auth
When host is set to 0.0.0.0, the gateway now enforces that either token
or token_issue_secret must be configured — it refuses to start otherwise.

Bootstrap endpoint behavior:
- token_issue_secret configured: always validate regardless of source IP
  (handles reverse-proxy scenarios where all connections appear as localhost)
- No secret: only localhost can bootstrap (local dev mode)

The frontend shows an authentication form when bootstrap returns 401/403,
persists the secret in localStorage, and retries automatically on reload.
2026-05-06 23:51:51 +08:00
chengyongruandXubin Ren 034bea1a44 fix(webui): require token_issue_secret for non-localhost bootstrap
The previous LAN-access fix (PR #3656) relaxed the bootstrap localhost
check when host was 0.0.0.0, but did not require any authentication —
any device on the network could obtain a token without credentials.

New behavior:
- token_issue_secret configured: always validate, regardless of source
  IP (handles reverse-proxy scenarios where all connections appear as
  localhost).
- No secret configured: only localhost can bootstrap (local dev mode).

This supersedes the host-based check from PR #3656.
2026-05-06 23:51:51 +08:00
chengyongruandXubin Ren bad584cb0e fix(webui): allow LAN access when host is 0.0.0.0
The webui bootstrap endpoint (/webui/bootstrap) rejected all non-localhost
connections with HTTP 403, preventing the embedded webui from working when
accessed from another device on the LAN — even when host was set to 0.0.0.0.

Skip the localhost check when the server is explicitly bound to 0.0.0.0 or ::,
since that signals intent to accept external connections.
2026-05-06 23:00:23 +08:00
790a03ec28 feat(webui): polish chat layout and titles
Align the WebUI sidebar and chat chrome with the updated design, and generate WebUI session titles asynchronously without blocking turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 22:20:35 +08:00
Xubin RenandGitHub d8fd4c80bf Merge PR #3646: fix(transcription): retry Whisper calls on transient failures
fix(transcription): retry Whisper calls on transient failures
2026-05-06 21:52:33 +08:00
chengyongru 40b4e01b13 merge: resolve conflict with main in transcription.py
Keep _post_transcription_with_retry from PR branch, drop inline
httpx calls that were replaced by the shared retry helper.
2026-05-06 21:26:28 +08:00
chengyongruandXubin Ren 4fad19dc17 fix: use sequential MCP server connections to prevent CPU spin
asyncio.create_task in connect_mcp_servers creates child tasks for
each MCP server, but close_mcp calls stack.aclose() from the main
task. anyio CancelScope requires enter/exit in the same task, so the
cross-task exit raises RuntimeError which gets silently caught. The
orphaned cancel scope keeps retrying via call_soon on every event
loop tick, consuming 100% CPU.

Fix: remove create_task/gather and connect servers sequentially in the
caller task. MCP servers are typically 1-2, so parallel connection
provides negligible benefit while introducing the cancel scope hazard.

Closes #3638
2026-05-06 21:18:51 +08:00
Tim O'BrienandXubin Ren 99209a806d fix(tool_hints): pass max_length to abbreviate_path for is_path tools
The is_path branch in _fmt_known was not passing max_length to
abbreviate_path, so read_file, write_file, edit, list_dir, and
web_fetch always truncated paths at 40 chars regardless of config.

Now all three branches (is_path, is_command, fallback) honor the
configured toolHintMaxLength.
2026-05-06 21:18:39 +08:00
Tim O'BrienandXubin Ren 67875d7a15 fix: wire toolHintMaxLength through AgentLoop constructors
The config field was added but never passed from config to AgentLoop.
The value was always falling back to the default (40) regardless of
what was set in config.json.

Now passes tool_hint_max_length through all AgentLoop() call sites:
- nanobot/nanobot.py (main bot)
- nanobot/cli/commands.py (CLI agent, dev, webui commands)

Also adds documentation in docs/configuration.md.
2026-05-06 21:18:39 +08:00
Tim O'BrienandXubin Ren daa4a25c9b feat(config): add toolHintMaxLength to control tool hint truncation
Add  to  config (default: 40, range: 20-500).
Controls how many characters of tool hints are shown in progress updates
(e.g. '$ cd …/project && npm test').

Set to 120+ to see full commands instead of truncated hints:

```json
{
  "agents": {
    "defaults": {
      "toolHintMaxLength": 120
    }
  }
}
```

- Thread max_length through format_tool_hints → _fmt_known/_fmt_mcp/_fmt_fallback
- Make path abbreviation in _abbreviate_command proportional to max_length
- Add TestToolHintMaxLength test class with 5 tests
- All 41 existing tests pass
2026-05-06 21:18:39 +08:00
hanyuanlingandXubin Ren 653de4a7ef fix(agent): gate provider progress deltas 2026-05-06 21:18:30 +08:00
chengyongruandXubin Ren 05e0106592 refactor(logging): preserve tracebacks and add channel context
- Preserve tracebacks: logger.error in except blocks → logger.exception
- Channel context: BaseChannel injects self.logger = logger.bind(channel=name)
- Third-party bridge: redirect_lib_logging() replaces ad-hoc stdlib-to-loguru bridges
- Log levels: network timeouts downgraded from ERROR → WARNING
- Fix --verbose flag to actually work with loguru (set handler to DEBUG)
2026-05-06 21:17:45 +08:00
chengyongru 3437ff273f fix(transcription): address review nits on PR #3253
- Correct api_key type hint to str | None in _post_transcription_with_retry
- Remove unreachable final return ""
- Fix test_openai_missing_api_key_short_circuits to actually test
  missing-key path (use audio_file fixture so file exists)
- Fix PermissionError patch for Windows (patch class method instead
  of instance attribute)
2026-05-06 15:52:29 +08:00
mohamed-elkholy95andchengyongru 7ebf611be8 fix(transcription): retry Whisper calls and guard malformed responses
A single transient failure between the agent and an OpenAI/Groq Whisper
endpoint currently vanishes as `return ""` in transcribe(). The voice
message arrives as the empty string and there is no way to tell real
silence apart from a failed upload. A malformed but successful response
body is even worse: the JSON-decode error escapes the helper unhandled.

Add a shared `_post_transcription_with_retry` used by both providers.

Retry behaviour:
  - exponential backoff 1s -> 2s -> 4s, up to 3 retries (4 attempts)
  - retryable HTTP statuses: 408, 429, 500, 502, 503, 504
  - retryable exceptions: TimeoutException, ConnectError, ReadError,
    WriteError, RemoteProtocolError

Non-transient failures short-circuit to "" on the first attempt --
retrying a misconfigured key or a broken upload only burns rate-limit
quota. Branches that short-circuit:
  - missing API key, missing audio file
  - file-read errors (PermissionError, OSError) on the audio path,
    preserving the nightly contract for direct provider callers
  - HTTP auth/4xx body issues via raise_for_status()
  - response.json() parse failures
  - non-dict JSON payloads

Sharing one helper means OpenAI and Groq cannot drift apart silently.

Thread `language` through the helper. The multipart files dict is rebuilt
inside the per-attempt loop, so when a caller sets self.language the
`language` field is sent on every attempt -- not just the first.

Tests cover:
  - every advertised retryable status and exception, parameterized
  - language present on attempts 1 and 2 of a 503->200 sequence
  - language absent when unset; present when set (both providers)
  - malformed JSON body and non-dict JSON body short-circuit to ""
  - PermissionError on file read short-circuits with no HTTP attempt
  - max-attempts give-up, exponential-backoff schedule, auth no-retry,
    missing-key / missing-file short-circuit

Test stub fix: the _StubResponse in tests/channels/test_channel_plugins.py
declared no status_code, which the new helper reads for retry classification.
Set status_code = 200 so the stub advertises the successful response that
those tests already simulate. Also moved the two transcription-provider
imports to the top of that file (previously placed mid-file) so the file
is ruff-clean (E402).
2026-05-06 15:52:25 +08:00
Xubin RenandXubin Ren e54fbfeb2a test(cron): avoid Windows timer race
Disable the externally updated cron job before yielding to the event loop so slow Windows CI cannot run the short-interval job before the test writes the update.
2026-05-06 00:43:00 +08:00
db14685a69 fix(agent): soften SSRF guard recovery
Keep private URL access blocked at the tool boundary, but return a clear non-retryable hint so the agent can recover conversationally instead of aborting the turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 00:43:00 +08:00
chengyongruandXubin Ren d97e177981 refactor(sdk): move SDKCaptureHook to agent/hook.py
Colocate the capture hook with the rest of the hook infrastructure
instead of inlining it in the top-level facade module.
2026-05-05 23:23:29 +08:00
Mohamed ElkholyandXubin Ren ca7877f272 fix(sdk): populate RunResult.tools_used and RunResult.messages
``Nanobot.run()`` has always documented ``RunResult.tools_used`` and
``RunResult.messages`` but actually returned ``[]`` for both, so SDK
consumers could never inspect which tools fired or what the final
message list looked like — the only useful field was ``content``.

This threads the data out via a tiny ``_SDKCaptureHook`` that installs
alongside any user-supplied hooks. The capture hook accumulates tool
names across iterations and snapshots the message list on each
``after_iteration`` call; the last snapshot reflects end-of-turn state.

Only the SDK facade is touched: ``AgentLoop.process_direct`` and
``AgentRunner`` signatures are unchanged, so channels / CLI / API paths
are unaffected.
2026-05-05 23:23:29 +08:00
4db50f2e32 fix(channels): reject unauthorized inbound before side effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 23:16:36 +08:00
1813fc5021 test(telegram): cover silent allowlist rejection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 23:16:36 +08:00
DG MulticaandXubin Ren 5aa61e08d3 fix(telegram): ignore unauthorized users silently 2026-05-05 23:16:36 +08:00
futuristandXubin Ren 358997554c fix-feishu-media-path 2026-05-05 22:28:44 +08:00
Jiajun XieandXubin Ren 9fa90b1034 fix: only advance dream_cursor on completed batches to prevent silent loss 2026-05-05 22:22:40 +08:00
chengyongruandXubin Ren c30e4d86f3 refactor(agent): simplify subagent concurrency with rejection over semaphore
Replace the asyncio.Semaphore queueing approach with a simple count
check in SpawnTool.execute(). When the concurrency limit is reached,
the tool returns an error string so the agent can perceive the reason
and adjust its behavior instead of silently queueing.

- Remove max_concurrent_subagents parameter threading through
  AgentLoop, commands.py, and nanobot.py
- SubagentManager reads the limit directly from AgentDefaults
- SpawnTool checks get_running_count() before calling spawn()
- Simplify tests to verify rejection behavior
2026-05-05 22:22:04 +08:00
04cbandXubin Ren 9d6afd86b5 fix(provider): backfill DeepSeek reasoning_content instead of dropping history (#3554, #3584) 2026-05-04 12:14:38 +08:00
chengyongruandXubin Ren 3ceabdecd5 feat(cli): support github-copilot in provider logout
Logout previously claimed to support github-copilot in --help text but had
no registered handler, so `provider logout github-copilot` failed with
"Logout not implemented". Add the handler, sharing token deletion with the
codex flow via `_delete_oauth_files`. Tighten handler-table types, fix the
codex test fixture filename, and cover github-copilot plus the unknown
provider path.
2026-05-04 12:10:06 +08:00
mikaku9944andXubin Ren 807b8188e3 style(cli): use English for docstrings in oauth commands 2026-05-04 12:10:06 +08:00
mikaku9944andXubin Ren 387988b8e9 feat(cli): add provider logout command
- Implement \
anobot provider logout <provider>\ to clear OAuth credentials.
- Add \_LOGOUT_HANDLERS\ registration mechanism mirroring login.
- Implement logout for \openai-codex\ by deleting local \oauth-cli-kit\ token and lock files.
- Fallback gracefully when attempting to logout from providers lacking local credentials or implementations.
- Fixes #2665
2026-05-04 12:10:06 +08:00
yorkhellenandXubin Ren 0f32c0451e fix: support WhatsApp voice message download 2026-05-04 11:44:25 +08:00
614b21368f fix(agent): tighten safety guard edge cases
Keep the /dev workspace guard exception scoped to the known benign device paths already handled by ExecTool, and add coverage that non-benign /dev targets still get blocked. Also add a streaming regression for tool_error responses so fatal tool failures are delivered by channels instead of being marked as already streamed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 01:25:52 +08:00
chengyongruandXubin Ren d3689d143c fix(agent): prevent safety guard false positives and streamed message drop
Three independent fixes for issues exposed by PR #3493:

1. shell.py: allow /dev/* paths in workspace guard
   Commands like `rm file.txt 2>/dev/null` were blocked because
   _extract_absolute_paths captured /dev/null as a path outside
   the workspace. Allow /dev like media_path is already allowed.

2. shell.py: remove | from home_paths regex prefix
   Loki query operator `|~` was misinterpreted as pipe + home
   directory, causing false workspace violation errors.

3. loop.py: change _streamed from blacklist to whitelist
   stop_reason "tool_error" was not in the exclusion set
   {"ask_user", "error"}, so _streamed=True was set on fatal
   errors. channel manager then skipped channel.send() because
   it assumed the content was already streamed — but it never
   was. Whitelist to only {"stop", "end_turn", "max_tokens"}.

Also fixes a pre-existing Windows bug in _spawn where
create_subprocess_exec + list2cmdline breaks commands with
paths containing spaces (e.g. D:\Program Files\python.exe).

Closes: #3599, #3605
2026-05-04 01:25:52 +08:00
2a7433b7ec chore(runner): tighten workspace guard comments and Windows tests
Keep the workspace-boundary changes easier to review by trimming long explanatory comments down to short local notes. Also make the #3599 POSIX command regression skip on Windows and normalize workspace violation signatures to POSIX separators so the throttle tests are platform-stable.

Tests:
- uv run pytest tests/tools/test_exec_security.py tests/utils/test_workspace_violation_throttle.py -q
- uv run pytest -q

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 01:18:39 +08:00
b8406be215 fix(runner): soft workspace boundary + per-target throttle (#3493 #3599 #3605)
Replaces PR #3493's blanket fatal abort with a "tell the model + throttle
the bypass loop" policy.  Workspace-bound rejections are now ordinary
recoverable tool errors enriched with a structured "this is a hard policy
boundary" instruction; SSRF stays the only marker that aborts the turn.

Why the fatal-abort approach broke
----------------------------------
PR #3493 promoted every shell `_guard_command` and filesystem path-resolution
rejection to a turn-fatal RuntimeError.  Two of those messages (`path
outside working dir` and `path traversal detected`) are heuristic substring
scans on the raw command, so legitimate commands like `rm <ws>/x.txt
2>/dev/null` or `find . -type f` killed the user's turn (#3599).  On
channels with outbound dedupe (Telegram) the user just saw silence (#3605),
and the noise polluted the LLM's context until it started hallucinating
guard rejections on plain relative paths (#3597).

Why we still need *some* throttle
---------------------------------
The original #3493 pain point was real: the LLM, refused once, would
swap tools and try again -- read_file -> exec cat -> exec cp -> bash -c
-> ln -sf -> python -c open(...).  Just removing the fatal escape lets
that loop run wild until max_iterations.

What this commit does
---------------------
- `nanobot/utils/runtime.py`: add `workspace_violation_signature` and
  `repeated_workspace_violation_error`.  The signature normalizes
  filesystem `path` arguments and the first absolute path inside an
  exec command, so swapping tools against the same outside target hits
  the same throttle bucket.  Two soft attempts are allowed; the third
  attempt's tool result is replaced with a hard "stop trying to bypass"
  message that quotes the target path and tells the model to ask the
  user for help.

- `nanobot/agent/runner.py`: split classification into `_is_ssrf_violation`
  (still fatal) and `_is_workspace_violation` (now soft).  All three
  failure branches in `_run_tool` (prep_error / exception / Error
  result) route through a shared `_classify_violation` that bumps the
  per-turn workspace_violation_counts dict and either keeps the tool's
  own message or substitutes the throttle escalation.  `_execute_tools`
  now threads that dict alongside the existing external_lookup_counts.

- `nanobot/agent/tools/shell.py`: append a structured boundary note to
  every workspace-bound guard rejection (`working_dir could not be
  resolved`, `working_dir is outside`, `path outside working dir`,
  `path traversal detected`).  SSRF errors stay short and direct so the
  model doesn't try to "phrase around" them.  Existing `2>/dev/null`
  allow-list and benign device passthrough from the previous commit
  remain.

- `nanobot/agent/tools/filesystem.py`: append the same boundary note to
  the `outside allowed directory` PermissionError so read_file / write_file
  / list_dir errors give the LLM the same explicit hint.

Tests
-----
- `tests/utils/test_workspace_violation_throttle.py` (new): signature
  collapses across read_file/exec/python -c against the same path,
  different paths get independent budgets, escalation only fires after
  the third attempt.

- `tests/agent/test_runner.py`:
  - `test_runner_does_not_abort_on_workspace_violation_anymore` -- v2
    contract: filesystem PermissionError is now soft, runner moves to
    the next iteration and finalizes cleanly.
  - `test_is_ssrf_violation_remains_fatal` + the existing
    `test_runner_aborts_on_ssrf_violation` -- SSRF still aborts on the
    first attempt.
  - `test_runner_lets_llm_recover_from_shell_guard_path_outside` -- end
    to end recovery from `path outside working dir`.
  - `test_runner_throttles_repeated_workspace_bypass_attempts` -- four
    bypass attempts against the same outside target produce at least
    one `workspace_violation_escalated` event and the run completes
    naturally without aborting the turn.
  - The two `_execute_tools` direct-call tests now pass the new
    workspace_violation_counts dict.

- `tests/tools/test_tool_validation.py`: relax three `==` assertions
  to `startswith` + "hard policy boundary" substring check to match
  the new structured error messages.

- `tests/tools/test_exec_security.py` keeps the prior `2>/dev/null`
  regression and the `> /etc/issue` negative case from the previous
  commit on this branch -- they still pass under the new policy.

Coverage status: full pytest 2648 passed / 2 skipped (was 2638 / 2
on origin/main).  Ruff is clean for every file touched in this commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 01:18:39 +08:00
Xubin RenandXubin Ren 7742f8fbdc fix(runner): narrow workspace_violation fatal classification (#3599, helps #3605 #3597)
PR #3493 promoted every shell `_guard_command` rejection to a turn-fatal
RuntimeError. The two heuristic outputs in that list -- `path outside
working dir` and `path traversal detected` -- routinely false-positive on
benign constructs (e.g. `2>/dev/null`, quoted `..` arguments to sed/find,
absolute paths inside inline scripts), so legitimate workspace commands
silently kill the user's turn (#3599) and the agent never gets a chance
to retry with a different approach (#3605).

Two changes, both narrowly scoped:

- `ExecTool._guard_command` now skips a small allow-list of kernel device
  files (`/dev/null`, the standard streams, `/dev/random`, `/dev/fd/N`,
  ...) before the workspace path check, matched against the pre-resolve
  string so symlinks like `/dev/stderr -> /proc/self/fd/2` still hit the
  allow-list. Real outside writes such as `> /etc/issue` remain blocked.
- `AgentRunner._WORKSPACE_BLOCK_MARKERS` keeps only the four hard
  path-resolution errors from filesystem.py / shell.py and the SSRF
  marker. The two heuristic substrings move out of the fatal list, so
  the LLM sees them as ordinary tool errors and can self-correct in the
  next iteration. SSRF stays fatal because retrying an internal URL
  with a different phrasing would defeat the safety boundary.

Tests:
- `tests/tools/test_exec_security.py`: parametrized regression for the
  exact #3599 command sample plus other stdio redirects and device
  reads; explicit negative case asserts `> /etc/issue` is still blocked.
- `tests/agent/test_runner.py`: `_is_workspace_violation` no longer
  fatals on the two heuristic markers, plus an end-to-end case proving
  the runner hands the guard error back to the LLM and finalizes the
  next turn cleanly.
2026-05-04 01:18:39 +08:00
9a9e446f3f fix(cron): clean persistence lint issues
Keep the cron persistence hardening clean under ruff without changing behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 00:16:39 +08:00
hussein1362andXubin Ren 75c2506c07 fix(cron): atomic write for jobs.json + don't silently overwrite corrupt store
Two related bugs that together caused scheduled jobs to disappear after
a container restart:

1. `_save_store()` used `Path.write_text(...)`, which truncates the
   destination in place.  A SIGKILL or shutdown mid-write left
   `jobs.json` either truncated or corrupt.

2. `_load_jobs()` caught any parse error, logged at WARNING, and
   returned an empty list.  `start()` then called `_save_store()`
   immediately, overwriting the corrupt-but-recoverable file with an
   empty job array.  Every scheduled job was silently lost with only a
   single warning line in the log.

Reproduction in production: container restart at 18:08, after which a
job that had fired correctly for two consecutive days never fired
again.  jobs.json on disk was missing the job entirely.

Fix:
- `_save_store()` now writes via temp file + `os.replace` + `fsync`
  (matches the session manager pattern from 512bf59,
  "fix(session): fsync sessions on graceful shutdown to prevent data
  loss").  An interrupted write cannot corrupt the live file.
- `_load_jobs()` now moves a corrupt store aside as
  `jobs.json.corrupt-<ts>` and returns `None` instead of `[]`.
- `start()` aborts with a `RuntimeError` when the on-disk store is
  corrupt, instead of starting empty and overwriting.
- `_load_store()` falls back to the previous in-memory snapshot when
  a hot reload encounters a corrupt file, so a transient corruption
  after start does not drop live jobs.

Tests cover the atomic-write path, the corrupt-file preservation,
the start-time refusal, the in-memory fallback, and a basic save/load
round trip across two service instances.  Existing 79 cron tests and
full suite (2553 tests) still pass.
2026-05-04 00:16:39 +08:00
66682eb46f test(cli): cover retry-wait interactive routing
Keep provider retry wait messages on the interactive progress path so they do not fall through as assistant responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:59:08 +08:00
04cbandXubin Ren c15d816d9c fix(cli): intercept _retry_wait so provider retry messages don't garble interactive output (#3600) 2026-05-03 22:59:08 +08:00
7faa339902 fix(webui): keep existing package lockfile
Restore the npm lockfile that is already present on main so this PR only carries the WebUI turn-completion changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:28:40 +08:00
96da6d8190 fix(webui): tighten turn completion handling
Keep the new turn-end signal scoped to WebSocket clients, preserve pending tool-call state across trailing tool result rows, and drop the accidental npm lockfile from the Bun-based WebUI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:28:40 +08:00
ramonpaoloandXubin Ren be83525f99 test(webui): cover turn-end streaming regressions 2026-05-03 22:28:40 +08:00
ramonpaoloandXubin Ren 08744ce408 fix(webui): isolate thread cache during chat switches 2026-05-03 22:28:40 +08:00
ramonpaoloandXubin Ren 76e3f74df7 feat(webui): improve beta turn completion and streaming UX 2026-05-03 22:28:40 +08:00
5853d5dfda fix: allow_patterns take priority over deny_patterns in ExecTool (#3594)
* fix: allow_patterns take priority over deny_patterns in ExecTool

Previously deny_patterns were checked first with no bypass, meaning
allow_patterns could never exempt commands from the built-in deny list.
This made it impossible to whitelist destructive commands for specific
directories (e.g. build/cleanup tasks).

Changes:
- shell.py: check allow_patterns first; if matched, skip deny check
- shell.py: deny_patterns now appends to built-in list (not replaces)
- schema.py: add allow_patterns/deny_patterns to ExecToolConfig
- loop.py/subagent.py: pass allow_patterns/deny_patterns to ExecTool
- Add test_exec_allow_patterns.py covering priority semantics

* fix: separate deny pattern errors from workspace violation detection

The deny pattern error message "Command blocked by safety guard" was
included in _WORKSPACE_BLOCK_MARKERS, causing deny_pattern blocks to be
misclassified as fatal workspace violations. This meant LLMs had no
chance to retry with a different command — the turn was aborted
immediately.

Changes:
- shell.py: deny/allowlist error messages now use distinct phrasing
  ("blocked by deny pattern filter" / "blocked by allowlist filter")
- runner.py: remove "blocked by safety guard" from
  _WORKSPACE_BLOCK_MARKERS so deny_pattern errors are treated as normal
  tool errors (LLM can retry) instead of fatal violations
- workspace path errors still use "blocked by safety guard" and remain
  fatal as intended

* fix: update test assertions to match new deny pattern error message

* fix: indentation error in test file

* fix: restore SSRF fatal classification and tidy exec pattern plumbing

Address review feedback on the deny/allow_patterns rework:

- runner.py: re-add "internal/private url detected" to
  _WORKSPACE_BLOCK_MARKERS. The earlier marker removal also stripped
  fatal classification from SSRF / internal-URL rejections (whose
  message still says "blocked by safety guard"), turning a hard
  security boundary into something the LLM could retry.
- loop.py / subagent.py: drop `or None` between ExecToolConfig and
  ExecTool. The schema default is an empty list and ExecTool already
  normalizes None back to [], so the indirection was a no-op.
- shell.py: extract `explicitly_allowed` flag in _guard_command so
  allow_patterns are scanned once instead of twice and the control
  flow no longer relies on a no-op `pass + else` branch.
- tests/agent/test_runner.py: add a regression test asserting that
  the SSRF block message is treated as fatal, while deny/allowlist
  filter messages are deliberately non-fatal.

* fix: remove unused exec allow-pattern test import

Keep the new ExecTool allow-pattern coverage clean under ruff.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Xubin Ren <xubinrencs@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 00:27:17 +08:00
Xubin Ren 2fa15ccf1b fix: improve media failure diagnostics and token fallback coverage 2026-05-02 11:37:07 +00:00
Xubin Ren fde530de01 refactor(setup): enhance SKILL.md for upgrade process clarity 2026-05-02 07:40:29 +00:00
861fbb0dde fix(provider): correct LongCat OpenAI base URL
Use the SDK-ready /v1 base so LongCat chat completions hit the documented endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 01:52:04 +08:00
moranfongandXubin Ren 051037ff08 feat(provider): add LongCat via OpenAI-compatible backend 2026-05-02 01:52:04 +08:00
yorkhellenandXubin Ren ee364c6ac1 fix(helpers): restore tiktoken fallback in estimate_prompt_tokens_chain 2026-05-02 00:07:45 +08:00
fd1a5a6267 test(provider): tidy Anthropic fallback imports
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-01 23:59:24 +08:00
4c54a2b153 fix(anthropic): auto-fallback to stream on long-request error
The Anthropic SDK raises a client-side ValueError when a non-streaming
`messages.create` call could exceed the 10-minute server timeout (e.g.
high `max_tokens` combined with extended thinking budget). The error
text "Streaming is required for operations that may take longer than
10 minutes" was bubbling up to the user as an opaque LLM error in
channels that use the non-stream path (e.g. wecom in #2709).

Detect this specific ValueError in `chat()` and transparently retry
through `chat_stream()` (without `on_content_delta` so behavior matches
the non-stream contract). Other ValueErrors continue to flow through
`_handle_error` unchanged.

Closes #2709

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:59:24 +08:00
4860a9a6c9 fix(matrix): stop sync loop on irrecoverable auth errors
When the Matrix homeserver returns M_UNKNOWN_TOKEN / M_FORBIDDEN /
M_UNAUTHORIZED (or soft_logout), the previous _sync_loop kept retrying
sync_forever every 2 seconds forever, spamming the homeserver and
filling logs (#1851). The auth state cannot recover by retrying, so
this is pure noise and a soft DoS on the homeserver.

- Extract `_is_fatal_auth_response()` helper
- In `_on_sync_error`, on fatal auth: set `_running=False` and call
  `stop_sync_forever()` so the loop exits cleanly
- Add exponential backoff (2s → 60s cap) to the generic exception path
  in `_sync_loop` so transient network blips also stop hammering

Closes #1851

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:59:09 +08:00
Xubin RenandXubin Ren 539d82eadc test(tools): accept spawn origin message context
Made-with: Cursor
2026-05-01 20:09:59 +08:00
Xubin RenandXubin Ren 188e6df757 fix(utils): cover complete trailing think markers
Made-with: Cursor
2026-05-01 20:09:59 +08:00
bravelandXubin Ren 2c397ad442 fix: strip partial think tags in streaming output 2026-05-01 20:09:59 +08:00
Xubin RenandXubin Ren aea5948b11 fix(tools): tighten web fetch URL cleaning
Made-with: Cursor
2026-05-01 19:58:19 +08:00
彭星杰andXubin Ren 5dc96505e8 fix(web_fetch): sanitize URL to strip markdown backticks and quotes before validation
LLM-generated tool calls may wrap URLs in markdown backticks or quotes
(e.g. \https://example.com\), causing urlparse to produce empty scheme
and netloc, which leads to all fetch attempts failing silently.

Add URL cleaning at the top of WebFetchTool.execute to strip whitespace,
backticks, double quotes, and single quotes, plus an early rejection guard
for non-http(s) URLs after cleaning.
2026-05-01 19:58:19 +08:00
Xubin RenandXubin Ren 43a58335f6 fix(provider): narrow DeepSeek reasoning history cleanup
Made-with: Cursor
2026-05-01 19:52:38 +08:00
Jiajun XieandXubin Ren 8ca575bdeb fix: adjust DeepSeek reasoning mode check condition
- Modified _drop_deepseek_incomplete_reasoning_history to properly handle reasoning mode detection
- Fixes issue #3554
2026-05-01 19:52:38 +08:00
Xubin RenandGitHub e16fa7c6b1 Merge PR #3561: fix: origin_message_id support and outbound deduplication
fix: origin_message_id support and outbound deduplication
2026-05-01 19:52:10 +08:00
Xubin Ren e157392250 fix(agent): scope subagent reply dedupe to origin message
Made-with: Cursor
2026-05-01 11:47:24 +00:00
yorkhellenandXubin Ren 08f326ec55 test: Add tests for sender_id runtime context injection 2026-05-01 19:43:38 +08:00
yorkhellenandXubin Ren c4170fa9ba feat: Add sender_id to LLM runtime context 2026-05-01 19:43:38 +08:00
hanyuanlingandXubin Ren 1040124ede Fix API stream lifecycle for tool-backed requests 2026-05-01 19:42:52 +08:00
liuZhouandXubin Ren 73840b0af6 fix(matrix): remove tuple default from allow_room_mentions 2026-05-01 19:41:58 +08:00
hinotoi-agentandXubin Ren ad952e0da2 fix(dingtalk): block SSRF in outbound media fetches 2026-05-01 19:31:45 +08:00
0284174df9 fix: prevent empty Matrix messages when progress callback sends empty content
Agent-Logs-Url: https://github.com/halldorjanetzko/nanobot/sessions/df528c59-8214-41a0-9b79-9d1d41857107

Co-authored-by: halldorjanetzko <158819146+halldorjanetzko@users.noreply.github.com>
2026-05-01 19:31:04 +08:00
15007afd4a fix(matrix): skip events received before bot startup
Matrix sync replays the room timeline on each startup or `/restart`,
causing already-handled messages to be reprocessed (#3553). Even with
`store_sync_tokens=True`, the sync token isn't reliably re-injected
when restoring a session via access_token + load_store(), so the
client re-reads recent timeline entries.

Filter `event.server_timestamp` against the process start time so old
events are dropped at the `_on_message` / `_on_media_message` entry
points. Trade-off: messages received during downtime won't be
processed, which matches the issue reporter's expectation.

Closes #3553

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:30:33 +08:00
Jack LuandXubin Ren d9800ecdd2 refactor: replace try-except blocks with contextlib.suppress for cleaner error handling across multiple files 2026-05-01 19:30:11 +08:00
Xubin Ren 1c24f10236 fix(skills): update restart instructions in upgrade process 2026-05-01 11:18:47 +00:00
Xubin RenandXubin Ren 39c38b593f refactor(tools): move file state lookup out of loop
Made-with: Cursor
2026-05-01 19:15:07 +08:00
Xubin RenandXubin Ren fae38319ca fix(tools): scope file state by session
Made-with: Cursor
2026-05-01 19:15:07 +08:00
LZDQandXubin Ren 58ae2d5b7e Claude: replace module-level file read states with per-loop per-session state class. fixes #3571 2026-05-01 19:15:07 +08:00
Xubin RenandXubin Ren 6891a7a4d4 fix(skills): correct update setup commands
Made-with: Cursor
2026-05-01 19:02:26 +08:00
chengyongruandXubin Ren 830730f82d feat(skills): add update-setup wizard skill 2026-05-01 19:02:26 +08:00
Xubin RenandXubin Ren 306958d6e6 add native Bedrock Converse provider
Made-with: Cursor
2026-05-01 18:52:03 +08:00
童天立 61a8ad27d9 fix: add origin_message_id parameter to SubagentManager.spawn() 2026-04-30 21:24:37 +08:00
童天立 4e06c00b46 fix: add origin_message_id support for spawn and message deduplication 2026-04-30 21:22:48 +08:00
hanyuanlingandXubin Ren 3c20d16117 fix subagent max iteration limit 2026-04-30 13:45:40 +08:00
Xubin RenandXubin Ren f8fd9f0011 fix(feishu): keep streaming replies in existing topics
Made-with: Cursor
2026-04-30 13:42:37 +08:00
hanyuanlingandXubin Ren d82f25e4d4 fix(feishu): respect reply_to_message for group threads 2026-04-30 13:42:37 +08:00
Xubin Ren 26e953f0b9 Revert "fix(feishu): streaming card and tool hint respect reply_to_message in…"
This reverts commit 651b6b933f.
2026-04-30 13:27:37 +08:00
04cbandXubin Ren 651b6b933f fix(feishu): streaming card and tool hint respect reply_to_message in groups 2026-04-30 12:51:08 +08:00
Xubin Ren 71eff09653 fix(whatsapp): refresh bridge when source changes 2026-04-30 04:18:31 +00:00
Xubin Ren d23bcae5a3 chore: update README with news for v0.1.5.post4 release 2026-04-29 11:12:50 +00:00
Xubin Ren 69bcf26ef4 chore: update README with news for v0.1.5.post3 release 2026-04-29 10:59:19 +00:00
Xubin Ren 0b1631f33d chore: bump version to 0.1.5.post3 and update README news
- pyproject.toml + __init__.py: 0.1.5.post2 → 0.1.5.post3
- README: add daily news entries for 2026-04-22 through 2026-04-28

Made-with: Cursor
2026-04-29 10:50:57 +00:00
Xubin RenandXubin Ren 3d7099b421 fix(memory): clean atomic write test hygiene
Made-with: Cursor
2026-04-29 16:57:50 +08:00
yorkhellenandXubin Ren 53ca2836e7 fix(memory): also fsync directory for rename durability 2026-04-29 16:57:50 +08:00
yorkhellenandXubin Ren 2af45945e2 fix(memory): ensure atomic write for history.jsonl
Use temp file + os.replace + fsync to prevent partial writes on crash.
Add tests for atomic write behavior and tmp file cleanup on exception.
2026-04-29 16:57:50 +08:00
chengyongruandXubin Ren 74270bb8a8 refactor(channels): resolve progress overrides at init-time like transcription 2026-04-29 16:43:09 +08:00
hanyuanlingandXubin Ren a0443e8f9e fix(channels): address progress override review 2026-04-29 16:43:09 +08:00
hanyuanlingandXubin Ren 0b111a0e0c fix(channels): support per-channel progress controls 2026-04-29 16:43:09 +08:00
Xubin Ren 67b4d113c9 chore: update pyproject.toml 2026-04-29 08:25:09 +00:00
Jiajun XieandXubin Ren 95715f5211 fix: sanitize Matrix user_id for Windows-safe store file names
- Replace ':' with '_' in store_name to avoid WinError 123
- Pass sanitized store_name via AsyncClientConfig
- Fixes issue #3506 where Matrix channel fails on Windows due to
  colon in user_id causing invalid file paths in matrix-nio's DefaultStore
2026-04-29 16:04:49 +08:00
masterlyjandXubin Ren 2b9b41f9c3 test(providers): cover reasoning_effort="none" and gemma auto-routing
- Anthropic: "none" must not enable extended thinking
- Azure: "none" must not suppress temperature or inject reasoning body
- DeepSeek/DashScope/Kimi: "none" sends thinking disabled, skips reasoning_effort field
- Gemini: gemma keyword enables auto-routing for gemma models
2026-04-29 15:41:11 +08:00
masterlyjandXubin Ren b94bc18e59 fix: treat reasoning_effort="none" as thinking disabled and route gemma to Gemini provider
- Do not send reasoning_effort="none" to APIs (prevents 400 on gemma/Gemini)
- Treat "none" as thinking disabled in thinking_style, Kimi, and reasoning_content backfill paths
- Fix Anthropic extended thinking not respecting "none"
- Fix Azure OpenAI temperature suppression and reasoning body for "none"
- Fix Codex reasoning body for "none"
- Add "gemma" keyword to Gemini ProviderSpec for correct auto routing
2026-04-29 15:41:11 +08:00
28f9bbff31 feat(web_search): add olostep provider
Adds Olostep (https://www.olostep.com) as an optional web_search backend
using the official olostep Python SDK (client.answers.create()).

Changes:
- pyproject.toml: adds olostep>=0.1.0 optional dependency
- schema.py: adds olostep to provider comment in WebSearchConfig
- web.py: adds _search_olostep() with lazy import and provider branching
- docs/configuration.md: documents Olostep setup under web search config
- tests: unit tests for the new provider

Backward compatible: existing users see no behavior change unless they
opt into provider: "olostep". No hard dependency at runtime path.

Co-authored-by: umerkay <umerkk164@gmail.com>
2026-04-28 19:09:38 +08:00
甘全andXubin Ren 0053e68423 fix(feishu): skip reaction transition on resuming stream end
Stream-end events are emitted at the end of every assistant turn. When
the agent has more tool-call rounds queued, the runner sets
`_resuming=True` on the metadata. Without a guard, every intermediate
stream end removed the OnIt reaction (the first one wins, since
`_reaction_ids.pop` empties the slot) and re-added `done_emoji`,
producing a DONE reaction after every tool call instead of only at
final completion.

Wrap the OnIt removal and `done_emoji` add in a `not _resuming` guard
so the OnIt indicator persists across tool-call rounds and DONE fires
exactly once when the agent's final response lands.

`_resuming` already flows through outbound metadata
(`nanobot/agent/loop.py:747`) and survives `_coalesce_stream_deltas`
because pure `_stream_end` messages without `_stream_delta` skip the
merge branch.

Tests:
- test_no_removal_when_resuming
- test_done_emoji_only_on_final_stream_end
2026-04-28 17:29:12 +08:00
Xubin RenandXubin Ren 278ef22776 docs(config): document provider extra body
Show how to configure OpenAI-compatible request body extensions such as sampling and chat template parameters.

Made-with: Cursor
2026-04-28 15:56:13 +08:00
hussein1362andXubin Ren 415e617398 feat(providers): add extra_body config for OpenAI-compatible endpoints
Add an `extra_body` field to `ProviderConfig` that merges arbitrary
key-value pairs into every OpenAI-compatible request body. This is the
escape hatch for provider-specific features that nanobot does not have
first-class fields for.

Real-world use cases this unblocks via config alone (no code changes):
- vLLM/TGI `chat_template_kwargs` (e.g. `enable_thinking: false`)
- vLLM guided decoding (`guided_json`, `guided_regex`)
- Local model sampling params (`repetition_penalty`, `top_k`, `min_p`)
- Any future provider-specific param without a new PR each time

The config extra_body is applied last via recursive deep-merge, so it
can extend or override provider-specific defaults (e.g. thinking
params) without clobbering sibling keys set by internal logic.

Changes:
- Add `extra_body: dict[str, Any] | None` to `ProviderConfig`
- Pass it through `factory.py` to `OpenAICompatProvider.__init__`
- Deep-merge into `_build_kwargs` after all internal extra_body entries
- Add `_deep_merge` helper (recursive dict merge, does not mutate inputs)
- 21 tests: deep-merge semantics, provider init, _build_kwargs
  integration, thinking coexistence, real-world patterns (guided_json,
  repetition_penalty), and schema validation
2026-04-28 15:56:13 +08:00
Xubin RenandGitHub 58f8c04bd5 Merge PR #3382: feat(web-tools): Improve to allow bypassing Cloudflare captchas
feat(web-tools): Improve to allow bypassing Cloudflare captchas
2026-04-28 15:27:47 +08:00
Xubin Ren f4d8783f5e test(web): cover configurable fetch behavior
Ensure custom user agents are applied to direct web requests and disabling Jina Reader forces the local readability path.

Made-with: Cursor
2026-04-28 07:25:47 +00:00
Xubin Ren 18432c313f Merge origin/main into web-tools
Made-with: Cursor
2026-04-28 07:17:05 +00:00
Xubin RenandXubin Ren 50698c3d1c test(telegram): cover local attachment filenames
Add a regression test for preserving the original basename when Telegram sends local media bytes.

Made-with: Cursor
2026-04-28 15:13:49 +08:00
SimonandXubin Ren e36e70fe16 fix(channels): send telegram attachments with named file path 2026-04-28 15:13:49 +08:00
Xubin RenandXubin Ren 48f3cc6390 fix(agent): stop on workspace violations from tool errors
Treat workspace and safety guard failures as fatal regardless of whether they arrive from tool preparation, returned tool output, or raised exceptions.

Made-with: Cursor
2026-04-28 15:13:27 +08:00
lihuaandXubin Ren f19d767b0f 权限错误要打断循环 2026-04-28 15:13:27 +08:00
Celina HanoutiandXubin Ren 2b455b1e14 feat(providers): add Hugging Face inference provider 2026-04-28 14:55:28 +08:00
Xubin RenandXubin Ren ad4802600e refactor(config): make max messages default explicit
Use 120 as the config-level default and normalize zero back to that limit so session replay always receives an explicit message cap.

Made-with: Cursor
2026-04-28 14:54:32 +08:00
hussein1362andXubin Ren d45ffcf519 feat(config): wire max_messages into session history replay
The max_messages config field in AgentDefaults was accepted by the
schema but never threaded through to the actual get_history() calls
in the agent loop.  Both call sites in _process_message hardcoded the
default, so sessions with slow or local models accumulated unbounded
history that inflated prompt tokens and caused LLM timeouts.

Changes:
- Add max_messages field to AgentDefaults (default 0 = use built-in
  constant, any positive value caps history replay)
- Store the value on AgentLoop and pass it to get_history() when
  non-zero
- Wire the config through all three AgentLoop construction sites in
  commands.py (gateway, API server, CLI chat)
- 14 focused tests covering schema validation, init storage, history
  slicing, boundary alignment, integration wiring, and the
  zero/default path
2026-04-28 14:54:32 +08:00
Xubin Ren 97981b911a fix(slack): skip empty progress messages that render as blank lines 2026-04-27 12:48:24 +00:00
Xubin Ren 12b9782f3e docs(deployment): clarify container user and config directory usage 2026-04-27 11:07:34 +00:00
Xubin RenandXubin Ren fdfecd3ba6 refactor(codex): name progress delta capability semantically
Use a provider capability name that describes user-visible progress delta support instead of the runner implementation detail.

Made-with: Cursor
2026-04-27 18:48:05 +08:00
hanyuanlingandXubin Ren ae14142a87 fix(codex): stream progress deltas to channels 2026-04-27 18:48:05 +08:00
Xubin RenandXubin Ren 2b886ffd1f fix(command): expose history in chat command menus
Made-with: Cursor
2026-04-27 18:23:35 +08:00
Xubin RenandXubin Ren 8ed10ac7df test(command): keep history tests lint-clean
Made-with: Cursor
2026-04-27 18:23:35 +08:00
Leo fuandXubin Ren 599e25dfbf feat(command): add /history command to show recent session messages
Adds /history [n] to display the last N user/assistant messages from
the current session (default 10, max 50).

- Tool and system messages are filtered out for readability
- Long messages are truncated to 200 characters with an ellipsis
- Multimodal content (image blocks) is collapsed to its text parts
- Invalid count argument returns a usage hint
- /history n uses prefix routing; /history uses exact routing

Also registers /history in build_help_text().
2026-04-27 18:23:35 +08:00
hussein1362andXubin Ren e72c415473 fix(heartbeat): prevent internal reasoning leaks and finalization fallback in delivery
Three failure modes addressed:

1. Model reflects HEARTBEAT.md instructions back as output instead of
   executing them ("HEARTBEAT.md has active tasks listed...")
2. Model narrates decision logic ("Best judgment call: stay quiet")
3. Model produces empty output for silence, runner treats it as failure,
   finalization retry generates "couldn't produce a final answer" which
   gets delivered to the user

Changes:
- Add _is_deliverable() pre-filter in HeartbeatService._tick() that catches
  finalization fallback messages and leaked reasoning patterns before they
  reach the evaluator
- Wrap Phase 2 task input with a delivery-awareness preamble telling the
  model its output goes directly to the user's messaging app
- Add meta-reasoning suppression criterion to evaluator template

No changes to agent/loop.py, runner.py, providers, or config schema.
2026-04-27 18:14:13 +08:00
hanyuanlingandXubin Ren 9dc99d1b34 fix(provider): bound OpenAI-compatible request timeouts 2026-04-27 17:47:31 +08:00
Xubin RenandGitHub b8932bc041 Merge PR #3397: fix(discord): full thread support with session isolation and allowlist enforcement
fix(discord): full thread support with session isolation and allowlist enforcement
2026-04-27 17:36:53 +08:00
Xubin Ren e31273ebaa Merge origin/main into fix/discord-allow-channel-threads
Made-with: Cursor
2026-04-27 09:26:24 +00:00
Xubin Ren 82c5083b15 fix(slack): preserve DM thread routing and strip trailing newlines 2026-04-27 09:01:04 +00:00
Xubin RenandGitHub 2fe8d21b6e Merge PR #3459: feat(session): enforce replay/file-cap invariants for history lifecycle
feat(session): enforce replay/file-cap invariants for history lifecycle
2026-04-27 16:17:23 +08:00
Xubin Ren eb4b3d9e26 refactor(session): internalize history/file-cap knobs as constants
Move sessionHistoryMaxMessages, sessionHistoryMaxTokens, and
sessionFileMaxMessages out of user-facing config into internal
constants (HISTORY_MAX_MESSAGES=120, FILE_MAX_MESSAGES=2000).

- Remove 3 fields from AgentDefaults and config pipeline
- Sink enforce_file_cap into Session (was AgentLoop)
- Auto-derive token budget from context window (was configurable)
- Net -113 lines across 7 files; 723 tests green

Made-with: Cursor
2026-04-27 08:06:50 +00:00
Xubin RenandGitHub 537c66a3f8 Merge PR #3440: fix: Automatically clean up unsupported or expired MSTeams session
fix: Automatically clean up unsupported or expired MSTeams session
2026-04-27 15:45:31 +08:00
hanyuanlingandXubin Ren 8e0ce59c0e fix(provider): normalize DeepSeek non-string message content 2026-04-27 15:43:41 +08:00
Xubin Ren 29ebc2d355 Merge origin/main into feat/session-replay-file-cap-invariants
Preserve main's timestamp/tool-context replay semantics while keeping the PR's session history and file-cap budgets.

Made-with: Cursor
2026-04-27 07:32:00 +00:00
Xubin Ren 367a6db78c test(msteams): align stale-ref test with sidecar metadata
The PR stores ref freshness in the metadata sidecar, so the merged main test should assert updated_at there instead of in the refs payload.

Made-with: Cursor
2026-04-27 07:30:17 +00:00
Xubin Ren 3d75aedcac Merge origin/main into fix/msteams-prune-stale-refs
Resolve the MSTeams stale-reference cleanup conflict by keeping the PR's locked, atomic sidecar-meta implementation and aligning the merged test expectation locally.

Made-with: Cursor
2026-04-27 07:29:48 +00:00
Xubin Ren 311a7fe36e fix(session): stop training the model to parrot [Message Time: ...]
Past assistant turns in history were prefixed with "[Message Time: ...]"
just like user turns. The model treated these as in-context demos and
started prefixing its own replies with the same marker, leaking
metadata to the user. Prompt-level warnings could not beat dozens of
prior assistant samples.

Annotate only user turns and proactive deliveries
(_channel_delivery=True, i.e. cron / heartbeat pushes whose timing is
the whole point and which are too infrequent to act as demos). Adjacent
user-side timestamps still pin every normal assistant reply for
relative-time reasoning. The now-redundant identity.md warning is
removed along with the demonstration source.
2026-04-27 07:11:20 +00:00
Xubin Ren 620d9e4f31 fix(slack): accept inbound file_share messages without dropping them
Slack inbound events with subtype=file_share were silently dropped, so
nanobot never saw messages that included attachments. Allow file_share
through, download Slack-private files using the bot token into the
local media dir, and pass them to the agent as media paths plus a
"[file: name]" / "[image: name]" placeholder in the content. Reject
responses that look like Slack's login HTML so an auth page is never
saved as if it were the user's file. Document the required files:read
scope alongside files:write so installs that read attachments are not
quietly missing the permission.
2026-04-27 07:11:11 +00:00
Xubin RenandXubin Ren 7dcf83e389 test(agent): cover threaded subagent routing
Made-with: Cursor
2026-04-27 14:37:36 +08:00
mt-huertaandXubin Ren 380309016a fix(agent): complete thread-session routing for spawn dispatch and system-channel branch
Builds on PR #3463 (commit 038a140), which introduced metadata and
session_key parameters through _LoopHook and _set_tool_context for the
cron and message tools. Three downstream gaps remained:

1. _set_tool_context's body still computes effective_key from
   channel:chat_id and passes that to spawn, even when the caller
   provides a thread-scoped session_key. The new parameter is wired in
   for cron/message but spawn dispatch ignores it. Result: subagent
   announces from threaded callers carry a channel-only
   session_key_override, dropping thread_ts.

2. _process_message's system-channel branch loads the session via
   key = f"{channel}:{chat_id}", ignoring msg.session_key_override.
   So even when the announce InboundMessage carries the right override
   (after fix 1), the consumer side discards it and routes to the
   channel-level session.

3. The OutboundMessage returned from the system-channel branch has no
   metadata, so slack's outbound dispatcher has no thread_ts to use and
   posts the LLM's reply to the channel top-level rather than the
   originating thread.

This change closes all three gaps with three small edits in loop.py.

Behavior change:
- Slack channels with reply_in_thread: true: subagent announces and
  follow-up replies now arrive in the originating thread session
  instead of leaking into the channel-level session.
- Other channels constructing thread-scoped session keys (matrix
  threads, telegram thread mode, etc.): the session-loading and
  effective-key fixes apply identically since they're platform-agnostic.
  The outbound thread_ts reconstruction is slack-specific by virtue of
  the session-key format slack uses; other channels would benefit from
  the same pattern but are out of scope for this PR.
- Unified session mode: no change. Falls back to UNIFIED_SESSION_KEY
  when session_key is not provided.
- CLI / non-channel callers: no change. They don't pass session_key
  and the fallback to f"{channel}:{chat_id}" matches prior behavior.

Reproducer (slack with reply_in_thread: true):
1. From a slack thread, send a message that triggers a subagent spawn.
2. Before fix: announce lands in slack:<channel>.jsonl session,
   parent agent in the thread never sees the completion event,
   eventual reply (if any) posts to the channel top-level, not the
   thread.
3. After fix: announce lands in slack:<channel>:<thread_ts>.jsonl,
   parent agent in the thread responds within seconds, reply posts in
   the thread.
2026-04-27 14:37:36 +08:00
Xubin RenandXubin Ren 9b6f3d7abc fix(agent): resolve message media against active workspace
Made-with: Cursor
2026-04-27 14:31:39 +08:00
chengyongruandXubin Ren 9b3e2524ac fix(agent): resolve relative media paths in MessageTool
When deployed with Docker and workspace mounted as a volume, sending
media files failed because relative paths (e.g. output/image.png) were
not resolved against the workspace directory. The process CWD differs
from the workspace in containerized environments, causing os.path.isfile
checks to fail in channel handlers. Normalize relative media paths at
the MessageTool entry point using get_workspace_path().
2026-04-27 14:31:39 +08:00
Xubin Ren eeaec1f951 fix(agent): prevent message time metadata from leaking into replies 2026-04-27 06:23:43 +00:00
Xubin RenandXubin Ren d89a824769 docs(readme): keep Slack upload scope in chat app docs
Keep the root README focused on the main setup path and leave Slack-specific upload permissions in the chat apps guide.

Made-with: Cursor
2026-04-27 12:45:00 +08:00
Xubin RenandXubin Ren 8a0917db7a fix(slack): polish thread UX and media support 2026-04-27 12:45:00 +08:00
Xubin RenandXubin Ren 5e9b9b9818 fix(slack): skip thread context for slash commands so /restart is not buried
_with_thread_context prepends conversation history to the message
content.  This turned "/restart" into "Slack thread context...\n\n
Current message:\n/restart", which the command router could not match
as a priority command.  Skip the context enrichment when the stripped
text starts with "/".

Made-with: Cursor
2026-04-27 12:45:00 +08:00
Xubin RenandXubin Ren 1fe3f0eb22 fix(restart): preserve channel metadata across /restart so reply lands in thread
cmd_restart only persisted channel + chat_id across the os.execv boundary, so
when the new process announced "Restart completed" the OutboundMessage had
no Slack thread_ts and the reply fell back to the channel root.

Serialize msg.metadata into NANOBOT_RESTART_NOTIFY_METADATA, restore it on the
RestartNotice, and forward it to OutboundMessage so the completion message
follows the same routing as the original /restart invocation.

Made-with: Cursor
2026-04-27 12:45:00 +08:00
Xubin RenandXubin Ren 1ef41052da fix(cron): rephrase fire-time prompt so agent delivers a natural reminder
The old prompt framed cron firing as a "task triggered" status report,
which led the agent to reply with things like "Done  已提醒
U0AV8BJPV8D 喝水" — exposing the user id and reading like a system log
instead of a friendly reminder. Reword it to instruct the agent to
speak directly to the user and forbid status-style language.

Made-with: Cursor
2026-04-27 12:45:00 +08:00
Xubin RenandXubin Ren 4801f54f5b fix(cron): persist channel_meta and session_key across reloads
Without writing these fields into jobs.json, cron jobs created in a
Slack thread lost their thread_ts (and original session_key) after the
service was reloaded, so reminders fired into the channel root.

Made-with: Cursor
2026-04-27 12:45:00 +08:00
chengyongruandXubin Ren 6eb178113e fix(mcp): sanitize MCP capability names for model API compatibility
MCP resource/prompt/tool names containing spaces or special characters
(e.g. "PostgreSQL System Information") were forwarded verbatim to model
provider APIs, causing validation errors from both Anthropic and OpenAI
which require names matching ^[a-zA-Z0-9_-]{1,128}$.

Add _sanitize_name() that replaces invalid characters with underscores
and collapses consecutive underscores. Applied in MCPToolWrapper,
MCPResourceWrapper, MCPPromptWrapper constructors and the enabled_tools
filtering logic.

Closes #3468
2026-04-27 11:49:50 +08:00
Xubin RenandGitHub ca66dd8cd1 Merge PR #3463: fix(agent): expose session timestamps in model context
fix(agent): expose session timestamps in model context
2026-04-27 02:22:37 +08:00
Xubin Ren 4a4ba1efc1 Merge branch 'main' into fix/session-history-timestamps
Made-with: Cursor
2026-04-26 18:13:11 +00:00
Xubin RenandXubin Ren 038a140ad3 fix(slack): preserve thread context for proactive replies
Capture Slack thread metadata for cron and message-tool deliveries so replies stay in the originating thread, and hydrate first thread mentions with recent Slack context.

Made-with: Cursor
2026-04-27 02:10:38 +08:00
Xubin Ren 7037764186 docs: clarify maintainer and contribution licensing 2026-04-26 18:01:55 +00:00
Xubin Ren df37a36174 fix(agent): expose session timestamps in model context
Include persisted turn timestamps when assembling LLM prompts so relative-date references like yesterday and today have concrete anchors.

Made-with: Cursor
2026-04-26 17:42:58 +00:00
hanyuanling 59dfd74842 feat(session): enforce replay/file-cap invariants for history lifecycle 2026-04-27 00:53:32 +08:00
Xubin RenandGitHub c64ec3e73c Merge PR #3454: feat(webui): add ask-user choices and model settings
feat(webui): add ask-user choices and model settings
2026-04-26 22:19:39 +08:00
Xubin Ren b2aec5528a refactor(agent): move provider refresh into subsystem owners 2026-04-26 14:18:37 +00:00
Xubin Ren f670da6c70 refactor(providers): move provider snapshot creation into factory 2026-04-26 14:05:13 +00:00
Xubin Ren 65b0ae81af Merge origin/main into webui-settings
Made-with: Cursor
2026-04-26 13:05:32 +00:00
Xubin RenandXubin Ren 82b8a3af7e fix(provider): handle incomplete DeepSeek reasoning history 2026-04-26 20:47:55 +08:00
Xubin RenandXubin Ren 3b82e14f85 fix(shell): preserve login PATH for path append
Made-with: Cursor
2026-04-26 20:32:38 +08:00
yorkhellenandXubin Ren 814345dd78 fix: update tests for path_append env dict change 2026-04-26 20:32:38 +08:00
yorkhellenandXubin Ren 2f2ac96ac7 fix: update tests for path_append env dict change 2026-04-26 20:32:38 +08:00
yorkhellenandXubin Ren 23dde7b84c fix: prevent shell injection via path_append in ExecTool 2026-04-26 20:32:38 +08:00
Xubin RenandXubin Ren 727086ddac test: tighten consolidation ratio coverage
Made-with: Cursor
2026-04-26 20:24:42 +08:00
chengyongruandXubin Ren fca56d324a test: add unit tests for configurable consolidation_ratio
Cover ratio propagation, schema validation, and consolidation
behavior with different ratio values (0.1, 0.5, 0.9).
2026-04-26 20:24:42 +08:00
SubalandXubin Ren 80ee4483f8 feat: make consolidation ratio configurable 2026-04-26 20:24:42 +08:00
chengyongruandXubin Ren 3de843a229 fix(provider): gate reasoning-to-content fallback behind spec flag
The non-streaming parse path unconditionally promoted the `reasoning`
response field to `content` when content was empty. This was intended
for StepFun (whose API returns the actual answer in `reasoning`), but
it applied to every OpenAI-compatible provider — causing internal
thinking chains from models like Xiaomi MIMO to be leaked as formal
replies.

Add `reasoning_as_content: bool` to ProviderSpec (default False) and
set it only for StepFun. The fallback now requires this flag rather
than running globally.

Fixes #3443
2026-04-26 20:11:08 +08:00
Xubin RenandXubin Ren 6036355ac5 fix(message): limit session recording to proactive sends
Only mark message-tool deliveries for channel-session recording while cron jobs are running, avoiding duplicate session writes during normal user turns.

Made-with: Cursor
2026-04-26 20:08:21 +08:00
Xubin RenandXubin Ren 799db33517 fix(heartbeat): record proactive deliveries in channel sessions
Route heartbeat, cron, and message-tool deliveries through one gateway helper so user-visible proactive messages are available when the channel replies.

Made-with: Cursor
2026-04-26 20:08:21 +08:00
hussein1362andXubin Ren 1572626100 fix(heartbeat): inject delivered messages into channel session for reply continuity
When heartbeat delivers output to a channel (e.g. Telegram), the message
is a raw OutboundMessage that bypasses the channel's session. If the user
replies, their reply enters a different session with no context about the
heartbeat message, so the agent cannot follow through.

This change injects the delivered heartbeat message as an assistant turn
into the target channel's session before publishing the outbound. When
the user replies, the channel session has conversational context.

Handles unified_session mode by resolving to UNIFIED_SESSION_KEY when
enabled, matching the agent loop's own session routing.

No changes to agent/loop.py, session/manager.py, channels, providers,
or config schema — uses existing add_message() and save() APIs.
2026-04-26 20:08:21 +08:00
Xubin RenandXubin Ren 1e11b35b45 fix(providers): tighten local endpoint detection
Parse the endpoint host before disabling keepalive so public hostnames that merely contain private-network substrings keep the default connection pool behavior.

Made-with: Cursor
2026-04-26 16:14:24 +08:00
hussein1362andXubin Ren 5943ab386d fix(providers): disable HTTP keepalive for local/LAN endpoints
Local model servers (Ollama, llama.cpp, vLLM) often close idle HTTP
connections before the client-side keepalive timer expires.  When two
LLM calls happen seconds apart — for example the heartbeat _decide()
phase followed immediately by process_direct() — the second call grabs
a now-dead pooled connection, causing a transient APIConnectionError
on every first attempt.

The fix detects local endpoints via:
- ProviderSpec.is_local (Ollama, LM Studio, vLLM, OVMS)
- Private-network URL patterns (localhost, 127.x, 192.168.x, 10.x,
  172.16-31.x, host.docker.internal, [::1])

For these endpoints, the AsyncOpenAI client is created with a custom
httpx.AsyncClient that sets keepalive_expiry=0, forcing a fresh TCP
connection for each request.  This is cheap on LAN (sub-5ms connect)
and eliminates the stale-connection retry tax entirely.

Cloud providers (OpenAI, Anthropic, OpenRouter, etc.) keep the default
5-second keepalive, which is fine for high-frequency API usage.

The private-network heuristic also covers the common case where users
configure provider='openai' but point apiBase at a LAN IP running
llama.cpp — the spec says is_local=False, but the URL clearly is.
2026-04-26 16:14:24 +08:00
Xubin RenandXubin Ren d0e1b1393a fix(feishu): scope streaming buffers by message
Keep concurrent Feishu group replies from sharing one streaming card buffer when sessions are split by topic or top-level message.

Made-with: Cursor
2026-04-26 16:09:31 +08:00
chengyongruandXubin Ren 39eea1b762 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 16:09:31 +08:00
chengyongruandXubin Ren 0e92936cf3 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 16:09:31 +08:00
chengyongruandXubin Ren 3eb8838dd9 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 16:09:31 +08:00
chengyongruandXubin Ren 2a9fc9392b 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 16:09:31 +08:00
chengyongruandXubin Ren 8717832771 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 16:09:31 +08:00
chengyongruandXubin Ren d36fba8bf5 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 16:09:31 +08:00
13bb31c789 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 16:09:31 +08:00
Xubin Ren b440e76d2f feat(webui): add model settings runtime refresh 2026-04-25 18:05:06 +00:00
T3chC0wb0yandXubin Ren fd3d7ea752 fix(msteams): normalize nbsp in inbound text 2026-04-26 00:56:06 +08:00
T3chC0wb0yandXubin Ren 722d935d37 fix(msteams): prune bad notify refs 2026-04-26 00:56:06 +08:00
T3chC0wb0yandXubin Ren 7e65884acb fix(msteams): send threaded replies via replyToId 2026-04-26 00:56:06 +08:00
Xubin Ren a58d9fd357 feat(webui): render ask_user choices
Made-with: Cursor
2026-04-25 15:46:47 +00:00
Xubin RenandXubin Ren 403ce23d22 fix(agent): tighten ask_user CLI handling
Made-with: Cursor
2026-04-25 22:10:19 +08:00
Xubin RenandXubin Ren 3b1ea99ee1 fix(agent): render ask_user options without buttons
Made-with: Cursor
2026-04-25 22:10:19 +08:00
Xubin RenandXubin Ren cfc76ffbbf feat(agent): add ask_user tool
Made-with: Cursor
2026-04-25 22:10:19 +08:00
Xubin RenandXubin Ren 830211b5d4 docs: simplify macOS launchd setup
Made-with: Cursor
2026-04-25 19:36:20 +08:00
Xubin RenandXubin Ren 8a4c338a01 docs: tighten macOS launchd setup
Made-with: Cursor
2026-04-25 19:36:20 +08:00
choikingandXubin Ren 41f7eae7b4 docs: add macOS launchd gateway setup 2026-04-25 19:36:20 +08:00
zhuzhh fe928a0d94 feat(msteams): split ref storage into main+meta sidecar files
- Separate updated_at into a meta sidecar file (msteams_conversations_meta.json)
    to keep backward compatibility with legacy data that never had updated_at.
    On first upgrade, legacy refs are kept alive by initializing updated_at to now
    instead of purging them immediately.
  - Add cross-process locking via fcntl (with Windows fallback) to prevent
    concurrent writes from different gateway processes overwriting each other.
  - Add ref_touch_interval_s config (default 300s) to throttle how often
    successful sends refresh updated_at, preventing unnecessary I/O.
  - Touch active refs on send success to prevent them from expiring while in use.
  - Add _safe_float and _normalize_ref_record for robust schema migration.
  - All refs operations now use threading.RLock within a process.
2026-04-25 15:39:43 +08:00
zhuzhh 15e9d0471f feat(msteams): make ref pruning configurable and atomic 2026-04-25 12:58:04 +08:00
zhuzhh 106ae2cf1f fix(msteams): prune stale and unsupported conversation refs 2026-04-25 12:22:36 +08:00
Xubin Ren 39a5a77874 fix(feishu): send videos with media message type 2026-04-24 20:00:56 +00:00
yorkhellenandXubin Ren 076e4166d7 fix(agent): add LLM request timeout to prevent session lock starvation 2026-04-25 03:40:34 +08:00
Xubin RenandXubin Ren e52fe2a8e2 feat(webui): render video media attachments
Add signed media URLs to live WebSocket replies and teach the WebUI to classify and render video attachments, so bot-sent videos can play inline in both live chats and session history.

Made-with: Cursor
2026-04-25 03:20:40 +08:00
Xubin RenandXubin Ren be05189f39 feat(channels): add video support for Telegram and WebSocket
Telegram previously sent all video files as documents via send_document,
so users saw a file icon instead of an inline player. WebSocket only
accepted image MIME types, rejecting video uploads entirely.

Telegram:
- Recognize video extensions (mp4/mov/avi/mkv/webm/3gp) in _get_media_type
- Route videos through send_video with supports_streaming=True
- Add VIDEO/VIDEO_NOTE/ANIMATION to inbound message filters
- Add video MIME mappings to _get_extension
- Fix: local file sends now use _call_with_retry (previously no retry)

WebSocket:
- Expand upload MIME whitelist with video/mp4, video/webm, video/quicktime
- Add per-type size limits (_MAX_VIDEO_BYTES=20MB, _MAX_VIDEOS_PER_MESSAGE=1)
- Expand media serving endpoint to serve video with correct Content-Type

Agent:
- Add "video" to message tool media parameter description
- Add .mp4 example to identity.md system prompt

Made-with: Cursor
2026-04-25 02:20:13 +08:00
Matt Van HornandXubin Ren ee14e2df56 perf(document): lazy-import heavy document parsers
Move pypdf, python-docx, openpyxl, and python-pptx imports from module
level into the _extract_pdf / _extract_docx / _extract_xlsx /
_extract_pptx functions that actually use them. These four libraries
became core dependencies in v0.1.5.post2 (~25 MB combined) and were
paying the import cost on every nanobot startup even when no document
parsing was needed for the session.

The module-level SUPPORTED_EXTENSIONS set and the extract_text()
dispatch stay as-is; the "[error: <lib> not installed]" branches move
from the old module-level None sentinels into the corresponding
extractor's try/except ImportError block. Behavior for the error
message and for successful parses is identical.

All 20 tests in tests/test_document_parsing.py pass unchanged.

Fixes #3422
2026-04-25 02:10:30 +08:00
Xubin RenandXubin Ren 3441d5f89c test(anthropic): cover remaining opus-4-7 temperature branches
The existing test only verified the adaptive path. Add two more cases:
- enabled thinking (high): temperature must also be omitted
- no thinking (None): temperature must still be omitted

Made-with: Cursor
2026-04-24 15:33:59 +08:00
04cbandXubin Ren 9239429a00 fix(anthropic): omit temperature for opus-4-7 (#3417) 2026-04-24 15:33:59 +08:00
Xubin RenandXubin Ren 7f1913f619 fix(provider): add DeepSeek thinking toggle; backfill reasoning_content on legacy messages
Two issues with DeepSeek V4 thinking mode support:

1. Missing thinking parameter injection.
   DeepSeek V4 requires `extra_body: {"thinking": {"type": "enabled/disabled"}}`
   — identical to VolcEngine/BytePlus. The code had this for volcengine,
   byteplus, dashscope, minimax, and kimi but not DeepSeek. This means
   `reasoning_effort=minimal` (thinking off) silently has no effect.

   Root cause: the thinking-style→wire-format mapping was an if/elif chain
   on provider *names*. DeepSeek was forgotten.

   Fix: make the mapping declarative via `ProviderSpec.thinking_style`:
   - "thinking_type" → {"thinking": {"type": "..."}} (DeepSeek, Volc, BytePlus)
   - "enable_thinking" → {"enable_thinking": bool} (DashScope)
   - "reasoning_split" → {"reasoning_split": bool} (MiniMax)
   `_build_kwargs` now does a single dict lookup. Adding a new provider
   with an existing wire format requires zero changes to the function.

2. Legacy session messages crash thinking-mode requests.
   When a session was started without thinking mode (or with a different
   model), assistant messages lack reasoning_content. DeepSeek V4 in
   thinking mode rejects these with 400:
   "The reasoning_content in the thinking mode must be passed back to the API."
   This affects ALL assistant messages, not just those with tool_calls
   (despite the docs only mentioning the tool_calls case).

   Fix: `_build_kwargs` backfills `reasoning_content: ""` on every
   assistant message missing it, but only when thinking mode is active.
   This is semantically neutral — the model treats empty reasoning_content
   as "no thinking happened on that turn". The backfill only touches the
   in-memory request copy; session files on disk are untouched.

Tests: +5 (3 thinking toggle, 2 backfill). Full suite: 2377 passed.
Made-with: Cursor
2026-04-24 15:06:39 +08:00
Xubin RenandXubin Ren 4531167c12 fix(agent): bound remaining memory/history pollution paths from #3412
#3412 stopped the headline raw_archive bloat but left four adjacent leaks
on the same pollution chain:

- archive() success path appended uncapped LLM summaries to history.jsonl,
  so a misbehaving LLM could re-open the #3412 bug from the happy path.
- maybe_consolidate_by_tokens did not advance last_consolidated when
  archive() fell back to raw_archive, causing duplicate [RAW] dumps of
  the same chunk on every subsequent call.
- Dream's Phase 1/2 prompt injected MEMORY.md / SOUL.md / USER.md and
  each history entry without caps, so any legacy oversized record (or an
  unbounded user edit) would blow past the context window every dream.
- append_history itself had no default cap, leaving future new callers
  one forgotten-cap-away from the same vector.

Changes:

- Cap LLM-produced summaries at 8K chars (_ARCHIVE_SUMMARY_MAX_CHARS)
  before writing to history.jsonl.
- Advance session.last_consolidated after archive() regardless of whether
  it summarized or raw-archived — both outcomes materialize the chunk;
  still break the round loop on fallback so a degraded LLM isn't hammered.
- Truncate MEMORY.md / SOUL.md / USER.md and each history entry in Dream's
  Phase 1 prompt preview (Phase 2 still reaches full files via read_file).
- Add _HISTORY_ENTRY_HARD_CAP (64K) as belt-and-suspenders default in
  append_history with a once-per-store warning, so any new caller that
  forgets its own tighter cap gets caught and observable.

Layer the caps by scope: raw_archive=16K, archive summary=8K,
append_history default=64K. Tight per-caller values cover expected
payloads; the wide default only catches regressions.

Tests: +9 regression tests covering each fix. Full suite: 2372 passed.
Made-with: Cursor
2026-04-24 04:17:19 +08:00
Xubin RenandXubin Ren 81a5af2352 test(consolidation): add regression tests for tiktoken truncation path and history char cap
Cover two untested boundaries from #3412:
- _truncate_to_token_budget with positive budget exercises tiktoken
- _MAX_HISTORY_CHARS caps Recent History section in system prompt

Made-with: Cursor
2026-04-24 03:57:59 +08:00
chengyongruandXubin Ren 4a1b9053ac 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 03:57:59 +08:00
chengyongruandXubin Ren 2848f69897 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 03:57:59 +08:00
Xubin RenandXubin Ren 52855d463e refactor(agent): move progress event helpers out of loop
Made-with: Cursor
2026-04-23 20:06:11 +08:00
Xubin RenandXubin Ren 469fc90fe6 fix(agent): on_progress tool_events only when callback accepts; align progress tests with main
Made-with: Cursor
2026-04-23 20:06:11 +08:00
c23d719780 feat(agent): emit structured _tool_events progress metadata
Extend the existing on_progress callback to carry structured tool-event
payloads alongside the plain-text hint, so channels can render rich
tool execution state (start/finish/error, arguments, results, file
attachments) rather than only the pre-formatted hint string.

Changes
-------
- AgentLoop._tool_event_start_payload() — builds a version-1 start
  payload from a ToolCallRequest
- AgentLoop._tool_event_result_extras() — extracts files/embeds from a
  tool result dict
- AgentLoop._tool_event_finish_payloads() — maps tool_calls +
  tool_results + tool_events from AgentHookContext into finish payloads
- _LoopHook.before_execute_tools() — passes tool_events=[...] to
  on_progress together with the existing tool_hint flag
- _LoopHook.after_iteration() — emits a second on_progress call with
  the finish payloads once tool results are available
- _bus_progress() — forwards tool_events as _tool_events in OutboundMessage
  metadata so channel implementations can read them
- on_progress type widened to Callable[..., Awaitable[None]] on all
  public entry points; _cli_progress updated to accept and ignore
  tool_events

The contract is additive: callers that only accept (content, *, tool_hint)
continue to work unchanged. Callers that also accept tool_events receive
the structured data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:06:11 +08:00
Xubin Ren 185a8fd34d fix(webui): opaque composer, equal-width message area, cleaner user pill 2026-04-23 07:48:32 +00:00
Xubin RenandXubin Ren 06503cd0fc fix(telegram): keep callback_data under Telegram's 64-byte cap
``InlineKeyboardButton(label, callback_data=label)`` fails Telegram's
API when the label exceeds 64 bytes UTF-8. An LLM-generated long
option (realistic in multilingual flows) used to 400 the ``send_message``
call silently — user got nothing, agent heard a successful retry-then-drop.

Decouple display from wire: button text keeps the full label, callback_data
gets truncated at a UTF-8 char boundary. Tap echoes the prefix back as the
user message; the LLM understands a prefix of its own option just fine,
and the display the user saw was always the full string.

Locks: helper boundary behavior (ASCII, CJK, short labels pass through)
and end-to-end ``_build_keyboard`` integration with an over-cap label.

Made-with: Cursor
2026-04-23 13:26:06 +08:00
Xubin RenandXubin Ren 6bc2983ab1 fix(telegram): fall back buttons to inline text when keyboard disabled
Buttons are semantic options, not a separate channel protocol: a user
who taps "Yes" and a user who types "yes" arrive at the agent as the
same string. Dropping ``msg.buttons`` when ``inline_keyboards=False``
was the worst of both worlds — the agent got told "Message sent with
N button(s)" while the user saw a question with no options.

Splice the labels into the message text instead. The LLM produces the
same ``message(buttons=...)`` call regardless of channel; the channel
layer picks the richest rendering it can afford — native keyboard when
enabled, bracketed inline text otherwise. Layout is preserved (one row
per line). Other channels can adopt the same helper incrementally.

Locks: canonical ``_buttons_as_text`` format, flag-off send-path
splices labels, flag-on send-path keeps content clean and rides
``reply_markup``.

Made-with: Cursor
2026-04-23 13:26:06 +08:00
Xubin RenandXubin Ren b9b81d9301 test(telegram): pin inline-keyboards flag gate and buttons validation
Two kill-switch tests for the new inline-keyboards path. Neither is
flashy — they just make sure the next unrelated refactor can't quietly
regress two narrow contracts the PR relies on.

  1. TelegramChannel._build_keyboard returns None whenever
     TelegramConfig.inline_keyboards is False, even if buttons are
     supplied. The flag defaults off; if someone ever flips that default
     the change should fail this test before it reaches prod bots.

  2. MessageTool rejects malformed `buttons` payloads (non-list, mixed
     list/str row, non-str label, None label) up front instead of
     letting them slip into the channel layer where Telegram would
     silently 400 the send. Parametrized over four shapes the guard
     needs to reject.

No production code touched.

Made-with: Cursor
2026-04-23 13:26:06 +08:00
Gunnar ThielebeinandXubin Ren 8d33c1cb37 feat(telegram): add inline keyboard buttons 2026-04-23 13:26:06 +08:00
Bongjin Lee 93ca791ac6 fix(discord): full thread support with session isolation and allowlist enforcement
Discord threads use their own channel IDs, so allowChannels was blocking
thread replies unless each thread ID was listed explicitly.

- Include the thread parent channel ID as an allowlist candidate
- Enforce allow_channels on slash commands (previously bypassed)
- Show parent channel ID in runtime context, reply to the thread
- Fix subagent cancel key via effective_key propagation
- Detect bot mentions via raw_mentions and reply-to-bot references
- Cache seen thread channels for outbound delivery
- Ignore system messages that become empty prompts
2026-04-23 04:05:39 +09:00
Xubin RenandXubin Ren e3bca929fb fix(webui): left-align prose inside user message pill 2026-04-23 00:07:27 +08:00
Xubin RenandXubin Ren e493eb09e7 test(webui): realign thread-composer attach test with current types 2026-04-23 00:07:27 +08:00
Xubin RenandXubin Ren 707c0d7f3a fix(websocket): scrub partial media batches, nosniff /api/media 2026-04-23 00:07:27 +08:00
Xubin RenandXubin Ren 61a28c2c0a feat(webui): support image uploads in composer and message bubbles 2026-04-23 00:07:27 +08:00
Xubin RenandXubin Ren c1e7aa5504 refactor(config): resolve env vars via in-place Pydantic walk
Replace the dump→resolve→model_validate roundtrip with a recursive walk
that substitutes ${VAR} in string values directly on BaseModel /
__pydantic_extra__ / dict / list nodes. Identity is preserved on any
subtree with no references, so the original Config instance is returned
unchanged when nothing needs resolving.

Side effects:
- exclude=True fields (e.g. DreamConfig.cron) now survive even when
  other fields in the same config contain ${VAR} references, closing
  the edge case left open by the previous fast-path-only fix.
- _has_env_refs is dropped (the walker short-circuits naturally).
- Added a regression test pairing cron with a resolved providers.groq
  api_key to lock the coexistence case.

Made-with: Cursor
2026-04-22 22:31:40 +08:00
Saimon VenturaandXubin Ren c9a21d96d8 fix(config): preserve excluded fields in resolve_config_env_vars
`resolve_config_env_vars` unconditionally dumped the config via
`model_dump(mode="json")` and revalidated it, which silently dropped
any field declared with `exclude=True` (e.g. `DreamConfig.cron` —
introduced by the Dream rename refactor in #2717). Result:
`agents.defaults.dream.cron` was never honored at runtime — the gateway
always fell back to the default `every 2h` schedule even when `cron`
was set in config.json.

Fix: skip the roundtrip entirely when the config has no `${VAR}`
references. Env-var interpolation still works unchanged when refs
exist; the legacy `cron` override now survives the common case of
fully-resolved config.

Regression test covers the bug path.
2026-04-22 22:31:40 +08:00
Xubin RenandXubin Ren 239e91a4d6 test(anthropic): pin tool_result image_url conversion regression
Adds a focused regression test so the fix for tool_result image
handling cannot silently revert. Two cases:

- list content with an image_url + text block -> image_url is
  translated to a native Anthropic image block, sibling text passes
  through unchanged
- plain string content passes through untouched (the new list branch
  must not alter the string path)

These cover the exact symptom surface (silent image drop with a
"Non-transient LLM error with image content" warning) and the only
two content shapes tool results actually take today.

Made-with: Cursor
2026-04-22 22:10:53 +08:00
lentanandXubin Ren 29a08df06a fix(anthropic): convert image_url blocks inside tool_result content
_tool_result_block passed list content through unchanged, so image_url
blocks returned by tools (e.g. read_file on an image file, which
returns OpenAI-format image_url blocks via build_image_content_blocks)
reached the Anthropic API unconverted and were rejected. User-role
messages already ran through _convert_user_content at the call site,
so inbound Telegram photos worked, but tool results did not.

Run _convert_user_content on list content inside _tool_result_block
so image_url blocks become native Anthropic image blocks. Required
making _convert_user_content a @staticmethod (it did not use self)
and calling _convert_image_block via the class to match.

Repro: an agent calling read_file on any image file got a
"Non-transient LLM error with image content, retrying without images"
warning and the image was silently dropped from the conversation.
2026-04-22 22:10:53 +08:00
chengyongruandXubin Ren 42c4af2118 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 20:02:19 +08:00
Mizarka 4c25b739b5 docs: add new web tool settings 2026-04-22 09:42:03 +00:00
Mizarka 3d40e159ae feat(web-tools): add option to disable fetching via Jina Reader
A new configuration block has been added for the web fetch tool, which
allows forcing the tool to use the local readability-lxml mode.

Combined with the previous option to modify the user agent, allows
bypassing most Cloudflare captchas and JS proof-of-work.

Assisted-by: Jo'Zahir:Qwen3.6-35B-A3B
2026-04-22 09:28:30 +00:00
Mizarka ec2f0ccfdb feat(web-tools): add configurable User-Agent
Assisted-by: Jo'Zahir:Qwen3.6-35B-A3B
2026-04-22 09:11:57 +00:00
Xubin Renandlahuman 7c21349828 Merge pull request #3379 from lahuman/fix/3324-windows-mcp-stdio
fix(mcp): avoid WinError 193 for Windows stdio launchers

Co-authored-by: lahuman <6156679+lahuman@users.noreply.github.com>
2026-04-22 08:09:46 +00:00
Xubin Ren 79247545ac Merge remote-tracking branch 'origin/main' into pr-3379 2026-04-22 08:08:05 +00:00
Xubin Renandgongpx20069 f718a71dcc Merge pull request #3380 from gongpx20069/fix/github-copilot-gpt5-support
fix(providers): support GPT-5 models on GitHub Copilot backend

Co-authored-by: gongpx20069 <21985921+gongpx20069@users.noreply.github.com>
2026-04-22 06:53:39 +00:00
Xubin Ren 427deb4a70 test(providers): add regression tests for GitHub Copilot /responses routing
Locks in the four behaviors introduced by the fix so they can't silently
revert:
- _should_use_responses_api accepts github_copilot on its non-OpenAI base
- _build_responses_body strips the 'github_copilot/' routing prefix
- /responses failures on github_copilot do not fall back to /chat/completions

Made-with: Cursor
2026-04-22 06:53:37 +00:00
Peixian GongandCopilot dd26b4407d fix(providers): make GitHub Copilot backend work with GPT-5/o-series models
Calling GitHub Copilot with `gpt-5.*` / `o*` models (e.g.
`github_copilot/gpt-5.4`, `github_copilot/gpt-5.4-mini`) failed with a
chain of misleading errors:

  1. `Unsupported parameter: 'max_tokens' is not supported with this
     model. Use 'max_completion_tokens' instead.`
  2. `model "gpt-5.4-mini" is not accessible via the /chat/completions
     endpoint` (`unsupported_api_for_model`).
  3. `The requested model is not supported.` (`model_not_supported`)
     even after routing to /responses.

Root causes (each one masked the next):

  * The `github_copilot` ProviderSpec did not opt into
    `supports_max_completion_tokens`, so `_build_kwargs` always sent the
    legacy `max_tokens` parameter that GPT-5/o-series reject.
  * `_should_use_responses_api` was hard-gated to
    `spec.name == "openai"` plus a direct-OpenAI base URL, so the
    GitHub Copilot backend always went through /chat/completions even
    for models the Copilot gateway exposes only via /responses
    (e.g. `gpt-5.4-mini`).
  * When /responses did fail on github_copilot, the existing
    "compatibility marker" heuristic silently fell back to
    /chat/completions — which can never succeed for these models — so
    the real upstream error was hidden.
  * `_build_responses_body` did not honour `spec.strip_model_prefix`,
    so the request body sent `model="github_copilot/gpt-5.4-mini"`
    (with the routing prefix), which the Copilot gateway rejects with
    `model_not_supported`. (`_build_kwargs` already stripped it; this
    branch was missed.)

Fix:

  * registry.py: set `supports_max_completion_tokens=True` on the
    `github_copilot` spec so requests use `max_completion_tokens`.
  * openai_compat_provider.py:
      - `_should_use_responses_api` now also allows the
        `github_copilot` spec, and skips the direct-OpenAI base check
        for it (the Copilot gateway is its own base URL).
      - `_build_responses_body` now strips the model routing prefix
        when `spec.strip_model_prefix` is set, matching `_build_kwargs`.
      - `chat` / `chat_stream` no longer fall back from /responses to
        /chat/completions on the `github_copilot` spec: the fallback
        cannot succeed for GPT-5/o-series and would mask the real
        gateway error.

Tests:

  * tests/cli/test_commands.py: switched the
    `test_github_copilot_provider_refreshes_client_api_key_before_chat`
    fixture model from `gpt-5.1` to `gpt-4` so it continues to exercise
    the /chat/completions code path it was designed for (gpt-5.1 now
    correctly routes to /responses on github_copilot).
  * `pytest tests/providers/ tests/cli/test_commands.py` — 314 passed.
  * Verified end-to-end against the live Copilot gateway with both
    `github_copilot/gpt-5.4` and `github_copilot/gpt-5.4-mini`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 14:28:19 +08:00
k 03ec28dd49 fix(mcp): avoid WinError 193 for Windows stdio launchers 2026-04-22 14:50:55 +09: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
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
Xubin RenandXubin Ren c1957e14ff refactor(memory): centralize cursor validation behind a single gate
Move the non-int cursor guard out of the two consumer sites and into a
shared ``_iter_valid_entries`` iterator so the invariant lives in one
place.  Closes three gaps left by the original fix:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #2966

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

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

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

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

Handles each case conservatively:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Split into two tests covering both branches:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #3113

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No production code changes.

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

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

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

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

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

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

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

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

Tests: 1993 passed locally.
Made-with: Cursor
2026-04-17 16:11:37 +08:00
Jiajun XieandXubin Ren 3db2eb66e4 ci: add Windows and Python 3.14 support 2026-04-17 16:11:37 +08:00
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
Xubin Ren 6483071485 chore: update version to 0.1.5.post1 2026-04-14 18:51:04 +00:00
Xubin Ren 1a5a16d1f3 chore: update README with recent news entries 2026-04-14 18:19:30 +00:00
razzhandXubin Ren 9e2278826f feat(provider): enable Kimi thinking via extra_body for k2.5 and k2.6
- Inject `thinking={"type": "enabled|disabled"}` via extra_body for
  Kimi thinking-capable models (kimi-k2.5, k2.6-code-preview).
- Add _is_kimi_thinking_model helper to handle both bare slugs and
  OpenRouter-style prefixed names (e.g. moonshotai/kimi-k2.5).
- reasoning_effort="minimal" maps to disabled; any other value enables it.
- Add tests for enabled/disabled states and OpenRouter prefix handling.
2026-04-15 01:59:32 +08:00
Xubin RenandXubin Ren a0812ad60e test: cover retry termination notifications
Lock the new interaction-channel retry termination hints so both exhausted standard retries and persistent identical-error stops keep emitting the final progress message.

Made-with: Cursor
2026-04-15 01:55:57 +08:00
aiguozhi123456andXubin Ren ec14933aa1 fix: add retry termination notification to interaction channel 2026-04-15 01:55:57 +08:00
Xubin RenandXubin Ren 25ded8e747 test: cover active task count in status
Lock the /status task counter to the actual stop scope by asserting it sums unfinished dispatch tasks with running subagents for the current session.

Made-with: Cursor
2026-04-15 01:49:42 +08:00
aiguozhi123456andXubin Ren 634f4b45c1 feat: show active task count in /status output 2026-04-15 01:49:42 +08:00
Xubin RenandXubin Ren b60e8dc0ba test: cover missing tool-call arguments normalization
Lock the strict-provider sanitization path so assistant tool calls without function.arguments are normalized to {} instead of being forwarded as missing values.

Made-with: Cursor
2026-04-15 01:37:41 +08:00
Michael-lhhandXubin Ren f293ff7f18 fix: normalize tool-call arguments for strict providers
Ensure assistant tool-call function.arguments is always emitted as valid JSON text so strict OpenAI-compatible backends (including Alibaba code models) do not reject requests. Add regressions for dict and malformed-string argument payloads in message sanitization.

Made-with: Cursor
2026-04-15 01:37:41 +08:00
Xubin RenandXubin Ren 1f33df1ea6 fix: preserve empty dict allow_from handling
Keep dict-backed channel configs compatible with both allow_from and allowFrom without losing empty-list semantics, and add focused regression coverage for the allow-list boundary.

Made-with: Cursor
2026-04-15 01:26:51 +08:00
samyandXubin Ren 73cf9a220b fix: handle dict config in is_allowed() and _validate_allow_from()
getattr() on a dict never finds custom keys — it only searches
object attributes, not dict keys. When channel config is loaded as
a Pydantic extra field (which is a plain dict), getattr(config,
'allow_from', []) always returns the default [], causing all access
to be denied regardless of the allowFrom configuration.

Fix both is_allowed() and _validate_allow_from() to use isinstance
checks, falling back to dict.get() for dict configs while preserving
getattr() for object-style configs.
2026-04-15 01:26:51 +08:00
Xubin Ren 89bf5d29d1 fix: reduce CLI streaming flicker and show model in welcome line 2026-04-14 13:38:06 +00:00
Xubin RenandGitHub cbc1161f75 Merge PR #2938: feat(api): support file uploads via JSON base64 and multipart/form-data
feat(api): support file uploads via JSON base64 and multipart/form-data
2026-04-14 21:23:28 +08:00
Xubin Ren c937c07178 fix: two bugs in document extraction pipeline
Bug 1: _drain_pending did not call extract_documents on follow-up
messages arriving mid-turn. Documents attached to queued messages were
silently dropped because _build_user_content only handles images.
Fix: call extract_documents before _build_user_content in _drain_pending.

Bug 2: extract_documents read the entire file into memory (up to 50 MB)
just to check 16 bytes of magic header for MIME detection.
Fix: read only the first 16 bytes via open()+read(16) instead of
Path.read_bytes().

Added regression tests for both bugs.

Made-with: Cursor
2026-04-14 13:15:04 +00:00
Xubin Ren 92d6fca323 refactor: centralize document extraction in AgentLoop._process_message
Move extract_documents() to nanobot.utils.document as a reusable helper
and call it once in AgentLoop._process_message, the single entry point
for all message processing (API + all channels).

This replaces the previous API-only _extract_documents() in server.py,
ensuring Telegram, Feishu, Slack, WeChat, and all other channels also
benefit from automatic document text extraction.

Adds a configurable max_file_size guard (default 50 MB) to skip
oversized files gracefully, preventing unbounded memory/CPU usage
from channel-downloaded attachments.

- server.py: removed _extract_documents and related imports
- document.py: added extract_documents() with size limit
- loop.py: calls extract_documents() at the top of _process_message
- Tests updated: 70 related tests pass

Made-with: Cursor
2026-04-14 13:10:03 +00:00
Xubin Ren 47f5795708 refactor: move document extraction from ContextBuilder to API layer
ContextBuilder._build_user_content now only handles images (its original
responsibility).  Document text extraction (PDF, DOCX, XLSX, PPTX) is
performed by the new _extract_documents() helper in server.py, called
before process_direct().  This keeps the core context builder free of
format-specific dependencies and makes the API boundary the single place
where uploaded files are pre-processed.

Tests updated to reflect the new responsibility boundary.

Made-with: Cursor
2026-04-14 13:00:59 +00:00
Xubin Ren 2502fc616b Merge origin/main into feat/api-file-upload
Keep the API file upload branch current with main, enforce the documented JSON base64 per-file limit, and avoid leaking document extraction error strings into user prompts.

Made-with: Cursor
2026-04-14 12:29:43 +00:00
Xubin RenandXubin Ren 0a51344483 fix(slack): keep cross-target sends out of origin threads
When Slack resolves a named target to another conversation, do not reuse the origin thread timestamp on the destination send, and keep reaction cleanup anchored to the source conversation.

Made-with: Cursor
2026-04-14 20:19:48 +08:00
yeyitechandXubin Ren 873be5180b feat(slack): resolve named message targets 2026-04-14 20:19:48 +08:00
chengyongruandXubin Ren 0adce5405b fix(feishu): remove resuming to avoid 10-min streaming card timeout
Feishu streaming cards auto-close after 10 minutes from creation,
regardless of update activity. With resuming enabled, a single card
lives across multiple tool-call rounds and can exceed this limit,
causing the final response to be silently lost.

Remove the _resuming logic from send_delta so each tool-call round
gets its own short-lived streaming card (well under 10 min). Add a
fallback that sends a regular interactive card when the final
streaming update fails.
2026-04-14 16:53:42 +08: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
Xubin RenandGitHub 12c12869b4 Merge PR #2625: feat: add HTTP health endpoint on gateway port
feat: add HTTP health endpoint on gateway port
2026-04-14 15:24:35 +08:00
Xubin Ren e4b3f9bd28 security(gateway): keep health endpoint local by default
Bind the gateway health listener to localhost by default and reduce the probe response to a minimal status payload so accidental public exposure leaks less information.

Made-with: Cursor
2026-04-14 07:19:38 +00:00
Xubin Ren 4999e2f734 Merge origin/main into feat/health-endpoint
Keep the gateway health endpoint patch current with the latest gateway runtime changes, and lock the new HTTP routes in with CLI regression coverage and README guidance.

Made-with: Cursor
2026-04-14 06:32:31 +00:00
yeyitechandXubin Ren 65a15f39ee test(loop): cover /stop checkpoint recovery 2026-04-14 14:15:22 +08:00
yeyitechandXubin Ren ee061f0595 fix(web): serialize duckduckgo search calls 2026-04-14 14:10:06 +08:00
yeyitech 655f3d2cc5 fix: harden cron tool contract and repeat guard 2026-04-14 12:40:23 +08:00
Xubin RenandXubin Ren a38bc637bd fix(runner): preserve injection flag after max-iteration drain
Keep late follow-up injections observable when they are drained during max-iteration shutdown so loop-level response suppression still makes the right decision.

Made-with: Cursor
2026-04-14 00:30:30 +08:00
chengyongruandXubin Ren a1e1eed2f1 refactor(runner): consolidate all injection drain paths and deduplicate tests
- Migrate "after tools" inline drain to use _try_drain_injections,
  completing the refactoring (all 6 drain sites now use the helper).
- Move checkpoint emission into _try_drain_injections via optional
  iteration parameter, eliminating the leaky split between helper
  and caller for the final-response path.
- Extract _make_injection_callback() test helper to replace 7
  identical inject_cb function bodies.
- Add test_injection_cycle_cap_on_error_path to verify the cycle
  cap is enforced on error exit paths.
2026-04-14 00:30:30 +08:00
chengyongruandXubin Ren d849a3fa06 fix(agent): drain injection queue on error/edge-case exit paths
When the agent runner exits due to LLM error, tool error, empty response,
or max_iterations, it breaks out of the iteration loop without draining
the pending injection queue. This causes leftover messages to be
re-published as independent inbound messages, resulting in duplicate or
confusing replies to the user.

Extract the injection drain logic into a `_try_drain_injections` helper
and call it before each break in the error/edge-case paths. If injections
are found, continue the loop instead of breaking. For max_iterations
(where the loop is exhausted), drain injections to prevent re-publish
without continuing.
2026-04-14 00:30:30 +08:00
moranfong 0750d1f182 fix(config): return provider default api base in config resolution 2026-04-13 23:42:58 +08:00
chengyongruandXubin Ren 3c06db7e4e 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 20:14: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
haosenwang1018andXubin Ren d33bf22e91 docs(provider): clarify responses api routing 2026-04-13 15:59:36 +08:00
haosenwang1018andXubin Ren 85c7996766 docs(api): clarify cross-channel message delivery 2026-04-13 15:59:36 +08:00
chengyongruandXubin Ren ac714803f6 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:54:39 +08:00
chengyongruandXubin Ren becaff3e9d 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:51:37 +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
Xubin RenandXubin Ren 6484c7c47a fix(agent): close interrupted early-persisted user turns
Track text-only user messages that were flushed before the turn loop completes, then materialize an interrupted assistant placeholder on the next request so session history stays legal and later turns do not skip their own assistant reply.

Made-with: Cursor
2026-04-13 10:26:09 +08:00
Xubin RenandXubin Ren b964a894d2 test(agent): cover early user-message persistence
Use session.add_message for the pre-turn user-message flush and add focused regression tests for crash-time persistence and duplicate-free successful saves.

Made-with: Cursor
2026-04-13 10:26:09 +08:00
nikubeandXubin Ren ea94a9c088 fix(agent): persist user message before running turn loop
The existing runtime_checkpoint mechanism preserves the in-flight
assistant/tool state if the process dies mid-turn, but the triggering
user message is only written to session history at the end of the turn
via _save_turn(). If the worker is killed (OOM, SIGKILL, a self-
triggered systemctl restart, container eviction, etc.) before the turn
completes, the user's message is silently lost: on restart, the session
log only shows the interrupted assistant turn without any record of
what the user asked. Any recovery tooling built on top of session logs
cannot reply because it has no prompt to reply to.

This patch appends the incoming user message to the session and flushes
it to disk immediately after the session is loaded and before the agent
loop runs, then adjusts the _save_turn skip offset so the final
persistence step does not duplicate it.

Limited to textual content (isinstance(msg.content, str)); list-shaped
content (media blocks) still flows through _save_turn's sanitization at
end of turn, preserving existing behavior for those cases.
2026-04-13 10:26:09 +08:00
Xubin RenandXubin Ren 49355b2bd6 test(tools): lock non-object parameter validation
Add focused registry coverage so the new read_file/read_write parameter guard stays actionable without changing generic validation behavior for other tools.

Made-with: Cursor
2026-04-13 09:55:05 +08:00
ramonpaoloandXubin Ren 830644c352 fix: add guard for non-dict tool call parameters
- Add type validation in registry.prepare_call() to catch list/other invalid params
- Add logger.warning() in provider layer when non-dict args detected
- Works for OpenAI-compatible and Anthropic providers
- Registry returns clear error hint for model to self-correct
2026-04-13 09:55:05 +08:00
haosenwang1018andXubin Ren 92ef594b6a fix(mcp): hint on stdio protocol pollution 2026-04-13 09:41:55 +08:00
haosenwang1018andXubin Ren 3573109408 fix(provider): preserve static error helper compatibility 2026-04-13 09:37:31 +08:00
haosenwang1018andXubin Ren c68b3edb9d fix(provider): clarify local 502 recovery hints 2026-04-13 09:37:31 +08:00
bahtyaandXubin Ren f879d81b28 fix(channels/qq): propagate network errors in send() instead of swallowing
The catch-all except Exception in QQ send() was swallowing
aiohttp.ClientError and OSError that _send_media correctly
re-raises. Add explicit catch for network errors before the
generic handler.
2026-04-13 00:30:45 +08:00
bahtyaandXubin Ren fa98524944 fix(channels): prevent retry amplification and silent message loss across channels
Audited all channel implementations for overly broad exception handling
that causes retry amplification or silent message loss during network
errors. This is the same class of bug as #3050 (Telegram _send_text).

Fixes by channel:

Telegram (send_delta):
- _stream_end path used except Exception for HTML edit fallback
- Network errors (TimedOut, NetworkError) triggered redundant plain
  text edit, doubling connection demand during pool exhaustion
- Changed to except BadRequest, matching the _send_text fix

Discord:
- send() caught all exceptions without re-raising
- ChannelManager._send_with_retry() saw successful return, never retried
- Messages silently dropped on any send failure
- Added raise after error logging

DingTalk:
- _send_batch_message() returned False on all exceptions including
  network errors — no retry, fallback text sent unnecessarily
- _read_media_bytes() and _upload_media() swallowed transport errors,
  causing _send_media_ref() to cascade through doomed fallback attempts
- Added except httpx.TransportError handlers that re-raise immediately

WeChat:
- Media send failure triggered text fallback even for network errors
- During network issues: 3×(media + text) = 6 API calls per message
- Added specific catches: TimeoutException/TransportError re-raise,
  5xx HTTPStatusError re-raises, 4xx falls back to text

QQ:
- _send_media() returned False on all exceptions
- Network errors triggered fallback text instead of retry
- Added except (aiohttp.ClientError, OSError) that re-raises

Tests: 331 passed (283 existing + 48 new across 5 channel test files)

Fixes: #3054
Related: #3050, #3053
2026-04-13 00:30:45 +08:00
bahtyaandXubin Ren 7e91aecd7d fix(telegram): narrow exception catch in _send_text to prevent retry amplification
Previously _send_text() caught all exceptions (except Exception) when
sending HTML-formatted messages, falling back to plain text even for
network errors like TimedOut and NetworkError. This caused connection
demand to double during pool exhaustion scenarios (3 retries × 2
fallback attempts = 6 calls per message instead of 3).

Now only catches BadRequest (HTML parse errors), letting network errors
propagate immediately to the retry layer where they belong.

Fixes: HKUDS/nanobot#3050
2026-04-13 00:30:45 +08:00
Xubin RenandXubin Ren 217e1fc957 test(retry): lock in-place image fallback behavior
Add a focused regression test for the successful no-image retry path so the original message history stays stripped after fallback and the repeated retry loop cannot silently return.

Made-with: Cursor
2026-04-12 20:10:06 +08:00
yanghan-cyberandXubin Ren b261201985 fix(retry): strip images in-place to prevent repeated error-retry cycles
When a non-transient LLM error occurs with image content, the retry
mechanism strips images from a copy but never updates the original
conversation history. Subsequent iterations rebuild context from the
unmodified history, causing the same error-retry cycle to repeat
every iteration until max_iterations is reached.

Add _strip_image_content_inplace() that mutates the original message
content lists in-place after a successful no-image retry, so callers
sharing those references (e.g. the runner's conversation history)
also see the stripped version.
2026-04-12 20:10:06 +08:00
Xubin RenandXubin Ren 7a7f5c9689 fix(dream): use valid builtin skill template paths
Point Dream skill creation at a readable builtin skill-creator template, keep skill writes rooted at the workspace, and document the new skill discovery behavior in README.

Made-with: Cursor
2026-04-12 16:49:55 +08:00
2a243bfe4f feat(agent): integrate skill discovery into Dream consolidation
Instead of a separate skill discovery system, extend Dream's two-phase
pipeline to also detect reusable behavioral patterns from conversation
history and generate SKILL.md files.

Phase 1 gains a [SKILL] output type for pattern detection.
Phase 2 gains write_file (scoped to skills/) and read access to builtin
skills, enabling it to check for duplicates and follow skill-creator's
format conventions before creating new skills.

Inspired by PR #3039 by @wanghesong2019.

Co-authored-by: wanghesong2019 <wanghesong2019@users.noreply.github.com>
2026-04-12 16:49:55 +08:00
Xubin RenandXubin Ren 5dc238c7ef fix(shell): allow read-only copies from internal state files
Keep the new exec guard focused on writes to history.jsonl and .dream_cursor while still allowing read-only copy operations out of those files.

Made-with: Cursor
2026-04-12 16:38:55 +08:00
04cbandXubin Ren 3f59bd1443 fix(shell): reject LLM-supplied working_dir outside workspace (#2826) 2026-04-12 16:38:55 +08:00
04cbandXubin Ren 00fb491bc9 fix(shell): block exec writes to history.jsonl and cursor files (#2989) 2026-04-12 16:38:55 +08:00
Xubin RenandGitHub a81e4c1791 Merge PR #2959: feat(skills): add disabled_skills config to exclude skills from loading
feat(skills): add disabled_skills config to exclude skills from loading
2026-04-12 10:46:50 +08:00
Xubin Ren a142788da9 docs(readme): document disabledSkills config
Explain the new agents.defaults.disabledSkills option so users can discover and configure skill exclusion from the main agent and subagents.

Made-with: Cursor
2026-04-12 02:42:52 +00:00
Xubin Ren e229c2ebc0 fix(pr): remove internal .docs file from PR
Keep the local review note out of the GitHub diff while preserving the actual code and test changes for this PR.

Made-with: Cursor
2026-04-12 02:21:46 +00:00
Xubin Ren 09c238ca0f Merge origin/main into pr-2959
Resolve the config plumbing conflicts and keep disabled skill filtering consistent for subagent prompts after syncing with main.

Made-with: Cursor
2026-04-12 02:02:39 +00:00
Dianqi JiandXubin Ren ee946d96ca feat(channels/feishu): add domain config for Lark global support
Add 'domain' field to FeishuConfig (Literal['feishu', 'lark'], default 'feishu').
Pass domain to lark.Client.builder() and lark.ws.Client to support Lark global
(open.larksuite.com) in addition to Feishu China (open.feishu.cn).
Existing configs default to 'feishu' for backward compatibility.

Also add documentation for domain field in README.md and add tests for
domain config.
2026-04-12 09:56:17 +08:00
Xubin RenandGitHub a70928cc5c Merge PR #3045: fix(agent): preserve tool results on fatal error to prevent orphan tool_calls
fix(agent): preserve tool results on fatal error to prevent orphan tool_calls (#2943)
2026-04-11 23:08:03 +08:00
laylaandGitHub f25cdb7138 Merge branch 'main' into fix/tool-call-result-order-2943 2026-04-11 22:00:07 +08:00
04cb 4cd4ed8ada fix(agent): preserve tool results on fatal error to prevent orphan tool_calls (#2943) 2026-04-11 21:50:44 +08:00
chengyongruandXubin Ren 9f433cab01 fix(wecom): use reply_stream for progress messages to avoid errcode=40008
The plain reply() uses cmd="reply" which does not support "text" msgtype
and causes WeCom API to return errcode=40008 (invalid message type).
Unify both progress and final text messages to use reply_stream()
(cmd="aibot_respond_msg"), differentiating via finish flag.

Fixes #2999
2026-04-11 21:47:19 +08:00
chengyongruandXubin Ren 0d03f10fa0 test(channels): add media support tests for QQ and WeCom channels
Cover helpers (sanitize_filename, guess media type), outbound send
(exception handling, media-then-text order, fallback), inbound message
processing (attachments, dedup, empty content), _post_base64file
payload filtering, and WeCom upload/download flows.
2026-04-11 21:47:19 +08:00
chengyongruandXubin Ren f6f712a2ae fix(wecom): harden upload/download, extract media type helper
- Use asyncio.to_thread for file I/O to avoid blocking event loop
- Add 200MB upload size limit with early rejection
- Fix file handle leak by using context manager
- Use memoryview for upload chunking to reduce peak memory
- Add inbound download size check to prevent OOM
- Use asyncio.to_thread for write_bytes in download path
- Extract inline media_type detection to _guess_wecom_media_type()
2026-04-11 21:47:19 +08:00
chengyongruandXubin Ren f900e4f259 fix(wecom): harden upload and inbound media handling
- Use asyncio.to_thread for file I/O to avoid blocking event loop
- Add 200MB upload size limit with early rejection
- Fix file handle leak by using context manager
- Free raw bytes early after chunking to reduce memory pressure
- Add file attachments to media_paths (was text-only, inconsistent with image)
- Use robust _sanitize_filename() instead of os.path.basename() for path safety
- Remove re-raise in send() for consistency with QQ channel
- Fix truncated media_id logging for short IDs
2026-04-11 21:47:19 +08:00
gem12andXubin Ren 48f6bbd256 feat(channels): Add full media support for QQ and WeCom channels
QQ channel improvements (on top of nightly):
- Add top-level try/except in _on_message and send() for resilience
- Use defensive getattr() for attachment attributes (botpy version compat)
- Skip file_name for image uploads to avoid QQ rendering as file attachment
- Extract only file_info from upload response to avoid extra fields
- Handle protocol-relative URLs (//...) in attachment downloads

WeCom channel improvements:
- Add _upload_media_ws() for WebSocket 3-step media upload protocol
- Send media files (image/video/voice/file) via WeCom rich media API
- Support progress messages (plain reply) vs final response (streaming)
- Support proactive send when no frame available (cron push)
- Pass media_paths to message bus for downstream processing
2026-04-11 21:47:19 +08:00
Xubin RenandXubin Ren cf8381f517 feat(agent): enhance message injection handling and content merging 2026-04-11 21:43:23 +08:00
Xubin RenandXubin Ren f6c39ec946 feat(agent): enhance session key handling for follow-up messages 2026-04-11 21:43:23 +08:00
chengyongruandXubin Ren 36d2a11e73 feat(agent): mid-turn message injection for responsive follow-ups (#2985)
* feat(agent): add mid-turn message injection for responsive follow-ups

Allow user messages sent during an active agent turn to be injected
into the running LLM context instead of being queued behind a
per-session lock. Inspired by Claude Code's mid-turn queue drain
mechanism (query.ts:1547-1643).

Key design decisions:
- Messages are injected as natural user messages between iterations,
  no tool cancellation or special system prompt needed
- Two drain checkpoints: after tool execution and after final LLM
  response ("last-mile" to prevent dropping late arrivals)
- Bounded by MAX_INJECTION_CYCLES (5) to prevent consuming the
  iteration budget on rapid follow-ups
- had_injections flag bypasses _sent_in_turn suppression so follow-up
  responses are always delivered

Closes #1609

* fix(agent): harden mid-turn injection with streaming fix, bounded queue, and message safety

- Fix streaming protocol violation: Checkpoint 2 now checks for injections
  BEFORE calling on_stream_end, passing resuming=True when injections found
  so streaming channels (Feishu) don't prematurely finalize the card
- Bound pending queue to maxsize=20 with QueueFull handling
- Add warning log when injection batch exceeds _MAX_INJECTIONS_PER_TURN
- Re-publish leftover queue messages to bus in _dispatch finally block to
  prevent silent message loss on early exit (max_iterations, tool_error, cancel)
- Fix PEP 8 blank line before dataclass and logger.info indentation
- Add 12 new tests covering drain, checkpoints, cycle cap, queue routing,
  cleanup, and leftover re-publish
2026-04-11 21:43:23 +08:00
Jiajun XieandXubin Ren f5640d69fe fix(feishu): improve voice message download with detailed logging
- Add explicit error logging for missing file_key and message_id
- Add logging for download failures
- Change audio extension from .opus to .ogg for better Whisper compatibility
- Feishu voice messages are opus in OGG container; .ogg is more widely recognized
2026-04-11 20:48:35 +08:00
Xubin RenandGitHub e0b9edf985 Merge PR #3017: feat(tool): improve file editing and add notebook tool
feat(tool): improve file editing and add notebook tool
2026-04-11 18:02:25 +08:00
Xubin RenandGitHub e7bbbe98f4 Merge PR #3019: fix(mcp): support multiple MCP servers
fix(mcp): support multiple MCP servers
2026-04-11 17:35:47 +08:00
Xubin Ren 322142f7ad Merge origin/main into main 2026-04-11 09:32:05 +00:00
Xubin RenandXubin Ren b959ae6d89 test(web): cover Kagi search provider
Add focused coverage for the Kagi web search provider, including the request format and the DuckDuckGo fallback when no API key is configured.
2026-04-11 16:53:05 +08:00
Mike TerharandXubin Ren 74dbce3770 add kagi info to README 2026-04-11 16:53:05 +08:00
Mike TerharandXubin Ren d3aa209cf6 add kagi web search tool 2026-04-11 16:53:05 +08:00
Xubin Ren 5bb7f77b80 feat(tests): add regression test for timer execution to prevent store rollback during job execution 2026-04-11 08:43:25 +00:00
Xubin RenandGitHub 1263869c0a Merge PR #3038: fix(cron): guard _load_store against reentrant reload during job execution
fix(cron): guard _load_store against reentrant reload during job execution
2026-04-11 16:28:47 +08:00
Xubin Ren 8fe8537505 Merge origin/main into fix/cron-reentrant-load-store 2026-04-11 08:25:47 +00:00
weitongtongandXubin Ren e0ba568089 fix(cron): 修复固定间隔任务因 store 并发替换导致的重复执行
_on_timer 中 await _execute_job 让出控制权期间,前端轮询触发的
list_jobs 调用 _load_store 从磁盘重新加载覆盖 self._store,
已执行任务的状态被旧值回退,导致再次触发。
引入 _timer_active 标志位,在任务执行期间阻止并发 _load_store
替换 store。同时修复 store 为空时未重新 arm timer 的问题。

Made-with: Cursor
2026-04-11 16:15:01 +08:00
Xubin RenandXubin Ren 5932482d01 refactor(agent): rename auto compact module
Rename the auto compact module to autocompact.py for a cleaner path while keeping the AutoCompact type and behavior unchanged. Update the agent loop import to match.
2026-04-11 15:56:41 +08:00
Xubin RenandXubin Ren 84e840659a refactor(config): rename auto compact config key
Prefer the more user-friendly idleCompactAfterMinutes name for auto compact while keeping sessionTtlMinutes as a backward-compatible alias. Update tests and README to document the retained recent-context behavior and the new preferred key.
2026-04-11 15:56:41 +08:00
Xubin RenandXubin Ren 1cb28b39a3 feat(agent): retain recent context during auto compact
Keep a legal recent suffix in idle auto-compacted sessions so resumed chats preserve their freshest live context while older messages are summarized. Recover persisted summaries even when retained messages remain, and document the new behavior.
2026-04-11 15:56:41 +08:00
chengyongruandXubin Ren d03458f034 fix(agent): eliminate race condition in auto compact summary retrieval
Make Consolidator.archive() return the summary string directly instead
of writing to history.jsonl then reading back via get_last_history_entry().
This eliminates a race condition where concurrent _archive calls for
different sessions could read each other's summaries from the shared
history file (cross-user context leak in multi-user deployments).

Also removes Consolidator.get_last_history_entry() — no longer needed.
2026-04-11 15:56:41 +08:00
chengyongruandXubin Ren 69d60e2b06 fix(agent): handle UnicodeDecodeError in _read_last_entry
history.jsonl may contain non-UTF-8 bytes (e.g. from email channel
binary content), causing auto compact to fail when reading the last
entry for summary generation. Catch UnicodeDecodeError alongside
FileNotFoundError and JSONDecodeError.
2026-04-11 15:56:41 +08:00
chengyongruandXubin Ren fb6dd111e1 feat(agent): auto compact — proactive session compression to reduce token cost and latency (#2982)
When a user is idle for longer than a configured TTL, nanobot **proactively** compresses the session context into a summary. 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 and fresh input.
2026-04-11 15:56:41 +08:00
Daniel PhangandClaude Opus 4.6 b52bfddf16 fix(cron): guard _load_store against reentrant reload during job execution
When on_job callbacks call list_jobs() (which triggers _load_store),
the in-memory state is reloaded from disk, discarding the next_run_at_ms
updates that _on_timer is actively computing. This causes jobs to
re-trigger indefinitely on the next tick.

Add an _executing flag around the job execution loop. While set,
_load_store returns the cached store instead of reloading from disk.

Includes regression test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:34:48 -07:00
04cbandXubin Ren e392c27f7e fix(utils): anchor unclosed think-tag regex to string start (#3004) 2026-04-11 13:46:15 +08:00
Xubin Ren 696b64b5a6 fix(notebook): remove unused imports
Clean up unused imports in notebook_edit so the Ruff F401 check passes cleanly.

Made-with: Cursor
2026-04-10 16:02:00 +00:00
worenidewen a167959027 fix(mcp): support multiple MCP servers by connecting each in isolated task
Each MCP server now connects in its own asyncio.Task to isolate anyio
cancel scopes and prevent 'exit cancel scope in different task' errors
when multiple servers (especially mixed transport types) are configured.

Changes:
- connect_mcp_servers() returns dict[str, AsyncExitStack] instead of None
- Each server runs in separate task via asyncio.gather()
- AgentLoop uses _mcp_stacks dict to track per-server stacks
- Tests updated to handle new API
2026-04-10 23:51:50 +08:00
Xubin Ren 651aeae656 improve file editing and add notebook tool
Enhance file tools with read tracking, PDF support, safer path handling,
smarter edit matching/diagnostics, and introduce notebook_edit with tests.
2026-04-10 15:44:50 +00:00
Xubin RenandXubin Ren 9bccfa63d2 fix test: use async/await for run_job, add sentinel coverage
Made-with: Cursor
2026-04-10 19:03:13 +08:00
weitongtongandXubin Ren 1a51f907aa feat(cron): 添加 CronService.update_job 方法
支持更新已有定时任务的名称、调度计划、消息内容、投递配置等可变字段。
系统任务(system_event)受保护不可编辑。包含完整的单元测试覆盖。

Made-with: Cursor
2026-04-10 19:03:13 +08:00
zhangxiaoyu.yorkandXubin Ren e7e1249585 fix(agent): avoid truncate_text name shadowing
Rename the boolean flag in _sanitize_persisted_blocks and alias the imported helper so session persistence cannot crash with TypeError when truncation is enabled.
2026-04-10 17:36:31 +08:00
Xubin Ren 2bef9cb650 fix(agent): preserve interrupted tool-call turns
Keep tool-call assistant messages valid across provider sanitization and avoid trailing user-only history after model errors. This prevents follow-up requests from sending broken tool chains back to the gateway.
2026-04-10 05:37:25 +00:00
Xubin RenandXubin Ren c579d67887 fix(memory): preserve consolidation turn boundaries under chunk cap
Made-with: Cursor
2026-04-10 12:58:58 +08:00
comadrejaandXubin Ren bfe53ebb10 fix(memory): harden consolidation with try/except on token estimation and chunk size cap
- Wrap both token estimation calls in try/except to prevent silent failures
  from crashing the consolidation cycle
- Add _MAX_CHUNK_MESSAGES = 60 to cap messages per consolidation round,
  avoiding oversized chunks being sent to the consolidation LLM
- Improve idle log to include unconsolidated message count for easier debugging

These are purely defensive improvements with no behaviour change for
normal sessions.
2026-04-10 12:58:58 +08:00
Xubin Ren 363a0704db refactor(runner): update message processing to preserve historical context
- Adjusted message handling in AgentRunner to ensure that historical messages remain unchanged during context governance.
- Introduced tests to verify that backfill operations do not alter the saved message boundary, maintaining the integrity of the conversation history.
2026-04-10 04:46:48 +00:00
chengyongruandXubin Ren 27e7a338a3 docs(feishu): add toolHintPrefix to README config example 2026-04-10 12:29:43 +08:00
chengyongruandXubin Ren 6fd2511c8a refactor(feishu): simplify tool hint to append-only, delegate to send_delta for throttling
- Make tool_hint_prefix configurable in FeishuConfig (default: 🔧)
- Delegate tool hint card updates from send() to send_delta() so hints
  automatically benefit from _STREAM_EDIT_INTERVAL throttling
- Fix staticmethod calls to use self.__class__ instead of self
- Document all supported metadata keys in send_delta docstring
- Add test for empty/whitespace-only tool hint with active stream buffer
2026-04-10 12:29:43 +08:00
xzq.xuandXubin Ren 049ce9baae fix(tool-hints): deduplicate by formatted string + per-line inline display
Two display fixes based on real-world Feishu testing:

1. tool_hints.py: format_tool_hints now deduplicates by comparing the
   fully formatted hint string instead of tool name alone. This fixes
   `ls /Desktop` and `ls /Downloads` being incorrectly merged as
   `ls /Desktop × 2`. Truly identical calls still fold correctly.
   (_group_consecutive and all abbreviation logic preserved unchanged.)

2. feishu.py: inline tool hints now display one tool per line with
   🔧 prefix, and use double-newline trailing to prevent Setext heading
   rendering when followed by markdown `---`.

Made-with: Cursor
2026-04-10 12:29:43 +08:00
xzq.xuandXubin Ren 512c3b88e3 fix(feishu): preserve tool hints in final card content
Tool hints should be kept as permanent content in the streaming card
so users can see which tools were called (matching the standalone card
behavior). Previously, hints were stripped when new deltas arrived or
when the stream ended, causing tool call information to disappear.

Now:
- New delta: hint becomes permanent content, delta appends after it
- New tool hint: replaces the previous hint (unchanged)
- Resuming/stream_end: hint is preserved in the final text

Updated 3 tests to verify hint preservation semantics.

Made-with: Cursor
2026-04-10 12:29:43 +08:00
xzq.xuandXubin Ren 589e3ac36e fix(feishu): prevent tool hint stacking and clean hints on stream_end
Three fixes for inline tool hints:

1. Consecutive tool hints now replace the previous one instead of
   stacking — the old suffix is stripped before appending the new one.

2. When _resuming flushes the buffer, any trailing tool hint suffix
   is removed so it doesn't persist into the next streaming segment.

3. When final _stream_end closes the card, tool hint suffix is
   cleaned from the text before the final card update.

Adds 3 regression tests covering all three scenarios.

Made-with: Cursor
2026-04-10 12:29:43 +08:00
xzq.xuandXubin Ren ac1795c158 feat(feishu): streaming resuming + inline tool hints
Two improvements to Feishu streaming card experience:

1. Handle _resuming in send_delta: when a mid-turn _stream_end arrives
   with resuming=True (tool call between segments), flush current text
   to the card but keep the buffer alive so subsequent segments append
   to the same card instead of creating a new one.

2. Inline tool hints into streaming cards: when a tool hint arrives
   while a streaming card is active, append it to the card content
   (e.g. "🔧 web_fetch(...)") instead of sending a separate card.
   The hint is automatically stripped when the next delta arrives.

Made-with: Cursor
2026-04-10 12:29:43 +08:00
JiajunandXubin Ren ce9829e92f feat(feishu): add done emoji support for reaction lifecycle (#2899)
* feat(feishu): add done emoji support for reaction lifecycle

* feat(feishu): add done emoji support and update documentation
2026-04-10 12:29:43 +08:00
chengyongruandXubin Ren e0c6e6f180 test: add regression tests for <thought> tag stripping 2026-04-10 12:10:23 +08:00
flobo3andXubin Ren 6b7e78a8e0 fix: strip <thought> blocks from Gemma 4 and similar models 2026-04-10 12:10:23 +08:00
Xubin RenandXubin Ren 69d748bf8f Merge origin/main; warn on partial proxy credentials; add only-password test
- Merged latest main (no conflicts)
- Added warning log when only one of proxy_username/proxy_password is set
- Added test_start_no_proxy_auth_when_only_password for coverage parity

Made-with: Cursor
2026-04-09 23:54:11 +08:00
JonasandXubin Ren 7506af7104 feat(channel): add proxy support for Discord channel
- Add proxy, proxy_username, proxy_password fields to DiscordConfig
- Pass proxy and proxy_auth to discord.Client
- Add aiohttp.BasicAuth when credentials are provided
- Add tests for proxy configuration scenarios
2026-04-09 23:54:11 +08:00
chenyahuiandXubin Ren 0e6331b66d feat(exec): support allowed_env_keys to pass specified env vars to subprocess
Add allowed_env_keys config field to selectively forward host environment variables (e.g. GOPATH, JAVA_HOME) into the sandboxed subprocess environment, while keeping the default allow-list unchanged.
2026-04-09 23:35:44 +08:00
Xubin RenandXubin Ren c625c0c2a7 Merge origin/main and add regression tests for streaming error delivery
- Merged latest main (no conflicts)
- Added test_llm_error_not_appended_to_session_messages: verifies error
  content stays out of session messages
- Added test_streamed_flag_not_set_on_llm_error: verifies _streamed is
  not set when LLM returns an error, so ChannelManager delivers it

Made-with: Cursor
2026-04-09 23:10:46 +08:00
yanghan-cyberandXubin Ren 10f6c875a5 fix(agent): deliver LLM errors to streaming channels and avoid polluting session context
When the LLM returns an error (e.g. 429 quota exceeded, stream timeout),
streaming channels silently drop the error message because `_streamed=True`
is set in metadata even though no content was actually streamed.

This change:
- Skips setting `_streamed` when stop_reason is "error", so error messages
  go through the normal channel.send() path and reach the user
- Stops appending error content to session history, preventing error
  messages from polluting subsequent conversation context
- Exposes stop_reason from _run_agent_loop to enable the above check
2026-04-09 23:10:46 +08:00
Xubin RenandXubin Ren ba8bce0f45 fix(tests): add missing from typing import Any in websocket integration tests
Made-with: Cursor
2026-04-09 18:22:35 +08:00
chengyongruandXubin Ren 42de13a1a9 docs(websocket): add WebSocket channel documentation
Comprehensive guide covering wire protocol, configuration reference,
token issuance, security notes, and common deployment patterns.
2026-04-09 18:22:35 +08:00
chengyongruandXubin Ren 56a5906db5 fix(websocket): harden security and robustness
- Use hmac.compare_digest for timing-safe static token comparison
- Add issued token capacity limit (_MAX_ISSUED_TOKENS=10000) with 429 response
- Use atomic pop in _take_issued_token_if_valid to eliminate TOCTOU window
- Enforce TLSv1.2 minimum version for SSL connections
- Extract _safe_send helper for consistent ConnectionClosed handling
- Move connection registration after ready send to prevent out-of-order delivery
- Add HTTP-level allow_from check and client_id truncation in process_request
- Make stop() idempotent with graceful shutdown error handling
- Normalize path via validator instead of leaving raw value
- Default websocket_requires_token to True for secure-by-default behavior
- Add integration tests and ws_test_client helper
- Refactor tests to use shared _ch factory and bus fixture
2026-04-09 18:22:35 +08:00
chengyongruandXubin Ren e0ccc401c0 fix(websocket): handle ConnectionClosed gracefully in send and send_delta 2026-04-09 18:22:35 +08:00
Jack LuandXubin Ren ad57bcd127 feat(channels): add WebSocket server channel and tests
Port Python implementation from a1ec7b192a
(websocket channel module and channel tests; excludes webui debug app).
2026-04-09 18:22:35 +08:00
chenyahui e9c4fe6824 feat(skills): add disabled_skills config to exclude skills from loading
Introduce a disabled_skills option in the config schema that allows
users to specify a list of skill names to be excluded. The setting is
threaded from config through Nanobot -> AgentLoop -> ContextBuilder ->
SkillsLoader. Disabled skills are filtered out from list_skills,
get_always_skills, and build_skills_summary. Four new test cases cover
the filtering behavior.
2026-04-09 14:11:47 +08:00
Xubin RenandGitHub 3361ac9dd1 Merge PR #2637: fix(providers): enforce role alternation for non-Claude providers
fix(providers): enforce role alternation for non-Claude providers
2026-04-09 12:47:41 +08:00
Xubin Ren dadf453097 Merge origin/main into fix/sanitize-messages-non-claude
Resolved conflict in azure_openai_provider.py by keeping main's
Responses API implementation (role alternation not needed for the
Responses API input format).

Made-with: Cursor
2026-04-09 04:45:45 +00:00
彭星杰andXubin Ren 1e3057d0d6 fix(cli): remove default green style from Enabled column in tables
The Enabled column in channels status and plugins list commands had a default green style that overrode the dim markup for disabled items. This caused no values to appear green instead of dimmed. Remove the default style to let cell-level markup control the display correctly.
2026-04-09 11:52:31 +08:00
Alfredo ArenasandXubin Ren 6445b3b0cf fix(helpers): repair corrupted split_message and ensure content never None
Fix accidental line corruption in split_message() where 'break' was
merged with unrelated code during manual editing.

The actual fix: build_assistant_message() now returns content or ""
instead of content (which could be None), preventing providers like
MiMo V2 Omni from rejecting tool-call messages with missing text field.

Fixes #2519
2026-04-09 11:42:53 +08:00
Alfredo ArenasandXubin Ren 6d74c88014 fix(helpers): ensure assistant message content is never None 2026-04-09 11:42:53 +08:00
Xubin Ren 1dd2d5486e docs: add unified session configuration to README for cross-channel continuity 2026-04-09 03:15:21 +00:00
Xubin RenandXubin Ren cf02408fc0 Merge origin/main; remove stale comment and fix blank-line style
Made-with: Cursor
2026-04-09 11:09:25 +08:00
whsandXubin Ren be1b34ed7c fix: remove unused import re 2026-04-09 11:09:25 +08:00
whsandXubin Ren b4c7cd654e fix: use effective session key for _active_tasks in unified mode 2026-04-09 11:09:25 +08:00
whsandXubin Ren 985f9c443b tests: add unified_session coverage for /new and consolidation 2026-04-09 11:09:25 +08:00
whsandXubin Ren 743e73da3f feat(session): add unified_session config to share one session across all channels 2026-04-09 11:09:25 +08:00
chenspandXubin Ren bfec06a2c1 Fix Windows exec env for Docker Desktop plugin discovery
nanobot's Windows exec environment was not forwarding ProgramFiles and related variables, so docker desktop start could not discover the desktop CLI plugin and reported unknown command. Forward the missing variables and add a regression test that covers the Windows env shape.
2026-04-09 10:55:53 +08:00
Rohit_Dayanand123andXubin Ren 3cc2ebeef7 Added bug fix to Dingtalk by zipping html to prevent raw failure 2026-04-09 10:49:00 +08:00
Leo fuandXubin Ren 42624f5bf3 test: update expected token display to match consistent 1000 divisor
The test fixtures use 65536 as context_window_tokens. With the divisor
corrected from 1024 to 1000, the display changes from 64k to 65k.
2026-04-09 10:40:20 +08:00
Leo fuandXubin Ren 66409784f4 fix(status): use consistent divisor (1000) for token count display
The /status command divided context_used by 1000 but context_total by
1024, producing inconsistent values. For example a 128000-token window
displayed as 125k instead of 128k. Tokens are not a binary unit, so
both should use 1000.
2026-04-09 10:40:20 +08:00
Xubin RenandXubin Ren 61dd5ac13a test(discord): cover streamed reply overflow
Lock the Discord streaming path with a regression test for final chunk splitting so oversized replies stay safe to merge and ship.

Made-with: Cursor
2026-04-09 00:24:11 +08:00
SHLE1andXubin Ren e49b6c0c96 fix(discord): enable streaming replies 2026-04-09 00:24:11 +08:00
Xubin RenandXubin Ren 715f2a79be fix(version): fall back to pyproject in source checkouts
Keep importlib.metadata as the primary source for installed packages, but avoid PackageNotFoundError when nanobot is imported directly from a source tree.

Made-with: Cursor
2026-04-08 23:47:36 +08:00
bahtyaandXubin Ren 1700166945 fix: use importlib.metadata for version to prevent mismatch with pyproject.toml
Fixes #2856

Previously __version__ was hardcoded as '0.4.1' in __init__.py while
pyproject.toml declared version '0.1.5'. This caused nanobot gateway to
report version 0.4.1 on startup while pip showed 0.1.5.

Now __version__ reads from importlib.metadata.version('nanobot-ai'),
keeping pyproject.toml as the single source of truth.
2026-04-08 23:47:36 +08:00
Xubin RenandXubin Ren 6bf101c79b fix(hook): keep composite hooks backward compatible
Avoid AttributeError regressions when hooks define their own __init__ or when a CompositeHook wraps another composite.

Made-with: Cursor
2026-04-08 23:41:31 +08:00
Lingao MengandXubin Ren d88be08bfd refactor(hook): add reraise flag to AgentHook and remove _LoopHookChain
Add reraise parameter to AgentHook so hooks can opt out of exception
swallowing in CompositeHook._for_each_hook_safe. _LoopHook sets
reraise=True to let its exceptions propagate. _LoopHookChain is removed
and replaced with CompositeHook([loop_hook] + extra_hooks).

Signed-off-by: Lingao Meng <menglingao@xiaomi.com>
2026-04-08 23:41:31 +08:00
Xubin RenandXubin Ren 142cb46956 fix(cron): preserve manual run state and merged history
Keep manual runs from flipping the scheduler's running flag, rebuild merged run history records from action logs, and avoid delaying sub-second jobs to a one-second floor. Add regression coverage for disabled/manual runs, merged history persistence, and sub-second timers.

Made-with: Cursor
2026-04-08 23:34:47 +08:00
xinnan.houandXubin Ren 0f1e3aa151 fix 2026-04-08 23:34:47 +08:00
Xubin Ren d084d10dc2 feat(openai): auto-route direct reasoning requests with responses fallback 2026-04-08 15:21:08 +00:00
Xubin RenandXubin Ren c092896922 fix(tool-hint): handle quoted paths in exec hints
Preserve path folding for quoted exec command paths with spaces so hint previews do not fall back to mid-path truncation. Add regression coverage for quoted Unix and Windows path cases.

Made-with: Cursor
2026-04-08 23:05:52 +08:00
b16865722b fix(tool-hint): fold paths in exec commands and deduplicate by formatted string
1. exec tool hints previously used val[:40] blind character truncation,
   cutting paths mid-segment. Now detects file paths via regex and
   abbreviates them with abbreviate_path. Supports Windows, Unix
   absolute, and ~/ home paths.

2. Deduplication now compares fully formatted hint strings instead of
   tool names alone. Fixes ls /Desktop and ls /Downloads being
   incorrectly merged as "ls /Desktop × 2".

Co-authored-by: xzq.xu <zhiqiang.xu@nodeskai.com>
2026-04-08 23:05:52 +08:00
stutiredboyandXubin Ren af6c75141f feat(): telegram support stream edit interval 2026-04-08 22:49:33 +08:00
dengjingren a068df5a79 feat(api): support file uploads via JSON base64 and multipart/form-data 2026-04-08 15:58:52 +08:00
kronk307andXubin Ren e21ba5f667 feat(telegram): add location/geo support
Forward static location pins as [location: lat, lon] content so the
agent can respond to geo messages and pass coordinates to MCP tools.

Closes HKUDS/nanobot#2909
2026-04-08 02:32:19 +08:00
Xubin RenandXubin Ren c7d10de253 feat(soul): restore friendly and curious tone to SOUL.md
Made-with: Cursor
2026-04-08 02:22:25 +08:00
Xubin RenandXubin Ren edb821e10d feat(agent): prompt behavior directives, tool descriptions, and loop robustness 2026-04-08 02:22:25 +08:00
Xubin RenandXubin Ren ef0284a4e0 fix(exec): add Windows support for shell command execution
ExecTool hardcoded bash, breaking exec on Windows. Now uses cmd.exe
via COMSPEC on Windows with a curated minimal env (PATH, SYSTEMROOT,
etc.) that excludes secrets. bwrap sandbox gracefully skips on Windows.
2026-04-08 01:48:55 +08:00
Xubin RenandXubin Ren 63acfc4f2f test: fix trailing-space mismatch and add regression tests for normal models
- Fix assertion in streaming dict fallback test (trailing space in data
  not reflected in expected value).
- Add two regression tests proving that models with reasoning_content
  (e.g. DeepSeek-R1) and standard models (no reasoning fields) are
  completely unaffected by the reasoning fallback.

Made-with: Cursor
2026-04-08 00:59:39 +08:00
moranfongandXubin Ren 12ff8b22d6 fix(provider): extend StepFun reasoning fallback to all code paths
- Add reasoning_content fallback from reasoning in _parse dict branch
- Add content fallback from msg.reasoning in _parse SDK object branch
- Add reasoning_content fallback in _parse SDK object branch
- Add reasoning fallback in _parse_chunks dict branch
- Add reasoning fallback in _parse_chunks SDK object branch

This ensures StepFun Plan API works correctly in both streaming and
non-streaming modes, for both dict and SDK object response formats.
2026-04-08 00:59:39 +08:00
moranfongandXubin Ren 9e7c07ac89 test(provider): add StepFun reasoning field fallback tests
Add comprehensive tests for the StepFun Plan API compatibility fix:
- _parse dict branch: content and reasoning_content fallback to reasoning
- _parse SDK object branch: same fallback for pydantic response objects
- _parse_chunks dict branch: reasoning field handled in streaming mode
- _parse_chunks SDK branch: reasoning fallback for SDK delta objects
- Precedence tests: reasoning_content field takes priority over reasoning

Refs: fix(provider): support StepFun Plan API reasoning field fallback
2026-04-08 00:59:39 +08:00
moranfongandXubin Ren 53107c6683 fix(provider): support StepFun Plan API reasoning field fallback
StepFun Plan API returns response content in the 'reasoning' field when
the model is in thinking mode and 'content' is empty. OpenAICompatProvider
previously only checked 'content' and 'reasoning_content', missing this field.

This patch adds a fallback: if content is empty and 'reasoning' is present,
extract text from reasoning to populate content, ensuring StepFun models
(step-3.5-flash, step-3.5-flash-2603) work correctly with tool calls.

Co-authored-by: moranfong <moranfong@gmail.com>
2026-04-08 00:59:39 +08:00
Xubin RenandXubin Ren c736cecc28 chore(gitignore): remove bogus extensions and relocate nano.*.save
- Drop *.pycs, *.pywz, *.pyzz — not real Python file extensions.
- Move nano.*.save from "Project-specific" to "Editors & IDEs" where
  it belongs (nano editor backup files, not project artifacts).

Made-with: Cursor
2026-04-08 00:42:25 +08:00
Jack LuandXubin Ren 873bf5e692 chore: update .gitignore to include additional project-specific, build, test, and environment files 2026-04-08 00:42:25 +08:00
Xubin RenandXubin Ren 8871a57b4c fix(mcp): forward prompt arg descriptions & standardise error format
- Propagate `description` from MCP prompt arguments into the JSON
  Schema so LLMs can better understand prompt parameters.
- Align generic-exception error message with tool/resource wrappers
  (drop redundant `{exc}` detail).
- Extend test fixture to mock `mcp.shared.exceptions.McpError`.
- Add tests for argument description forwarding and McpError handling.

Made-with: Cursor
2026-04-08 00:28:04 +08:00
Tim O'BrienandXubin Ren 7cc527cf65 feat(mcp): expose MCP resources and prompts as read-only tools
Add MCPResourceWrapper and MCPPromptWrapper classes that expose MCP
server resources and prompts as nanobot tools. Resources are read-only
tools that fetch content by URI, and prompts are read-only tools that
return filled prompt templates with optional arguments.

- MCPResourceWrapper: reads resource content (text and binary) via URI
- MCPPromptWrapper: gets prompt templates with typed arguments
- Both handle timeouts, cancellation, and MCP SDK 1.x error types
- Resources and prompts are registered during server connection
- Gracefully handles servers that don't support resources/prompts
2026-04-08 00:28:04 +08:00
Xubin RenandXubin Ren ce7986e492 fix(memory): add timestamp and cap to recent history injection 2026-04-08 00:03:11 +08:00
Xubin RenandXubin Ren 05d8062c70 test: add regression tests for unprocessed history injection in system prompt
Made-with: Cursor
2026-04-07 23:41:05 +08:00
Lingao MengandXubin Ren 31c154a7b8 fix(memory): prevent potential loss of compressed session history
When the Consolidator compresses old session messages into history.jsonl,
those messages are immediately removed from the LLM's context. Dream
processes history.jsonl into long-term memory (memory.md) on a cron
schedule (default every 2h), creating a window where compressed content
is invisible to the LLM.

This change closes the gap by injecting unprocessed history entries
(history.jsonl entries not yet consumed by Dream) directly into the
system prompt as "# Recent History".

Key design notes:
- Uses read_unprocessed_history(since_cursor=last_dream_cursor) so only
  entries not yet reflected in long-term memory are included, avoiding
  duplication with memory.md
- No overlap with session messages: Consolidator advances
  last_consolidated before returning, so archived messages are already
  removed from get_history() output
- Token-safe: Consolidator's estimate_session_prompt_tokens calls
  build_system_prompt via the same build_messages function, so the
  injected entries are included in token budget calculations and will
  trigger further consolidation if needed

Signed-off-by: Lingao Meng <menglingao@xiaomi.com>
2026-04-07 23:41:05 +08:00
Xubin RenandXubin Ren acafcf3cb0 docs: fix inaccurate claim about supports_streaming and dict config
supports_streaming already handles dict configs via isinstance check;
only is_allowed() fails with plain dicts. Narrow the explanation.

Made-with: Cursor
2026-04-07 23:01:30 +08:00
invictusandXubin Ren 4648cb9e87 docs: use model_dump(by_alias=True) for default_config in plugin guide 2026-04-07 23:01:30 +08:00
invictusandXubin Ren 83ad013be5 docs: fix channel plugin guide — require Pydantic config model 2026-04-07 23:01:30 +08:00
Xubin RenandXubin Ren 1e8a6663ca test(anthropic): add regression tests for thinking modes incl. adaptive
Also update schema comment to mention 'adaptive' as a valid value.

Made-with: Cursor
2026-04-07 22:53:43 +08:00
Balor.LC3andXubin Ren 1c2f4aba17 feat(anthropic): add adaptive thinking mode
Extends reasoning_effort to accept 'adaptive' in addition to
low/medium/high. When set, uses Anthropic's type: 'adaptive'
thinking API instead of a fixed budget, letting the model decide
when and how much to think per turn.

Also auto-enables interleaved thinking between tool calls on
claude-sonnet-4-6 and claude-opus-4-6.

Usage:
  "reasoning_effort": "adaptive" in agents.defaults config
2026-04-07 22:53:43 +08:00
423aab09dd test(cron): add regression test for running service picking up external adds
Co-authored-by: chengyongru
Made-with: Cursor
2026-04-07 22:48:40 +08:00
xinnan.houandXubin Ren a982d9f9be add reload jobs test 2026-04-07 22:48:40 +08:00
xinnan.houandXubin Ren fd2bb3bb7d fix comment 2026-04-07 22:48:40 +08:00
xinnan.houandXubin Ren 4e914d0e2a fix not reload job config 2026-04-07 22:48:40 +08:00
chengyongruandXubin Ren b4f985f3dc feat(memory):dream enhancement (#2887)
* feat(dream): enhance memory cleanup with staleness detection

- Phase 1: add [FILE-REMOVE] directive and staleness patterns (14-day
  threshold, completed tasks, superseded info, resolved tracking)
- Phase 2: add explicit cleanup rules, file paths section, and deletion
  guidance to prevent LLM path confusion
- Inject current date and file sizes into Phase 1 context for age-aware
  analysis
- Add _dream_debug() helper for observability (dream-debug.log in workspace)
- Log Phase 1 analysis output and Phase 2 tool events for debugging

Tested with glm-5-turbo: MEMORY.md reduced from 149 to 108-129 lines
across two rounds, correctly identifying and removing weather data,
detailed incident info, completed research, and stale discussions.

* refactor(dream): replace _dream_debug file logger with loguru

Remove the custom _dream_debug() helper that wrote to dream-debug.log
and use the existing loguru logger instead. Phase 1 analysis is logged
at debug level, tool events at info level — consistent with the rest
of the codebase and no extra log file to manage.

* fix(dream): make stale scan independent of conversation history

Reframe Phase 1 from a single comparison task to two independent
tasks: history diff AND proactive stale scan. The LLM was skipping
stale content that wasn't referenced in conversation history (e.g.
old triage snapshots). Now explicitly requires scanning memory files
for staleness patterns on every run.

* fix(dream): correct old_text param name and truncate debug log

- Phase 2 prompt: old_string -> old_text to match EditFileTool interface
- Phase 1 debug log: truncate analysis to 500 chars to avoid oversized lines

* refactor(dream): streamline prompts by separating concerns

Phase 1 owns all staleness judgment logic; Phase 2 is pure execution
guidance. Remove duplicated cleanup rules from Phase 2 since Phase 1
already determines what to add/remove. Fix remaining old_string -> old_text.
Total prompt size reduced ~45% (870 -> 480 tokens).

* fix(dream): add FILE-REMOVE execution guidance to Phase 2 prompt

Phase 2 was only processing [FILE] additions and ignoring [FILE-REMOVE]
deletions after the cleanup rules were removed. Add explicit mapping:
[FILE] → add content, [FILE-REMOVE] → delete content.
2026-04-07 22:39:47 +08:00
Xubin RenandXubin Ren 82dec12f66 refactor: extract tool hint formatting to utils/tool_hints.py
- Move _tool_hint implementation from loop.py to nanobot/utils/tool_hints.py
- Keep thin delegation in AgentLoop._tool_hint for backward compat
- Update test imports to test format_tool_hints directly

Made-with: Cursor
2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren 3e3a7654f8 fix(agent): address code review findings for tool hint enhancement
- C1: Fix IndexError on empty list arguments via _get_args() helper
- I1: Remove redundant branch in _fmt_known
- I2: Export abbreviate_path from nanobot.utils.__init__
- I3: Fix _abbreviate_url negative-budget format consistency
- S1: Move FORMATS to class-level _TOOL_HINT_FORMATS constant
- S2: Add list_dir to FORMATS registry (ls path)
- G1-G5: Add tests for empty list args, None args, URL edge cases,
  mixed folding groups, and list_dir format
2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren b1d3c00deb test(feishu): add compatibility tests for new tool hint format 2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren 238a9303d0 test: update tool_hint assertion to match new format 2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren 8ca9960077 feat(agent): rewrite _tool_hint with registry, path abbreviation, and call folding 2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren f452af6c62 feat(utils): add abbreviate_path for smart path/URL truncation 2026-04-07 15:15:07 +08:00
Xubin RenandXubin Ren 02597c3ec9 fix(runner): silent retry on empty response before finalization 2026-04-07 15:03:41 +08:00
Xubin RenandXubin Ren 0355f20919 test: add regression tests for _resolve_mentions
7 tests covering: single mention, dual IDs, no-id skip, multiple mentions,
no mentions, empty text, and key-not-in-text edge case.

Made-with: Cursor
2026-04-07 14:03:55 +08:00
wudongxueandXubin Ren b3294f79aa fix(feishu): ensure access token is initialized before fetching bot open_id
The lark-oapi client requires token types to be explicitly configured
so that the SDK can obtain and attach the tenant_access_token to raw
requests. Without this, `_fetch_bot_open_id()` would fail with
"Missing access token for authorization" because the token had not
been provisioned at the time of the call.
2026-04-07 14:03:55 +08:00
wudongxueandXubin Ren 0291d1f716 feat: resolve mentions data 2026-04-07 14:03:55 +08:00
Xubin RenandXubin Ren 075bdd5c3c refactor: move SafeFileHistory to module level + add regression tests
- Promote _SafeFileHistory to module-level SafeFileHistory for testability
- Add 5 regression tests: surrogates, normal text, emoji, mixed CJK, multi-surrogates

Made-with: Cursor
2026-04-07 13:57:34 +08:00
bahtyaandXubin Ren 64bd7234b3 fix(cli): sanitize surrogate characters in prompt history to prevent UnicodeEncodeError
On Windows, certain Unicode input (emoji, mixed-script text, surrogate
pairs) causes prompt_toolkit's FileHistory to crash with
UnicodeEncodeError when writing the history file.

Fix: wrap FileHistory with a _SafeFileHistory subclass that sanitizes
surrogate characters before writing, replacing invalid sequences instead
of crashing.

Fixes #2846
2026-04-07 13:57:34 +08:00
flobo3andXubin Ren 67e6f8cc7a fix(docker): strip Windows CRLF from entrypoint.sh 2026-04-07 13:32:01 +08:00
Jiajun XieandXubin Ren 5ee96721f7 ci: add ruff lint check for unused imports and variables
Add CI step to detect unused imports (F401) and unused variables (F841)
with ruff. Clean up existing violations:

- Remove unused Consolidator import in agent/__init__.py
- Remove unused re import in agent/loop.py
- Remove unused Path import in channels/feishu.py
- Remove unused ContentRepositoryConfigError import in channels/matrix.py
- Remove unused field and CommandHandler imports in channels/telegram.py
- Remove unused exception variable in channels/weixin.py
2026-04-07 13:30:49 +08:00
04cbandXubin Ren f4904c4bdf fix(cron): add optional name parameter to separate job label from message (#2680) 2026-04-07 13:22:20 +08:00
Leo fuandXubin Ren 44c7992095 fix(filesystem): correct write success message from bytes to characters
len(content) counts Unicode code points, not UTF-8 bytes. For non-ASCII
content such as Chinese or emoji, the reported count would be lower than
the actual bytes written to disk, which is misleading to the agent.
2026-04-07 13:22:00 +08:00
bahtyaandXubin Ren cefeddab8e fix(matrix): correct e2eeEnabled camelCase alias mapping
The pydantic to_camel function generates 'e2EeEnabled' (treating 'ee'
as a word boundary) for the field 'e2ee_enabled'. Users writing
'e2eeEnabled' in their config get the default value instead.

Fix: add explicit alias='e2eeEnabled' to override the incorrect
auto-generated alias. Both 'e2eeEnabled' and 'e2ee_enabled' now work.

Fixes #2851
2026-04-07 13:20:55 +08:00
Xubin Ren bf459c7887 fix(docker): fix volume mount path and add permission error guidance 2026-04-06 13:15:40 +00:00
Xubin Ren 4dac0a8930 docs: update nanobot docs badge 2026-04-06 11:55:47 +00:00
Xubin Ren a30e84bfd1 docs: update v0.1.5 release news 2026-04-06 11:46:16 +00:00
Xubin Ren 6269876bc7 docs: update v0.1.5 release news 2026-04-06 11:45:37 +00:00
Xubin Ren bc2253c83f docs: update v0.1.5 release news 2026-04-06 11:45:08 +00:00
Xubin Ren b719da7400 fix(feishu): use RawRequest for bot info API 2026-04-06 11:39:23 +00:00
Xubin Ren 79234d237e chore: bump version to 0.1.5 2026-04-06 11:26:07 +00:00
Xubin Ren 1243c08745 docs: update news section 2026-04-06 11:22:20 +00:00
Xubin RenandXubin Ren dad9c07843 fix(tests): update Tavily usage tests to match actual API response shape
The _parse_tavily_usage implementation was updated to use the real
{account: {plan_usage, plan_limit, ...}} structure, but the tests
still used the old flat {used, limit, breakdown} format.

Made-with: Cursor
2026-04-06 19:17:55 +08:00
yanghan-cyberandXubin Ren e528e6dd96 fix(status): parse actual Tavily API response structure
The Tavily /usage endpoint returns a nested "account" object with
plan_usage/plan_limit/search_usage/etc fields, not the flat structure
with used/limit/breakdown that was assumed. This caused all usage
values to be None.
2026-04-06 19:17:55 +08:00
yanghan-cyberandXubin Ren 84f0571e0d fix(status): use correct AgentLoop attribute for web search config
The /status command tried to access web search config via
`loop.config.tools.web.search`, but AgentLoop has no `config` attribute.
This caused the search usage lookup to silently return None, so web
search provider usage was never displayed.

Fix: use `loop.web_config.search` which is the actual attribute
set during AgentLoop.__init__.
2026-04-06 19:17:55 +08:00
Xubin RenandGitHub f65f788ab1 Merge PR #2762: fix: make app-layer retry classification structured
fix: make app-layer retry classification structured (408/409/timeout/connection)
2026-04-06 16:47:49 +08:00
Xubin Ren 35f53a721d refactor: consolidate _parse_retry_after_headers into base class
Merge the three retry-after header parsers (base, OpenAI, Anthropic)
into a single _extract_retry_after_from_headers on LLMProvider that
handles retry-after-ms, case-insensitive lookup, and HTTP date.

Remove the per-provider _parse_retry_after_headers duplicates and
their now-unused email.utils / time imports. Add test for retry-after-ms.

Made-with: Cursor
2026-04-06 08:44:52 +00:00
Xubin Ren aeba9a23e6 refactor: remove dead _error_response wrapper in Anthropic provider
Fold _error_response back into _handle_error to match OpenAI/Azure
convention. Update all call sites and tests accordingly.

Made-with: Cursor
2026-04-06 08:35:02 +00:00
Xubin Ren b575aed20e Merge origin/main into fix/structured-retry-classification-main
Made-with: Cursor
2026-04-06 08:28:20 +00:00
Xubin RenandXubin Ren d108879b48 security: bind api port to localhost by default
Prevents accidental exposure to the public internet. Users who need
external access can change to 0.0.0.0:8900:8900 explicitly.

Made-with: Cursor
2026-04-06 16:20:20 +08:00
Xubin RenandXubin Ren 634261f07a fix: correct api-workspace path for non-root container user
The Dockerfile runs as user nanobot (HOME=/home/nanobot), not root.

Made-with: Cursor
2026-04-06 16:20:20 +08:00
d99331ad31 feat(docker): add nanobot-api service with isolated workspace
- Add nanobot-api service (OpenAI-compatible HTTP API on port 8900)
- Uses isolated workspace (/root/.nanobot/api-workspace) to avoid
  session/memory conflicts with nanobot-gateway

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 16:20:20 +08:00
Xubin RenandXubin Ren ebf29d87ae fix: include byteplus providers, guard None reasoning_effort, merge extra_body
- Add byteplus and byteplus_coding_plan to thinking param providers
- Only send extra_body when reasoning_effort is explicitly set
- Use setdefault().update() to avoid clobbering existing extra_body
- Add 7 regression tests for thinking params

Made-with: Cursor
2026-04-06 16:12:08 +08:00
PlayDustinDBandXubin Ren bd94454b91 feat(think): adjust thinking method for dashscope and modelark 2026-04-06 16:12:08 +08:00
Xubin RenandXubin Ren c0e161de23 docs: add attachment example to email config JSON
Made-with: Cursor
2026-04-06 15:09:44 +08:00
Xubin RenandXubin Ren b98a0aabfc style: fix stdlib import ordering in email.py
Made-with: Cursor
2026-04-06 15:09:44 +08:00
Ben LenartsandXubin Ren 0c4b1a4a0e docs(email): document attachment extraction options in README 2026-04-06 15:09:44 +08:00
Ben LenartsandXubin Ren d0527a8cf4 feat(email): add attachment extraction support
Save inbound email attachments to the media directory with configurable
MIME type filtering (glob patterns like "image/*"), per-attachment size
limits, and max attachment count. Filenames are sanitized to prevent
path traversal. Controlled by allowed_attachment_types — empty (default)
means disabled, non-empty enables extraction for matching types.
2026-04-06 15:09:44 +08:00
Xubin RenandGitHub 9174a85b4e Merge PR #2520: fix(telegram): split oversized final streamed replies
fix(telegram): split oversized final streamed replies
2026-04-06 14:41:00 +08:00
Xubin Ren bdec2637ae test: add regression test for oversized stream-end splitting
Made-with: Cursor
2026-04-06 06:39:23 +00:00
Xubin Ren 09ec9991e1 Merge remote-tracking branch 'origin/main' into pr-2520
Made-with: Cursor

# Conflicts:
#	nanobot/channels/telegram.py
2026-04-06 06:37:54 +00:00
Xubin RenandGitHub b92d54140d Merge PR #2449: fix: cron reminder notifications being suppressed
fix: cron reminder notifications being suppressed
2026-04-06 14:33:20 +08:00
Xubin Ren c9d4b7b905 Merge remote-tracking branch 'origin/main' into pr-2449
Made-with: Cursor

# Conflicts:
#	nanobot/utils/evaluator.py
2026-04-06 06:30:11 +00:00
Xubin RenandGitHub 219c9c6137 Merge PR #2531: fix(whatsapp): detect phone vs LID by JID suffix, not field name
fix(whatsapp): detect phone vs LID by JID suffix, not field name
2026-04-06 14:21:06 +08:00
Xubin Ren 897d5a7e58 test: add regression tests for JID suffix classification and LID cache
Made-with: Cursor
2026-04-06 06:19:06 +00:00
Xubin Ren 722ffe0654 Merge remote-tracking branch 'origin/main' into pr-2531
Made-with: Cursor

# Conflicts:
#	nanobot/channels/whatsapp.py
2026-04-06 06:17:56 +00:00
Xubin RenandGitHub 4c6a4321e0 Merge PR #2530: feat: unify voice message transcription via OpenAI/Groq Whisper
feat: unify voice message transcription via OpenAI/Groq Whisper
2026-04-06 14:16:09 +08:00
Xubin Ren 019eaff225 simplify: remove transcription fallback, respect explicit config
Configured provider is the only one used — no silent fallback.

Made-with: Cursor
2026-04-06 06:13:43 +00:00
Xubin Ren 3bf1fa5225 feat: auto-fallback to other transcription provider on failure
When the primary transcription provider fails (bad key, API error, etc.),
automatically try the other provider if its API key is available.

Made-with: Cursor
2026-04-06 06:10:08 +00:00
Xubin Ren 35dde8a30e refactor: unify voice transcription config across all channels
- Move transcriptionProvider to global channels config (not per-channel)
- ChannelManager auto-resolves API key from matching provider config
- BaseChannel gets transcription_provider attribute, no more getattr hack
- Remove redundant transcription fields from WhatsAppConfig
- Update README: document transcriptionProvider, update provider table

Made-with: Cursor
2026-04-06 06:07:30 +00:00
Xubin Ren 7b7a3e5748 fix: media_paths NameError, import order, add error logging and tests
- Move media_paths assignment before voice message handling to prevent
  NameError at runtime
- Fix broken import layout in transcription.py (httpx/loguru after class)
- Add error logging to OpenAITranscriptionProvider matching Groq style
- Add regression tests for voice transcription and no-media fallback

Made-with: Cursor
2026-04-06 06:01:14 +00:00
Xubin Ren 413740f585 Merge remote-tracking branch 'origin/main' into pr-2530
Made-with: Cursor

# Conflicts:
#	nanobot/channels/whatsapp.py
2026-04-06 05:59:31 +00:00
Xubin RenandXubin Ren 71061a0c82 fix: return on login failure, use loguru format strings, fix import order
- Add missing return after failed password login to prevent starting
  sync loop with no credentials
- Replace f-strings in logger calls with loguru {} placeholders
- Fix stdlib import order (asyncio before json)

Made-with: Cursor
2026-04-06 13:57:57 +08:00
Lim Ding WenandXubin Ren c40801c8f9 fix(matrix): fix e2ee authentication 2026-04-06 13:57:57 +08:00
Xubin RenandXubin Ren f82b5a1b02 fix: graceful fallback when langfuse is not installed
- Use import importlib.util (not bare importlib) for find_spec
- Warn and fall back to standard openai instead of crashing with
  ImportError when LANGFUSE_SECRET_KEY is set but langfuse is missing

Made-with: Cursor
2026-04-06 13:53:42 +08:00
lang07123andXubin Ren 4e06e12ab6 feat(provider): 添加 Langfuse 观测平台的集成支持
feat(provider): 添加 Langfuse 观测平台的集成支持
2026-04-06 13:53:42 +08:00
Xubin RenandXubin Ren c88d97c652 fix: fall back to heuristic when bot open_id fetch fails
If _fetch_bot_open_id returns None the exact-match path would silently
disable all @mention detection. Restore the old heuristic as a fallback.
Add 6 unit tests for _is_bot_mentioned covering both paths.

Made-with: Cursor
2026-04-06 13:49:38 +08:00
有泉andXubin Ren 1b368a33dc fix(feishu): match bot's own open_id in _is_bot_mentioned to prevent cross-bot false positives
Previously, _is_bot_mentioned used a heuristic (no user_id + open_id
prefix "ou_") which caused other bots in the same group to falsely
think they were mentioned. Now fetches the bot's own open_id via
GET /open-apis/bot/v3/info at startup and does an exact match.
2026-04-06 13:49:38 +08:00
Xubin RenandXubin Ren 424b9fc262 refactor: extract _kill_process helper to DRY timeout/cancel cleanup
Made-with: Cursor
2026-04-06 13:47:09 +08:00
Lingao MengandXubin Ren 0e617c32cd fix(shell): kill subprocess on CancelledError to prevent orphan processes
When an agent task is cancelled (e.g. via /stop), the ExecTool was only
handling TimeoutError but not CancelledError. This left the child process
running as an orphan. Now CancelledError also triggers process.kill() and
waitpid cleanup before re-raising.
2026-04-06 13:47:09 +08:00
Ben LenartsandXubin Ren 202938ae73 feat: support ${VAR} env var interpolation in config secrets
Allow config.json to reference environment variables via ${VAR_NAME}
syntax. Variables are resolved at runtime by resolve_config_env_vars(),
keeping the raw templates in the Pydantic model so save_config()
preserves them. This lets secrets live in a separate env file
(e.g. loaded by systemd EnvironmentFile=) instead of plain text
in config.json.
2026-04-06 13:43:26 +08:00
Xubin RenandXubin Ren 7ffd93f48d refactor: move search_usage to utils/searchusage, remove brave stub
- Rename agent/tools/search_usage.py → utils/searchusage.py
  (not an LLM tool, matches utils/ naming convention)
- Remove redundant _fetch_brave_usage — handled by else branch
- Move test to tests/utils/test_searchusage.py

Made-with: Cursor
2026-04-06 13:37:55 +08:00
whsandXubin Ren bc0ff7f214 feat(status): add web search provider usage to /status command 2026-04-06 13:37:55 +08:00
qixinboandXubin Ren b2e751f21b docs: another two places for renaming assitant to agent 2026-04-06 13:21:25 +08:00
Xubin RenandXubin Ren 28e0a76b80 fix: path_append must not clobber login shell PATH
Seeding PATH in the env before bash -l caused /etc/profile
to skip its default PATH setup, breaking standard commands.
Move path_append to an inline export so the login shell
establishes a proper base PATH first.

Add regression test: ls still works when path_append is set.

Made-with: Cursor
2026-04-06 13:20:53 +08:00
Ben LenartsandXubin Ren be6063a142 security: prevent exec tool from leaking process env vars to LLM
The exec tool previously passed the full parent process environment to
child processes, which meant LLM-generated commands could access secrets
stored in env vars (e.g. API keys from EnvironmentFile=).

Switch from subprocess_shell with inherited env to bash login shell
with a minimal environment (HOME, LANG, TERM only). The login shell
sources the user's profile for PATH setup, making the pathAppend
config option a fallback rather than the primary PATH mechanism.
2026-04-06 13:20:53 +08:00
Xubin Ren 84b1c6a0d7 docs: update nanobot features 2026-04-05 20:07:11 +00:00
Xubin Ren 3c28d1e651 docs: rename Assistant to Agent across README 2026-04-05 20:06:38 +00:00
Xubin RenandGitHub ee71d8a31f Merge PR #121: fix typos in readme 2026-04-06 04:01:25 +08:00
Xubin Ren 861072519a chore: remove codespell CI workflow and config, keep typo fixes only
Made-with: Cursor
2026-04-05 19:59:49 +00:00
Xubin Ren 70bdf4a9f5 Merge origin/main into enh-codespell (resolve pyproject.toml conflict)
Made-with: Cursor
2026-04-05 19:57:50 +00:00
Xubin RenandGitHub 5e01a910bf Merge PR #1940: feat: sandbox exec calls with bwrap and run container as non-root
feat: sandbox exec calls with bwrap and run container as non-root (minimally fixes #1873)
2026-04-06 03:33:53 +08:00
Xubin Ren 9823130432 docs: clarify bwrap sandbox is Linux-only 2026-04-05 19:28:46 +00:00
Xubin Ren 9f96be6e9b fix(sandbox): mount media directory read-only inside bwrap sandbox 2026-04-05 19:08:38 +00:00
Xubin Ren cef0f3f988 refactor: replace podman-seccomp.json with minimal cap_add, harden bwrap, add sandbox tests 2026-04-05 19:03:06 +00:00
Xubin Ren a8707ca8f6 Merge origin/main into feat/best_skill_and_hook (resolve 4 conflicts)
Made-with: Cursor
2026-04-05 18:53:17 +00:00
Jack LuandXubin Ren bcb8352235 refactor(agent): streamline hook method calls and enhance error logging
- Introduced a helper method `_for_each_hook_safe` to reduce code duplication in hook method implementations.
- Updated error logging to include the method name for better traceability.
- Improved the `SkillsLoader` class by adding a new method `_skill_entries_from_dir` to simplify skill listing logic.
- Enhanced skill loading and filtering logic, ensuring workspace skills take precedence over built-in ones.
- Added comprehensive tests for `SkillsLoader` to validate functionality and edge cases.
2026-04-06 02:51:10 +08:00
Xubin RenandXubin Ren bb9da29eff test: add regression tests for private DM thread session key derivation
Made-with: Cursor
2026-04-06 02:44:21 +08:00
Ilya SemenovandXubin Ren 0d6bc7fc11 fix(telegram): support threads in DMs 2026-04-06 02:44:21 +08:00
Xubin RenandXubin Ren 4b4d8b506d test: add regression test for DuckDuckGo asyncio.wait_for timeout guard
Made-with: Cursor
2026-04-06 02:21:51 +08:00
6bd2950b99 Fix: add asyncio timeout guard for DuckDuckGo search
DDGS's internal `timeout=10` relies on `requests` read-timeout semantics,
which only measure the gap between bytes — not total wall-clock time.
When the underlying HTTP connection enters CLOSE-WAIT or the server
dribbles data slowly, this timeout never fires, causing `ddgs.text` to
hang indefinitely via `asyncio.to_thread`.

Since `asyncio.to_thread` cannot cancel the underlying OS thread, the
agent's session lock is never released, blocking all subsequent messages
on the same session (observed: 8+ hours of unresponsiveness).

Fix:
- Add `timeout` field to `WebSearchConfig` (default: 30s, configurable
  via config.json or NANOBOT_TOOLS__WEB__SEARCH__TIMEOUT env var)
- Wrap `asyncio.to_thread` with `asyncio.wait_for` to enforce a hard
  wall-clock deadline

Closes #2804

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 02:21:51 +08:00
Xubin RenandXubin Ren 90caf5ce51 test: remove duplicate test_jina_422_falls_back_to_duckduckgo
The same test function name appeared twice; Python silently shadows the
first definition so it never ran.  Keep the version that also asserts
the request URL contains "s.jina.ai".

Made-with: Cursor
2026-04-06 02:06:00 +08:00
KimGLeeandXubin Ren f422de8084 fix(web-search): fix Jina search format and fallback 2026-04-06 02:06:00 +08:00
Xubin Ren acf652358c feat(dream): non-blocking /dream with progress feedback 2026-04-05 15:48:00 +00:00
chengyongruandXubin Ren 401d1f57fa fix(dream): allow LLM to retry on tool errors instead of failing immediately
Dream Phase 2 uses fail_on_tool_error=True, which terminates the entire
run on the first tool error (e.g. old_text not found in edit_file).
Normal agent runs default to False so the LLM can self-correct and retry.
Dream should behave the same way.
2026-04-05 22:10:34 +08:00
chengyongruandXubin Ren 5479a44691 fix: stop leaking reasoning_content to stream output
The streaming path in OpenAICompatProvider.chat_stream() was passing
reasoning_content deltas through on_content_delta(), causing model
internal reasoning to be displayed to the user alongside the actual
response content.

reasoning_content is already collected separately in _parse_chunks()
and stored in LLMResponse.reasoning_content for session history.
It should never be forwarded to the user-facing stream.
2026-04-05 17:27:14 +08:00
chengyongruandXubin Ren 2cecaf0d5d fix(feishu): support video (media) download by converting type to 'file'
Feishu's GetMessageResource API only accepts 'image' or 'file' as the
type parameter. Video messages have msg_type='media', which was passed
through unchanged, causing error 234001 (Invalid request param). Now
both 'audio' and 'media' are converted to 'file' for download.
2026-04-05 16:53:05 +08:00
chengyongruandXubin Ren 3003cb8465 test(feishu): add unit tests for reaction add/remove and auto-cleanup 2026-04-05 16:53:05 +08:00
Jiajun XieandXubin Ren bb70b6158c feat: auto-remove reaction after message processing complete
- _add_reaction now returns reaction_id on success
- Add _remove_reaction_sync and _remove_reaction methods
- Remove reaction when stream ends to clear processing indicator
- Store reaction_id in metadata for later removal
2026-04-05 16:53:05 +08:00
JiajunandXubin Ren 7e1ae3eab4 feat(provider): add Qianfan provider support (#2699) 2026-04-05 16:52:37 +08:00
FloandXubin Ren fce1e333b9 feat(telegram): render tool hints as expandable blockquotes (#2752) 2026-04-05 16:52:08 +08:00
Jiajun XieandXubin Ren f86f226c17 fix(cli): prevent spinner ANSI escape codes from being printed verbatim
Fixes #2591

The "nanobot is thinking..." spinner was printing ANSI escape codes
literally in some terminals, causing garbled output like:
  ?[2K?[32m⠧?[0m ?[2mnanobot is thinking...?[0m

Root causes:
1. Console created without force_terminal=True, so Rich couldn't
   reliably detect terminal capabilities
2. Spinner continued running during user input prompt, conflicting
   with prompt_toolkit

Changes:
- Set force_terminal=True in _make_console() for proper ANSI handling
- Add stop_for_input() method to StreamRenderer
- Call stop_for_input() before reading user input in interactive mode
- Add tests for the new functionality
2026-04-05 16:50:49 +08:00
Xubin RenandGitHub 04a41e31ac Merge PR #2754: feat(agent): add built-in grep and glob search tools
feat(agent): add built-in grep and glob search tools
2026-04-04 23:30:18 +08:00
Xubin Ren 33bef8d508 Merge remote-tracking branch 'origin/main' into feat/search-tools
Made-with: Cursor
2026-04-04 14:37:59 +00:00
Xubin RenandXubin Ren f4983329c6 fix(docker): preserve both github ssh rewrite rules for npm install 2026-04-04 22:33:46 +08:00
Wenzhang-ChenandXubin Ren c9d6491814 fix(docker): rewrite github ssh git deps to https for npm build 2026-04-04 22:33:46 +08:00
Xubin Ren 1c1eee523d fix: secure whatsapp bridge with automatic local auth token 2026-04-04 14:16:46 +00:00
Xubin RenandGitHub cf56d15bdf Merge PR #2722: perf(cache): stabilize tool prefix caching under MCP tool churn
perf(cache): stabilize tool prefix caching under MCP tool churn
2026-04-04 21:57:15 +08:00
Xubin Ren 77a88446fb Merge remote-tracking branch 'origin/main' into pr-2722 2026-04-04 13:51:59 +00:00
Xubin RenandXubin Ren 17d9d74ccc fix(provider): omit temperature for GPT-5 models 2026-04-04 20:18:22 +08:00
7dc8c9409c feat(providers): add GPT-5 model family support for OpenAI provider
Enable GPT-5 models (gpt-5, gpt-5.4, gpt-5.4-mini, etc.) to work
correctly with the OpenAI-compatible provider by:

- Setting `supports_max_completion_tokens=True` on the OpenAI provider
  spec so `max_completion_tokens` is sent instead of the deprecated
  `max_tokens` parameter that GPT-5 rejects.
- Adding `_supports_temperature()` to conditionally omit the
  `temperature` parameter for reasoning models (o1/o3/o4) and when
  `reasoning_effort` is active, matching the existing Azure provider
  behaviour.

Both changes are backward-compatible: older GPT-4 models continue to
work as before since `max_completion_tokens` is accepted by all recent
OpenAI models and temperature is only omitted when reasoning is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:18:22 +08:00
Xubin RenandXubin Ren 11c84f21a6 test(session): preserve reasoning_content in session history 2026-04-04 20:08:44 +08:00
Lingao MengandXubin Ren 519911456a test(provider): fix incorrect assertion in reasoning_content sanitize test
The test test_openai_compat_strips_message_level_reasoning_fields was
added in fbedf7a and incorrectly asserted that reasoning_content and
extra_content should be stripped from messages. This contradicts the
intent of b5302b6 which explicitly added these fields to _ALLOWED_MSG_KEYS
to preserve them through sanitization.

Rename the test and fix assertions to match the original design intent:
reasoning_content and extra_content at message level should be preserved,
and extra_content inside tool_calls should also be preserved.

Signed-off-by: Lingao Meng <menglingao@xiaomi.com>
2026-04-04 20:08:44 +08:00
Lingao MengandXubin Ren 3f8eafc89a fix(provider): restore reasoning_content and extra_content in message sanitization
reasoning_content and extra_content were accidentally dropped from
_ALLOWED_MSG_KEYS.

Also fix session/manager.py to include reasoning_content when building
LLM messages from session history, so the field is not lost across
turns.

Without this fix, providers such as Kimi, emit reasoning_content in
assistant messages will have it stripped on the next request, breaking
multi-turn thinking mode.

Fixes: https://github.com/HKUDS/nanobot/issues/2777
Signed-off-by: Lingao Meng <menglingao@xiaomi.com>
2026-04-04 20:08:44 +08:00
Xubin RenandXubin Ren 05fe7d4fb1 fix(tools): isolate decorated tool schemas and add regression tests 2026-04-04 19:58:44 +08:00
Jack LuandXubin Ren e7798a28ee refactor(tools): streamline Tool class and add JSON Schema for parameters
Refactor Tool methods and type handling; introduce JSON Schema support for tool parameters (schema module, validation tests).

Made-with: Cursor
2026-04-04 19:58:44 +08:00
Xubin RenandXubin Ren 9ef5b1e145 fix: reset ssrf whitelist on config reload and document config refresh 2026-04-04 19:43:18 +08:00
04cbandXubin Ren 5f08d61d8f fix(security): add ssrfWhitelist config to unblock Tailscale/CGNAT (#2669) 2026-04-04 19:43:18 +08:00
Xubin RenandGitHub 193eccdac7 Merge PR #2779: feat: integrate Jinja2 templating for agent responses and memory
feat: integrate Jinja2 templating for agent responses and memory
2026-04-04 19:17:56 +08:00
Xubin Ren c3b4ebae53 refactor(agent): move internal prompts into packaged templates 2026-04-04 11:09:37 +00:00
Xubin Ren 7b852506ff fix(telegram): register Dream menu commands with Telegram-safe aliases
Use dream_log and dream_restore in Telegram's bot command menu so command registration succeeds, while still accepting the original dream-log and dream-restore forms in chat. Keep the internal command routing unchanged and add coverage for the alias normalization path.
2026-04-04 10:31:26 +00:00
Xubin Ren 549e5ea8e2 fix(telegram): shorten polling network errors 2026-04-04 10:26:58 +00:00
Xubin RenandGitHub b9ee236ca1 Merge PR #2717: feat(memory): two-stage memory system with Dream consolidation
feat(memory): two-stage memory system with Dream consolidation
2026-04-04 18:18:43 +08:00
Xubin Ren 04419326ad fix(memory): migrate legacy HISTORY.md even when history.jsonl is empty 2026-04-04 10:11:53 +00:00
Xubin Ren 0a3a60a7a4 refactor(memory): simplify Dream config naming and rename gitstore module 2026-04-04 10:01:45 +00:00
Xubin Ren a166fe8fc2 docs: clarify memory design and source-vs-release features 2026-04-04 09:34:37 +00:00
Xubin Ren 408a61b0e1 feat(memory): protect Dream cron and polish migration UX 2026-04-04 09:01:42 +00:00
Xubin Ren 6e896249c8 feat(memory): harden legacy history migration and Dream UX 2026-04-04 08:41:46 +00:00
Jack Lu d436a1d678 feat: integrate Jinja2 templating for agent responses and memory consolidation
- Added Jinja2 template support for various agent responses, including identity, skills, and memory consolidation.
- Introduced new templates for evaluating notifications, handling subagent announcements, and managing platform policies.
- Updated the agent context and memory modules to utilize the new templating system for improved readability and maintainability.
- Added a new dependency on Jinja2 in pyproject.toml.
2026-04-04 14:18:22 +08:00
pikaxinge 31d3061a0a fix(retry): classify 429 as WAIT vs STOP using semantic signals 2026-04-04 05:23:21 +00:00
pikaxinge cabf093915 Merge remote-tracking branch 'origin/main' into fix/structured-retry-classification-main
# Conflicts:
#	nanobot/providers/anthropic_provider.py
#	nanobot/providers/base.py
#	nanobot/providers/openai_compat_provider.py
2026-04-04 05:04:43 +00:00
Xubin Ren 7e0c196797 fix(memory): repair Dream follow-up paths and move GitStore to utils
Made-with: Cursor
2026-04-04 04:49:42 +00:00
Xubin Ren 30ea048f19 Merge remote-tracking branch 'origin/main' into pr-2717-review 2026-04-04 04:42:52 +00:00
Xubin RenandXubin Ren 7229a81594 fix(providers): disable Azure SDK retries by default
Made-with: Cursor
2026-04-04 12:36:45 +08:00
pikaxingeandXubin Ren dbdf7e5955 fix: prevent retry amplification by disabling SDK retries 2026-04-04 12:36:45 +08:00
Xubin RenandGitHub 6fbcecc880 Merge PR #2761: fix: Retry-After was ignored, causing premature retries
fix: Retry-After was ignored, causing premature retries (now honors header/json hints)
2026-04-04 03:10:14 +08:00
Xubin Ren 91a9b7db24 Merge origin/main into fix/retry-after-robust
Made-with: Cursor
2026-04-03 19:07:30 +00:00
Xubin RenandXubin Ren 9840270f7f test(tools): cover media dir access under workspace restriction
Made-with: Cursor
2026-04-04 03:03:58 +08:00
ShinieseandXubin Ren 84c4ba7609 refactor: use unified get_media_dir() to get media path 2026-04-04 03:03:58 +08:00
ShinieseandXubin Ren 624f607872 fix(filesystem): add media directory exemption to filesystem tool path checks 2026-04-04 03:03:58 +08:00
ShinieseandXubin Ren bc879386fe fix(shell): allow media directory access when restrict_to_workspace is enabled 2026-04-04 03:03:58 +08:00
Xubin Ren ca3b918cf0 docs: clarify retry behavior and web search defaults 2026-04-03 18:57:44 +00:00
Xubin RenandGitHub b084122f9e Merge PR #2643: feat: unify web tool config under WebToolsConfig
feat: unify web tool config under WebToolsConfig + add web tool toggle controls
2026-04-04 02:51:40 +08:00
Xubin Ren 400f8eb38e docs: update web search configuration information 2026-04-03 18:44:46 +00:00
Xubin Ren 652377bee9 Merge origin/main into feat/web-disable-flag
Made-with: Cursor
2026-04-03 18:41:43 +00:00
imfondofandXubin Ren 896d578677 fix(restart): show restart completion with elapsed time across channels 2026-04-04 02:21:42 +08:00
imfondofandXubin Ren ba7c07ccf2 fix(restart): send completion notice after channel is ready and unify runtime keys 2026-04-04 02:21:42 +08:00
Lingao MengandXubin Ren a05f83da89 test(providers): cover reasoning_content extraction in OpenAI compat provider
Add regression tests for the non-streaming (_parse dict branch) and
streaming (_parse_chunks dict and SDK-object branches) paths that extract
reasoning_content, ensuring the field is populated when present and None
when absent.

Signed-off-by: Lingao Meng <menglingao@xiaomi.com>
2026-04-04 02:09:57 +08:00
Lingao MengandXubin Ren 210643ed68 feat(provider): support reasoning_content in OpenAI compat provider
Extract reasoning_content from both non-streaming and streaming responses
in OpenAICompatProvider. Accumulate chunks during streaming and merge into
LLMResponse, enabling reasoning chain display for models like MiMo and DeepSeek-R1.

Signed-off-by: Lingao Meng <menglingao@xiaomi.com>
2026-04-04 02:09:57 +08:00
Xubin RenandGitHub 0a31e84044 Merge PR #2495: feat(provider): add Xiaomi MiMo LLM support
feat(provider): add Xiaomi MiMo LLM support
2026-04-04 02:02:16 +08:00
Xubin RenandGitHub 4d7493dd4a Merge PR #2646: fix(weixin): restore weixin typing indicator
fix: restore Weixin typing indicator
2026-04-04 02:00:47 +08:00
Xubin Ren f409337fcf Merge remote-tracking branch 'origin/main' into pr-2646 2026-04-03 17:53:52 +00:00
FloandXubin Ren 3ada54fa5d fix(telegram): change drop_pending_updates to False on startup (#2686) 2026-04-04 01:52:39 +08:00
FloandXubin Ren 8b4d6b6512 fix(tools): strip <think> blocks from message tool content (#2621) 2026-04-04 01:52:39 +08:00
daliu858andXubin Ren 06989fd65b feat(qq): add configurable instant acknowledgment message (#2561)
Add ack_message config field to QQConfig (default: Processing...). When non-empty, sends an instant text reply before agent processing begins, filling the silence gap for users. Uses existing _send_text_only method; failure is logged but never blocks normal message handling.

Made-with: Cursor
2026-04-04 01:52:39 +08:00
FloandXubin Ren 49c40e6b31 feat(telegram): include author context in reply tags (#2605) (#2606)
* feat(telegram): include author context in reply tags (#2605)

* fix(telegram): handle missing attributes in reply_user safely
2026-04-04 01:52:39 +08:00
FloandXubin Ren 2e5308ff28 fix(telegram): remove acknowledgment reaction when response completes (#2564) 2026-04-04 01:52:39 +08:00
FloandXubin Ren 0709fda568 fix(telegram): handle RetryAfter delay internally in channel (#2552) 2026-04-04 01:52:39 +08:00
FloandXubin Ren 0fa82298d3 fix(telegram): support commands with bot username suffix in groups (#2553)
* fix(telegram): support commands with bot username suffix in groups

* fix(command): preserve metadata in builtin command responses
2026-04-04 01:52:39 +08:00
Xubin Ren cb84f2b908 docs: update nanobot news section 2026-04-03 16:18:36 +00:00
Xubin Ren 3c3a72ef82 update .gitignore 2026-04-03 16:02:23 +00:00
Lingao Meng cf6c979339 feat(provider): add Xiaomi MiMo LLM support
Register xiaomi_mimo as an OpenAI-compatible provider with its API base URL,
add xiaomi_mimo to the provider config schema, and document it in README.

Signed-off-by: Lingao Meng <menglingao@xiaomi.com>
2026-04-03 14:42:57 +08:00
pikaxinge b951b37c97 fix: use structured error metadata for app-layer retry 2026-04-02 18:42:20 +00:00
pikaxinge 5d1ea43858 fix: robust Retry-After extraction across provider backends 2026-04-02 18:39:24 +00:00
chengyongruandchengyongru f824a629a8 feat(memory): add git-backed version control for dream memory files
- Add GitStore class wrapping dulwich for memory file versioning
- Auto-commit memory changes during Dream consolidation
- Add /dream-log and /dream-restore commands for history browsing
- Pass tracked_files as constructor param, generate .gitignore dynamically
2026-04-03 00:32:54 +08:00
Xubin Ren 15cc9b23b4 feat(agent): add built-in grep and glob search tools 2026-04-02 15:37:57 +00:00
chengyongruandchengyongru a9e01bf838 fix(memory): extract successful solutions in consolidate prompt
Add "Solutions" category to consolidate prompt so trial-and-error
workflows that reach a working approach are captured in history for
Dream to persist. Remove overly broad "debug steps" skip rule that
discarded these valuable findings.
2026-04-02 23:02:42 +08:00
chengyongruandchengyongru b9616674f0 feat(agent): two-stage memory system with Dream consolidation
Replace single-stage MemoryConsolidator with a two-stage architecture:

- Consolidator: lightweight token-budget triggered summarization,
  appends to HISTORY.md with cursor-based tracking
- Dream: cron-scheduled two-phase processor that analyzes HISTORY.md
  and updates SOUL.md, USER.md, MEMORY.md via AgentRunner with
  edit_file tools for surgical, fault-tolerant updates

New files: MemoryStore (pure file I/O), Dream class, DreamConfig,
/dream and /dream-log commands. 89 tests covering all components.
2026-04-02 22:42:25 +08:00
Xubin RenandGitHub 7113ad34f4 Merge PR #2733: harden agent runtime for long-running tasks 2026-04-02 22:34:00 +08:00
Xubin Ren e4b335ce81 refactor: extract runtime response guards into utils runtime module 2026-04-02 13:54:40 +00:00
Xubin Ren 714a4c7bb6 fix(runtime): address review feedback on retry and cleanup 2026-04-02 10:57:12 +00:00
Xubin Ren eefd7e60f2 Merge remote-tracking branch 'origin/main' into feat/runtime-hardening 2026-04-02 10:40:49 +00:00
Xubin RenandXubin Ren 3558fe4933 fix(cli): honor custom config path in channel commands 2026-04-02 18:37:46 +08:00
masterlyjandXubin Ren 11ba733ab6 fix(test): update load_config mock to accept config_path parameter 2026-04-02 18:37:46 +08:00
masterlyjandXubin Ren 7332d133a7 feat(cli): add --config option to channels login and status commands
Allows users to specify custom config file paths when managing channels.

Usage:
  nanobot channels login weixin --config .nanobot-feishu/config.json
    nanobot channels status -c .nanobot-qq/config.json

    - Added optional --config/-c parameter to both commands
    - Defaults to ~/.nanobot/config.json when not specified
    - Maintains backward compatibility
2026-04-02 18:37:46 +08:00
haosenwang1018andXubin Ren 7a6416bcb2 test(matrix): skip cleanly when optional deps are missing 2026-04-02 18:17:00 +08:00
pikaxinge 87d493f354 refactor: deduplicate tool cache marker helper in base provider 2026-04-02 07:29:07 +00:00
cypggs ca68a89ce6 merge: resolve conflicts with upstream/main, preserve typing indicator 2026-04-02 14:28:23 +08:00
Xubin RenandXubin Ren cc33057985 refactor(providers): rename openai responses helpers 2026-04-02 13:43:34 +08:00
Xubin RenandXubin Ren ded0967c18 fix(providers): sanitize azure responses input messages 2026-04-02 13:43:34 +08:00
Kunal KarmakarandXubin Ren 61d7411238 Fix failing test 2026-04-02 13:43:34 +08:00
Kunal KarmakarandXubin Ren 76226274bf Failing test 2026-04-02 13:43:34 +08:00
Kunal KarmakarandXubin Ren e206cffd7a Add tests and handle json 2026-04-02 13:43:34 +08:00
Kunal KarmakarandXubin Ren ac2ee58791 Add tests and logs 2026-04-02 13:43:34 +08:00
Kunal KarmakarandXubin Ren 7c44aa92ca Fill up gaps 2026-04-02 13:43:34 +08:00
Kunal KarmakarandXubin Ren 8c0607e079 Use SDK for stream 2026-04-02 13:43:34 +08:00
Kunal KarmakarandXubin Ren 0417c3f03b Use OpenAI responses API 2026-04-02 13:43:34 +08:00
Xubin RenandXubin Ren 9ba413c82e test(cron): cover deliver flag on scheduled jobs 2026-04-02 13:03:46 +08:00
lucarioandXubin Ren 15faa3b115 fix(cron): fix extra indent for properties closing brace and required field 2026-04-02 13:03:46 +08:00
lucarioandXubin Ren 35b51c0694 fix(cron): fix extra indent for deliver param 2026-04-02 13:03:46 +08:00
lucarioandXubin Ren 5f2157baeb fix(cron): move deliver param before job_id in parameters schema 2026-04-02 13:03:46 +08:00
archlinuxandXubin Ren 2e3cb5b20e fix default value True 2026-04-02 13:03:46 +08:00
lucarioandXubin Ren 73e80b199a feat(cron): add deliver parameter to support silent jobs, default true for backward compatibility 2026-04-02 13:03:46 +08:00
Xubin RenandXubin Ren a3e4c77fff fix(providers): normalize anthropic cached token usage 2026-04-02 12:51:45 +08:00
chengyongruandXubin Ren da08dee144 feat(provider): show cache hit rate in /status (#2645) 2026-04-02 12:51:45 +08:00
Tejas1KoliandXubin Ren 42fa8fa933 fix(providers): only apply cache_control for Claude models on OpenRouter 2026-04-02 04:04:18 +08:00
Tejas1KoliandXubin Ren 05fe73947f fix(providers): only apply cache_control for Claude models on OpenRouter 2026-04-02 04:04:18 +08:00
Xubin RenandXubin Ren 485c75e065 test(exec): verify windows drive-root workspace guard 2026-04-02 04:00:03 +08:00
zhangxiaoyu.yorkandXubin Ren bc2e474079 Fix ExecTool to block root directory paths when restrict_to_workspace is enabled 2026-04-02 04:00:03 +08:00
ddc9fc4fd2 fix: also check channel match before inheriting default message_id
Different channels could theoretically share the same chat_id.
Check both channel and chat_id to avoid cross-channel reply issues.

Co-authored-by: layla <111667698+04cb@users.noreply.github.com>
2026-04-02 03:46:54 +08:00
WormWandXubin Ren 6973bfff24 fix(agent): message tool incorrectly replies to original chat when targeting different chat_id
When the message tool is used to send a message to a different chat_id

than the current conversation, it was incorrectly including the default

message_id from the original context. This caused channels like Feishu

to send the message as a reply to the original chat instead of creating

a new message in the target chat.

Changes:

- Only use default message_id when chat_id matches the default context

- When targeting a different chat, set message_id to None to avoid

  unintended reply behavior
2026-04-02 03:46:54 +08:00
Xubin RenandXubin Ren 7e719f41cc test(providers): cover github copilot lazy export 2026-04-02 03:46:40 +08:00
Xubin RenandXubin Ren 2ec68582eb fix(sdk): route github copilot through oauth provider 2026-04-02 03:46:40 +08:00
RongLeiandXubin Ren c5f0997381 fix: refresh copilot token before requests
Address PR review feedback by avoiding an async method reference as the OpenAI client api_key.

Initialize the client with a placeholder key, refresh the Copilot token before each chat/chat_stream call, and update the runtime client api_key before dispatch.

Add a regression test that verifies the client api_key is refreshed to a real string before chat requests.

Generated with GitHub Copilot, GPT-5.4.
2026-04-02 03:46:40 +08:00
RongLeiandXubin Ren a37bc26ed3 fix: restore GitHub Copilot auth flow
Implement the real GitHub device flow and Copilot token exchange for the GitHub Copilot provider.

Also route github-copilot models through a dedicated backend and strip the provider prefix before API requests.

Add focused regression coverage for provider wiring and model normalization.

Generated with GitHub Copilot, GPT-5.4.
2026-04-02 03:46:40 +08:00
Xubin Ren fbedf7ad77 feat: harden agent runtime for long-running tasks 2026-04-01 19:12:49 +00:00
pikaxinge 607fd8fd7e fix(cache): stabilize tool ordering and cache markers for MCP 2026-04-01 17:07:22 +00:00
Xubin RenandGitHub 63d646f731 Merge PR #2676: fix(test): fix flaky test_fixed_session_requests_are_serialized
fix(test): fix flaky test_fixed_session_requests_are_serialized
2026-03-31 22:08:47 +08:00
chengyongru 69624779dc fix(test): fix flaky test_fixed_session_requests_are_serialized
Remove the fragile barrier-based synchronization that could cause
deadlock when the second request is scheduled first. Instead, rely
on the session lock for serialization and handle either execution
order in assertions.
2026-03-31 21:50:33 +08:00
Xubin RenandGitHub a4dfbdf996 Merge PR #2614: feat(weixin): weixin multimodal capabilities and align with version 2.1.1
feat(weixin): weixin multimodal capabilities and align with version 2.1.1
2026-03-31 19:43:02 +08:00
Xubin RenandXubin Ren 949a10f536 fix(weixin): reset QR poll host after refresh 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren 2a6c616080 fix(WeiXin): fix full_url download error 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren 1bcd5f9742 fix(weixin): fix test file version reader 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren 26947db479 feat(weixin): add voice message, typing keepalive, getConfig cache, and QR polling resilience 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren 0514233217 fix(weixin): align full_url AES key handling and quoted media fallback logic with reference
1. Fix full_url path for non-image media to require AES key and skip download when missing,
   instead of persisting encrypted bytes as valid media.
2. Restrict quoted media fallback trigger to only when no top-level media item exists,
   not when top-level media download/decryption fails.
2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren 345c393e53 feat(weixin): implement getConfig and sendTyping 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren faf2b07923 feat(weixin): add fallback logic for referenced media download 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren efd42cc236 feat(weixin): implement QR redirect handling 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren 3823042290 fix(weixin): correct PKCS7 unpadding for AES-ECB; support full_url for media download 2026-03-31 19:40:13 +08:00
xcosmosboxandXubin Ren 5bdb7a90b1 feat(weixin):
1.align protocol headers with package.json metadata
2.support upload_full_url with fallback to upload_param
2026-03-31 19:40:13 +08:00
Xubin Ren bc8fbd1ce4 fix(weixin): reset QR poll host after refresh 2026-03-31 11:34:33 +00:00
Xubin Ren 6aad945719 Merge remote-tracking branch 'origin/main' into pr-2614 2026-03-31 11:29:36 +00:00
Xubin RenandXubin Ren f450c6ef6c fix(channel): preserve threaded streaming context 2026-03-31 19:26:07 +08:00
8956df3668 feat(discord): configurable read receipt + subagent working indicator (#2330)
* feat(discord): channel-side read receipt and subagent indicator

- Add 👀 reaction on message receipt, removed after bot reply
- Add 🔧 reaction on first progress message, removed on final reply
- Both managed purely in discord.py channel layer, no subagent.py changes
- Config: read_receipt_emoji, subagent_emoji with sensible defaults

Addresses maintainer feedback on HKUDS/nanobot#2330

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(discord): add both reactions on inbound, not on progress

_progress flag is for streaming chunks, not subagent lifecycle.
Add 👀 + 🔧 immediately on message receipt, clear both on final reply.

* fix: remove stale _subagent_active reference in _clear_reactions

* fix(discord): clean up reactions on message handling failure

Previously, if _handle_message raised an exception, pending reactions
(read receipt + subagent indicator) would remain on the user's message
indefinitely since send() — which handles normal cleanup — would never
be called.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(discord): replace subagent_emoji with delayed working indicator

- Rename subagent_emoji → working_emoji (honest naming: not tied to
  subagent lifecycle)
- Add working_emoji_delay (default 2s) — cosmetic delay so 🔧 appears
  after 👀, cancelled if bot replies before delay fires
- Clean up: cancel pending task + remove both reactions on reply/error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:26:07 +08:00
Paresh MathurXubin RenPares Mathurgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
0506e6c1c1 feat(discord): Use discord.py for stable discord channel (#2486)
Co-authored-by: Pares Mathur <paresh.2047@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-31 19:26:07 +08:00
b94d4c0509 feat(matrix): streaming support (#2447)
* Added streaming message support with incremental updates for Matrix channel

* Improve Matrix message handling and add tests

* Adjust Matrix streaming edit interval to 2 seconds

---------

Co-authored-by: natan <natan@podbielski>
2026-03-31 19:26:07 +08:00
xcosmosbox d0c68157b1 fix(WeiXin): fix full_url download error 2026-03-31 12:55:29 +08:00
Xubin RenandXubin Ren 351e3720b6 test(agent): cover disabled subagent exec tool
Add a regression test for the maintainer fix so subagents cannot register ExecTool when exec support is disabled.

Made-with: Cursor
2026-03-31 12:14:28 +08:00
zhangxiaoyu.yorkandXubin Ren c3c1424db3 fix:register exec when enable exec_config 2026-03-31 12:14:28 +08:00
04cbandXubin Ren 929ee09499 fix(utils): ensure reasoning_content present with thinking_blocks (#2579) 2026-03-31 11:49:23 +08:00
04cbandXubin Ren 3f21e83af8 fix(tools): clarify cron message param as agent instruction (#2566) 2026-03-31 11:49:23 +08:00
04cbandXubin Ren 8682b017e2 fix(tools): add Accept header for MCP SSE connections (#2651) 2026-03-31 11:49:23 +08:00
Xubin RenandXubin Ren 7fad14802e feat: add Python SDK facade and per-session isolation 2026-03-31 11:26:43 +08:00
Xubin RenandXubin Ren 842b8b255d fix(agent): preserve core hook failure semantics 2026-03-31 02:19:29 +08:00
Xubin RenandXubin Ren 758c4e74c9 fix(agent): preserve LoopHook error semantics when extra hooks are present 2026-03-31 02:19:29 +08:00
sontianyeandXubin Ren f08de72f18 feat(agent): add CompositeHook for composable lifecycle hooks
Introduce a CompositeHook that fans out lifecycle callbacks to an
ordered list of AgentHook instances with per-hook error isolation.
Extract the nested _LoopHook and _SubagentHook to module scope as
public LoopHook / SubagentHook so downstream users can subclass or
compose them.  Add `hooks` parameter to AgentLoop.__init__ for
registering custom hooks at construction time.

Closes #2603
2026-03-31 02:19:29 +08:00
Xubin RenandGitHub 1814272583 Merge PR #1362: feat: add OpenAI-compatible API
feat: add OpenAI-compatible API
2026-03-30 23:40:04 +08:00
Xubin Ren 5e99b81c6e refactor(api): reduce compatibility and test noise
Make the fixed-session API surface explicit, document its usage, exclude api/ from core agent line counts, and remove implicit aiohttp pytest fixture dependencies from API tests.
2026-03-30 15:05:06 +00:00
Xubin Ren d9a5080d66 refactor(api): tighten fixed-session API contract
Require a single user message, reject mismatched models, document the OpenAI-compatible API, and exclude api/ from core agent line counts so the interface matches nanobot's minimal fixed-session runtime.
2026-03-30 14:43:22 +00:00
Xubin Ren 55501057ac refactor(api): tighten fixed-session chat input contract
Reject mismatched models and require a single user message so the OpenAI-compatible endpoint reflects the fixed-session nanobot runtime without extra compatibility noise.
2026-03-30 14:20:14 +00:00
qcypggsandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 0340f81cfd fix: restore Weixin typing indicator
Fetch and cache typing tickets so the Weixin channel shows typing while nanobot is processing and clears it after the final reply.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-03-30 19:25:55 +08:00
Shiniese 7f1dca3186 feat: unify web tool config under WebToolsConfig + add web tool toggle controls
- Rename WebSearchConfig references to the new WebToolsConfig root struct that wraps both search config and global proxy settings
- Add 'enable' flag to WebToolsConfig to allow fully disabling all web-related tools (WebSearch, WebFetch) at runtime
- Update AgentLoop and SubagentManager to receive the full web config object instead of separate web_search_config/web_proxy parameters
- Update CLI command initialization to pass the consolidated web config struct instead of split fields
- Change default web search provider from brave to duckduckgo for better out-of-the-box usability (no API key required)
2026-03-30 16:22:11 +08:00
Ziyan Lin 26ae906116 fix(providers): enforce role alternation for non-Claude providers
Some LLM providers (OpenAI-compat, Azure, vLLM, Ollama) reject requests
with consecutive same-role messages or trailing assistant messages. Add
_enforce_role_alternation() to merge consecutive same-role user/assistant
messages and strip trailing assistant messages before sending to the API.
2026-03-30 15:15:15 +08:00
xcosmosbox 2dce5e07c1 fix(weixin): fix test file version reader 2026-03-30 09:06:49 +08:00
Xubin Ren 5635907e33 feat(api): load serve settings from config
Read serve host, port, and timeout from config by default, keep CLI flags higher priority, and bind the API to localhost by default for safer local usage.
2026-03-29 15:32:33 +00:00
Xubin Ren a0684978fb feat(api): add fixed-session OpenAI-compatible endpoint
Expose OpenAI-compatible chat completions and models endpoints through a single persistent API session, keeping the integration simple without adding multi-session isolation yet.
2026-03-29 14:48:52 +00:00
rav-melisono bc357208bb feat: add HTTP health endpoint on gateway port
Binds a lightweight asyncio HTTP server on the configured gateway
port (default 18790) alongside the existing agent and channel tasks.

Endpoints:
  GET /       -> "nanobot" (plain text, for service discovery)
  GET /health -> JSON with service, version, status, uptime, channels

Zero new dependencies — uses asyncio.start_server.
2026-03-29 15:31:29 +01:00
xcosmosbox 1a4ad67628 feat(weixin): add voice message, typing keepalive, getConfig cache, and QR polling resilience 2026-03-29 21:28:58 +08:00
xcosmosbox ed2ca759e7 fix(weixin): align full_url AES key handling and quoted media fallback logic with reference
1. Fix full_url path for non-image media to require AES key and skip download when missing,
   instead of persisting encrypted bytes as valid media.
2. Restrict quoted media fallback trigger to only when no top-level media item exists,
   not when top-level media download/decryption fails.
2026-03-29 20:27:23 +08:00
xcosmosbox 79a915307c feat(weixin): implement getConfig and sendTyping 2026-03-29 16:25:25 +08:00
xcosmosbox 2abd990b89 feat(weixin): add fallback logic for referenced media download 2026-03-29 15:19:57 +08:00
xcosmosbox 0207b541df feat(weixin): implement QR redirect handling 2026-03-29 13:37:22 +08:00
xcosmosbox b1d5475681 fix(weixin): correct PKCS7 unpadding for AES-ECB; support full_url for media download 2026-03-29 13:14:22 +08:00
xcosmosbox e04e1c24ff feat(weixin):
1.align protocol headers with package.json metadata
2.support upload_full_url with fallback to upload_param
2026-03-29 13:01:44 +08:00
Xubin Ren c8c520cc9a docs: update providers information 2026-03-28 13:28:56 +00:00
CharlesandXubin Ren bee89df422 fix(skill-creator): Fix grammar in SKILL.md: 'another the agent' 2026-03-28 20:37:45 +08:00
Xubin Ren 17d21c8e64 docs: update news section for v0.1.4.post6 release 2026-03-27 15:18:31 +00:00
Xubin Ren aebe928cf0 docs: update v0.1.4.post6 release news 2026-03-27 15:17:22 +00:00
Xubin Ren a42a4e9d83 docs: update v0.1.4.post6 release news 2026-03-27 15:16:28 +00:00
Xubin Ren c15f63a320 chore: bump version to 0.1.4.post6 2026-03-27 14:42:19 +00:00
Xubin Ren 9652e67204 Merge remote-tracking branch 'origin/main' into advisory-email-fix 2026-03-27 14:28:40 +00:00
Xubin RenandXubin Ren f8c580d015 test(telegram): cover network error logging 2026-03-27 22:17:01 +08:00
flobo3andXubin Ren 5968b408dc fix(telegram): log network errors as warnings without stacktrace 2026-03-27 22:17:01 +08:00
Xubin RenandXubin Ren e464a81545 fix(feishu): only stream visible cards 2026-03-27 21:59:11 +08:00
LeftXandXubin Ren 0ba71298e6 feat(feishu): support stream output (cardkit) (#2382)
* feat(feishu): add streaming support via CardKit PATCH API

Implement send_delta() for Feishu channel using interactive card
progressive editing:
- First delta creates a card with markdown content and typing cursor
- Subsequent deltas throttled at 0.5s to respect 5 QPS PATCH limit
- stream_end finalizes with full formatted card (tables, rich markdown)

Also refactors _send_message_sync to return message_id (str | None)
and adds _patch_card_sync for card updates.

Includes 17 new unit tests covering streaming lifecycle, config,
card building, and edge cases.

Made-with: Cursor

* feat(feishu): close CardKit streaming_mode on stream end

Call cardkit card.settings after final content update so chat preview
leaves default [生成中...] summary (Feishu streaming docs).

Made-with: Cursor

* style: polish Feishu streaming (PEP8 spacing, drop unused test imports)

Made-with: Cursor

* docs(feishu): document cardkit:card:write for streaming

- README: permissions, upgrade note for existing apps, streaming toggle
- CHANNEL_PLUGIN_GUIDE: Feishu CardKit scope and when to disable streaming

Made-with: Cursor

* docs: address PR 2382 review (test path, plugin guide, README, English docstrings)

- Move Feishu streaming tests to tests/channels/
- Remove Feishu CardKit scope from CHANNEL_PLUGIN_GUIDE (plugin-dev doc only)
- README Feishu permissions: consistent English
- feishu.py: replace Chinese in streaming docstrings/comments

Made-with: Cursor
2026-03-27 21:59:11 +08:00
Xubin RenandXubin Ren cf25a582ba fix(channel): stop delta coalescing at stream boundaries 2026-03-27 21:43:57 +08:00
chengyongruandXubin Ren 5ff9146a24 fix(channel): coalesce queued stream deltas to reduce API calls
When LLM generates faster than channel can process, asyncio.Queue
accumulates multiple _stream_delta messages. Each delta triggers a
separate API call (~700ms each), causing visible delay after LLM
finishes.

Solution: In _dispatch_outbound, drain all queued deltas for the same
(channel, chat_id) before sending, combining them into a single API
call. Non-matching messages are preserved in a pending buffer for
subsequent processing.

This reduces N API calls to 1 when queue has N accumulated deltas.
2026-03-27 21:43:57 +08:00
FloandXubin Ren 1331084873 fix(providers): make max_tokens and max_completion_tokens mutually exclusive (#2491)
* fix(providers): make max_tokens and max_completion_tokens mutually exclusive

* docs: document supports_max_completion_tokens ProviderSpec option
2026-03-27 21:19:23 +08:00
Xubin Ren ace3fd6049 feat: add default OpenRouter app attribution headers 2026-03-27 11:40:23 +00:00
Xubin RenandXubin Ren 5bf0f6fe7d refactor: unify agent runner lifecycle hooks 2026-03-27 12:41:17 +08:00
comadreja 59396bdbef fix(whatsapp): detect phone vs LID by JID suffix, not field name
The bridge's pn/sender fields don't consistently map to phone/LID
across different versions. Classify by JID suffix instead:
  @s.whatsapp.net  → phone number
  @lid.whatsapp.net → LID (internal WhatsApp identifier)

This ensures allowFrom works reliably with phone numbers regardless
of which field the bridge populates.
2026-03-26 21:48:30 -05:00
comadreja db50dd8a77 feat(whatsapp): add voice message transcription via OpenAI/Groq Whisper
Automatically transcribe WhatsApp voice messages using OpenAI Whisper
or Groq. Configurable via transcriptionProvider and transcriptionApiKey.

Config:
  "whatsapp": {
    "transcriptionProvider": "openai",
    "transcriptionApiKey": "sk-..."
  }
2026-03-26 21:46:31 -05:00
Xubin RenandXubin Ren e7d371ec1e refactor: extract shared agent runner and preserve subagent progress on failure 2026-03-27 02:49:43 +08:00
Michael-lhh e8e85cd1bc fix(telegram): split oversized final streamed replies
Prevent Telegram Message_too_long failures on stream finalization by editing only the first chunk and sending overflow chunks as follow-up messages.

Made-with: Cursor
2026-03-26 22:38:40 +08:00
Xubin Ren 33abe915e7 fix telegram streaming message boundaries 2026-03-26 02:35:12 +00:00
longyongshenandXubin Ren 813de554c9 feat(provider): add Step Fun (阶跃星辰) provider support
Made-with: Cursor
2026-03-25 22:43:47 +08:00
Xubin RenandXubin Ren f0f0bf02d7 refactor(channel): centralize retry around explicit send failures
Make channel delivery failures raise consistently so retry policy lives in ChannelManager rather than being split across individual channels. Tighten Telegram stream finalization, clarify sendMaxRetries semantics, and align the docs with the behavior the system actually guarantees.
2026-03-25 22:37:11 +08:00
chengyongruandXubin Ren 5e9fa28ff2 feat(channel): add message send retry mechanism with exponential backoff
- Add send_max_retries config option (default: 3, range: 0-10)
- Implement _send_with_retry in ChannelManager with 1s/2s/4s backoff
- Propagate CancelledError for graceful shutdown
- Fix telegram send_delta to raise exceptions for Manager retry
- Add comprehensive tests for retry logic
- Document channel settings in README
2026-03-25 22:37:11 +08:00
Xubin RenandXubin Ren 3f71014b7c fix(agent): use configured timezone when registering cron tool
Read the default timezone from the agent context when wiring the cron tool so startup no longer depends on an out-of-scope local variable. Add a regression test to ensure AgentLoop passes the configured timezone through to cron.

Made-with: Cursor
2026-03-25 22:07:14 +08:00
Xubin RenandXubin Ren fab14696a9 refactor(cron): align displayed times with schedule timezone
Make cron list output render one-shot and run-state timestamps in the same timezone context used to interpret schedules. This keeps scheduling logic and user-facing time displays consistent.

Made-with: Cursor
2026-03-25 22:07:14 +08:00
Xubin RenandXubin Ren 4a7d7b8823 feat(cron): inherit agent timezone for default schedules
Make cron use the configured agent timezone when a cron expression omits tz or a one-shot ISO time has no offset. This keeps runtime context, heartbeat, and scheduling aligned around the same notion of time.

Made-with: Cursor
2026-03-25 22:07:14 +08:00
Xubin RenandXubin Ren 13d6c0ae52 feat(config): add configurable timezone for runtime context
Add agent-level timezone configuration with a UTC default, propagate it into runtime context and heartbeat prompts, and document valid IANA timezone usage in the README.
2026-03-25 22:07:14 +08:00
flobo3andXubin Ren ef10df9acb fix(providers): add max_completion_tokens for openai o1 compatibility 2026-03-25 16:57:02 +08:00
Xubin RenandXubin Ren b5302b6f3d refactor(provider): preserve extra_content verbatim for Gemini thought_signature round-trip
Replace the flatten/unflatten approach (merging extra_content.google.*
into provider_specific_fields then reconstructing) with direct pass-through:
parse extra_content as-is, store on ToolCallRequest.extra_content, serialize
back untouched.  This is lossless, requires no hardcoded field names, and
covers all three parsing branches (str, dict, SDK object) plus streaming.
2026-03-25 10:00:29 +08:00
Yohei NishikuboandXubin Ren af84b1b8c0 fix(Gemini): update ToolCallRequest and OpenAICompatProvider to handle thought signatures in extra_content 2026-03-25 10:00:29 +08:00
Yohei NishikuboandXubin Ren 7b720ce9f7 feat(OpenAICompatProvider): enhance tool call handling with provider-specific fields 2026-03-25 10:00:29 +08:00
Xubin Ren 263069583d fix(provider): accept plain text OpenAI-compatible responses
Handle string and dict-shaped responses from OpenAI-compatible backends so non-standard providers no longer crash on missing choices fields. Add regression tests to keep SDK, dict, and plain-text parsing paths aligned.
2026-03-25 01:22:21 +00:00
SeeratulandXubin Ren 321214e2e0 Update group policy explanation in README
Clarified instructions for group policy behavior in README.
2026-03-25 09:08:10 +08:00
SeeratulandXubin Ren b7df3a0aea Update README with group policy clarification
Clarify group policy behavior for bot responses in group channels.
2026-03-25 09:08:10 +08:00
xcosmosboxandXubin Ren 0ccfcf6588 fix(WeiXin): version migration 2026-03-25 02:58:19 +08:00
xcosmosboxandXubin Ren 0dad6124a2 chore(WeiXin): version migration and compatibility update 2026-03-25 02:58:19 +08:00
xcosmosboxandXubin Ren 48902ae95a fix(WeiXin): auto-refresh expired QR code during login to improve success rate 2026-03-25 02:58:19 +08:00
xcosmosboxandXubin Ren 1f5492ea9e fix(WeiXin): persist _context_tokens with account.json to restore conversations after restart 2026-03-25 02:58:19 +08:00
xcosmosboxandXubin Ren 9c872c3458 fix(WeiXin): resolve polling issues in WeiXin plugin
- Prevent repeated retries on expired sessions in the polling thread
- Stop sending messages to invalid agent sessions to eliminate noise logs and unnecessary requests
2026-03-25 02:58:19 +08:00
xcosmosboxandXubin Ren 3a9d6ea536 feat(WeXin): add route_tag property to adapt to WeChat official ilinkai 1.0.3 requirements 2026-03-25 02:58:19 +08:00
MrBob b26a93c14a fix: preserve cron reminder context for notifications 2026-03-24 15:56:23 -03:00
Xubin Ren 7b31af2204 docs: update news section 2026-03-24 18:11:50 +00:00
Xubin Ren c3031c9cb8 docs: update news section about litellm 2026-03-24 18:11:03 +00:00
Xubin RenandXubin Ren 3dfdab704e refactor: replace litellm with native openai + anthropic SDKs
- Remove litellm dependency entirely (supply chain risk mitigation)
- Add AnthropicProvider (native SDK) and OpenAICompatProvider (unified)
- Merge CustomProvider into OpenAICompatProvider, delete custom_provider.py
- Add ProviderSpec.backend field for declarative provider routing
- Remove _resolve_model, find_gateway, find_by_model (dead heuristics)
- Pass resolved spec directly into provider — zero internal lookups
- Stub out litellm-dependent model database (cli/models.py)
- Add anthropic>=0.45.0 to dependencies, remove litellm
- 593 tests passed, net -1034 lines
2026-03-25 01:58:48 +08:00
Xubin Ren 38ce054b31 fix(security): pin litellm and add supply chain advisory note 2026-03-24 15:55:43 +00:00
chengyongruandXubin Ren 72acba5d27 refactor(tests): optimize unit test structure 2026-03-24 15:12:22 +08:00
Xubin RenandXubin Ren d25985be0b fix(filesystem): clarify optional tool argument handling
Keep the mypy-friendly optional execute signatures while returning clearer errors for missing arguments and locking that behavior with regression tests.

Made-with: Cursor
2026-03-24 11:49:10 +08:00
19emtuckandXubin Ren d4a7194c88 remove some none used f string 2026-03-24 11:49:10 +08:00
19emtuckandXubin Ren 69f1dcdba7 proposal to adopt mypy some e.g. interfaces problems 2026-03-24 11:49:10 +08:00
Xubin RenandGitHub c00e64a817 Merge PR #2386: feat(channel): enhance Telegram, QQ, Feishu, and WhatsApp
feat: telegram/qq/whatsapp/feishu enhancement
2026-03-24 11:40:15 +08:00
Xubin Ren a96dd8babb Merge branch 'main' into feat/channel_enhancement
Keep the channel enhancements aligned with the current codebase while preserving a simpler product surface. This keeps QQ, Feishu, Telegram, and WhatsApp improvements together, removes the extra Telegram-only tool hint toggle, and makes WhatsApp mention-only groups actually work.
2026-03-24 03:33:44 +00:00
Xubin Ren 14763a6ad1 fix(provider): accept canonical and alias provider names consistently 2026-03-24 03:03:59 +00:00
Xubin Ren d454386f32 docs(weixin): clarify source-only installation in README 2026-03-24 02:51:50 +00:00
Xubin RenandGitHub b5c95b1a34 Merge PR #2204: fix(cron): scope cron state to each workspace with safe default-only migration
fix(cron): scope cron state to each workspace with safe default-only migration
2026-03-24 10:46:49 +08:00
Xubin Ren 186357e80c Merge branch 'main' into fix/workspace-scoped-cron-store
Keep cron state workspace-scoped while only migrating legacy jobs into the default workspace. This preserves seamless upgrades for existing installs without polluting intentionally new workspaces.
2026-03-24 02:41:58 +00:00
Xubin Ren 1d58c9b9e1 docs: update channel table and add plugin dev note 2026-03-23 17:17:10 +00:00
Xubin RenandXubin Ren 25288f9951 feat(whatsapp): add outbound media support via bridge 2026-03-24 01:11:33 +08:00
Xubin RenandXubin Ren bef88a5ea1 docs: require explicit channel login command 2026-03-24 01:11:33 +08:00
Xubin RenandXubin Ren d164548d9a docs(weixin): add setup guide and focused channel tests 2026-03-24 01:11:33 +08:00
Xubin RenandXubin Ren 0ca639bf22 fix(cli): use discovered class for channel login 2026-03-24 01:11:33 +08:00
556b21d011 refactor(channels): abstract login() into BaseChannel, unify CLI commands
Move channel-specific login logic from CLI into each channel class via a
new `login(force=False)` method on BaseChannel. The `channels login <name>`
command now dynamically loads the channel and calls its login() method.

- WeixinChannel.login(): calls existing _qr_login(), with force to clear saved token
- WhatsAppChannel.login(): sets up bridge and spawns npm process for QR login
- CLI no longer contains duplicate login logic per channel
- Update CHANNEL_PLUGIN_GUIDE to document the login() hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 01:11:33 +08:00
11e1bbbab7 feat(weixin): add outbound media file sending via CDN upload
Previously the WeChat channel's send() method only handled text messages,
completely ignoring msg.media. When the agent called message(media=[...]),
the file was never delivered to the user.

Implement the full WeChat CDN upload protocol following the reference
@tencent-weixin/openclaw-weixin v1.0.2:
  1. Generate a client-side AES-128 key (16 random bytes)
  2. Call getuploadurl with file metadata + hex-encoded AES key
  3. AES-128-ECB encrypt the file and POST to CDN with filekey param
  4. Read x-encrypted-param from CDN response header as download param
  5. Send message with the media item (image/video/file) referencing
     the CDN upload

Also adds:
- _encrypt_aes_ecb() for AES-128-ECB encryption (reverse of existing
  _decrypt_aes_ecb)
- Media type detection from file extension (image/video/file)
- Graceful error handling: failed media sends notify the user via text
  without blocking subsequent text delivery

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:11:33 +08:00
8abbe8a6df fix(agent): instruct LLM to use message tool for file delivery
During testing, we discovered that when a user requests the agent to
send a file (e.g., "send me IMG_1115.png"), the agent would call
read_file to view the content and then reply with text claiming
"file sent" — but never actually deliver the file to the user.

Root cause: The system prompt stated "Reply directly with text for
conversations. Only use the 'message' tool to send to a specific
chat channel", which led the LLM to believe text replies were
sufficient for all responses, including file delivery.

Fix: Add an explicit IMPORTANT instruction in the system prompt
telling the LLM it MUST use the 'message' tool with the 'media'
parameter to send files, and that read_file only reads content
for its own analysis.

Co-Authored-By: qulllee <qullkui@tencent.com>
2026-03-24 01:11:33 +08:00
qullleeandXubin Ren bc9f861bb1 feat: add media message support in agent context and message tool
Cherry-picked from PR #2355 (ad128a7) — only agent/context.py and agent/tools/message.py.

Co-Authored-By: qulllee <qullkui@tencent.com>
2026-03-24 01:11:33 +08:00
ebc4c2ec35 feat(weixin): add personal WeChat channel via ilinkai HTTP long-poll API
Add a new WeChat (微信) channel that connects to personal WeChat using
the ilinkai.weixin.qq.com HTTP long-poll API. Protocol reverse-engineered
from @tencent-weixin/openclaw-weixin v1.0.2.

Features:
- QR code login flow (nanobot weixin login)
- HTTP long-poll message receiving (getupdates)
- Text message sending with proper WeixinMessage format
- Media download with AES-128-ECB decryption (image/voice/file/video)
- Voice-to-text from WeChat + Groq Whisper fallback
- Quoted message (ref_msg) support
- Session expiry detection and auto-pause
- Server-suggested poll timeout adaptation
- Context token caching for replies
- Auto-discovery via channel registry

No WebSocket, no Node.js bridge, no local WeChat client needed — pure
HTTP with a bot token obtained via QR code scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:11:33 +08:00
Xubin RenandXubin Ren 2056061765 refine heartbeat session retention boundaries 2026-03-24 00:33:43 +08:00
flobo3andXubin Ren ba0a3d14d9 fix: clear heartbeat session to prevent token overflow
(cherry picked from commit 5c871d75d5)
2026-03-24 00:33:43 +08:00
Eric YangandXubin Ren 84a7f8af73 refactor(shell): fix syntax error 2026-03-24 00:02:49 +08:00
Eric YangandXubin Ren e2e1c9c276 refactor(shell): use finally block to reap zombie processes on timeoutx 2026-03-24 00:02:49 +08:00
Eric YangandXubin Ren dbcc7cb539 refactor(shell): use finally block to reap zombie processes on timeout 2026-03-24 00:02:49 +08:00
Eric YangandXubin Ren e423ceef9c fix(shell): reap zombie processes when command timeout kills subprocess 2026-03-24 00:02:49 +08:00
gem12andXubin Ren 97fe9ab7d4 feat(agent): replace global lock with per-session locks for concurrent dispatch
Replace the single _processing_lock (asyncio.Lock) with per-session locks
so that different sessions can process LLM requests concurrently, while
messages within the same session remain serialised.

An optional global concurrency cap is available via the
NANOBOT_MAX_CONCURRENT_REQUESTS env var (default 3, <=0 for unlimited).

Also re-binds tool context before each tool execution round to prevent
concurrent sessions from clobbering each other's routing info.

Tested in production and manually reviewed.

(cherry picked from commit c397bb4229)
2026-03-23 18:57:03 +08:00
Xubin RenandXubin Ren 20494a2c52 refactor command routing for future plugins and clearer CLI structure 2026-03-23 16:48:42 +08:00
kohathandchengyongru 4145f3eacc feat(feishu): add thread reply support for topic group messages 2026-03-23 15:52:14 +08:00
flobo3andchengyongru b14d5a0a1d feat(whatsapp): add group_policy to control bot response behavior in groups 2026-03-23 15:48:51 +08:00
chengyongru e4137736f6 fix(qq): handle file:// URI on Windows in _read_media_bytes
urlparse on Windows puts the path in netloc, not path. Use
(parsed.path or parsed.netloc) to get the correct raw path.
2026-03-23 15:48:31 +08:00
2db2cc18f1 fix(qq): fix local file outbound and add svg as image type (#2294)
- Fix _read_media_bytes treating local paths as URLs: local file
  handling code was dead code placed after an early return inside the
  HTTP try/except block. Restructure to check for local paths (plain
  path or file:// URI) before URL validation, so files like
  /home/.../.nanobot/workspace/generated_image.svg can be read and
  sent correctly.
- Add .svg to _IMAGE_EXTS so SVG files are uploaded as file_type=1
  (image) instead of file_type=4 (file).
- Add tests for local path, file:// URI, and missing file cases.

Fixes: https://github.com/HKUDS/nanobot/pull/1667#issuecomment-4096400955

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:48:21 +08:00
d7373db419 feat(qq): bot can send and receive images and files (#1667)
Implement file upload and sending for QQ C2C messages

Reference: https://github.com/tencent-connect/botpy/blob/master/examples/demo_c2c_reply_file.py

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
2026-03-23 15:47:59 +08:00
Floandchengyongru 80ee2729ac feat(telegram): add silent_tool_hints config to disable notifications for tool hints (#2252) 2026-03-23 15:46:08 +08:00
flobo3andchengyongru 9a2b1a3f1a feat(telegram): add react_emoji config for incoming messages 2026-03-23 15:37:11 +08:00
Xubin Ren 9f19297056 Merge remote-tracking branch 'origin/main' into advisory-email-fix
Made-with: Cursor

# Conflicts:
#	nanobot/config/schema.py
2026-03-23 05:06:00 +00:00
Xubin RenandXubin Ren aba0b83a77 fix(memory): reserve completion headroom for consolidation
Trigger token consolidation before prompt usage reaches the full context window so response tokens and tokenizer estimation drift still fit safely within the model budget.

Made-with: Cursor
2026-03-23 11:54:44 +08:00
Xubin Ren 8f5c2d1a06 fix(cli): stop spinner after non-streaming interactive replies 2026-03-23 03:28:10 +00:00
chengyongruandXubin Ren a46803cbd7 docs(provider): add mistral intro 2026-03-23 11:07:46 +08:00
Desmond SowandXubin Ren f64ae3b900 feat(provider): add OpenVINO Model Server provider (#2193)
add OpenVINO Model Server provider
2026-03-23 11:07:46 +08:00
Matt von RohrandXubin Ren 7878340031 feat(providers): add Mistral AI provider
Register Mistral as a first-class provider with LiteLLM routing,
MISTRAL_API_KEY env var, and https://api.mistral.ai/v1 default base.

Includes schema field, registry entry, and tests.
2026-03-23 11:07:46 +08:00
Xubin RenandXubin Ren 9d5e511a6e feat(streaming): centralize think-tag filtering and add Telegram streaming
- Add strip_think() to helpers.py as single source of truth
- Filter deltas in agent loop before dispatching to consumers
- Implement send_delta in TelegramChannel with progressive edit_message_text
- Remove duplicate think filtering from CLI stream.py and telegram.py
- Remove legacy fake streaming (send_message_draft) from Telegram
- Default Telegram streaming to true
- Update CHANNEL_PLUGIN_GUIDE.md with streaming documentation

Made-with: Cursor
2026-03-23 10:20:41 +08:00
Xubin RenandXubin Ren f2e1cb3662 feat(cli): extract streaming renderer to stream.py with Rich Live
Move ThinkingSpinner and StreamRenderer into a dedicated module to keep
commands.py focused on orchestration. Uses Rich Live with manual refresh
(auto_refresh=False) and ellipsis overflow for stable streaming output.

Made-with: Cursor
2026-03-23 10:20:41 +08:00
Xubin RenandXubin Ren bd621df57f feat: add streaming channel support with automatic fallback
Provider layer: add chat_stream / chat_stream_with_retry to all providers
(base fallback, litellm, custom, azure, codex). Refactor shared kwargs
building in each provider.

Channel layer: BaseChannel gains send_delta (no-op) and supports_streaming
(checks config + method override). ChannelManager routes _stream_delta /
_stream_end to send_delta, skips _streamed final messages.

AgentLoop._dispatch builds bus-backed on_stream/on_stream_end callbacks
when _wants_stream metadata is set. Non-streaming path unchanged.

CLI: clean up spinner ANSI workarounds, simplify commands.py flow.
Made-with: Cursor
2026-03-23 10:20:41 +08:00
Xubin RenandXubin Ren e79b9f4a83 feat(agent): add streaming groundwork for future TUI
Preserve the provider and agent-loop streaming primitives plus the CLI experiment scaffolding so this work can be resumed later without blocking urgent bug fixes on main.

Made-with: Cursor
2026-03-23 10:20:41 +08:00
Xubin RenandGitHub 5fd66cae5c Merge PR #1109: perf: optimize prompt cache hit rate for Anthropic models
perf: optimize prompt cache hit rate for Anthropic models
2026-03-22 14:23:41 +08:00
Xubin Ren 931cec3908 Merge remote-tracking branch 'origin/main' into pr-1109
Resolve conflict in context.py: keep main's build_messages which already
merges runtime context into user message (achieving the same cache goal).
The real value-add from this PR is the second cache breakpoint in
litellm_provider.py.

Made-with: Cursor
2026-03-22 06:14:18 +00:00
Xubin RenandXubin Ren 1c71489121 fix(agent): count all message fields in token estimation
estimate_prompt_tokens() only counted the `content` text field, completely
missing tool_calls JSON (~72% of actual payload), reasoning_content,
tool_call_id, name, and per-message framing overhead. This caused the
memory consolidator to never trigger for tool-heavy sessions (e.g. cron
jobs), leading to context window overflow errors from the LLM provider.

Also adds reasoning_content counting and proper per-message overhead to
estimate_message_tokens() for consistent boundary detection.

Made-with: Cursor
2026-03-22 12:19:44 +08:00
Xubin RenandXubin Ren 48c71bb61e refactor(agent): unify process_direct to return OutboundMessage
Merge process_direct() and process_direct_outbound() into a single
interface returning OutboundMessage | None. This eliminates the
dual-path detection logic in CLI single-message mode that relied on
inspect.iscoroutinefunction to distinguish between the two APIs.

Extract status rendering into a pure function build_status_content()
in utils/helpers.py, decoupling it from AgentLoop internals.

Made-with: Cursor
2026-03-22 00:39:38 +08:00
Xubin RenandGitHub 064ca256f5 Merge PR #1985: feat: add /status command to show runtime info
feat: add /status command to show runtime info
2026-03-22 00:11:34 +08:00
Xubin Ren a8176ef2c6 fix(cli): keep direct-call rendering compatible in tests
Only use process_direct_outbound when the agent loop actually exposes it as an async method, and otherwise fall back to the legacy process_direct path. This keeps the new CLI render-metadata flow without breaking existing test doubles or older direct-call implementations.

Made-with: Cursor
2026-03-21 16:07:14 +00:00
Xubin Ren e430b1daf5 fix(agent): refine status output and CLI rendering
Keep status output responsive while estimating current context from session history, dropping low-value queue/subagent counters, and marking command-style replies for plain-text rendering in CLI. Also route direct CLI calls through outbound metadata so help/status formatting stays explicit instead of relying on content heuristics.

Made-with: Cursor
2026-03-21 15:52:10 +00:00
Xubin Ren 4d1897609d fix(agent): make status command responsive and accurate
Handle /status at the run-loop level so it can return immediately while the agent is busy, and reset last-usage stats when providers omit usage data. Also keep Telegram help/menu coverage for /status without changing the existing final-response send path.

Made-with: Cursor
2026-03-21 15:21:32 +00:00
Xubin Ren 570ca47483 Merge branch 'main' into pr-1985 2026-03-21 09:48:09 +00:00
Xubin RenandXubin Ren e87bb0a82d fix(mcp): preserve schema semantics during normalization
Only normalize nullable MCP tool schemas for OpenAI-compatible providers so optional params still work without collapsing unrelated unions. Also teach local validation to honor nullable flags and add regression coverage for nullable and non-nullable schemas.

Made-with: Cursor
2026-03-21 14:35:47 +08:00
haosenwang1018andXubin Ren b6cf7020ac fix: normalize MCP tool schema for OpenAI-compatible providers 2026-03-21 14:35:47 +08:00
Xubin Ren 9f10ce072f Merge PR #2304: feat(agent): implement native multimodal tool perception
Add native image content blocks for read_file and web_fetch, preserve the multimodal tool-result path through the agent loop, and keep session history compact with image placeholders. Also harden web_fetch against redirect-based SSRF bypasses and add regression coverage for image reads and blocked private redirects.
2026-03-21 05:39:17 +00:00
Xubin Ren 445a96ab55 fix(agent): harden multimodal tool result flow
Keep multimodal tool outputs on the native content-block path while
restoring redirect SSRF checks for web_fetch image responses. Also share
image block construction, simplify persisted history sanitization, and
add regression tests for image reads and blocked private redirects.

Made-with: Cursor
2026-03-21 05:34:56 +00:00
Xubin Ren 834f1e3a9f Merge branch 'main' into pr-2304 2026-03-21 04:14:40 +00:00
Xubin RenandXubin Ren 32f4e60145 refactor(providers): hide oauth-only providers from config setup
Exclude openai_codex alongside github_copilot from generated config,
filter OAuth-only providers out of the onboarding wizard, and clarify in
README that OAuth login stores session state outside config. Also unify
the GitHub Copilot login command spelling and add regression tests.

Made-with: Cursor
2026-03-21 03:20:59 +08:00
Harvey MackieandXubin Ren e029d52e70 chore: remove redundant github_copilot field from config.json 2026-03-21 03:20:59 +08:00
Harvey MackieandXubin Ren 055e2f3816 docs: add github copilot oauth channel setup instructions 2026-03-21 03:20:59 +08:00
Xubin RenandXubin Ren 542455109d fix(email): preserve fetched messages across IMAP retry
Keep messages already collected in the current poll cycle when a stale
IMAP connection dies mid-fetch, so retrying once does not drop emails
that were already parsed and marked seen. Add a regression test covering
a mid-cycle disconnect after the first message succeeds.

Made-with: Cursor
2026-03-21 03:00:39 +08:00
jr_blue_551andXubin Ren b16bd2d9a8 Harden email IMAP polling retries 2026-03-21 03:00:39 +08:00
KianandXubin Ren d7f6cbbfc4 fix: add openssh-client and use HTTPS for GitHub in Docker build
- Add openssh-client to apt dependencies for git operations
- Configure git to use HTTPS instead of SSH for github.com to avoid
  SSH key requirements during Docker build

Made-with: Cursor
2026-03-21 02:43:11 +08:00
James WrigleyandXubin Ren 9aaeb7ebd8 Add support for -h in the CLI 2026-03-21 02:36:48 +08:00
Xubin RenandXubin Ren 09ad9a4673 feat(cron): add run history tracking for cron jobs
Record run_at_ms, status, duration_ms and error for each execution,
keeping the last 20 entries per job in jobs.json. Adds CronRunRecord
dataclass, get_job() lookup, and four regression tests covering
success, error, trimming and persistence.

Closes #1837

Made-with: Cursor
2026-03-21 02:28:35 +08:00
Xubin RenandGitHub ec2e12b028 Merge PR #1824: feat(tools): enhance ExecTool with enable flag
feat(tools): enhance ExecTool with enable flag
2026-03-21 01:54:18 +08:00
Xubin Ren 1c39a4d311 refactor(tools): keep exec enable without configurable deny patterns
Made-with: Cursor
2026-03-20 17:46:08 +00:00
Xubin Ren dc1aeeaf8b docs: document exec tool enable and denyPatterns
Made-with: Cursor
2026-03-20 17:24:40 +00:00
Xubin Ren 3825ed8595 merge origin/main into pr-1824
- wire tools.exec.enable and deny_patterns into the current AgentLoop
- preserve the current WebSearchTool config-based registration path
- treat deny_patterns=[] as an explicit override instead of falling back
  to the default blacklist
- add regression coverage for disabled exec registration and custom deny
  patterns

Made-with: Cursor
2026-03-20 17:21:42 +00:00
vandazia 71a88da186 feat: implement native multimodal autonomous sensory capabilities 2026-03-20 22:00:38 +08:00
Xubin RenandXubin Ren aacbb95313 fix(agent): preserve external cancellation in message loop
Made-with: Cursor
2026-03-20 19:27:26 +08:00
cdkey85andXubin Ren d83ba36800 fix(agent): handle asyncio.CancelledError in message loop
- Catch asyncio.CancelledError separately from generic exceptions
- Re-raise CancelledError only when loop is shutting down (_running is False)
- Continue processing messages if CancelledError occurs during normal operation
- Prevents anyio/MCP cancel scopes from prematurely terminating the agent loop
2026-03-20 19:27:26 +08:00
Xubin RenandXubin Ren fc1ea07450 fix(custom_provider): truncate raw error body to prevent huge HTML pages
Made-with: Cursor
2026-03-20 19:12:09 +08:00
8b971a7827 fix(custom_provider): show raw API error instead of JSONDecodeError
When an OpenAI-compatible API returns a non-JSON response (e.g. plain
text "unsupported model: xxx" with HTTP 200), the OpenAI SDK raises a
JSONDecodeError whose message is the unhelpful "Expecting value: line 1
column 1 (char 0)".  Extract the original response body from
JSONDecodeError.doc (or APIError.response.text) so users see the actual
error message from the API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 19:12:09 +08:00
Xubin RenandXubin Ren f44c4f9e3c refactor: remove deprecated memory_window, harden wizard display 2026-03-20 18:46:13 +08:00
Xubin RenandXubin Ren c3a4b16e76 refactor: optimize onboard wizard - mask secrets, remove emoji, reduce repetition
- Mask sensitive fields (api_key/token/secret/password) in all display
  surfaces, showing only the last 4 characters
- Replace all emoji with pure ASCII labels for consistent cross-platform
  terminal rendering
- Extract _print_summary_panel helper, eliminating 5x duplicate table
  construction in _show_summary
- Replace 3 one-line wrapper functions with declarative _SETTINGS_SECTIONS
  dispatch tables and _MENU_DISPATCH in run_onboard
- Extract _handle_model_field / _handle_context_window_field into a
  _FIELD_HANDLERS registry, shrinking _configure_pydantic_model
- Return FieldTypeInfo NamedTuple from _get_field_type_info for clarity
- Replace global mutable _PROVIDER_INFO / _CHANNEL_INFO with @lru_cache
- Use vars() instead of dir() in _get_channel_info for reliable config
  class discovery
- Defer litellm import in model_info.py so non-wizard CLI paths stay fast
- Clarify README Quick Start wording (Add -> Configure)
2026-03-20 18:46:13 +08:00
45e89d917b fix(onboard): require explicit save in interactive wizard
Cherry-pick from d6acf1a with manual merge resolution.
Keep onboarding edits in draft state until users choose Done or Save and
Exit, so backing out or discarding the wizard no longer persists partial
changes.

Co-Authored-By: Jason Zhao <144443939+JasonZhaoWW@users.noreply.github.com>
2026-03-20 18:46:13 +08:00
chengyongruandXubin Ren a6fb90291d feat(onboard): pass CLI args as initial config to interactive wizard
--workspace and --config now work as initial defaults in interactive mode:
- The wizard starts with these values pre-filled
- Users can view and modify them in the wizard
- Final saved config reflects user's choices

This makes the CLI args more useful for interactive sessions while
still allowing full customization through the wizard.
2026-03-20 18:46:13 +08:00
67528deb4c fix(tests): use --no-interactive for non-interactive onboard tests
Tests for non-interactive onboard mode now explicitly use --no-interactive
flag since the default changed to interactive mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:46:13 +08:00
chengyongruandXubin Ren 606e8fa450 feat(onboard): add field hints and Escape/Left navigation
- Add `_SELECT_FIELD_HINTS` for select fields with predefined choices
  (e.g., reasoning_effort: low/medium/high with hint text)
- Add `_select_with_back()` using prompt_toolkit for custom key bindings
- Support Escape and Left arrow keys to go back in menus
- Apply to field config, provider selection, and channel selection menus
2026-03-20 18:46:13 +08:00
chengyongruandXubin Ren 814c72eac3 refactor(tests): extract onboard logic tests to dedicated module
- Move onboard-related tests from test_commands.py and test_config_migration.py
  to new test_onboard_logic.py for better organization
- Add comprehensive unit tests for:
  - _merge_missing_defaults recursive config merging
  - _get_field_type_info type extraction
  - _get_field_display_name human-readable name generation
  - _format_value display formatting
  - sync_workspace_templates file synchronization
- Remove unused dev dependencies (matrix-nio, mistune, nh3) from pyproject.toml
2026-03-20 18:46:13 +08:00
chengyongruandXubin Ren 3369613727 feat(onboard): add model autocomplete and auto-fill context window
- Add model_info.py module with litellm-based model lookup
- Provide autocomplete suggestions for model names
- Auto-fill context_window_tokens when model changes (only at default)
- Add "Get recommended value" option for manual context lookup
- Dynamically load provider keywords from registry (no hardcoding)

Resolves #2018
2026-03-20 18:46:13 +08:00
chengyongruandXubin Ren f127af0481 feat: add interactive onboard wizard for LLM provider and channel configuration 2026-03-20 18:46:13 +08:00
Xubin RenandXubin Ren c138b2375b docs: refine spawn workspace guidance wording
Adjust the spawn tool description to keep the workspace-organizing hint while
avoiding language that sounds like the system automatically assigns a dedicated
working directory for subagents.

Made-with: Cursor
2026-03-20 13:30:21 +08:00
JilunSun7274andXubin Ren e5179aa7db delete redundant whitespaces in subagent prompts 2026-03-20 13:30:21 +08:00
JilunSun7274andXubin Ren 517de6b731 docs: add subagent workspace assignment hint to spawn tool description 2026-03-20 13:30:21 +08:00
mamamiyearandXubin Ren d70ed0d97a fix: nanobot onboard update config crash
when use onboard and choose N,
maybe sometimes will be crash and
config file will be invalid.
2026-03-20 13:16:56 +08:00
0b1beb0e9f Fix TypeError for MCP tools with nullable JSON Schema params
MCP servers (e.g. Zapier) return JSON Schema union types like
`"type": ["string", "null"]` for nullable parameters. The existing
`validate_params()` and `cast_params()` methods expected only simple
strings as `type`, causing `TypeError: unhashable type: 'list'` on
every MCP tool call with nullable parameters.

Add `_resolve_type()` helper that extracts the first non-null type
from union types, and use it in `_cast_value()` and `_validate()`.
Also handle `None` values correctly when the schema declares a
nullable type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:13:11 +08:00
Xubin RenandXubin Ren dd7e3e499f fix: separate Telegram connection pools and add timeout retry to prevent pool exhaustion
The root cause of "Pool timeout" errors is that long-polling (getUpdates)
and outbound API calls (send_message, send_photo, etc.) shared the same
HTTPXRequest pool — polling holds connections indefinitely, starving sends
under concurrent load (e.g. cron jobs + user chat).

- Split into two independent pools: API calls (default 32) and polling (4)
- Expose connection_pool_size / pool_timeout in TelegramConfig for tuning
- Add _call_with_retry() with exponential backoff (3 attempts) on TimedOut
- Apply retry to _send_text and remote media URL sends
2026-03-19 16:15:41 +08:00
mamamiyearandXubin Ren d9cb729596 feat: support feishu code block 2026-03-19 13:59:31 +08:00
Xubin Ren 214bf66a29 docs(readme): clarify nanobot is unrelated to crypto 2026-03-18 15:18:38 +00:00
Xubin RenandXubin Ren 4b052287cb fix(telegram): validate remote media URLs 2026-03-18 23:12:11 +08:00
h4nz4andXubin Ren a7bd0f2957 feat(telegram): support HTTP(S) URLs for media in TelegramChannel
Fixes #1792
2026-03-18 23:12:11 +08:00
Xubin RenandXubin Ren 728d4e88a9 fix(providers): lazy-load provider exports 2026-03-18 22:01:29 +08:00
Javis486andXubin Ren 28127d5210 When using custom_provider, a prompt "LiteLLM:WARNING" will still appear during conversation 2026-03-18 22:01:29 +08:00
MiguelPFandClaude Opus 4.6 4e56481f0b add one-time migration for legacy global cron store
When upgrading, if jobs.json exists at the old global path and not yet
at the workspace path, move it automatically.  Prevents silent loss of
existing cron jobs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:16:06 +01:00
MiguelPFandClaude Opus 4.6 c33e01ee62 fix(cron): scope cron job store to workspace instead of global directory
Replace `get_cron_dir()` with `config.workspace_path / "cron"` so each
workspace keeps its own `jobs.json`.  This lets users run multiple
nanobot instances with independent cron schedules without cross-talk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:11:01 +01:00
Xubin Ren 4e40f0aa03 docs: MiniMax gifts to the nanobot community 2026-03-18 05:09:03 +00:00
vivganesandXubin Ren e6910becb6 logo: transparent background
Also useful when we build the gateway.  Dark and bright modes can use the same logo.
2026-03-18 12:41:38 +08:00
Xubin RenandXubin Ren 5bd1c9ab8f fix(cron): preserve exact intervals in list output 2026-03-18 12:39:06 +08:00
12aa7d7aca test(cron): add unit tests for _format_timing and _format_state helpers
Tests the helpers directly without needing CronService, covering all
schedule kinds, edge cases (missing fields, unknown status), and
combined state output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:39:06 +08:00
8d45fedce7 refactor(cron): extract _format_timing and _format_state helpers
Addresses review feedback: moves schedule formatting and state
formatting into dedicated static methods, removes duplicate
in-loop imports, and simplifies _list_jobs() to a clean loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:39:06 +08:00
228e1bb3de style: apply ruff format to cron tool
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:39:06 +08:00
5d8c5d2d25 style(test): fix import sorting and remove unused imports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:39:06 +08:00
787e667dc9 test(cron): add tests for _list_jobs() schedule and state formatting
Covers all three schedule kinds (cron/every/at), human-readable interval
formatting, run state display (last run, status, errors, next run),
and disabled job filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:39:06 +08:00
eb83778f50 fix(cron): show schedule details and run state in _list_jobs() output
_list_jobs() only displayed job name, id, and schedule kind (e.g. "cron"),
omitting the actual timing and run state. The agent couldn't answer
"when does this run?" or "did it run?" even though CronSchedule and
CronJobState had all the data.

Now surfaces:
- Cron expression + timezone for cron jobs
- Human-readable interval for every jobs
- ISO timestamp for one-shot at jobs
- Enabled/disabled status
- Last run time + status (ok/error/skipped) + error message
- Next scheduled run time

Fixes #1496

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:39:06 +08:00
zhangxiaoyu.yorkandXubin Ren f72ceb7a3c fix:set subagent result message role = assistant 2026-03-18 00:43:46 +08:00
angleyanalbedoandXubin Ren 20e3eb8fce docs(readme): fix broken link to Channel Plugin Guide 2026-03-17 23:09:35 +08:00
Xubin RenandXubin Ren 8cf11a0291 fix: preserve image paths in fallback and session history 2026-03-17 22:37:09 +08:00
Xubin RenandXubin Ren 7086f57d05 test(feishu): cover media msg_type mapping 2026-03-17 17:05:13 +08:00
weipeng0098andXubin Ren 47e2a1e8d7 fix(feishu): use correct msg_type for audio/video files 2026-03-17 17:05:13 +08:00
Xubin RenandXubin Ren 41d59c3b89 test(feishu): cover heading and table markdown rendering 2026-03-17 16:51:02 +08:00
Your NameandXubin Ren 9afbf386c4 fix(feishu): fix markdown rendering issues in headings and tables
- Fix double bold markers (****) when heading text already contains **
- Strip markdown formatting (**bold**, *italic*, ~~strike~~) from table cells
  since Feishu table elements do not support markdown rendering

Fixes rendering issues where:
1. Headings like '**text**' were rendered as '****text****'
2. Table cells with '**bold**' showed raw markdown instead of plain text
2026-03-17 16:51:02 +08:00
Xubin RenandXubin Ren 91ca82035a feat(slack): add default done reaction on completion 2026-03-17 16:19:08 +08:00
Sihyeon JangandXubin Ren 8aebe20cac feat(slack): update reaction emoji on task completion
Remove the in-progress reaction (reactEmoji) and optionally add a
done reaction (doneEmoji) when the final response is sent, so users
get visual feedback that processing has finished.

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2026-03-17 16:19:08 +08:00
kinchahoy 7913e7150a feat: sandbox exec calls with bwrap and run container as non-root 2026-03-16 23:55:19 -07:00
Xubin RenandXubin Ren 49fc50b1e6 test(custom): cover empty choices response handling 2026-03-17 14:24:55 +08:00
Jiajun XieandXubin Ren 2eb0c283e9 fix(providers): handle empty choices in custom provider response 2026-03-17 14:24:55 +08:00
Xubin RenandGitHub b939a916f0 Merge PR #1763: align onboard with config and workspace overrides
align onboard with config and workspace overrides
2026-03-17 14:03:50 +08:00
Xubin Ren 499d0e1588 docs(readme): update multi-instance onboard examples 2026-03-17 05:58:13 +00:00
Xubin Ren b2a550176e feat(onboard): align setup with config and workspace flags 2026-03-17 05:42:49 +00:00
Xubin RenandGitHub a9621e109f Merge PR #1136: fix: workspace path in onboard command ignores config setting
fix: workspace path in onboard command ignores config setting
2026-03-17 13:10:32 +08:00
Xubin Ren 40a022afd9 fix(onboard): use configured workspace path on setup 2026-03-17 05:01:34 +00:00
Xubin Ren c4cc2a9fb4 Merge remote-tracking branch 'origin/main' into pr-1136 2026-03-17 04:42:01 +00:00
Xubin Ren db37ecbfd2 fix(custom): support extraHeaders for OpenAI-compatible endpoints 2026-03-17 04:28:24 +00:00
Xubin Ren 84565d702c docs: update v0.1.4.post5 release news 2026-03-16 15:28:41 +00:00
Xubin Ren df7ad91c57 docs: update to v0.1.4.post5 release 2026-03-16 15:27:40 +00:00
Xubin Ren 337c4600f3 bump version to 0.1.4.post5 2026-03-16 15:11:15 +00:00
Xubin Ren dbe9cbc78e docs: update news section 2026-03-16 14:27:28 +00:00
PeterandXubin Ren 4e67bea697 Delete .claude directory 2026-03-16 22:17:40 +08:00
Peter van EijkandXubin Ren 93f363d4d3 qol: add version id to logging 2026-03-16 22:17:40 +08:00
Peter van EijkandXubin Ren ad1e9b2093 pull remote 2026-03-16 22:17:40 +08:00
Xubin RenandXubin Ren 2eceb6ce8a fix(cli): pause spinner cleanly before printing progress output 2026-03-16 22:17:29 +08:00
who96andXubin Ren 9a652fdd35 refactor(cli): restore context manager pattern for spinner lifecycle
Replace manual _active_spinner + _pause_spinner/_resume_spinner with
_ThinkingSpinner class that owns the spinner lifecycle via __enter__/
__exit__ and provides a pause() context manager for temporarily
stopping the spinner during progress output.

Benefits:
- Restores Pythonic context manager pattern matching original code
- Eliminates duplicated start/stop boilerplate between single-message
  and interactive modes
- pause() context manager guarantees resume even if print raises
- _active flag prevents post-teardown resume from async callbacks
2026-03-16 22:17:29 +08:00
who96andXubin Ren 48fe92a8ad fix(cli): stop spinner before printing tool progress lines
The Rich console.status() spinner ('nanobot is thinking...') was not
cleared when tool call progress lines were printed during processing,
causing overlapping/garbled terminal output.

Replace the context-manager approach with explicit start/stop lifecycle:
- _pause_spinner() stops the spinner before any progress line is printed
- _resume_spinner() restarts it after printing
- Applied to both single-message mode (_cli_progress) and interactive
  mode (_consume_outbound)

Closes #1956
2026-03-16 22:17:29 +08:00
Xubin RenandXubin Ren 92f3d5a8b3 fix: keep truncated session history tool-call consistent 2026-03-16 17:25:30 +08:00
riseandXubin Ren db276bdf2b Fix orphan tool results in truncated session history 2026-03-16 17:25:30 +08:00
Xubin Ren 94b5956309 perf: background post-response memory consolidation for faster replies 2026-03-16 09:06:05 +00:00
Xubin Ren 46b19b15e1 perf: background post-response memory consolidation for faster replies 2026-03-16 09:01:11 +00:00
Xubin Ren 6d63e22e86 Merge remote-tracking branch 'origin/main' into pr-1961
Made-with: Cursor

# Conflicts:
#	.gitignore
2026-03-16 08:47:28 +00:00
Xubin RenandXubin Ren b29275a1d2 refactor(/new): background archival with guaranteed persistence
Replace fire-and-forget consolidation with archive_messages(), which
retries until the raw-dump fallback triggers — making it effectively
infallible. /new now clears the session immediately and archives in
the background. Pending archive tasks are drained on shutdown via
close_mcp() so no data is lost on process exit.
2026-03-16 16:40:09 +08:00
chengyongruandXubin Ren 9820c87537 fix(loop): restore /new immediate return with safe background consolidation
PR #881 (commit 755e424) fixed the race condition between normal consolidation
and /new consolidation, but did so by making /new wait for consolidation to
complete before returning. This hurts user experience - /new should be instant.

This PR restores the original immediate-return behavior while keeping safety:

1. **Immediate return**: Session clears and user sees "New session started" right away
2. **Background archival**: Consolidation runs in background via asyncio.create_task
3. **Serialized consolidation**: Uses the same lock as normal consolidation via
   `memory_consolidator.get_lock()` to prevent concurrent writes

If consolidation fails after session clear, archived messages may be lost.
This is acceptable because:
- User already sees the new session and can continue working
- Failure is logged for debugging
- The alternative (blocking /new on every call) hurts UX for all users
2026-03-16 16:40:09 +08:00
Xubin RenandXubin Ren 6e2b6396a4 security: add SSRF protection, untrusted content marking, and internal URL blocking 2026-03-16 15:05:26 +08:00
Xubin RenandXubin Ren d6df665a2c docs: add contributing guide and align CI with nightly branch 2026-03-16 11:13:46 +08:00
chengyongruandXubin Ren 5a220959af docs: add branching strategy and CONTRIBUTING guide
- Add CONTRIBUTING.md with detailed contribution guidelines
- Add branching strategy section to README.md explaining main/nightly branches
- Include maintainer information and development setup instructions
2026-03-16 11:13:46 +08:00
Xubin RenandXubin Ren 5d1528a5f3 fix(heartbeat): inject shared current time context into phase 1 2026-03-16 10:52:26 +08:00
who96andXubin Ren 0dda2b23e6 fix(heartbeat): inject current datetime into Phase 1 prompt
Phase 1 _decide() now includes "Current date/time: YYYY-MM-DD HH:MM UTC"
in the user prompt and instructs the LLM to use it for time-aware scheduling.
Without this, the LLM defaults to 'run' for any task description regardless
of whether it is actually due, defeating Phase 1's pre-screening purpose.

Closes #1929
2026-03-16 10:52:26 +08:00
Meng YuhangandXubin Ren f9ba6197de fix: save DingTalk downloaded files to media dir instead of /tmp 2026-03-15 23:21:22 +08:00
Meng YuhangandXubin Ren 34358eabc9 feat: support file/image/richText message receiving for DingTalk 2026-03-15 23:21:22 +08:00
Xubin RenandXubin Ren d684fec27a Replace load_skill tool with read_file extra_allowed_dirs for builtin skills access
Instead of adding a separate load_skill tool to bypass workspace restrictions,
extend ReadFileTool with extra_allowed_dirs so it can read builtin skill paths
while keeping write/edit tools locked to the workspace. Fixes the original issue
for both main agent and subagents.

Made-with: Cursor
2026-03-15 23:21:02 +08:00
BenandXubin Ren 45832ea499 Add load_skill tool to bypass workspace restriction for builtin skills
When restrictToWorkspace is enabled, the agent cannot read builtin skill
files via read_file since they live outside the workspace. This adds a
dedicated load_skill tool that reads skills by name through the SkillsLoader,
which accesses files directly via Python without the workspace restriction.

- Add LoadSkillTool to filesystem tools
- Register it in the agent loop
- Update system prompt to instruct agent to use load_skill instead of read_file
- Remove raw filesystem paths from skills summary
2026-03-15 23:21:02 +08:00
Xubin RenandXubin Ren c4628038c6 fix: handle image_url rejection by retrying without images
Replace the static provider-level supports_vision check with a
reactive fallback: when a model returns an image-unsupported error,
strip image_url blocks from messages and retry once. This avoids
maintaining an inaccurate vision capability table and correctly
handles gateway/unknown model scenarios.

Also extract _safe_chat() to deduplicate try/except boilerplate
in chat_with_retry().
2026-03-15 22:32:34 +08:00
coldxiangyuandXubin Ren de0b5b3d91 fix: filter image_url for non-vision models at provider layer
- Add  field to ProviderSpec (default True)
- Add  and  methods in LiteLLMProvider
- Filter image_url content blocks in  before sending to non-vision models
- Reverts session-layer filtering from original PR (wrong layer)

This fixes the issue where switching from Claude (vision-capable) to
non-vision models (e.g., Baidu Qianfan) causes API errors due to
unsupported image_url content blocks.

The provider layer is the correct place for this filtering because:
1. It has access to model/provider capabilities
2. It only affects non-vision models
3. It preserves session layer purity (storage should not know about model capabilities)
2026-03-15 22:32:34 +08:00
Xubin RenandXubin Ren 196e0ddbb6 fix(openrouter): revert custom_llm_provider, always apply gateway prefix 2026-03-15 10:52:36 +08:00
Xubin RenandXubin Ren 350d110fb9 fix(openrouter): remove litellm_prefix to prevent double-prefixed model names
With custom_llm_provider kwarg handling routing, the openrouter/ prefix
caused model names like anthropic/claude-sonnet-4-6 to become
openrouter/anthropic/claude-sonnet-4-6, which OpenRouter API rejects.
2026-03-15 10:52:36 +08:00
Xubin RenandXubin Ren 5ccf350db1 test(litellm_kwargs): add regression tests for PR #2026 OpenRouter kwargs injection 2026-03-15 10:52:36 +08:00
Paresh MathurandXubin Ren 445e0aa2c4 refactor(openrouter): move litellm kwargs into registry 2026-03-15 10:52:36 +08:00
Paresh MathurandXubin Ren 03b55791b4 fix(openrouter): preserve native model prefix 2026-03-15 10:52:36 +08:00
Xubin Ren f6cefcc123 Merge PR #1966: feat(feishu): display tool calls in code block messages + fix empty 2026-03-14 15:48:10 +00:00
Xubin Ren 19ae7a167e fix(feishu): avoid breaking tool hint formatting and think stripping 2026-03-14 15:40:53 +00:00
Xubin Ren 44af7eca3f merge: resolve PR #1966 conflicts with main 2026-03-14 15:32:19 +00:00
Xubin RenandGitHub f1a82c0165 Merge PR #1963: feat(feishu): implement message reply/quote support
feat(feishu): implement message reply/quote support
2026-03-14 23:10:45 +08:00
Xubin Ren a4f6b7d978 merge: resolve PR #1963 conflicts with main 2026-03-14 14:00:00 +00:00
Xubin RenandGitHub 37b994202d Merge PR #1796: fix(telegram): avoid media filename collisions
fix(telegram): avoid media filename collisions
2026-03-14 21:29:07 +08:00
Xubin Ren 86cfbce077 Merge remote-tracking branch 'origin/main' into pr-1796 2026-03-14 13:11:56 +00:00
robbyczgw-cla 43475ed67c Merge remote-tracking branch 'upstream/main' into feat/status-command
# Conflicts:
#	nanobot/channels/telegram.py
2026-03-14 10:48:12 +00:00
Xubin Ren 61f0923c66 fix(telegram): include restart in help text 2026-03-14 10:45:37 +00:00
chengyongruandXubin Ren a2acacd8f2 fix: add exception handling to prevent agent loop crash 2026-03-14 18:34:22 +08:00
Xubin RenandXubin Ren a1241ee68c fix(mcp): clarify enabledTools filtering semantics
- support both raw and wrapped MCP tool names
- treat [\"*\"] as all tools and [] as no tools
- add warnings, tests, and README docs for enabledTools
2026-03-14 18:33:48 +08:00
lihuaandXubin Ren 40fad91ec2 注册mcp时,支持指定tool 2026-03-14 18:33:48 +08:00
lihuaandXubin Ren 4dde195a28 init 2026-03-14 18:33:48 +08:00
Xubin RenandXubin Ren 411b059dd2 refactor: replace <SILENT_OK> with structured post-run evaluation
- Add nanobot/utils/evaluator.py: lightweight LLM tool-call to decide notify/silent after background task execution
- Remove magic token injection from heartbeat and cron prompts
- Clean session history (no more <SILENT_OK> pollution)
- Add tests for evaluator and updated heartbeat three-phase flow
2026-03-14 17:41:08 +08:00
SJK-pyandXubin Ren e6c1f520ac suppress unnecessary heartbeat notifications
Appends a strict instruction to background task prompts (cron and heartbeat) 
directing the agent to return a `<SILENT_OK>` token if there is nothing 
material to report. Adds conditional logic to intercept this token and 
suppress the outbound message to the user, preventing notification spam 
from autonomous background checks.
2026-03-14 17:41:08 +08:00
SJK-pyandXubin Ren 4990c7478b suppress unnecessary cron notifications
Appends a strict instruction to background task prompts (cron and heartbeat) 
directing the agent to return a `<SILENT_OK>` token if there is nothing 
material to report. Adds conditional logic to intercept this token and 
suppress the outbound message to the user, preventing notification spam 
from autonomous background checks.
2026-03-14 17:41:08 +08:00
Peixian GongandXubin Ren 58fc34d3f4 refactor: use shutil.which() instead of shell=True for npm calls
Replace platform-specific shell=True logic with shutil.which('npm') to
resolve the full path to the npm executable. This is cleaner because:

- No shell=True needed (safer, no shell injection risk)
- No platform-specific branching (sys.platform checks removed)
- Works identically on Windows, macOS, and Linux
- shutil.which() resolves npm.cmd on Windows automatically

The npm path check that already existed in _get_bridge_dir() is now
reused as the resolved path for subprocess calls. The same pattern is
applied to channels_login().
2026-03-14 17:19:01 +08:00
Peixian GongandXubin Ren 805228e91e fix: add shell=True for npm subprocess calls on Windows
On Windows, npm is installed as npm.cmd (a batch script), not a direct
executable. When subprocess.run() is called with a list like
['npm', 'install'] without shell=True, Python's CreateProcess cannot
locate npm.cmd, resulting in:

  FileNotFoundError: [WinError 2] The system cannot find the file specified

This fix adds a sys.platform == 'win32' check before each npm subprocess
call. On Windows, it uses shell=True with a string command so the shell
can resolve npm.cmd. On other platforms, the original list-based call is
preserved unchanged.

Affected locations:
- _get_bridge_dir(): npm install, npm run build
- channels_login(): npm start

No behavioral change on Linux/macOS.
2026-03-14 17:19:01 +08:00
Protocol Zero c9cc160600 merge: resolve PR #1796 conflicts with main
Merge the latest main branch into the Telegram media filename fix and keep the file_unique_id-based download path on top of the refactored media handling and newer Telegram tests.

Made-with: Cursor
2026-03-14 08:33:56 +00:00
Xubin Ren af65145bc8 fix(qq): add configurable message format and onboard backfill 2026-03-14 08:25:44 +00:00
chengyongruandXubin Ren 91d95f139e fix: cross-platform test compatibility
- test_channel_plugins: fix assertion logic for discoverable channels
- test_filesystem_tools: normalize path separators for Windows
- test_tool_validation: use python to generate output, avoid cmd line limits
2026-03-14 16:13:38 +08:00
Xubin RenandXubin Ren dbdb43faff feat: channel plugin architecture with decoupled configs
- Add plugin discovery via Python entry_points (group: nanobot.channels)
- Move 11 channel Config classes from schema.py into their own channel modules
- ChannelsConfig now only keeps send_progress + send_tool_hints (extra=allow)
- Each built-in channel parses dict->Pydantic in __init__, zero internal changes
- All channels implement default_config() for onboard auto-population
- nanobot onboard injects defaults for all discovered channels (built-in + plugins)
- Add nanobot plugins list CLI command
- Add Channel Plugin Guide (docs/CHANNEL_PLUGIN_GUIDE.md)
- Fully backward compatible: existing config.json and sessions work as-is
- 340 tests pass, zero regressions
2026-03-14 16:13:38 +08:00
robbyczgw-cla a628741459 feat: add /status command to show runtime info 2026-03-13 16:36:29 +00:00
Xubin RenandGitHub 58389766a7 Merge PR #1981: chore: bump wecom-aibot-sdk-python to >=0.1.5
chore: bump wecom-aibot-sdk-python to >=0.1.5
2026-03-13 23:42:43 +08:00
Tink 9d69ba9f56 fix: isolate /new consolidation in API mode 2026-03-13 19:26:50 +08:00
chengyongru 1e163d615d chore: bump wecom-aibot-sdk-python to >=0.1.5
- Includes bug fixes for duplicate recv loops
- Handles disconnected_event properly
- Fixes heartbeat timeout
2026-03-13 18:45:41 +08:00
TinkandClaude Opus 4.6 f5cf0bfdee Merge origin/main into feat/openai-compatible-session-isolation (resolve conflicts)
Resolved 6 conflicted files:
- loop.py: adopt MemoryConsolidator pattern from main, keep _isolated_memory_store
- web.py, base.py, helpers.py: merge both sides' imports
- pyproject.toml: keep both api and wecom optional deps
- test_consolidate_offset.py: adopt main's _make_loop helper and consolidate_messages signatures
- test_openai_api.py: remove tests for deleted _consolidate_memory method

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:29:44 +08:00
nne998 a8fbea6a95 cleanup 2026-03-13 16:53:57 +08:00
nne998 e3cb3a814d cleanup 2026-03-13 15:14:26 +08:00
nne998 aac076dfd1 add uvlock to .gitignore 2026-03-13 15:11:01 +08:00
mru4913 670d2a6ff8 feat(feishu): implement message reply/quote support
- Add `reply_to_message: bool = False` config to `FeishuConfig`
- Parse `parent_id` and `root_id` from incoming events into metadata
- Fetch quoted message content via `im.v1.message.get` and prepend
  `[Reply to: ...]` context for the LLM when a user quotes a message
- Add `_reply_message_sync` using `im.v1.message.reply` API so the
  bot's response appears as a threaded quote in Feishu
- First outbound message uses reply API; subsequent chunks fall back
  to `create` to avoid duplicate quote bubbles; progress messages
  always use `create`
- Add 19 unit tests covering all new code paths
2026-03-13 15:02:57 +08:00
Tony 2787523f49 fix: prevent empty </think> tags from appearing in messages
- Enhance _strip_think to handle stray tags:
  * Remove unmatched closing tags (</think>)
  * Remove incomplete blocks (<think> ... to end of string)
- Apply _strip_think to tool hint messages as well
- Prevents blank/parse errors from showing </think> in chat outputs

Fixes issue with empty </think> appearing in Feishu tool call cards and other messages.
2026-03-13 14:55:34 +08:00
Tony 87ab980bd1 refactor(feishu): extract tool hint card sending into dedicated method
- Extract card creation logic into _send_tool_hint_card() helper
- Improves code organization and testability
- Update tests to use pytest.mark.asyncio for cleaner async testing
- Remove redundant asyncio.run() calls in favor of native async test functions
2026-03-13 14:52:15 +08:00
Tony 82064efe51 feat(feishu): improve tool call card formatting for multiple tools
- Format multiple tool calls each on their own line
- Change title from 'Tool Call' to 'Tool Calls' (plural)
- Add explicit 'text' language for code block
- Improves readability and supports displaying longer content
- Update tests to match new formatting
2026-03-13 14:48:36 +08:00
Tony 7261bd8c3f feat(feishu): display tool calls in code block messages
- Tool hint messages with _tool_hint metadata now render as formatted code blocks
- Uses Feishu interactive card message type with markdown code fences
- Shows "Tool Call" header followed by code in a monospace block
- Adds comprehensive unit tests for the new functionality

Co-Authorship-Bot: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:43:47 +08:00
TonyandClaude Opus 4.6 df89bd2dfa feat(feishu): display tool calls in code block messages
- Add special handling for tool hint messages (_tool_hint metadata)
- Send tool calls using Feishu's "code" message type with formatting
- Tool calls now appear as formatted code snippets in Feishu chat
- Add unit tests for the new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:41:54 +08:00
Tony 6ec56f5ec6 cleanup 2026-03-13 14:09:38 +08:00
Tony e977d127bf ignore .DS_Store 2026-03-13 14:08:10 +08:00
Tony da740c871d test 2026-03-13 14:06:22 +08:00
Xubin Ren 65cbd7eb78 docs: update web search configuration instruction 2026-03-13 05:54:51 +00:00
Tony d286926f6b feat(memory): implement async background consolidation
Implement asynchronous memory consolidation that runs in the background when
sessions are idle, instead of blocking user interactions after each message.

Changes:
- MemoryConsolidator: Add background task management with idle detection
  * Track session activity timestamps
  * Background loop checks idle sessions every 30s
  * Consolidation triggers only when session idle > 60s
- AgentLoop: Integrate background task lifecycle
  * Start consolidation task when loop starts
  * Stop gracefully on shutdown
  * Record activity on each message
- Refactor maybe_consolidate_by_tokens: Keep sync API but schedule async
- Add debug logging for consolidation completion

Benefits:
- Non-blocking: Users no longer wait for consolidation after responses
- Efficient: Only consolidate idle sessions, avoiding redundant work
- Scalable: Background task can process multiple sessions efficiently
- Backward compatible: Existing API unchanged

Tests: 11 new tests covering background task lifecycle, idle detection,
scheduling, and error handling. All passing.

🤖 Generated with Claude Code
2026-03-13 13:52:36 +08:00
Xubin Ren 3040102c02 Merge PR #398: multi-provider web search 2026-03-13 05:44:16 +00:00
Xubin Ren ca5047b602 feat(web): multi-provider web search + Jina Reader fetch 2026-03-13 05:44:16 +00:00
Xubin Ren 511a335e82 Merge branch 'main' into pr-398 2026-03-13 05:12:10 +00:00
Xubin Ren 04b45e0e5c Merge PR #1920: langsmith integration 2026-03-13 04:54:22 +00:00
Xubin Ren 20b4fb3bff fix: langsmith callback 防覆盖 + 加 optional dep 2026-03-13 04:54:22 +00:00
Xubin Ren da325e4532 Merge branch 'main' into pr-1920 2026-03-13 04:20:14 +00:00
Xubin RenandGitHub 3ee80b000c Merge PR #1949: docs: correct BaiLian dashscope apiBase endpoint
docs: correct BaiLian dashscope apiBase endpoint
2026-03-13 12:18:00 +08:00
Xubin Ren bd4ec46681 merge: PR #1916 add CI workflow + fix matrix init + test cleanup 2026-03-13 04:05:11 +00:00
Xubin Ren 84b107cf6c fix(ci): upgrade setup-python, add system deps, simplify test assertions 2026-03-13 04:05:08 +00:00
Xubin Ren 4b50a7b6c0 Merge branch 'main' into pr-1916 2026-03-13 03:57:09 +00:00
Xubin Ren 2490af99d4 merge: PR #1810 validate save_memory payload + raw-archive fallback 2026-03-13 03:54:53 +00:00
Xubin Ren 6d3a0ab6c9 fix(memory): validate save_memory payload and raw-archive on repeated failure
- Require both history_entry and memory_update, reject null/empty values
- Fallback to tool_choice=auto when provider rejects forced function call
- After 3 consecutive consolidation failures, raw-archive messages to
  HISTORY.md without LLM summarization to prevent context window overflow
2026-03-13 03:53:50 +00:00
Xubin Ren 60c29702cc Merge branch 'main' into pr-1810
# Conflicts:
#	nanobot/agent/memory.py
#	tests/test_memory_consolidation_types.py
2026-03-13 03:29:16 +00:00
Xubin RenandGitHub 62a2e71748 Merge PR #1958: fix(restart): use -m nanobot for Windows compatibility
fix(restart): use -m nanobot for Windows compatibility
2026-03-13 11:19:57 +08:00
Xubin Ren 4f77b9385c fix(memory): fallback to tool_choice=auto when provider rejects forced function call
Some providers (e.g. Dashscope in thinking mode) reject object-style
tool_choice with "does not support being set to required or object".
Retry once with tool_choice="auto" instead of failing silently.

Made-with: Cursor
2026-03-13 03:18:08 +00:00
Xubin Ren e30d19e94d merge: PR #1919 reorder Hatch build tables in pyproject 2026-03-13 03:07:27 +00:00
Xubin Ren 4f05e30331 Merge remote-tracking branch 'origin/main' into pr-1919 2026-03-13 03:02:17 +00:00
chengyongru 6ad30f12f5 fix(restart): use -m nanobot for Windows compatibility
On Windows, sys.argv[0] may be just "nanobot" without full path when
running from PATH. os.execv() doesn't search PATH, causing restart to
fail with "No such file or directory".

Fix by using `python -m nanobot` instead of relying on sys.argv[0].

Fixes #1937
2026-03-13 11:01:01 +08:00
Xubin RenandGitHub ba045f56d8 Merge PR #1941: fix(qq): restore plain text replies for legacy clients
fix(qq): restore plain text replies for legacy clients
2026-03-13 10:57:58 +08:00
Xubin RenandGitHub aab909e936 Merge PR #1953: fix: catch BaseException in MCP connection to handle CancelledError
fix: catch BaseException in MCP connection to handle CancelledError
2026-03-13 10:57:11 +08:00
Xubin Ren fb9d54da21 docs: update .gitignore to add .docs 2026-03-13 02:41:52 +00:00
chengyongru 127ac39063 fix: catch BaseException in MCP connection to handle CancelledError 2026-03-13 10:23:15 +08:00
Frank d48dd00682 docs: correct BaiLian dashscope apiBase endpoint 2026-03-12 18:23:05 -07:00
Frank a09245e919 fix(qq): restore plain text replies for legacy clients 2026-03-12 12:48:25 -07:00
Re-bin 774452795b fix(memory): use explicit function name in tool_choice for DashScope compatibility 2026-03-12 16:09:24 +00:00
Re-bin 109ae13301 Merge PR #1930: fix async interactive CLI formatting with prompt_toolkit 2026-03-12 15:38:39 +00:00
Re-bin 3fa62e7fda fix: remove duplicate dim/arrow prefix in interactive progress line 2026-03-12 15:38:39 +00:00
Re-bin 48c74a11d4 Merge remote-tracking branch 'origin/main' into pr-1930 2026-03-12 15:28:57 +00:00
Re-bin ab087ed05f Merge PR #1608: add VolcEngine/BytePlus providers and improve local provider auto-selection 2026-03-12 15:22:15 +00:00
Re-bin 3467a7faa6 fix: improve local provider auto-selection and update docs for VolcEngine/BytePlus 2026-03-12 15:22:15 +00:00
Jiajun Xie ec6e099393 feat(ci): add GitHub Actions workflow for test directory
- nanobot/channels/matrix.py: Add keyword-only parameters restrict_to_workspace/workspace to MatrixChannel.__init__ and assign them to _restrict_to_workspace/_workspace with proper type conversion and path resolution
- tests/test_commands.py: Add _strip_ansi() function to remove ANSI escape codes, use regex assertions for --workspace/--config parameters to allow 1 or 2 dashes
2026-03-12 21:54:22 +08:00
chengdu121 d51ec7f0e8 fix: preserve interactive CLI formatting for async subagent output 2026-03-12 19:15:04 +08:00
gaoyiman 556cb3e83d feat: add support for Ollama local models in ProvidersConfig 2026-03-12 14:58:03 +08:00
gaoyiman 8865b6848c Merge branch 'main' into feat-volcengine-tuning 2026-03-12 14:56:05 +08:00
HuangMinlongandGitHub 9e9051229e Integrate Langsmith for conversation tracking
Added support for Langsmith API key to enable conversation viewing.
2026-03-12 14:34:32 +08:00
lvguangchuan001 8e412b9603 [紧急]修复we_chat在pyproject.toml配置的问题 2026-03-12 14:28:33 +08:00
Re-bin c38579dc22 Merge PR #1900: telegram reply context and media forwarding 2026-03-12 06:16:57 +00:00
Re-bin 64888b4b09 Simplify reply context extraction, fix slash commands broken by reply injection, attach reply media regardless of caption 2026-03-12 06:16:57 +00:00
Re-bin 869149ef1e Merge branch 'main' into pr-1900 2026-03-12 06:06:26 +00:00
Re-bin 6141b95037 fix: feishu bot mention detection — user_id can be None, not just empty string 2026-03-12 06:00:39 +00:00
Re-bin af4e3b2647 Merge PR #1768: feishu group mention policy 2026-03-12 04:45:57 +00:00
Re-bin bd1ce8f144 Simplify feishu group_policy: default to mention, clean up mention detection 2026-03-12 04:45:57 +00:00
Re-bin 94e9b06086 Merge branch 'main' into pr-1768 2026-03-12 04:38:49 +00:00
Re-bin 95c741db62 docs: update nanobot key features 2026-03-12 04:35:34 +00:00
Re-bin ad2be4ea8b Merge PR #1751: add /restart command 2026-03-12 04:33:51 +00:00
Re-bin 64aeeceed0 Add /restart command: restart the bot process from any channel 2026-03-12 04:33:51 +00:00
Re-bin 231b02963d Merge branch 'main' into pr-1751
Made-with: Cursor

# Conflicts:
#	nanobot/agent/loop.py
2026-03-12 03:53:59 +00:00
Xubin RenandGitHub fc4f7cca21 Merge PR #1909: fix: raise tool result history limit to 16k and force save_memory in consolidation
fix: raise tool result history limit to 16k and force save_memory in consolidation
2026-03-12 11:11:01 +08:00
Re-bin 0a0017ff45 fix: raise tool result history limit to 16k and force save_memory in consolidation 2026-03-12 03:08:53 +00:00
Xubin RenandGitHub d313765442 Merge PR #1897: fix: wecom-aibot-sdk-python should use pypi version
fix: wecom-aibot-sdk-python should use pypi version
2026-03-12 10:52:37 +08:00
Re-bin 35260ca157 fix: raise persisted tool result limit to 16k 2026-03-12 02:50:28 +00:00
John Doe 3f799531cc Add media download functionality 2026-03-12 06:43:59 +07:00
John Doe 1eedee0c40 add reply context extraction for Telegram messages 2026-03-12 06:23:02 +07:00
Re-bin 6155a43b8a Merge PR #1845: absorb shell path guard improvements 2026-03-11 17:27:17 +00:00
Re-bin dff1643fb3 Merge branch 'main' into pr-1845 2026-03-11 17:25:22 +00:00
chengyongru 64ab6309d5 fix: wecom-aibot-sdk-python should use pypi version 2026-03-12 00:38:28 +08:00
Xubin RenandGitHub 214693ce6e Merge PR #1895: enhance: improve filesystem & shell tools with pagination, fallback matching, and smarter output
enhance: improve filesystem & shell tools with pagination, fallback matching, and smarter output
2026-03-12 00:22:32 +08:00
Re-bin 0d94211a93 enhance: improve filesystem & shell tools with pagination, fallback matching, and smarter output 2026-03-11 16:20:11 +00:00
Re-bin f869a53531 Merge PR #1827: tighten shell path guard for quoted home paths 2026-03-11 15:43:07 +00:00
Re-bin 9d0db072a3 fix: guard quoted home paths in shell tool 2026-03-11 15:43:04 +00:00
Re-bin 85609c99b3 Merge remote-tracking branch 'origin/main' into pr-1827 2026-03-11 15:32:52 +00:00
Re-bin d954e774dd Merge PR #1874: preserve provider-specific tool-call fields 2026-03-11 15:30:33 +00:00
Re-bin 9fc74bde9a Merge remote-tracking branch 'origin/main' into pr-1874 2026-03-11 15:26:39 +00:00
Xubin RenandGitHub ff10d01d58 Merge PR #1885: feat: allow direct references in hatch metadata for wecom dep
feat: allow direct references in hatch metadata for wecom dep
2026-03-11 22:53:56 +08:00
Xubin RenandGitHub 0321fbe2ab Merge PR #1888: refactor: auto-discover channels via pkgutil, eliminate hardcoded registry
refactor: auto-discover channels via pkgutil, eliminate hardcoded registry
2026-03-11 22:24:25 +08:00
Re-bin 254cfd48ba refactor: auto-discover channels via pkgutil, eliminate hardcoded registry 2026-03-11 14:23:19 +00:00
for13to1 2c5226550d feat: allow direct references in hatch metadata for wecom dep 2026-03-11 20:35:04 +08:00
Re-bin b957dbc4cf Merge PR #1868: generation settings owned by provider, loop/memory/subagent agnostic 2026-03-11 09:47:04 +00:00
Re-bin c72c2ce7e2 refactor: move generation settings to provider level, eliminate parameter passthrough 2026-03-11 09:47:04 +00:00
Re-bin a180e84536 Merge remote-tracking branch 'origin/main' into pr-1868 2026-03-11 09:10:29 +00:00
Re-bin 89eff6f573 chore: remove stray nano backup files 2026-03-11 08:44:38 +00:00
Re-bin e7761aae5b Merge PR #1863: add Ollama as a local LLM provider 2026-03-11 08:42:12 +00:00
Re-bin 4478838424 fix(pr-1863): complete Ollama provider routing and README docs 2026-03-11 08:42:12 +00:00
Re-bin a6f37f61e8 Merge remote-tracking branch 'origin/main' into pr-1863 2026-03-11 08:22:02 +00:00
Re-bin ec87946c04 docs: update table of contents position 2026-03-11 08:11:28 +00:00
Re-bin 486df1ddbd docs: update table of contents in README 2026-03-11 08:10:38 +00:00
Re-bin 7ceddcded6 fix(wecom): await async disconnect, add SDK attribution in README 2026-03-11 08:04:14 +00:00
Re-bin 0dff7d374e Merge PR #1327: add WeCom channel 2026-03-11 07:57:12 +00:00
Re-bin d0b4f0d70d feat(wecom): add WeCom channel with SDK pinned to GitHub tag v0.1.2 2026-03-11 07:57:12 +00:00
WhalerO 6ef7ab53d0 refactor: centralize tool call serialization in ToolCallRequest 2026-03-11 15:32:43 +08:00
WhalerO ed82f95f0c fix: preserve provider-specific tool call metadata for Gemini 2026-03-11 15:32:26 +08:00
Re-bin eb6310c438 merge origin/main into pr-1327
Made-with: Cursor
2026-03-11 07:30:38 +00:00
ethanclaw 12104c8d46 fix(memory): pass temperature, max_tokens and reasoning_effort to memory consolidation
Fix issue #1823: Memory consolidation does not inherit agent temperature
and maxTokens configuration.

The agent's configured generation parameters were not being passed through
to the memory consolidation call, causing it to fall back to default values.
This resulted in the consolidation response being truncated before the
save_memory tool call was emitted.

- Pass temperature, max_tokens, reasoning_effort from AgentLoop to
  MemoryConsolidator and then to MemoryStore.consolidate()
- Forward these parameters to the provider.chat_with_retry() call

Fixes #1823
2026-03-11 14:22:33 +08:00
ethanclaw b75222d952 Merge remote main to fix branch 2026-03-11 13:12:26 +08:00
ethanclaw c7e2622ee1 fix(subagent): pass reasoning_content and thinking_blocks in subagent messages
Fix issue #1834: Spawn/subagent tool fails with Deepseek Reasoner
due to missing reasoning_content field when using thinking mode.

The subagent was not including reasoning_content and thinking_blocks
in assistant messages with tool calls, causing the Deepseek API to
reject subsequent requests.

- Add reasoning_content to assistant message when subagent makes tool calls
- Add thinking_blocks to assistant message for Anthropic extended thinking
- Add tests to verify both fields are properly passed

Fixes #1834
2026-03-11 12:25:28 +08:00
Jerome Sonnet (letzdoo)andClaude Opus 4.6 dee4f27dce feat: add Ollama as a local LLM provider
Add native Ollama support so local models (e.g. nemotron-3-nano) can be
used without an API key. Adds ProviderSpec with ollama_chat LiteLLM
prefix, ProvidersConfig field, and skips API key validation for local
providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:13:14 +04:00
Re-bin 82f4607b99 merge: PR #1856 exclude hidden files when syncing workspace templates 2026-03-11 03:50:54 +00:00
Re-bin 76c6063141 chore: normalize helpers.py file mode 2026-03-11 03:50:54 +00:00
Re-bin f339f505cd Merge remote-tracking branch 'origin/main' into pr-1856 2026-03-11 03:48:05 +00:00
Re-bin 40721a7871 merge: PR #1848 preserve subagent reasoning fields across tool turns 2026-03-11 03:47:24 +00:00
Re-bin ddccf25bb1 fix(subagent): preserve reasoning fields across tool turns
Share assistant message construction between the main agent and subagents, and add a regression test to keep reasoning_content and thinking_blocks in follow-up tool rounds.
2026-03-11 03:47:24 +00:00
Re-bin 1d611f9bf3 Merge remote-tracking branch 'origin/main' into pr-1848 2026-03-11 03:42:14 +00:00
Re-bin df5c0496d3 merge: PR #1859 support DingTalk voice recognition text 2026-03-11 03:40:33 +00:00
Re-bin 91f17cad00 feat(dingtalk): support voice recognition text fallback
Read DingTalk recognition text when text.content is empty, and add a handler-level regression test for voice transcript delivery.
2026-03-11 03:40:33 +00:00
Re-bin ef88a5be00 Merge remote-tracking branch 'origin/main' into pr-1859 2026-03-11 03:32:07 +00:00
Xubin RenandGitHub 4f7613d608 Merge PR #1855: fix: bump litellm version to 1.82.1 for Moonshot provider support
fix: bump litellm version to 1.82.1 for Moonshot provider support
2026-03-11 11:31:14 +08:00
dingyanyi2019 35d811c997 feat: support retrieving DingTalk voice recognition text 2026-03-11 10:19:43 +08:00
YinAnPing d1df53aaf7 fix: exclude hidden files when syncing workspace templates
Skip files starting with '.' (e.g., macOS extended attributes like ._AGENTS.md)
to prevent UnicodeDecodeError during template synchronization.
2026-03-11 09:30:33 +08:00
greyishsong a44ee115d1 fix: bump litellm version to 1.82.1 for Moonshot provider support
see issue #1628
2026-03-11 09:02:28 +08:00
Re-bin 6747b23c00 merge: PR #1704 switch memory consolidation to token-based context windows 2026-03-10 19:55:06 +00:00
Re-bin 62ccda43b9 refactor(memory): switch consolidation to token-based context windows
Move consolidation policy into MemoryConsolidator, keep backward compatibility for legacy config, and compress history by token budget instead of message count.
2026-03-10 19:55:06 +00:00
Re-bin 4784eb4128 merge origin/main into pr-1704 2026-03-10 18:09:15 +00:00
lailoo 2ffeb9295b fix(subagent): preserve reasoning_content in assistant messages
Subagent's _run_subagent() was dropping reasoning_content and
thinking_blocks when building assistant messages for the conversation
history. Providers like Deepseek Reasoner require reasoning_content on
every assistant message when thinking mode is active, causing a 400
BadRequestError on the second LLM round-trip.

Align with the main AgentLoop which already preserves these fields via
ContextBuilder.add_assistant_message().

Closes #1834
2026-03-11 00:47:09 +08:00
Nikolas de Hor 808064e26b fix: detect tilde paths in restrictToWorkspace shell guard
_extract_absolute_paths() only matched paths starting with / or drive
letters, missing ~ paths that expand to the home directory. This
allowed agents to bypass restrictToWorkspace by using commands like
cat ~/.nanobot/config.json to access files outside the workspace.

Add tilde path extraction regex and use expanduser() before resolving.
Also switch from manual parent-chain check to is_relative_to() for
more robust path containment validation.

Fixes #1817
2026-03-10 13:45:05 -03:00
Re-bin 947ed508ad chore: exclude skills from core agent line count 2026-03-10 10:13:46 +00:00
Re-bin a3b617e602 Merge PR #1512: share transient LLM retry across agent paths 2026-03-10 10:10:40 +00:00
Re-bin b0a5435b87 refactor(llm): share transient retry across agent paths 2026-03-10 10:10:37 +00:00
Re-bin 46b31ce7e7 Merge remote-tracking branch 'origin/main' into pr-1512 2026-03-10 09:40:48 +00:00
Re-bin 417a8a22b0 Merge PR #1416: sync missing scripts from upstream openclaw repository and restore skill-creator validation 2026-03-10 09:20:22 +00:00
Re-bin b7ecc94c9b fix(skill-creator): restore validation and align packaging docs 2026-03-10 09:16:23 +00:00
idealist17 6e428b7939 fix: verify Authentication-Results (SPF/DKIM) for inbound emails 2026-03-10 17:02:39 +08:00
Re-bin 6abd3d10ce Merge remote-tracking branch 'origin/main' into pr-1416 2026-03-10 09:00:02 +00:00
suger-m 6c70154fee fix(exec): enforce workspace guard for home-expanded paths 2026-03-10 15:55:04 +08:00
angleyanalbedo 746d7f5415 feat(tools): enhance ExecTool with enable flag and custom deny_patterns
- Add `enable` flag to `ExecToolConfig` to conditionally register the tool.
- Add `deny_patterns` to allow users to override the default command blacklist.
- Remove `allow_patterns` (whitelist) to maintain tool flexibility.
- Fix initialization logic to properly handle empty list (`[]`), allowing users to completely clear the default blacklist.
2026-03-10 15:10:09 +08:00
Re-bin a1b5f21b8b merge: PR #1389 add Telegram groupPolicy support 2026-03-10 04:34:18 +00:00
Re-bin 4f9857f85f feat(telegram): add configurable group mention policy 2026-03-10 04:34:15 +00:00
Re-bin 8aa754cd2e Merge branch 'main' into pr-1389 2026-03-10 04:26:12 +00:00
Re-bin d803144f44 merge: PR #1785 respect gateway port from config when --port omitted 2026-03-10 04:08:00 +00:00
Re-bin 0ecfb0a9d6 Merge branch 'main' into pr-1785 2026-03-10 04:07:53 +00:00
Re-bin 39d21bc19d merge: PR #1797 let gateway use configured port by default 2026-03-10 03:54:46 +00:00
shenchengtsi b24d6ffc94 fix(memory): validate save_memory payload before persisting 2026-03-10 11:32:11 +08:00
Chris Alexander d633ed6e51 fix(subagent): avoid missing from_legacy call 2026-03-09 20:36:31 +00:00
Chris AlexanderandClaude Opus 4.6 71d90de31b feat(web): configurable web search providers with fallback
Add multi-provider web search support: Brave (default), Tavily,
DuckDuckGo, and SearXNG. Falls back to DuckDuckGo when provider
credentials are missing. Providers are dispatched via a map with
register_provider() for plugin extensibility.

- WebSearchConfig with env-var resolution and from_legacy() bridge
- Config migration for legacy flat keys (tavilyApiKey, searxngBaseUrl)
- SearXNG URL validation, explicit error for unknown providers
- ddgs package (replaces deprecated duckduckgo-search)
- 16 tests covering all providers, fallback, env resolution, edge cases
- docs/web-search.md with full config reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:36:14 +00:00
Protocol Zero 1284c7217e fix(cli): let gateway use config port by default
Respect config.gateway.port when --port is omitted, while keeping CLI flags as the highest-precedence override.
2026-03-09 20:12:11 +00:00
Protocol Zero 0104a2253a fix(telegram): avoid media filename collisions
Use file_unique_id when storing downloaded Telegram media so different uploads do not silently overwrite each other on disk.
2026-03-09 20:11:16 +00:00
Re-bin 99b896f5d4 merge: PR #1784 refine Slack thread handling 2026-03-09 17:18:13 +00:00
Re-bin 28330940d0 fix(slack): skip thread_ts for direct messages 2026-03-09 17:18:10 +00:00
chengyongru 45c0eebae5 docs(wecom): add wecom configuration guide in readme 2026-03-10 00:53:23 +08:00
Re-bin 757921fb27 Merge branch 'main' into pr-1784 2026-03-09 16:35:10 +00:00
ailuntz 9c88e40a61 fix(cli): respect gateway port from config when --port omitted 2026-03-10 00:32:42 +08:00
Xubin RenandGitHub 81b22a9e3a Merge PR #1741: fix: ensure feishu audio file has .opus extension for Groq Whisper compatibility
fix: ensure feishu audio file has .opus extension for Groq Whisper compatibility
2026-03-10 00:26:30 +08:00
ailuntz 620d7896c7 fix(slack): define thread usage when sending messages 2026-03-10 00:14:34 +08:00
chengyongru a660a25504 feat(wecom): add wecom channel [wobsocket]
support text/audio[wecom support audio message by default]
2026-03-09 22:46:35 +08:00
Zek 711903bc5f feat(feishu): add global group mention policy
- Add group_policy config: 'open' (default) or 'mention'
- 'open': Respond to all group messages (backward compatible)
- 'mention': Only respond when @mentioned in any group
- Auto-detect bot mentions by pattern matching:
  * If open_id configured: match against mentions
  * Otherwise: detect bot by empty user_id + ou_ open_id pattern
- Support @_all mentions
- Private chats unaffected (always respond)
- Clean implementation with minimal logging

docs: update Feishu README with group policy documentation
2026-03-09 17:54:02 +08:00
skiyo dfb4537867 feat: add --dir option to onboard command for Multiple Instances
- Add --dir parameter to specify custom base directory for config and workspace
- Enables Multiple Instances initialization with isolated configurations
- Config and workspace are created under the specified directory
- Maintains backward compatibility with default ~/.nanobot/
- Updates help text and next steps with actual paths
- Updates README.md with --dir usage examples for Multiple Instances

Example usage:
  nanobot onboard --dir ~/.nanobot-A
  nanobot onboard --dir ~/.nanobot-B
  nanobot onboard  # uses default ~/.nanobot/
2026-03-09 16:25:56 +08:00
Tink 37060dea0b Merge origin/main into feat/openai-compatible-session-isolation (resolve conflicts)
# Conflicts:
#	nanobot/agent/context.py
#	nanobot/providers/litellm_provider.py
2026-03-09 10:06:51 +08:00
Renato Machado 85c56d7410 feat: add "restart" command 2026-03-09 01:37:35 +00:00
chengyongru 4044b85d4b fix: ensure feishu audio file has .opus extension for Groq Whisper compatibility 2026-03-09 01:32:10 +08:00
Re-bin f19cefb1b9 docs: update v0.1.4.post4 release news 2026-03-08 17:00:46 +00:00
Re-bin 4147d0ff9d docs: update v0.1.4.post4 release news 2026-03-08 17:00:09 +00:00
Re-bin 998021f571 docs: refresh install/update guidance and bump v0.1.4.post4 2026-03-08 16:57:28 +00:00
Re-bin a0bb4320f4 chore: bump version to 0.1.4.post4 2026-03-08 16:44:47 +00:00
Re-bin cd2b0f74c9 Merge PR #1579: refine platform policy and memory skill docs 2026-03-08 16:39:40 +00:00
Re-bin 4715321319 Merge branch 'main' into pr-1579 and tighten platform guidance 2026-03-08 16:39:37 +00:00
Re-bin ce9b516b11 Merge branch 'main' into pr-1579 2026-03-08 16:29:54 +00:00
Re-bin e7bd5140c3 Merge PR #1728: harden MCP tool cancellation handling 2026-03-08 16:03:24 +00:00
Re-bin 5eb67facff Merge branch 'main' into pr-1728 and harden MCP tool cancellation handling 2026-03-08 16:01:06 +00:00
Re-bin 4e197dc18e Merge branch 'main' into pr-1728 2026-03-08 15:51:06 +00:00
Xubin RenandGitHub 51d113d5a5 Merge PR #1727: feat(qq): send messages using markdown payload
feat(qq): send messages using markdown payload
2026-03-08 23:40:30 +08:00
Re-bin 7cbb254a8e fix: remove stale IDENTITY bootstrap entry 2026-03-08 15:39:40 +00:00
Alfredo ArenasandGitHub ed3b9c16f9 fix: handle CancelledError in MCP tool calls to prevent process crash
MCP SDK's anyio cancel scopes can leak CancelledError on timeout or
failure paths. Since CancelledError is a BaseException (not Exception),
it escapes both MCPToolWrapper.execute() and ToolRegistry.execute(),
crashing the agent loop.

Now catches CancelledError and returns a graceful error to the LLM,
while still re-raising genuine task cancellations from /stop.
Also catches general Exception for other MCP failures (connection
drops, invalid responses, etc.).

Related: #1055
2026-03-08 08:05:18 -06:00
TheAutomatic 1421ac501c feat(qq): send messages using markdown payload 2026-03-08 07:04:06 -07:00
VITOHJL 274edc5451 fix(compression): prefer provider prompt token usage 2026-03-08 17:25:59 +08:00
VITOHJL 1b16d48390 fix(loop): update _cumulative_tokens in _save_turn and preserve it in compression methods 2026-03-08 15:26:49 +08:00
VITOHJL a984e0df37 feat(loop): add history message count logging in compression 2026-03-08 15:23:55 +08:00
VITOHJL 2706d3c317 fix(commands): use max_tokens_output instead of max_tokens from AgentDefaults 2026-03-08 15:20:34 +08:00
VITOHJL 2dcb4de422 fix(commands): update AgentLoop calls to use token-based compression parameters 2026-03-08 15:04:38 +08:00
VITOHJL dbc518098e refactor: implement token-based context compression mechanism
Major changes:
- Replace message-count-based memory window with token-budget-based compression
- Add max_tokens_input, compression_start_ratio, compression_target_ratio config
- Implement _maybe_compress_history() that triggers based on prompt token usage
- Use _build_compressed_history_view() to provide compressed history to LLM
- Refactor MemoryStore.consolidate() -> consolidate_chunk() for chunk-based compression
- Remove last_consolidated from Session, use _compressed_until metadata instead
- Add background compression scheduling to avoid blocking message processing

Key improvements:
- Compression now based on actual token usage, not arbitrary message counts
- Better handling of long conversations with large context windows
- Non-destructive compression: old messages remain in session, but excluded from prompt
- Automatic compression when history exceeds configured token thresholds
2026-03-08 14:20:16 +08:00
Re-bin 0b68360286 Merge PR #1635: add agent config/workspace CLI support 2026-03-08 03:26:30 +00:00
Re-bin bf0ab93b06 Merge branch 'main' into pr-1635 2026-03-08 03:24:15 +00:00
Re-bin fb4f696085 Merge branch 'main' into pr-1635 2026-03-08 03:14:20 +00:00
Re-bin 0a5daf3c86 docs: update readme for multiple instances and cli 2026-03-08 03:03:25 +00:00
Re-bin 7fa0cd437b merge: integrate pr-1581 multi-instance path cleanup 2026-03-08 02:58:28 +00:00
Re-bin 20dfaa5d34 refactor: unify instance path resolution and preserve workspace override 2026-03-08 02:58:25 +00:00
Re-bin bdac08161b Merge branch 'main' into pr-1581 2026-03-08 02:05:23 +00:00
Re-bin 822d2311e0 docs: update nanobot march news 2026-03-08 01:44:06 +00:00
Re-bin 3ca89d7821 docs: update nanobot news 2026-03-08 01:42:30 +00:00
Re-bin 5a08beee1e fix(slack): handle empty text responses without regressing thread and media support 2026-03-07 16:52:18 +00:00
Re-bin 2e50a98a57 merge main into pr-673 and keep slack empty-text fallback without regressing thread/media support 2026-03-07 16:51:48 +00:00
Xubin RenandGitHub 55fb771e1e Merge PR #1677: fix(auth): prevent allowlist bypass via sender_id token splitting
fix(auth): prevent allowlist bypass via sender_id token splitting
2026-03-08 00:37:09 +08:00
Re-bin 057927cd24 fix(auth): prevent allowlist bypass via sender_id token splitting 2026-03-07 16:36:12 +00:00
Re-bin 74066e2823 feat(qq): support group at messages without regressing msg_seq deduplication or startup behavior 2026-03-07 16:22:44 +00:00
Re-bin 3e9c5aa34a merge main into pr-532 and keep qq msg_seq/startup behavior while adding group @message support with regression tests 2026-03-07 16:22:41 +00:00
Re-bin cf7833176f Merge pull request #1467 from contributors/dingtalk-group-chat-support 2026-03-07 16:07:57 +00:00
Re-bin 4e25ac5c82 test(dingtalk): cover group reply routing 2026-03-07 16:07:57 +00:00
shawn_wxn 73991779b3 fix(dingtalk): use msg_key variable instead of hardcoded 2026-03-08 00:01:08 +08:00
shawn_wxn caa2aa596d fix(dingtalk): correct msgKey parameter for group messages 2026-03-08 00:01:08 +08:00
shawn_wxn 26670d3e80 feat(dingtalk): add support for group chat messages 2026-03-08 00:01:08 +08:00
Re-bin 3508909ae4 Merge pull request #436 from contributors/preserve-telegram-document-extension 2026-03-07 15:51:53 +00:00
Re-bin 83433198ca Merge main into pr-436 2026-03-07 15:51:53 +00:00
Re-bin 8d35e13162 Merge PR #1476 without regressing Telegram proxy handling 2026-03-07 15:38:28 +00:00
Re-bin 512ccad636 Merge main into pr-1476, keep current Telegram proxy fix 2026-03-07 15:38:27 +00:00
Re-bin 0b520fc67f Merge pull request #1482 from contributors/telegram-topic-support 2026-03-07 15:33:24 +00:00
Re-bin 515b3588af Merge main into pr-1482 2026-03-07 15:33:24 +00:00
Re-bin 8a72931b74 Merge pull request #1535 from contributors/fix-telegram-proxy-crash 2026-03-07 15:11:09 +00:00
Re-bin a9f3552d6e test(telegram): cover proxy request initialization 2026-03-07 15:11:09 +00:00
Re-bin 369dbec70a Merge branch 'main' into pr-1535 2026-03-07 15:05:54 +00:00
Re-bin aee358c58e Merge pull request #332 from contributors/feishu-event-handlers 2026-03-07 15:02:06 +00:00
Re-bin 4021f5212c Merge main into pr-332 2026-03-07 15:02:06 +00:00
Re-bin 851e9c06d8 Merge pull request #1655 from contributors/fix-telegram-inline-keyboard-chat-id 2026-03-07 14:53:14 +00:00
Re-bin 43fc59da00 fix: hide internal reasoning in progress 2026-03-07 14:53:14 +00:00
Re-bin 04e4d17a51 Merge remote-tracking branch 'origin/main' into pr-1655 2026-03-07 14:45:28 +00:00
Re-bin f03adab5b4 Merge PR #1648: add Feishu audio transcription with Groq Whisper 2026-03-07 14:44:44 +00:00
Re-bin 4f80e5318d Merge remote-tracking branch 'origin/main' into pr-1648 2026-03-07 14:42:40 +00:00
Re-bin 1d06519248 Merge PR #1660: fix Telegram stop command handler 2026-03-07 14:36:43 +00:00
Gleb 44327d6457 fix(telegram): added "stop" command handler, fixed stop command 2026-03-07 12:38:52 +02:00
VITOHJL cf76011c1a fix: hide reasoning_content from user progress updates 2026-03-07 17:09:59 +08:00
chengyongru 215360113f feat(feishu): add audio transcription support using Groq Whisper 2026-03-07 16:21:52 +08:00
Re-bin ab89775d59 Merge PR #1610: auto cast tool params to match schema 2026-03-07 05:28:12 +00:00
Re-bin c3f2d1b01d fix(tools): narrow parameter auto-casting 2026-03-07 05:28:12 +00:00
Re-bin 67e6d9639c Merge remote-tracking branch 'origin/main' into pr-1610 2026-03-07 05:19:39 +00:00
Re-bin ff9c051c5f Merge PR #1613: enhance Discord message sending with attachments 2026-03-07 04:07:25 +00:00
Re-bin c81d32c40f fix(discord): handle attachment reply fallback 2026-03-07 04:07:25 +00:00
Re-bin 614d6fef34 Merge remote-tracking branch 'origin/main' into pr-1613 2026-03-07 04:01:24 +00:00
Re-bin 082a2f9f45 Merge PR #1618: support Azure OpenAI 2026-03-07 03:57:57 +00:00
Re-bin 576ad12ef1 fix(azure): sanitize messages and handle temperature 2026-03-07 03:57:57 +00:00
Re-bin 7c074e4684 Merge remote-tracking branch 'origin/main' into pr-1618 2026-03-07 03:42:02 +00:00
Re-bin 7b491ed4b3 Merge PR #1637: fix tool_call_id length error for GitHub Copilot provider 2026-03-07 03:30:36 +00:00
Re-bin c94ac351f1 fix(litellm): normalize tool call ids 2026-03-07 03:30:36 +00:00
Re-bin c1da9df071 Merge remote-tracking branch 'origin/main' into pr-1637 2026-03-07 03:09:25 +00:00
Re-bin 4bbdd78809 Merge PR #1638: add WhatsApp media support 2026-03-07 03:06:19 +00:00
Re-bin 64112eb9ba fix(whatsapp): avoid dropping media-only messages 2026-03-07 03:06:19 +00:00
04cb e381057356 Fix tool_call_id length error for GitHub Copilot provider
GitHub Copilot and some other providers have a 64-character limit on
tool_call_id. When switching from providers that generate longer IDs
(such as OpenAI Codex), this caused validation errors.

This fix truncates tool_call_id to 64 characters by preserving the first
32 and last 32 characters to maintain uniqueness while respecting the
provider's limit.

Fixes #1554
2026-03-07 08:31:15 +08:00
fat-operator 067965da50 Refactored from image support to generic media 2026-03-07 00:26:49 +00:00
fat-operator 8c25897532 Remove image sending capabilities - cant be tested 2026-03-07 00:26:49 +00:00
fat-operator fdd161d7b2 Implemented image support for whatsapp 2026-03-07 00:26:49 +00:00
Maciej Wojcik 79f3ca4f12 feat(cli): add workspace and config flags to agent 2026-03-06 20:32:10 +00:00
Kunal Karmakar 73be53d4bd Add SSL verification 2026-03-06 18:16:15 +00:00
Kunal Karmakar 7e4594e08d Increase timeout for chat completion calls 2026-03-06 18:12:46 +00:00
Kunal Karmakar 13236ccd38 Merge branch 'main' of https://github.com/kunalk16/nanobot into feat-support-azure-openai 2026-03-06 17:21:55 +00:00
Kunal Karmakar 43022b1718 Fix unit test after updating error message 2026-03-06 17:20:52 +00:00
Re-bin 0409d72579 feat(telegram): improve streaming UX and add table rendering 2026-03-06 16:19:19 +00:00
Kunal Karmakar a8ce0a3084 Adding some more insights for failure in Azure OpenAI calls 2026-03-06 16:05:43 +00:00
TinkandClaude Opus 4.6 6b3997c463 fix: add from __future__ import annotations across codebase
Ensure all modules using PEP 604 union syntax (X | Y) include
the future annotations import for Python <3.10 compatibility.
While the project requires >=3.11, this avoids import-time
TypeErrors when running tests on older interpreters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:13:56 +08:00
TinkandClaude Opus 4.6 e868fb32d2 fix: add from __future__ import annotations to fix Python <3.11 compat
These two files from upstream use PEP 604 union syntax (str | None)
without the future annotations import. While the project requires
Python >=3.11, this makes local testing possible on 3.9/3.10.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:09:38 +08:00
Tink f958eb4cc9 Merge remote-tracking branch 'origin/main' into feat/openai-compatible-session-isolation
# Conflicts:
#	nanobot/agent/context.py
#	tests/test_consolidate_offset.py
2026-03-06 19:03:41 +08:00
Kunal Karmakar 33c52cfb74 Merge branch 'main' of https://github.com/kunalk16/nanobot into feat-support-azure-openai 2026-03-06 10:39:29 +00:00
Kunal Karmakar 0b0f47f09f Update readme with azure openai support 2026-03-06 10:37:16 +00:00
samsonchoi 858b136f30 docs: add comprehensive multi-instance configuration guide
- Add detailed setup examples with directory structure
- Document complete isolation mechanism (config, workspace, cron, logs, media)
- Include use cases and production deployment patterns
- Add management scripts for systemd (Linux) and launchd (macOS)
- Provide step-by-step configuration examples
2026-03-06 17:57:21 +08:00
Kunal Karmakar 7684f5b902 Fix the temperature issue, remove temperature 2026-03-06 09:49:26 +00:00
Kunal Karmakar 52e725053c Always use temperature 1 2026-03-06 09:20:47 +00:00
SLAR_Edge a25923b793 feat: enhance message sending to include file attachments in Discord API 2026-03-06 17:10:53 +08:00
Kunal Karmakar 813d37ad35 Support Azure OpenAI 2026-03-06 08:43:58 +00:00
Barry WangandGitHub 81a8a1be1e Merge branch 'HKUDS:main' into feat/improve-tool-validation-tests 2026-03-06 15:42:26 +08:00
Re-bin 473ae5ef18 Merge PR #1546: fix: lazy import providers to avoid loading unused heavy dependencies 2026-03-06 07:18:54 +00:00
Re-bin cbce674669 Merge remote-tracking branch 'origin/main' into pr-1546 2026-03-06 07:18:06 +00:00
Re-bin 7755cab74b Merge PR #1555: fix: merge tool_calls from multiple choices in LiteLLM response 2026-03-06 07:16:20 +00:00
Re-bin dcebb94b01 style: remove trailing whitespace 2026-03-06 07:16:20 +00:00
Re-bin ef5792162f Merge remote-tracking branch 'origin/main' into pr-1555 2026-03-06 07:15:22 +00:00
Re-bin 23cbc86da8 Merge PR #1563: chore: clean up duplicate MatrixConfig, add Alibaba Coding Plan docs 2026-03-06 07:13:04 +00:00
Re-bin b817463939 chore: simplify Alibaba Coding Plan to apiBase hint, remove dedicated provider 2026-03-06 07:13:04 +00:00
Re-bin e81b6ceb49 merge origin/main into pr-1563 2026-03-06 07:01:23 +00:00
Re-bin 8e727283ef Merge PR #1567: refactor(channels): extract split_message utility to reduce duplication 2026-03-06 06:53:55 +00:00
Re-bin 7e9616cbd3 merge origin/main into pr-1567 2026-03-06 06:51:28 +00:00
Re-bin 7c20c56d7d Merge PR #1573: fix(context): detect image MIME type from magic bytes 2026-03-06 06:49:09 +00:00
Re-bin 3a01fe536a refactor: move detect_image_mime to utils/helpers for reuse 2026-03-06 06:49:09 +00:00
gaoyiman b3710165c0 Merge branch 'main' into feat-volcengine-tuning 2026-03-06 14:47:08 +08:00
Re-bin 91b3ccee96 Merge remote-tracking branch 'origin/main' into pr-1573 2026-03-06 06:41:00 +00:00
Re-bin 3bbeb147b6 Merge PR #1605: fix(feishu): smart message format selection 2026-03-06 06:09:46 +00:00
Re-bin ba63f6f62d chore: remove pr-description.md from repo 2026-03-06 06:09:46 +00:00
Re-bin 645e30557b Merge remote-tracking branch 'origin/main' into pr-1605 2026-03-06 06:00:32 +00:00
Re-bin 1c76803e57 Merge PR #1603: fix(memory): handle list tool call args + fix(cli): Windows signal compatibility 2026-03-06 05:27:39 +00:00
Re-bin fc0b38c304 fix(memory): improve warning message for empty/non-dict list arguments 2026-03-06 05:27:39 +00:00
Re-bin a211e32e50 Merge remote-tracking branch 'origin/main' into pr-1603 2026-03-06 05:24:59 +00:00
Re-bin 1daef5c22f Merge PR #1594: fix(feishu): use msg_type media for mp4 video files 2026-03-06 05:11:26 +00:00
nanobot-contributor 6fb4204ac6 fix(memory): handle list type tool call arguments
Some LLM providers return tool_calls[0].arguments as a list instead of
dict or str. Add handling to extract the first dict element from the list.

Fixes /new command warning: 'unexpected arguments type list'
2026-03-06 11:47:00 +08:00
PiKaqqqqqq c3526a7fdb fix(feishu): smart message format selection (fixes #1548)
Instead of always sending interactive cards, detect the optimal
message format based on content:
- text: short plain text (≤200 chars, no markdown)
- post: medium text with links (≤2000 chars)
- interactive: complex content (code, tables, headings, bold, lists)
2026-03-06 10:11:53 +08:00
nanobot-contributor 9ab4155991 fix(cli): add Windows compatibility for signal handlers (PR #1400)
SIGHUP and SIGPIPE are not available on Windows. Add hasattr() checks
before registering these signal handlers to prevent AttributeError on
Windows systems.

Fixes compatibility issue introduced in PR #1400.
2026-03-06 09:57:03 +08:00
pikaqqqqqq 5ced08b1f2 fix(feishu): use msg_type "media" for mp4 video files
Previously, mp4 video files were sent with msg_type "file", which meant
users had to download them to play. Feishu requires msg_type "media" for
audio and video files to enable inline playback in the chat.

Changes:
- Add _VIDEO_EXTS constant for video file extensions (.mp4, .mov, .avi)
- Use msg_type "media" for both audio (_AUDIO_EXTS) and video (_VIDEO_EXTS)
- Keep msg_type "file" for documents and other file types

The upload_file API already uses file_type="mp4" for video files via the
existing _FILE_TYPE_MAP, so only the send msg_type needed fixing.
2026-03-06 01:54:00 +08:00
VITOHJL 958c23fb01 chore: refine platform policy and memory SKILL docs 2026-03-05 23:57:43 +08:00
samsonchoi 4e4d40ef33 feat: multi-instance support with --config parameter
Add support for running multiple nanobot instances with complete isolation:

- Add --config parameter to gateway command for custom config file path
- Implement set_config_path() in config/loader.py for dynamic config path
- Derive data directory from config file location (e.g., ~/.nanobot-xxx/)
- Update get_data_path() to use unified data directory from config loader
- Ensure cron jobs use instance-specific data directory

This enables running multiple isolated nanobot instances by specifying
different config files, with each instance maintaining separate:
- Configuration files
- Workspace (memory, sessions, skills)
- Cron jobs
- Logs and media

Example usage:
  nanobot gateway --config ~/.nanobot-instance2/config.json --port 18791
2026-03-05 23:48:45 +08:00
Re-bin c8f86fd052 Merge PR #1384: fix(feishu): split card messages when content has multiple tables 2026-03-05 15:21:19 +00:00
Re-bin 573fc7cd95 Merge remote-tracking branch 'origin/main' into pr-1384 2026-03-05 15:19:50 +00:00
Re-bin 68a1a0268d Merge PR #1522: feat(telegram): implement draft/progress streaming messages 2026-03-05 15:17:30 +00:00
Re-bin d32c6f946c fix(telegram): pin ptb>=22.6, fix double progress, clean up stale hatch config 2026-03-05 15:17:30 +00:00
Re-bin b070ae5b2b Merge remote-tracking branch 'origin/main' into pr-1522 2026-03-05 15:05:26 +00:00
Re-bin 80392d158a Merge PR #1400: fix: add SIGTERM, SIGHUP handling and ignore SIGPIPE 2026-03-05 14:59:03 +00:00
Re-bin 4ba8d137bc Merge remote-tracking branch 'origin/main' into pr-1400 2026-03-05 14:56:18 +00:00
Re-bin bea0f2a15d Merge PR #1435: feat(gateway): support multiple instances with --workspace and --config options 2026-03-05 14:54:53 +00:00
Re-bin 0343d66224 fix(gateway): remove duplicate load_config() that overwrote custom workspace/config 2026-03-05 14:54:53 +00:00
Re-bin 6d342fe79d Merge remote-tracking branch 'origin/main' into pr-1435 2026-03-05 14:51:13 +00:00
Re-bin cd0bcc162e docs: update introduction of nanobot 2026-03-05 14:48:57 +00:00
Re-bin 57d8aefc22 docs: update introduction of nanobot 2026-03-05 14:46:03 +00:00
Re-bin ec7bc33441 Merge PR #1488: feat(mcp): add SSE transport support with auto-detection 2026-03-05 14:44:45 +00:00
Re-bin b71c1bdca7 fix(mcp): hoist sse/http imports, annotate auto-detection heuristic, restore field comments 2026-03-05 14:44:45 +00:00
Re-bin 2306d4c11c Merge remote-tracking branch 'origin/main' into pr-1488 2026-03-05 14:35:02 +00:00
Re-bin c0d10cb508 Merge PR #553: feat(discord): add group policy to control group respond behaviour 2026-03-05 14:33:14 +00:00
Re-bin 06fcd2cc3f fix(discord): correct group_policy default to mention and style cleanup 2026-03-05 14:33:14 +00:00
Re-bin 376b7d6d58 Merge remote-tracking branch 'origin/main' into pr-553 2026-03-05 14:28:50 +00:00
Re-bin bc52ad3dad Merge pull request #1428: feat(custom-provider) session affinity header 2026-03-05 14:27:24 +00:00
Re-bin fb77176cfd feat(custom-provider): keep instance-level session affinity header for cache locality 2026-03-05 14:25:46 +00:00
Re-bin a3c68ef140 Merge branch 'main' into pr-1428 2026-03-05 14:12:37 +00:00
coldxiangyu 46192fbd2a fix(context): detect image MIME type from magic bytes instead of file extension
Feishu downloads images with incorrect extensions (e.g. .jpg for PNG files).
mimetypes.guess_type() relies on the file extension, causing a MIME mismatch
that Anthropic rejects with 'image was specified using image/jpeg but appears
to be image/png'.

Fix: read the first bytes of the image data and detect the real MIME type via
magic bytes (PNG: 0x89PNG, JPEG: 0xFFD8FF, GIF: GIF87a/GIF89a, WEBP: RIFF+WEBP).
Fall back to mimetypes.guess_type() only when magic bytes are inconclusive.
2026-03-05 20:29:10 +08:00
ouyangwulinandGitHub d720235061 Merge branch 'HKUDS:main' into coding-plan 2026-03-05 17:47:41 +08:00
Xubin RenandGitHub 7b676962ed Merge PR #1568: fix(feishu): isolate lark ws Client event loop from main asyncio loop
fix(feishu): isolate lark ws Client event loop from main asyncio loop
2026-03-05 17:38:57 +08:00
ouyangwulin 6770a6e7e9 supported aliyun coding plan. 2026-03-05 17:34:36 +08:00
coldxiangyuandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 97522bfa03 fix(feishu): isolate lark ws Client event loop from main asyncio loop
Commit 0209ad5 moved `import lark_oapi as lark` inside the start()
method (lazy import) to suppress DeprecationWarnings. This had an
unintended side effect: the import now happens after the main asyncio
loop is already running, so lark_oapi's module-level

    loop = asyncio.get_event_loop()

captures the running main loop. When the WebSocket thread then calls
loop.run_until_complete() inside Client.start(), Python raises:

    RuntimeError: This event loop is already running

and the _connect/_disconnect coroutines are never awaited.

Fix: in run_ws(), create a fresh event loop with asyncio.new_event_loop(),
set it as the thread's current loop, and patch lark_oapi.ws.client.loop
to point to this dedicated loop before calling Client.start(). The loop
is closed on thread exit.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-03-05 17:27:17 +08:00
suger-m 323e5f22cc refactor(channels): extract split_message utility to reduce code duplication
Extract the _split_message function from discord.py and telegram.py
into a shared utility function in utils/helpers.py.

Changes:
- Add split_message() to nanobot/utils/helpers.py with configurable max_len
- Update Discord channel to use shared utility (2000 char limit)
- Update Telegram channel to use shared utility (4000 char limit)
- Remove duplicate implementations from both channels

Benefits:
- Reduces code duplication
- Centralizes message splitting logic for easier maintenance
- Makes the function reusable for future channels

The function splits content into chunks within max_len, preferring
to break at newlines or spaces rather than mid-word.
2026-03-05 17:16:47 +08:00
Barry Wang 667613d594 fix edge case casting and more test cases 2026-03-05 16:57:39 +08:00
Barry Wang 9e42ccb51e feat: auto casting tool params to match schema type 2026-03-05 16:57:39 +08:00
ouyangwulin cf3e7e3f38 feat: Add Alibaba Cloud Coding Plan API support
Add dashscope_coding_plan provider to registry with OpenAI-compatible
endpoint for BaiLian coding assistance.

- Supports API key detection by 'sk-sp-' prefix pattern
- Adds provider config schema entry for proper loading
- Updates documentation with configuration instructions
- Fixes duplicate MatrixConfig class issue in schema
- Follow existing nanobot provider patterns for consistency
2026-03-05 16:54:15 +08:00
Peixian Gong 5cc3c03245 fix: merge tool_calls from multiple choices in LiteLLM response
GitHub Copilot's API returns tool_calls split across multiple choices:
- choices[0]: content only (tool_calls=null)
- choices[1]: tool_calls only (content=null)

The existing _parse_response only inspected choices[0], so tool_calls
were silently lost, causing the agent to never execute tools when using
github_copilot/ models.

This fix scans all choices and merges tool_calls + content, so
providers that return multi-choice responses work correctly.
Single-choice providers (OpenAI, Anthropic, etc.) are unaffected since
the loop over one choice is equivalent to the original code.
2026-03-05 15:15:37 +08:00
gaoyiman 0d60acf2d5 fix(schema): rename volcengine_plan and byteplus_plan to *_coding_plan for consistency 2026-03-05 14:40:18 +08:00
gaoyimanandGitHub 80bf5e55f1 Merge branch 'HKUDS:main' into feat-volcengine-tuning 2026-03-05 14:14:33 +08:00
hcanyz a08aae93e6 fix: not imported when LiteLLMProvider is not used
LiteLLM:WARNING: get_model_cost_map.py:213 - LiteLLM: Failed to fetch remote model cost map from https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json: The read operation timed out. Falling back to local backup.
2026-03-05 11:33:24 +08:00
Xubin RenandGitHub fb74281434 Merge PR #1499: fix(qq): add msg_seq to prevent message deduplication error
fix(qq): add msg_seq to prevent message deduplication error
2026-03-05 10:39:45 +08:00
Xubin RenandGitHub 2484dc5ea6 Merge PR #1533: fix(tests): resolve failing tests
fix(tests): resolve failing tests
2026-03-05 10:28:39 +08:00
Sergio Sánchez Vallés 33f59d8a37 fix(agent): separate reasoning and tool hints to respect channel config 2026-03-05 00:45:15 +01:00
Sergio Sánchez Vallés c27d2b1522 fix(agent): prevent tool hints from overwriting reasoning in streaming drafts 2026-03-05 00:33:27 +01:00
姚远 f78d655aba Fix: Telegram channel crash when proxy is configured 2026-03-05 04:29:00 +08:00
Sergio Sánchez VallésandGitHub d019ff06d2 Merge branch 'main' into fix/test-failures 2026-03-04 20:07:58 +01:00
Sergio Sánchez Vallés e032faaeff Merge branch 'main' of upstream/main into fix/test-failures 2026-03-04 20:04:00 +01:00
Sergio Sánchez Vallés 0209ad57d9 fix(tests): resolve RequestsDependencyWarning and lark-oapi asyncio/websockets DeprecationWarnings 2026-03-04 19:31:39 +01:00
Xubin RenandGitHub 4e9f08cafa Merge PR #1511: fix: add size limit to ReadFileTool to prevent OOM
fix: add size limit to ReadFileTool to prevent OOM
2026-03-05 01:12:40 +08:00
Xubin RenandGitHub fd3b4389d2 Merge PR #1531: fix(feishu): convert audio type to file for API compatibility
fix(feishu): convert audio type to file for API compatibility
2026-03-05 00:38:15 +08:00
Xubin RenandGitHub a156a8ee93 Merge PR #1507: fix: guard validate_params against non-dict input
fix: guard validate_params against non-dict input
2026-03-05 00:36:29 +08:00
Xubin RenandGitHub d9ce3942fa Merge PR #1508: fix: handle invalid ISO datetime in CronTool gracefully
fix: handle invalid ISO datetime in CronTool gracefully
2026-03-05 00:35:16 +08:00
Xubin RenandGitHub 522cf89d53 Merge PR #1521: test: fix test failures from refactored cron and context builder
test: fix test failures from refactored cron and context builder
2026-03-05 00:34:01 +08:00
Xubin RenandGitHub a2762351b3 Merge PR #1525: fix(codex): pass reasoning_effort to Codex API
fix(codex): pass reasoning_effort to Codex API
2026-03-05 00:31:09 +08:00
Ben bdfe7d6449 fix(feishu): convert audio type to file for API compatibility
Feishu's GetMessageResource API only accepts 'image' or 'file' as the
type parameter. When downloading voice messages, nanobot was passing
'audio' which caused the API to reject the request with an error.

This fix converts 'audio' to 'file' in _download_file_sync method
before making the API call, allowing voice messages to be downloaded
and transcribed successfully.

Fixes voice message download failure in Feishu channel.
2026-03-05 00:16:31 +08:00
chengyongruandSergio Sánchez Vallés 88d7642c1e test: fix test failures from refactored cron and context builder
- test_context_prompt_cache: Update test to reflect merged runtime
  context and user message (commit ad99d5a merged them into one)
- Remove test_cron_commands.py: cron add CLI command was removed
  in commit c05cb2e (unified scheduling via cron tool)
2026-03-04 17:13:25 +01:00
Sergio Sánchez Vallés c64fe0afd8 fix(tests): resolve failing tests on main branch
- Unskip matrix logic by adding missing deps (matrix-nio, nh3, mistune)
- Update matrix tests for 'allow_from' default deny security change
- Fix asyncio typing keepalive leak in matrix tests
- Update context prompt cache assert after runtime message merge
- Fix flaky cron service test with mtime sleep
- Remove obsolete test_cron_commands.py testing deleted CLI commands
2026-03-04 16:53:07 +01:00
Daniel Emden ecdf309404 fix(codex): pass reasoning_effort to Codex API
The OpenAI Codex provider accepts reasoning_effort but silently
discards it. Wire it through as {"reasoning": {"effort": ...}} in
the request body so the config option actually takes effect.
2026-03-04 15:31:56 +01:00
chengyongru bb8512ca84 test: fix test failures from refactored cron and context builder
- test_context_prompt_cache: Update test to reflect merged runtime
  context and user message (commit ad99d5a merged them into one)
- Remove test_cron_commands.py: cron add CLI command was removed
  in commit c05cb2e (unified scheduling via cron tool)
2026-03-04 20:49:02 +08:00
Sergio Sánchez Vallés ca1f41562c Fix telegram stop typing if not final message 2026-03-04 13:19:35 +01:00
Sergio Sánchez Vallés 61f658e045 add reasoning content to on progress message 2026-03-04 12:11:18 +01:00
Kiplangatkorir d0c6479186 feat: add LLM retry with exponential backoff for transient errors
provider.chat() had no retry logic — a transient 429 rate limit,
502 gateway error, or network timeout would permanently fail the
entire message. For a system running cron jobs and heartbeats 24/7,
even a brief provider blip causes lost tasks.

Adds _chat_with_retry() that:
- Retries up to 3 times with 1s/2s/4s exponential backoff
- Only retries transient errors (429, 5xx, timeout, connection)
- Returns immediately on permanent errors (400, 401, etc.)
- Falls through to the final attempt if all retries exhaust
2026-03-04 11:20:50 +03:00
Kiplangatkorir ce65f8c11b fix: add size limit to ReadFileTool to prevent OOM
ReadFileTool had no file size check — reading a multi-GB file would
load everything into memory and crash the process. Now:
- Rejects files over ~512KB at the byte level (fast stat check)
- Truncates at 128K chars with a notice if content is too long
- Guides the agent to use exec with head/tail/grep for large files

This matches the protection already in ExecTool (10KB) and
WebFetchTool (50KB).
2026-03-04 11:15:45 +03:00
Kiplangatkorir edaf7a244a fix: handle invalid ISO datetime in CronTool gracefully
datetime.fromisoformat(at) raises ValueError for malformed strings,
which propagated uncaught and crashed the tool execution. Now catches
ValueError and returns a user-friendly error message instead.
2026-03-04 10:55:17 +03:00
Kiplangatkorir df8d09f2b6 fix: guard validate_params against non-dict input
When the LLM returns malformed tool arguments (e.g. a list or string
instead of a dict), validate_params would crash with AttributeError
in _validate() when calling val.items(). Now returns a clear
validation error instead of crashing.
2026-03-04 10:53:30 +03:00
LiwxandGitHub 20bec3bc26 Update qq.py 2026-03-04 14:06:19 +08:00
LiwxandGitHub d0a48ed23c Update qq.py 2026-03-04 14:00:40 +08:00
WufeiHalfandGitHub 832e2e8ecd Merge branch 'HKUDS:main' into main 2026-03-04 10:51:10 +08:00
worenidewen 3e83425142 feat(mcp): add SSE transport support with auto-detection 2026-03-04 01:10:19 +08:00
Sergio Sánchez Vallés 102b9716ed feat: Implement Telegram draft/progress messages (streaming) 2026-03-03 17:16:08 +01:00
Xubin RenandGitHub 1303cc6669 Merge PR #1485: fix: add missed openai dependency
fix: add missed `openai` dependency
2026-03-04 00:14:52 +08:00
cocolato 5f7fb9c75a add missed dependency 2026-03-03 23:40:56 +08:00
WufeiHalf 0f1cc40b22 feat(telegram): add Telegram group topic support 2026-03-03 22:08:01 +08:00
astvacpandGitHub 01744029d8 fix problem with proxy for Telegram
This PR fixes problem with proxy for Telegram
2026-03-03 18:08:50 +07:00
Yan-ke Guo a7be0b3c9e sync missing scripts from upstream openclaw repository 2026-03-03 18:14:26 +08:00
Re-bin c05cb2ef64 refactor(cron): remove CLI cron commands and unify scheduling via cron tool 2026-03-03 05:51:24 +00:00
Re-bin 9a41aace1a Merge PR #1458: prevent cron self-scheduling safely 2026-03-03 05:36:50 +00:00
Re-bin 30803afec0 fix(cron): isolate cron-execution guard with contextvars 2026-03-03 05:36:48 +00:00
Re-bin ec6430fa0c Merge branch 'main' into pr-1458 2026-03-03 05:18:28 +00:00
Re-bin caa8acf6d9 Merge PR #1456: merge user messages and harden save_turn multimodal persistence 2026-03-03 05:13:20 +00:00
Re-bin 03b83fb79e fix(agent): skip empty multimodal user entries after runtime-context strip 2026-03-03 05:13:17 +00:00
Nikolas de Hor da8a4fc68c fix: prevent cron job execution from scheduling new jobs
When a cron job fires, the agent processes the scheduled message and
has access to the cron tool. If the original message resembles a
scheduling instruction (e.g. "remind me in 10 seconds"), the agent
would call cron.add again, creating an infinite feedback loop.

Add a cron-context flag to CronTool that blocks add operations during
cron job execution. The flag is set before process_direct() and cleared
in a finally block to ensure cleanup even on errors.

Fixes #1441
2026-03-03 01:02:33 -03:00
Nikolas de Hor ad99d5aaa0 fix: merge consecutive user messages into single message
Some LLM providers (Minimax, Dashscope) strictly reject consecutive
messages with the same role. build_messages() was emitting two separate
user messages back-to-back: the runtime context and the actual user
content.

Merge them into a single user message, handling both plain text and
multimodal (image) content. Update _save_turn() to strip the runtime
context prefix from the merged message when persisting to session
history.

Fixes #1414
Fixes #1344
2026-03-03 00:59:58 -03:00
chengyongru 8f4baaa5ce feat(gateway): support multiple instances with --workspace and --config options
- Add --workspace/-w flag to specify workspace directory
- Add --config/-c flag to specify config file path
- Move cron store to workspace directory for per-instance isolation
- Enable running multiple nanobot instances simultaneously
2026-03-02 23:18:54 +08:00
David Markey ecdfaf0a5a feat(custom-provider): add x-session-affinity header for prompt caching 2026-03-02 11:03:12 +00:00
Re-bin 3c79404194 fix(providers): sanitize thinking_blocks by provider and harden content normalization 2026-03-02 06:58:10 +00:00
Re-bin 1601470436 Merge PR #1399: reload cron store on timer tick 2026-03-02 06:38:00 +00:00
Re-bin 9877195de5 chore(cron): remove redundant timer comment 2026-03-02 06:37:57 +00:00
Re-bin f3979c0ee6 Merge branch 'main' into pr-1399 2026-03-02 06:30:43 +00:00
Re-bin 3f79245b91 Merge PR #1406: normalize Matrix media metadata and attachment upload call 2026-03-02 06:28:48 +00:00
Re-bin be4f83a760 Merge branch 'main' into pr-1406 2026-03-02 06:24:53 +00:00
Re-bin b575606c9e Merge PR #1403: deny-by-default allowFrom with startup validation 2026-03-02 06:13:40 +00:00
Re-bin bbfc1b40c1 security: deny-by-default allowFrom with wildcard support and startup validation 2026-03-02 06:13:37 +00:00
Wenjie Lei 2c63946519 fix(matrix): normalize media metadata and keyword-call attachment upload 2026-03-01 21:56:08 -08:00
chengyongru d447be5ca2 security: deny by default in is_allowed for all channels
When allow_from is not configured, block all access by default
instead of allowing everyone. This prevents unauthorized access
when channels are enabled without explicit allow lists.
2026-03-02 13:18:43 +08:00
Joel Chan e9d023f52c feat(discord): add group policy to control group respond behaviour 2026-03-02 12:16:49 +08:00
yzchen dba93ae83a cron: reload jobs store on each timer tick 2026-03-02 11:19:45 +08:00
chengyongruandClaude Opus 4.6 aed1ef5529 fix: add SIGTERM, SIGHUP handling and ignore SIGPIPE
- Add handler for SIGTERM to prevent "Terminated" message on Linux
- Add handler for SIGHUP for terminal closure handling
- Ignore SIGPIPE to prevent silent process termination
- Change os._exit(0) to sys.exit(0) for proper cleanup

Fixes issue #1365

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:04:53 +08:00
chengyongruandClaude Opus 4.6 ae788a17f8 chore: add .worktrees to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:03:54 +08:00
Sense_wang 521217a7f5 fix(telegram): enforce group_policy in _on_message
When `group_policy` is set to "mention", skip messages in group chats
unless the bot is @mentioned or the message is a reply to the bot.

Fixes #1380
2026-03-01 16:50:36 +00:00
Sense_wang 43329018f7 fix(telegram): add group_policy config for Telegram groups
Add `group_policy` field to `TelegramConfig` with "open" (default) and
"mention" options, consistent with Slack and Matrix channel configs.
2026-03-01 16:50:02 +00:00
zerone0x 8571df2e63 fix(feishu): split card messages when content has multiple tables
Feishu rejects interactive cards that contain more than one table element
(API error 11310: card table number over limit).

Add FeishuChannel._split_elements_by_table_limit() which partitions the flat
card-elements list into groups of at most one table each.  The send() method
now iterates over these groups and sends each as its own card message, so all
tables are delivered to the user instead of the entire message being dropped.

Single-table and table-free messages are unaffected (one card, same as before).

Fixes #1382
2026-03-01 15:13:44 +01:00
Re-bin a5962170f6 Merge PR #1370: add web tools proxy support 2026-03-01 12:53:20 +00:00
Re-bin 15529c668e fix(web): sanitize proxy logs and polish search key hint 2026-03-01 12:53:18 +00:00
Re-bin f5c0c75648 Merge branch 'main' into pr-1370 2026-03-01 12:48:01 +00:00
Re-bin 1109fdc682 Merge PR #1375: improve cron reminder context handling 2026-03-01 12:46:06 +00:00
Re-bin a7d24192d9 fix(cron): route scheduled jobs through process_direct with english reminder prefix 2026-03-01 12:45:53 +00:00
VITOHJL 468dfc406b feat(cron): improve cron job context handling
Improve cron job execution context to ensure proper message delivery and
session history recording.

Changes:
- Add [绯荤粺瀹氭椂浠诲姟] prefix to cron reminder messages to clearly mark
  them as system-driven, not user queries
- Use user role for cron reminder messages (required by some LLM APIs)
- Properly handle MessageTool to avoid duplicate message delivery
- Correctly save turn history with proper skip count
- Ensure Runtime Context is included in the message list

This ensures that:
1. Cron jobs execute with proper context
2. Messages are correctly delivered to users
3. Session history accurately records cron job interactions
4. The LLM understands these are system-driven reminders, not user queries
2026-03-01 17:05:04 +08:00
chengyongru 82be2ae1a5 feat(tool): add web search proxy 2026-03-01 16:51:54 +08:00
Re-bin aff8d8e9e1 Merge PR #1361: fix(feishu): parse post wrapper payload for rich text messages 2026-03-01 06:36:32 +00:00
Re-bin 4752e95a24 merge origin/main into pr-1361 2026-03-01 06:36:29 +00:00
Re-bin c2bbd6d20d Merge branch 'main' into pr-1361 2026-03-01 06:30:10 +00:00
Re-bin 7eae842132 Merge PR #1339: style: unify code formatting 2026-03-01 06:13:29 +00:00
Re-bin 3c6c49cc5d Merge branch 'main' into pr-1339
Made-with: Cursor

# Conflicts:
#	nanobot/cron/service.py
2026-03-01 06:06:01 +00:00
Xubin RenandGitHub c69e45f987 Merge PR #1371 to auto-reload jobs.json when modified externally
fix(cron): auto-reload jobs.json when modified externally
2026-03-01 14:02:37 +08:00
Re-bin 89e5a28097 fix(cron): auto-reload jobs.json when modified externally 2026-03-01 06:01:47 +00:00
Jack LuandGitHub 3ee061b879 Merge branch 'main' into main 2026-03-01 13:35:24 +08:00
Tink 80219baf25 feat(api): add OpenAI-compatible endpoint with x-session-key isolation 2026-03-01 10:53:45 +08:00
yzchen 2fc16596d0 fix(feishu): parse post wrapper payload for rich text messages 2026-03-01 02:17:10 +08:00
Re-bin f172c9f381 docs: reformat release news with v0.1.4.post3 release 2026-02-28 18:06:56 +00:00
Re-bin ee9bd6a96c docs: update v0.1.4.post3 release news 2026-02-28 18:04:12 +00:00
Re-bin 4f0530dd61 release: v0.1.4.post3 2026-02-28 17:55:18 +00:00
Re-bin 925302c01f Merge PR #1330: fix thinking mode support (reasoning_content + thinking_blocks) 2026-02-28 17:37:15 +00:00
Re-bin 5ca386ebf5 fix: preserve reasoning_content and thinking_blocks in session history 2026-02-28 17:37:12 +00:00
Re-bin a47c2e9a37 Merge branch 'main' into pr-1330
Made-with: Cursor

# Conflicts:
#	nanobot/providers/litellm_provider.py
2026-02-28 17:25:53 +00:00
Xubin RenandGitHub 422969d468 Merge PR #1348: fix(lark): Remove non-existent stop() call on Lark ws.Client when enable lark channel
fix(lark): Remove non-existent stop() call on Lark ws.Client when enable lark channel
2026-03-01 01:23:27 +08:00
Xubin RenandGitHub 8c1627c594 Merge PR #1351 to add reasoning_effort config to enable LLM thinking mode
feat: add reasoning_effort config to enable LLM thinking mode
2026-03-01 01:20:49 +08:00
Re-bin f9d72e2e74 feat: add reasoning_effort config to enable LLM thinking mode 2026-02-28 17:18:05 +00:00
zhangxiaoyu.york 9e2f69bd5a tidy up 2026-03-01 00:51:17 +08:00
Re-bin 0a5f3b6194 Merge PR #1346: fix(qq): disable botpy file log on read-only fs 2026-02-28 16:45:08 +00:00
Re-bin c34e1053f0 fix(qq): disable botpy file log to fix read-only filesystem error 2026-02-28 16:45:06 +00:00
Re-bin e0a78d78f9 Merge branch 'main' into pr-1346 2026-02-28 16:43:45 +00:00
Xubin RenandGitHub 76c3144c7c Merge PR #1347 to streamline subagent prompt
refactor: streamline subagent prompt by reusing ContextBuilder and SkillsLoader
2026-03-01 00:38:44 +08:00
zerone0xandClaude cfe33ff7cd fix(qq): disable botpy file log to fix read-only filesystem error
When nanobot is run as a systemd service with ProtectSystem=strict,
the process cwd defaults to the read-only root filesystem (/). botpy's
default Client configuration includes a TimedRotatingFileHandler that
writes 'botpy.log' to os.getcwd(), which raises [Errno 30] Read-only
file system.

Pass ext_handlers=False when constructing the botpy Client subclass to
suppress the file handler. nanobot already routes all log output through
loguru, so botpy's file handler is redundant.

Fixes #1343

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-28 17:35:07 +01:00
Re-bin 8545d5790e refactor: streamline subagent prompt by reusing ContextBuilder and SkillsLoader 2026-02-28 16:32:50 +00:00
zhangxiaoyu.york 5d829ca575 bugfix: remove client.stop 2026-03-01 00:30:03 +08:00
Re-bin a422c606d8 Merge PR #1337: feat(dingtalk): send images and media as proper message types 2026-02-28 16:23:44 +00:00
Re-bin 73a708770e refactor: compress DingTalk helpers 2026-02-28 16:23:43 +00:00
zhangxiaoyu.york b3af59fc8e bugfix: remove client.stop 2026-03-01 00:20:32 +08:00
coldxiangyu bd09cc3e6f perf: optimize prompt cache hit rate for Anthropic models
Part 1: Make system prompt static
- Move Current Time from system prompt to user message prefix
- System prompt now only changes when config/skills change, not every minute
- Timestamp injected as [YYYY-MM-DD HH:MM (Day) (TZ)] prefix on each user message

Part 2: Add second cache_control breakpoint
- Existing: system message breakpoint (caches static system prompt)
- New: second-to-last message breakpoint (caches conversation history prefix)
- Refactored _apply_cache_control with shared _mark() helper

Before: 0% cache hit rate (system prompt changed every minute)
After: ~90% savings on cached input tokens for multi-turn conversations

Closes #981
2026-02-28 22:41:01 +08:00
JK_Lu 977ca725f2 style: unify code formatting and import order
- Remove trailing whitespace and normalize blank lines
- Unify string quotes and line breaks for long lines
- Sort imports alphabetically across modules
2026-02-28 20:55:43 +08:00
siyuan.qsy cfc55d626a feat(dingtalk): send images as image messages, keep files as attachments 2026-02-28 20:34:23 +08:00
fengxiaohu 52222a9f84 fix(providers): allow reasoning_content in message history for thinking models 2026-02-28 18:46:15 +08:00
Re-bin bfc2fa88f3 Merge PR #1325: add message deduplication to WhatsApp channel 2026-02-28 08:38:29 +00:00
Re-bin 95ffe47e34 refactor: use OrderedDict for WhatsApp dedup, consistent with Feishu 2026-02-28 08:38:29 +00:00
Re-bin d8d954ad46 Merge remote-tracking branch 'origin/main' into pr-1325 2026-02-28 08:33:13 +00:00
Xubin RenandGitHub 9e546442d2 Merge PR #1326: use WeakValueDictionary for consolidation locks
refactor: use WeakValueDictionary for consolidation locks
2026-02-28 16:31:16 +08:00
Re-bin 8410f859f7 refactor: use WeakValueDictionary for consolidation locks — auto-cleanup, no manual pop 2026-02-28 08:26:55 +00:00
spartan077andClaude Sonnet 4.6 c0ad986504 fix: add message deduplication to WhatsApp channel
Prevent infinite loops by tracking processed message IDs in WhatsApp
channel. The bridge may send duplicate messages which caused the bot
to respond repeatedly with the same generic message.

Changes:
- Add _processed_message_ids deque (max 2000) to track seen messages
- Skip processing if message_id was already processed
- Align WhatsApp dedup with other channels (Feishu, Email, Mochat, QQ)

This fixes the issue where WhatsApp gets stuck in a loop sending
identical responses repeatedly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 13:44:22 +05:30
Re-bin e1832e75b5 Merge PR #1286: fix Windows path regex truncation in ExecTool 2026-02-28 08:09:56 +00:00
Re-bin b89b5a7e2c refactor: compress _extract_absolute_paths comments 2026-02-28 08:09:56 +00:00
Re-bin 05e0d271fc Merge remote-tracking branch 'origin/main' into pr-1286 2026-02-28 08:07:07 +00:00
Re-bin b1f0335090 Merge PR #1294: fix tool hint crash when arguments is a list (Kimi K2.5) 2026-02-28 08:06:20 +00:00
Re-bin 89c0f4cae9 refactor: compress tool hint args handling to two lines 2026-02-28 08:06:20 +00:00
Re-bin 90eb90335a Merge remote-tracking branch 'origin/main' into pr-1294 2026-02-28 08:01:04 +00:00
Xubin RenandGitHub 08752fab2f Merge PR #1307 to pass msg_id in QQ C2C reply
fix: pass msg_id in QQ C2C reply to avoid proactive message permissio…
2026-02-28 15:54:39 +08:00
Xubin RenandGitHub 44e120dd0b Merge PR #1317: modify Feishu bot permissions in README
Modify Feishu bot permissions in README
2026-02-28 15:48:59 +08:00
Re-bin 72b47446eb Merge PR #1323: fix Feishu interactive card content extraction 2026-02-28 07:40:31 +00:00
Re-bin 7bb7b85788 Merge remote-tracking branch 'origin/main' into pr-1323 2026-02-28 07:36:31 +00:00
Re-bin 1bbc5a6f89 Merge PR #1314: prevent session poisoning from null/error LLM responses 2026-02-28 07:35:07 +00:00
Re-bin 0036116e0b fix: filter empty assistant messages in _save_turn instead of patching at send time 2026-02-28 07:35:07 +00:00
Re-bin 069f93f6f5 Merge remote-tracking branch 'origin/main' into pr-1314 2026-02-28 07:29:04 +00:00
阿正andGitHub e440aa72c5 fix the interactive message text cannot be extracted 2026-02-28 15:10:35 +08:00
Yan-ke GuoandGitHub 936e094a7f Modify Feishu bot permissions in README
Updated permissions for Feishu bot setup instructions.
2026-02-28 14:03:36 +08:00
Xubin RenandGitHub 32f42df7ef Merge PR #1316 to remove overly broad "codex" keyword from openai_codex provider
fix: remove overly broad "codex" keyword from openai_codex provider
2026-02-28 12:14:30 +08:00
Nikolas de Hor cc8864dc1f fix: remove overly broad "codex" keyword from openai_codex provider
The bare keyword "codex" causes false positive matches when any model
name happens to contain "codex" (e.g. "gpt-5.3-codex" on a custom
provider).  This incorrectly routes the request through the OAuth-based
OpenAI Codex provider, producing "OAuth credentials not found" errors
even when a valid custom api_key and api_base are configured.

Keep only the explicit "openai-codex" keyword so that auto-detection
requires the canonical prefix.  Users can still set provider: "custom"
to force the custom endpoint, but auto-detection should not collide.

Closes #1311
2026-02-28 01:01:20 -03:00
Nikolas de Hor 66063abb8c fix: prevent session poisoning from null/error LLM responses
When an LLM returns content: null on a plain assistant message (no
tool_calls), the null gets saved to session history and causes
permanent 400 errors on every subsequent request.

- Sanitize None content on plain assistant messages to "(empty)" in
  _sanitize_empty_content(), matching the existing empty-string handling
- Skip persisting error responses (finish_reason="error") to the
  message history in _run_agent_loop(), preventing poison loops

Closes #1303
2026-02-28 00:57:08 -03:00
GabrielWithTinaandClaude Sonnet 4.6 8842fb2b4d fix: pass msg_id in QQ C2C reply to avoid proactive message permission error
QQ's bot API requires a msg_id (original inbound message ID) to send a
passive reply. Without it the request is treated as a proactive message
and fails with error 40034102 (无权限). The message_id was already stored
in InboundMessage.metadata and forwarded to OutboundMessage, but was never
read in send().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 09:44:28 +08:00
Michael-lhh 11f1880c02 fix: handle list-type tool arguments in _tool_hint
Some models (e.g., Kimi K2.5 via OpenRouter) return tool call arguments
as a list instead of a dict. This caused an AttributeError when trying
to call .values() on the list.

The fix checks if arguments is a list and extracts the first element
before accessing .values().

Made-with: Cursor
2026-02-28 00:18:00 +08:00
Xubin RenandGitHub a4d95fd064 Merge PR #1293 to generate short alphanumeric tool_call_id for Mistral compatibility
fix: generate short alphanumeric tool_call_id for Mistral compatibility
2026-02-28 00:15:52 +08:00
Re-bin 1fe94898f6 fix: generate short alphanumeric tool_call_id for Mistral compatibility 2026-02-27 16:13:26 +00:00
Xubin RenandGitHub ef09add825 Merge PR #1278 to guide llm grep using timestamp
Fix(prompt): guide llm grep using timestamp
2026-02-27 23:42:05 +08:00
fengxiaohu 7229d86bb3 fix(shell): parse full Windows absolute paths in workspace guard 2026-02-27 21:46:46 +08:00
aiguozhi123456 db4185c8b7 Add timestamp format hint for HISTORY.md grep searching 2026-02-27 11:11:42 +00:00
Xubin RenandGitHub e86cfcde22 Merge PR #1200 to update heartbeat tests to match two-phase tool-call architecture
fix: update heartbeat tests to match two-phase tool-call architecture
2026-02-27 18:10:35 +08:00
Re-bin fdd2c25aed Merge PR #1222: fix runtime context leaking into session history 2026-02-27 10:07:22 +00:00
Re-bin bc558d0592 refactor: merge user-role branches in _save_turn 2026-02-27 10:07:22 +00:00
Re-bin 6bdb590028 Merge remote-tracking branch 'origin/main' into pr-1222 2026-02-27 09:57:45 +00:00
Re-bin a6aa5fbd7c Merge PR #1239: register Matrix channel in manager and schema 2026-02-27 09:53:31 +00:00
Re-bin 12f3365103 fix: remove duplicate import, tidy MatrixConfig comments 2026-02-27 09:53:31 +00:00
Re-bin 2d33371366 Merge remote-tracking branch 'origin/main' into pr-1239 2026-02-27 09:51:33 +00:00
Re-bin 858a62dd9b refactor: slim down helpers.py — remove dead code, compress docstrings 2026-02-27 09:50:12 +00:00
Re-bin 21e9644944 Merge PR #1253: auto-sync workspace templates on startup 2026-02-27 09:46:57 +00:00
Re-bin d5808bf586 refactor: streamline workspace template sync 2026-02-27 09:46:57 +00:00
Re-bin e260219ce6 Merge remote-tracking branch 'origin/main' into pr-1253 2026-02-27 09:41:13 +00:00
Re-bin b7561848e1 Merge PR #1257: feat(feishu): make reaction emoji configurable 2026-02-27 09:32:20 +00:00
Re-bin 969b15dbce Merge remote-tracking branch 'origin/main' into pr-1257 2026-02-27 09:31:12 +00:00
Re-bin 32ecfd32f3 Merge PR #1258: fix Telegram media-group aggregation 2026-02-27 09:30:01 +00:00
Re-bin aa2987be3e refactor: streamline Telegram media-group buffering 2026-02-27 09:30:01 +00:00
Tanish Rajput 568a54ae3e Initialize Matrix channel in ChannelManager when enabled in config 2026-02-27 11:39:01 +05:30
Kim a3e0543eae chore(telegram): keep media-group fix without unrelated formatting changes 2026-02-27 12:16:51 +08:00
Kim aa774733ea fix(telegram): aggregate media-group images into a single inbound turn 2026-02-27 12:08:48 +08:00
kimkitsuragi26 6641bad337 feat(feishu): make reaction emoji configurable
Replace hardcoded THUMBSUP with configurable react_emoji field
in FeishuConfig, consistent with SlackConfig.react_emoji pattern.

Default remains THUMBSUP for backward compatibility.
2026-02-27 11:45:44 +08:00
Re-bin cab901b2fb Merge PR #1228: fix(web): use self.api_key instead of undefined api_key 2026-02-27 02:44:19 +00:00
Re-bin b24df8afeb Merge remote-tracking branch 'origin/main' into pr-1228 2026-02-27 02:43:37 +00:00
Re-bin ec8dee802c refactor: simplify message tool suppress and inline consolidation locks 2026-02-27 02:39:38 +00:00
Hon Jia Xuan cb999ae826 feat: implement automatic workspace template synchronization 2026-02-27 10:39:05 +08:00
Re-bin c3a0c7c9eb Merge PR #1206: fix message tool suppress for cross-channel sends 2026-02-27 02:27:18 +00:00
Re-bin 29e6709e26 refactor: simplify message tool suppress — bool check instead of target tracking 2026-02-27 02:27:18 +00:00
Re-bin ac1c40db91 Merge remote-tracking branch 'origin/main' into pr-1206 2026-02-27 02:17:04 +00:00
gaoyiman cf2ed8a6a0 tune volcengine provider 2026-02-26 16:22:24 +08:00
Yongfeng Huang 7a3788fee9 fix(web): use self.api_key instead of undefined api_key
Made-with: Cursor
2026-02-26 15:43:04 +08:00
Kim 286e67ddef style(agent): remove inline comment in runtime-context history filter 2026-02-26 14:21:44 +08:00
Kim 45ae410f05 fix(agent): do not persist runtime context metadata in session history 2026-02-26 14:12:37 +08:00
Re-bin cc425102ac docs: update Matrix channel guideline and schema 2026-02-26 03:08:00 +00:00
Re-bin a1e930d942 Merge PR #420: feat: add Matrix (Element) channel 2026-02-26 03:04:13 +00:00
Re-bin 988a85d8de refactor: optimize matrix channel — optional deps, trim comments, simplify methods 2026-02-26 03:04:01 +00:00
Re-bin 84f2f3c316 Merge remote-tracking branch 'origin/main' into pr-420 2026-02-26 02:48:21 +00:00
Re-bin a77add9d8c Merge PR #1191: fix base64 images stored in session history causing context overflow 2026-02-26 02:43:50 +00:00
Re-bin a1440cf4cb refactor: inline base64 image stripping in _save_turn 2026-02-26 02:43:45 +00:00
Re-bin 0a9bb1d8df Merge remote-tracking branch 'origin/main' into pr-1191 2026-02-26 02:39:53 +00:00
Re-bin 4eb44cfb5c Merge PR #1198: fix assistant messages without tool calls not being saved to session 2026-02-26 02:33:38 +00:00
Re-bin 3902e31165 refactor: drop redundant tool_calls=None in final assistant message 2026-02-26 02:33:38 +00:00
Re-bin 23b9880478 Merge remote-tracking branch 'origin/main' into pr-1198 2026-02-26 02:29:45 +00:00
Re-bin 7e1a08d33c docs: add provider option to Quick Start config example 2026-02-26 02:23:07 +00:00
Xubin RenandGitHub cffba8d0be Merge PR #1214 to support explicit provider selection in config
feat: support explicit provider selection in config
2026-02-26 10:17:08 +08:00
Re-bin 65477e4bf3 feat: support explicit provider selection in config 2026-02-26 02:15:42 +00:00
Re-bin 39ab89cbd1 Merge PR #1180: feat: /stop command with task-based dispatch 2026-02-25 17:04:19 +00:00
Re-bin cdbede2fa8 refactor: simplify /stop dispatch, inline commands, trim verbose docstrings 2026-02-25 17:04:08 +00:00
chengyongru fafd8d4eb8 fix(agent): only suppress final reply when message tool sends to same target
A refactoring in commit 132807a introduced a regression where the final
response was silently discarded whenever the message tool was used,
regardless of the target. This restored the original logic from PR #832
that only suppresses the final reply when the message tool sends to the
same (channel, chat_id) as the original message.

Changes:
- message.py: Replace _sent_in_turn: bool with _turn_sends: list[tuple]
  to track actual send targets, add get_turn_sends() method
- loop.py: Check if (msg.channel, msg.chat_id) is in sent_targets before
  suppressing final reply. Also move the "Response to" log after the
  suppress check to avoid misleading logs.
- Add unit tests for the suppress logic

This ensures:
- Email sent via message tool → Feishu still gets confirmation
- Message tool sends to same Feishu chat → No duplicate (suppressed)
2026-02-26 00:32:48 +08:00
Re-bin 149f26af32 Merge branch 'main' into pr-1180 2026-02-25 16:16:18 +00:00
Re-bin becb0a4b87 Merge PR #1126: feat: add untrusted runtime context layer for stable prompt prefix 2026-02-25 16:13:48 +00:00
Re-bin d55a850357 refactor: simplify runtime context injection — drop JSON/dedup, keep untrusted tag 2026-02-25 16:13:48 +00:00
Re-bin b19c729eee Merge branch 'main' into pr-1126 2026-02-25 16:04:06 +00:00
Re-bin 3f41e39c8d Merge PR #1083: feat(exec): add path_append config to extend PATH for subprocess 2026-02-25 15:57:50 +00:00
Re-bin 9eca7f339e docs: shorten pathAppend description in config table 2026-02-25 15:57:50 +00:00
Re-bin e1a2ef4f29 Merge branch 'main' into pr-1083 2026-02-25 15:50:00 +00:00
Elliot LeeandClaude Opus 4.6 19a5efa89e fix: update heartbeat tests to match two-phase tool-call architecture
HeartbeatService was refactored from free-text HEARTBEAT_OK token
matching to a structured two-phase design (LLM tool call for
skip/run decision, then execution). The tests still used the old
on_heartbeat callback constructor and HEARTBEAT_OK_TOKEN import.

- Remove obsolete test_heartbeat_ok_detection test
- Update test_start_is_idempotent to use new provider+model constructor
- Add tests for _decide() skip path, trigger_now() run/skip paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:47:52 -08:00
VITOHJL f2e0847d64 Fix assistant messages without tool calls not being saved to session 2026-02-25 23:27:41 +08:00
dxtime 6aed4265b7 Fix: The base64 images are stored in the session history, causing context overflow. 2026-02-25 20:58:59 +08:00
coldxiangyu 4768b9a09d fix: parallel subagent cancellation + register task before lock
- cancel_by_session: use asyncio.gather for parallel cancellation
  instead of sequential await per task
- _dispatch: register in _active_tasks before acquiring lock so /stop
  can find queued tasks (synced from #1179)
2026-02-25 18:21:46 +08:00
coldxiangyu 2466b8b843 feat: /stop cancels spawned subagents via session tracking
- SubagentManager tracks _session_tasks: session_key -> {task_id, ...}
- cancel_by_session() cancels all subagents for a session
- SpawnTool passes session_key through to SubagentManager
- /stop response reports subagent cancellation count
- Cleanup callback removes from both _running_tasks and _session_tasks

Builds on #1179
2026-02-25 17:53:54 +08:00
coldxiangyu 3c12efa728 feat: extensible command system + task-based dispatch with /stop
- Add commands.py with CommandDef registry, parse_command(), get_help_text()
- Refactor run() to dispatch messages as asyncio tasks (non-blocking)
- /stop is an 'immediate' command: handled inline, cancels active task
- Global processing lock serializes message handling (safe for shared state)
- _pending_tasks set prevents GC of dispatched tasks before lock acquisition
- _dispatch() registers/clears active tasks, catches CancelledError gracefully
- /help now auto-generated from COMMANDS registry

Closes #849
2026-02-25 17:51:00 +08:00
aiguozhi123456 a50a2c6868 fix(docs): clarify platform-specific path separator 2026-02-25 01:53:04 +00:00
aiguozhi123456 e959b13926 docs: add pathAppend option to exec config docs 2026-02-25 01:49:56 +00:00
Re-bin 9e806d7159 Merge PR #1074: fix: preserve reasoning_content in message sanitization for thinking models 2026-02-25 00:38:51 +00:00
Re-bin 8fffee124b Merge branch 'main' into pr-1074 2026-02-25 00:38:20 +00:00
danfeiyang 22e129b514 fix:Workspace path in onboard command ignores config setting 2026-02-25 01:40:25 +08:00
rickthemad4 87a2084ee2 feat: add untrusted runtime context layer for stable prompt prefix 2026-02-24 16:38:29 +00:00
Re-bin a3963bfba3 docs: update v0.1.4.post2 release news 2026-02-24 16:35:50 +00:00
Re-bin 637c200dee docs: update v0.1.4.post2 release news 2026-02-24 16:34:22 +00:00
Re-bin 17de3699ab chore: bump version to 0.1.4.post2 2026-02-24 16:24:47 +00:00
Re-bin abc7b0aeb2 Merge PR #1107: fix(slack): post-process slackify_markdown output to catch leftover artifacts 2026-02-24 16:20:28 +00:00
Re-bin 96e1730af5 style: simplify _fixup_mrkdwn and trim docstring in SlackChannel 2026-02-24 16:20:28 +00:00
Re-bin a3f7cce416 Merge branch 'main' into pr-1107 2026-02-24 16:19:14 +00:00
Re-bin f223a4c5a3 Merge PR #1115: fix: stabilize system prompt for better cache reuse 2026-02-24 16:15:21 +00:00
Re-bin f294e9d065 refactor: merge runtime context helpers and move imports to top 2026-02-24 16:15:21 +00:00
rickthemad4 56b9b33c6d fix: stabilize system prompt for better cache reuse 2026-02-24 14:18:50 +00:00
Re-bin a818fff8fa chore: trim verbose docstrings 2026-02-24 13:47:17 +00:00
Re-bin a54b0853f0 Merge PR #1071: refactor(web): resolve api_key via property instead of inline 2026-02-24 13:42:35 +00:00
Re-bin 4b9ffea3fc merge origin/main into pr-1071, adopt @property api_key pattern 2026-02-24 13:41:49 +00:00
nanobot-agentandCursor 81b669b36e fix(slack): post-process slackify_markdown output to catch leftover artifacts
The slackify_markdown library (markdown-it) fails to convert **bold** when
the closing ** is immediately followed by non-space text (e.g. **Status:**OK).
This is a very common LLM output pattern that results in raw ** showing up
in Slack messages.

Add _fixup_mrkdwn() post-processor that:
- Converts leftover **bold** → *bold* (Slack mrkdwn)
- Converts leftover ## headers → *bold* (safety net)
- Fixes over-escaped &amp; in bare URLs
- Protects code fences and inline code from being mangled

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:44:17 +00:00
nanobot-agentandCursor 8686f060d9 fix(slack): add post-processing to fix mrkdwn conversion edge cases
The slackify_markdown library misses several patterns that LLMs commonly
produce, causing raw Markdown symbols (**bold**, ##headers) to appear
in Slack messages.

Add _fixup_mrkdwn() post-processor that:
- Converts leftover **bold** patterns (e.g. **Status:**OK where closing
  ** is adjacent to non-space chars)
- Fixes &amp; over-escaping in bare URLs
- Protects code blocks from false-positive fixups

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:43:21 +00:00
aiguozhi123456 07ae82583b fix: pass path_append from config to ExecTool 2026-02-24 12:31:18 +00:00
Re-bin 0e4dba8d19 Merge PR #1062: fix(mcp): disable httpx default timeout for HTTP transport 2026-02-24 12:15:33 +00:00
Re-bin e080902d61 Merge remote-tracking branch 'origin/main' into pr-1062 2026-02-24 12:14:00 +00:00
aiguozhi123456 7be278517e fix(exec): use empty default and os.pathsep for cross-platform 2026-02-24 12:13:52 +00:00
Re-bin f828a1d5d1 fix(gateway): show actual heartbeat interval in startup log 2026-02-24 12:09:19 +00:00
Re-bin e4888d39f7 Merge PR #1077: fix(email): auto_reply_enabled should not block proactive sends 2026-02-24 12:08:13 +00:00
Re-bin c6b933df4a Merge remote-tracking branch 'origin/main' into pr-1077 2026-02-24 11:38:38 +00:00
Re-bin f514ba02e9 Merge PR #1090: feat(feishu): extract and download images from post messages 2026-02-24 11:32:04 +00:00
Re-bin 04218276ab Merge remote-tracking branch 'origin/main' into pr-1090 2026-02-24 11:31:40 +00:00
Re-bin cd5a8ac03d Merge PR #1061: fix(memory): handle JSON-string tool call arguments from providers 2026-02-24 11:23:10 +00:00
Re-bin d546cbac6e style(memory): use loguru {} formatting in warning 2026-02-24 11:23:10 +00:00
Re-bin b9eb9d4963 Merge remote-tracking branch 'origin/main' into pr-1061 2026-02-24 11:22:01 +00:00
Re-bin abd35b1295 Merge PR #1098: fix(web): resolve API key on each call + improve error message 2026-02-24 11:18:33 +00:00
Re-bin cda3a02f68 style(web): inline api key resolution, remove unnecessary method 2026-02-24 11:18:33 +00:00
Re-bin fdf24e8fd2 Merge branch 'main' into pr-1098 2026-02-24 11:14:37 +00:00
Xubin RenandGitHub 8d1eec114a Merge PR #1102 to replace HEARTBEAT_OK token with virtual tool-call decision
fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision
2026-02-24 19:07:55 +08:00
Re-bin ec55f77912 fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision 2026-02-24 11:04:56 +00:00
coldxiangyu ef57225974 fix(web): resolve API key on each call + improve error message
- Defer Brave API key resolution to execute() time instead of __init__,
  so env var or config changes take effect without gateway restart
- Improve error message to reference actual config path
  (tools.web.search.apiKey) instead of only mentioning env var

Fixes #1069 (issues 1 and 2 of 3)
2026-02-24 18:19:47 +08:00
xzq.xuandCursor 4f8033627e feat(feishu): support images in post (rich text) messages
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 13:42:07 +08:00
aiguozhi123456 abcce1e1db feat(exec): add path_append config to extend PATH for subprocess 2026-02-24 03:18:23 +00:00
chengyongru 91e13d91ac fix(email): allow proactive sends when autoReplyEnabled is false
Previously, `autoReplyEnabled=false` would block ALL email sends,
including proactive emails triggered from other channels (e.g., asking
nanobot on Feishu to send an email).

Now `autoReplyEnabled` only controls automatic replies to incoming
emails, not proactive sends. This allows users to disable auto-replies
while still being able to ask nanobot to send emails on demand.

Changes:
- Check if recipient is in `_last_subject_by_chat` to determine if
  it's a reply
- Only skip sending when it's a reply AND auto_reply_enabled is false
- Add test for proactive send with auto_reply_enabled=false
- Update existing test to verify reply behavior
2026-02-24 04:27:14 +08:00
haosenwang1018 8de2f8d588 fix: preserve reasoning_content in message sanitization for thinking models
_sanitize_messages strips all non-standard keys from messages, including
reasoning_content. Thinking-enabled models like Moonshot Kimi k2.5
require reasoning_content to be present in assistant tool call messages
when thinking mode is on, causing a BadRequestError (#1014).

Add reasoning_content to _ALLOWED_MSG_KEYS so it passes through
sanitization when present.

Fixes #1014
2026-02-24 04:21:55 +08:00
haosenwang1018 eeaad6e0c2 fix: resolve API key at call time so config changes take effect without restart
Previously, WebSearchTool cached the API key in __init__, so keys added
to config.json or env vars after gateway startup were never picked up.
This caused a confusing 'BRAVE_API_KEY not configured' error even after
the key was correctly set (issue #1069).

Changes:
- Store the init-time key separately, resolve via property at each call
- Improve error message to guide users toward the correct fix

Closes #1069
2026-02-24 04:06:22 +08:00
Re-bin 30361c9307 refactor: replace cron usage docs in TOOLS.md with reference to cron skill 2026-02-23 18:28:09 +00:00
dulltackle f8dc6fafa9 **fix(mcp): Remove default timeout for HTTP transport to avoid tool timeout conflicts**
Always provide an explicit httpx client to prevent MCP HTTP transport from inheriting httpx's default 5-second timeout, thereby avoiding conflicts with the upper layer tool's timeout settings.
2026-02-24 01:26:56 +08:00
alairjt 3eeac4e8f8 Fix: handle non-string tool call arguments in memory consolidation
Fixes #1042. When the LLM returns tool call arguments as a dict or
JSON string instead of parsed values, memory consolidation would fail
with "TypeError: data must be str, not dict".

Changes:
- Add type guard in MemoryStore.consolidate() to parse string arguments
  and reject unexpected types gracefully
- Add regression tests covering dict args, string args, and edge cases
2026-02-23 13:59:49 -03:00
Re-bin 2f573e591b fix(session): get_history uses last_consolidated cursor, aligns to user turn 2026-02-23 16:57:08 +00:00
Re-bin 35e3f7ed26 fix(templates): tighten AGENTS.md tool call guidelines to reduce hallucinations 2026-02-23 14:10:43 +00:00
Re-bin c76a8d2e83 Merge PR #1029: fix: break Discord typing loop on persistent HTTP failure 2026-02-23 14:06:36 +00:00
Re-bin 3c2cc3a71c Merge remote-tracking branch 'origin/main' into pr-1029 2026-02-23 14:01:43 +00:00
Re-bin f4b3bbd87c Merge PR #1039: fix(heartbeat): make start idempotent and add tests 2026-02-23 13:59:47 +00:00
Re-bin eae6059889 fix: remove extra blank line 2026-02-23 13:59:47 +00:00
Re-bin 6f4d1c2cdc merge origin/main into pr-1039, adopt HEARTBEAT_OK in-check and on_notify 2026-02-23 13:57:28 +00:00
Xubin RenandGitHub 54e350a496 Merge PR #1054 to deliver agent response to user and fix HEARTBEAT_OK detection
fix(heartbeat): deliver agent response to user and fix HEARTBEAT_OK detection
2026-02-23 21:52:08 +08:00
Re-bin 7671239902 fix(heartbeat): suppress progress messages and deliver agent response to user 2026-02-23 13:45:09 +00:00
Re-bin 2c09f23c02 Merge PR #1048: feat(slack): isolate session context per thread 2026-02-23 13:10:55 +00:00
Re-bin 2b983c708d refactor: pass session_key as explicit param instead of via metadata 2026-02-23 13:10:47 +00:00
Re-bin 0be70b05b1 Merge remote-tracking branch 'origin/main' into pr-1048 2026-02-23 13:04:54 +00:00
Re-bin ea1c4ef025 fix: suppress heartbeat progress messages to external channels 2026-02-23 12:33:29 +00:00
Paul 1f7a81e5ee feat(slack): isolate session context per thread
Each Slack thread now gets its own conversation session instead of
sharing one session per channel. DM sessions are unchanged.

Added as a generic feature to also support if Feishu threads support
is added in the future.
2026-02-23 10:23:55 +00:00
Xubin RenandGitHub b2a1d1208e Merge PR #1046 to improve agent reliability: behavioral constraints, full tool history, error hints
improve agent reliability: behavioral constraints, full tool history, error hints
2026-02-23 17:16:09 +08:00
Re-bin d9462284e1 improve agent reliability: behavioral constraints, full tool history, error hints 2026-02-23 09:13:08 +00:00
Re-bin 491739223d fix: lower default temperature from 0.7 to 0.1 2026-02-23 08:24:53 +00:00
Xubin RenandGitHub e69ff8ac0e Merge pull request #1043 to move workspace/ to nanobot/templates/ for packaging
refactor: move workspace/ to nanobot/templates/ for packaging
2026-02-23 16:11:44 +08:00
Re-bin 577b3d104a refactor: move workspace/ to nanobot/templates/ for packaging 2026-02-23 08:08:01 +00:00
Re-bin f8e8cbee6a Merge PR #1036: fix(heartbeat): route heartbeat runs to enabled chat context 2026-02-23 07:45:20 +00:00
Re-bin e4376896ed Merge remote-tracking branch 'origin/main' into pr-1036 2026-02-23 07:16:40 +00:00
Re-bin 0fdbd5a037 Merge PR #1000: feat(channels): add send_progress option to control progress message delivery 2026-02-23 07:12:55 +00:00
Re-bin df2c837e25 feat(channels): split send_progress into send_progress + send_tool_hints 2026-02-23 07:12:41 +00:00
Re-bin c20b867497 Merge remote-tracking branch 'origin/main' into pr-1000 2026-02-23 06:12:25 +00:00
yzchen bfdae1b177 fix(heartbeat): make start idempotent and check exact OK token 2026-02-23 13:56:37 +08:00
Re-bin bc32e85c25 fix(memory): trigger consolidation by unconsolidated count, not total 2026-02-23 05:51:44 +00:00
Kim 9025c7088f fix(heartbeat): route heartbeat runs to enabled chat context 2026-02-23 12:28:21 +08:00
Yingwen Luo-LUOYW 31a873ca59 Merge branch 'main' of https://github.com/HKUDS/nanobot 2026-02-23 09:41:56 +08:00
Yingwen Luo-LUOYW 0c412b3728 feat(channels): add send_progress option to control progress message delivery
Add a boolean config option `channels.sendProgress` (default: false) to
control whether progress messages (marked with `_progress` metadata) are
sent to chat channels. When disabled, progress messages are filtered
out in the outbound dispatcher.
2026-02-23 09:41:13 +08:00
Nikolas de Hor 4303026e0d fix: break Discord typing loop on persistent HTTP failure
The typing indicator loop catches all exceptions with bare
except/pass, so a permanent HTTP failure (client closed, auth
error, etc.) causes the loop to spin every 8 seconds doing
nothing until the channel is explicitly stopped.

Log the error and exit the loop instead, letting the task
clean up naturally.
2026-02-22 22:01:16 -03:00
Re-bin 25f0a236fd docs: fix MiniMax API key link 2026-02-22 18:29:09 +00:00
Re-bin c6f670809c Merge PR #949: fix(provider): filter empty text content blocks causing API 400 2026-02-22 18:26:42 +00:00
Re-bin b653183bb0 refactor(providers): move empty content sanitization to base class 2026-02-22 18:26:42 +00:00
Re-bin 2f7835a301 Merge remote-tracking branch 'origin/main' into pr-949 2026-02-22 18:21:47 +00:00
Re-bin 6913d541c8 Merge PR #986: fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 18:16:45 +00:00
Re-bin efe89c9091 fix(feishu): pass msg_type as resource_type and clean up style 2026-02-22 18:16:45 +00:00
Re-bin 3d55c9cd03 Merge remote-tracking branch 'origin/main' into pr-986 2026-02-22 18:13:37 +00:00
Re-bin 4f0930f517 Merge PR #955: fix(providers): normalize empty reasoning_content to None at provider level 2026-02-22 18:11:45 +00:00
Re-bin c8881c5d49 Merge remote-tracking branch 'origin/main' into pr-955 2026-02-22 18:08:43 +00:00
Re-bin e46edf2806 Merge PR #950: fix(mcp): add configurable timeout to MCP tool calls 2026-02-22 18:04:13 +00:00
Re-bin 437ebf4e6e feat(mcp): make tool_timeout configurable per server via config 2026-02-22 18:04:13 +00:00
Re-bin 51f6247aed Merge remote-tracking branch 'origin/main' into pr-950 2026-02-22 17:52:24 +00:00
Re-bin 14ba50c172 Merge PR #968: docs: add systemd user service instructions to README 2026-02-22 17:51:23 +00:00
Re-bin 1aa06ea03d docs: improve Linux Service section in README 2026-02-22 17:51:23 +00:00
Re-bin 12af652d5a Merge remote-tracking branch 'origin/main' into pr-968 2026-02-22 17:48:32 +00:00
Re-bin e322f82f9c Merge PR #962: fix(qq): make start() long-running per base channel contract 2026-02-22 17:35:53 +00:00
Re-bin b53c3d39ed fix(qq): remove dead _bot_task field and fix stop() to close client 2026-02-22 17:35:53 +00:00
Re-bin 9efe95970e Merge branch 'main' into pr-962 2026-02-22 17:24:34 +00:00
Re-bin b13d7f853e fix(agent): make tool hint a fallback when no content in on_progress 2026-02-22 17:17:35 +00:00
Re-bin d5e820df98 Merge PR #881: fix(loop): serialize /new consolidation, track task refs, archive before clear 2026-02-22 17:11:59 +00:00
Re-bin 1cfcc647b7 fix(loop): resolve conflicts with main and improve /new handler 2026-02-22 17:11:59 +00:00
Re-bin 60751909cb Merge PR #959: fix(email): evict oldest half of dedup set instead of clearing entirely 2026-02-22 15:48:49 +00:00
Re-bin 4e8c8cc227 fix(email): fix misleading comment and simplify uid eviction 2026-02-22 15:48:49 +00:00
Re-bin d82c292c99 Merge branch 'main' into pr-959 2026-02-22 15:41:09 +00:00
Re-bin 598f7dafd1 Merge PR #958: fix(session): handle errors in legacy session migration 2026-02-22 15:40:17 +00:00
Re-bin 71de1899e6 fix(session): use logger.exception and move import to top 2026-02-22 15:40:17 +00:00
Re-bin b8a06f8d19 Merge branch 'main' into pr-958 2026-02-22 15:39:09 +00:00
Re-bin b161628ad7 Merge PR #957: fix(slack): add exception handling to socket listener 2026-02-22 15:38:19 +00:00
Re-bin b93b77a485 fix(slack): use logger.exception to capture full traceback 2026-02-22 15:38:19 +00:00
Re-bin c53deecdb1 Merge branch 'main' into pr-957 2026-02-22 15:35:26 +00:00
Re-bin ef64739736 Merge PR #956: fix(security): prevent path traversal bypass via startswith check 2026-02-22 15:34:36 +00:00
Re-bin e0743d6345 Merge branch 'main' into pr-956 2026-02-22 15:33:28 +00:00
FloRa 0d3a2963d0 fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 17:37:33 +08:00
FloRa 973061b01e fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 17:15:00 +08:00
Xubin RenandGitHub fff6207c6b Merge PR #982 to add DingTalk, QQ, and Email to channels status output
feat(cli): add DingTalk, QQ, and Email to channels status output
2026-02-22 14:57:44 +08:00
TANISH RAJPUTandGitHub 1532f11b45 Merge pull request #7 from Athemis/feat/matrix-improvements
fix(matrix): harmonize units and keep typing indicator during tool calls
2026-02-22 11:47:17 +05:30
Yingwen Luo-LUOYW b323087631 feat(cli): add DingTalk, QQ, and Email to channels status output 2026-02-22 12:42:33 +08:00
Rok Pergarec 3e40600483 docs: add systemd user service instructions to README 2026-02-21 20:55:54 +01:00
Alexander Minges 494fa8966a refactor(matrix): use milliseconds for typing timing constants 2026-02-21 20:45:09 +01:00
Alexander Minges de5104ab2a fix(matrix): keep typing indicator during progress updates 2026-02-21 20:44:51 +01:00
andienguyen-ecoligo 8c55b40b9f fix(qq): make start() long-running per base channel contract
QQ channel's start() created a background task and returned immediately,
violating the base Channel contract which specifies start() should be
"a long-running async task". This caused the gateway to exit prematurely
when QQ was the only enabled channel.

Now directly awaits _run_bot() to stay alive like other channels.

Fixes #894
2026-02-21 12:38:24 -05:00
andienguyen-ecoligo ba66c64750 fix(email): evict oldest half of dedup set instead of clearing entirely
When _processed_uids exceeds 100k entries, the entire set was cleared
with .clear(), allowing all previously seen emails to be re-processed.

Now evicts the oldest 50% of entries, keeping recent UIDs to prevent
duplicate processing while still bounding memory usage.

Fixes #890
2026-02-21 12:36:04 -05:00
andienguyen-ecoligo 54a0f3d038 fix(session): handle errors in legacy session migration
shutil.move() in _load() can fail due to permissions, disk full, or
concurrent access. Without error handling, the exception propagates up
and prevents the session from loading entirely.

Wrap in try/except so migration failures are logged as warnings and the
session falls back to loading from the legacy path on next attempt.

Fixes #863
2026-02-21 12:35:21 -05:00
andienguyen-ecoligo ef96619039 fix(slack): add exception handling to socket listener
_handle_message() in _on_socket_request() had no try/except. If it
throws (bus full, permission error, etc.), the exception propagates up
and crashes the Socket Mode event loop, causing missed messages.

Other channels like Telegram already have explicit error handlers.

Fixes #895
2026-02-21 12:34:50 -05:00
andienguyen-ecoligo 5c9cb3a208 fix(security): prevent path traversal bypass via startswith check
`startswith` string comparison allows bypassing directory restrictions.
For example, `/home/user/workspace_evil` passes the check against
`/home/user/workspace` because the string starts with the allowed path.

Replace with `Path.relative_to()` which correctly validates that the
resolved path is actually inside the allowed directory tree.

Fixes #888
2026-02-21 12:34:14 -05:00
andienguyen-ecoligo de63c31d43 fix(providers): normalize empty reasoning_content to None at provider level
PR #947 fixed the consumer side (context.py) but the root cause is at
the provider level — getattr returns "" (empty string) instead of None
when reasoning_content is empty. This causes DeepSeek API to reject the
request with "Missing reasoning_content field" error.

`"" or None` evaluates to None, preventing empty strings from
propagating downstream.

Fixes #946
2026-02-21 12:30:57 -05:00
Re-bin 0040c62b74 Merge PR #939: Remove redundant tools description from system prompt 2026-02-21 17:07:02 +00:00
Re-bin 13d768cd93 Merge branch 'main' into pr-939 2026-02-21 17:06:05 +00:00
Xubin RenandGitHub 6a9152f0c4 Merge PR #947 to Fix 'Missing reasoning_content field' error for deepseek provider.
fix(context): Fix 'Missing `reasoning_content` field' error for deepseek provider.
2026-02-22 00:47:58 +08:00
Xubin RenandGitHub 9b4273f6a4 Merge PR #951 to change VolcEngine litellm prefix from openai to volcengine
fix: change VolcEngine litellm prefix from openai to volcengine
2026-02-22 00:45:49 +08:00
init-new-world deae84482d fix: change VolcEngine litellm prefix from openai to volcengine 2026-02-22 00:42:41 +08:00
muskliu 6b7d7e2eb8 fix(mcp): add 30s timeout to MCP tool calls to prevent agent hangs 2026-02-22 00:39:53 +08:00
Re-bin edc671a8a3 docs: update format of news section 2026-02-21 16:39:26 +00:00
muskliuandnanobot 83ccdf6186 fix(provider): filter empty text content blocks causing API 400
When MCP tools return empty content, messages may contain empty-string
text blocks. OpenAI-compatible providers reject these with HTTP 400.

Changes:
- Add _prevent_empty_text_blocks() to filter empty text items from
  content lists and handle empty string content
- For assistant messages with tool_calls, set content to None (valid)
- For other messages, replace with '(empty)' placeholder
- Only copy message dict when modification is needed (zero-copy path
  for normal messages)

Co-Authored-By: nanobot <noreply@anthropic.com>
2026-02-22 00:20:00 +08:00
nanobot-bot 01c835aac2 fix(context): Fix 'Missing reasoning_content field' error for deepseek provider. 2026-02-21 23:11:30 +08:00
Re-bin 88ca2e0530 docs: update v.0.1.4.post1 release news 2026-02-21 13:20:55 +00:00
Re-bin af71ccf051 release: v0.1.4.post1 2026-02-21 13:05:14 +00:00
vincentchen b3acd19c7b Remove redundant tools description (because tools information is passed in with each self.provider.chat() call) 2026-02-21 20:28:42 +08:00
Re-bin 9c61e1389c docs: update nanobot news 2026-02-21 08:33:31 +00:00
Re-bin ec4bdb651f docs: update nanobot news 2026-02-21 08:33:02 +00:00
Re-bin f89f8a972c Merge pull request #926: fix(agent): skip empty fallback outbound for non-cli channels 2026-02-21 08:27:54 +00:00
Re-bin 0b30f514b4 style(loop): compact empty outbound message construction 2026-02-21 08:27:49 +00:00
Re-bin 012a5e78e5 Merge branch 'main' into pr-926 2026-02-21 08:21:17 +00:00
Xubin RenandGitHub 4dca2872bf Merge pull request #930 to slim down agent loop
refactor: extract memory consolidation to MemoryStore, slim down agent loop
2026-02-21 16:19:08 +08:00
Re-bin ab026c5131 refactor: extract memory consolidation to MemoryStore, slim down AgentLoop 2026-02-21 08:14:46 +00:00
Re-bin 668dd6e2f5 Merge pull request #866: refactor(memory): use tool call instead of JSON text for memory consolidation 2026-02-21 08:02:03 +00:00
Re-bin 8c15454379 Merge branch 'main' into pr-866 2026-02-21 07:46:25 +00:00
Xubin RenandGitHub 6076f98527 Merge pull request #928 to remove interim text retry, use system prompt constraint instead
refactor(loop): remove interim text retry, use system prompt constraint instead
2026-02-21 15:35:35 +08:00
Re-bin aeb07d3450 refactor(loop): remove interim text retry, use system prompt constraint instead 2026-02-21 07:32:58 +00:00
Re-bin a0820eceee Merge pull request #887: fix(loop): preserve interim content as fallback when retry produces empty response 2026-02-21 07:17:35 +00:00
Re-bin 8bb849470b Merge branch 'main' into pr-887 2026-02-21 07:12:58 +00:00
Alexander Minges c4bee640b8 fix(agent): skip empty fallback outbound for non-cli channels 2026-02-21 07:51:28 +01:00
Re-bin 900604e9ca Merge pull request #921: fix(tools): provide diff hint when edit_file old_text not found 2026-02-21 06:39:14 +00:00
Re-bin 4f5cb7d1e4 style(filesystem): simplify best-match loop 2026-02-21 06:39:04 +00:00
Re-bin 09a45f8993 Merge pull request #921: fix(tools): provide diff hint when edit_file old_text not found 2026-02-21 06:35:14 +00:00
Re-bin e0edb904bd style(filesystem): move difflib import to top level 2026-02-21 06:35:10 +00:00
Re-bin 7bc77c1b41 Merge branch 'main' into pr-921 2026-02-21 06:32:57 +00:00
Re-bin 6f266f1a8a Merge pull request #922: feat(feishu): multimedia download and share card parsing 2026-02-21 06:30:31 +00:00
Re-bin 8125d9b6bc fix(feishu): fix double recursion, English placeholders, top-level Path import 2026-02-21 06:30:26 +00:00
coldxiangyuandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> b9c3f8a5a3 feat(feishu): add share card and interactive message parsing
- Add content extraction for share cards (chat, user, calendar event)
- Add recursive parsing for interactive card elements
- Fix image download API to use GetMessageResourceRequest with message_id
- Handle BytesIO response from message resource API

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-21 14:08:25 +08:00
coldxiangyuandClaude Opus 4.6 98ef57e370 feat(feishu): add multimedia download support for images, audio and files
Add download functionality for multimedia messages in Feishu channel,
enabling agents to process images, audio recordings, and file attachments
sent through Feishu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:56:57 +08:00
themavikandCursor 33396a522a fix(tools): provide detailed error messages in edit_file when old_text not found
Uses difflib to find the best match and shows a helpful diff,
making it easier to debug edit_file failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 23:52:40 -05:00
TANISH RAJPUTandGitHub 2502f68fd8 Merge pull request #6 from Athemis/feat/matrix-improvements
feat(matrix): E2E, typing, markdown/HTML, group policy, inbound+outbound media, thread replies
2026-02-20 23:01:56 +05:30
Alexander Minges fcece3ec62 fix(matrix): match fork/main formatting exactly 2026-02-20 18:17:27 +01:00
Alexander Minges 13561772ad fix(matrix): align with fork/main (docstrings, type annotations, formatting) 2026-02-20 18:15:32 +01:00
Alexander Minges dd61a9143a fix: remove accidental whitespace-only formatting changes from schema.py 2026-02-20 18:11:29 +01:00
Alexander Minges 52d086d46a revert: restore context.py and manager.py to tanishra baseline (out of scope) 2026-02-20 18:08:04 +01:00
Alexander Minges e8a4671565 test: remove message tool media test (message.py changes out of scope) 2026-02-20 18:06:13 +01:00
Alexander Minges 36d650e475 revert: restore message.py to tanishra baseline (out of scope) 2026-02-20 18:05:00 +01:00
Alexander Minges 334078e242 fix(message): apply media path filtering and drop attachment count from return value
Conflict resolution correction: HEAD's message.py retained raw media list and
attachment count in return string, but tests from 3de30bb require stripped/filtered
media_paths and a plain return message. Aligns HEAD behavior with cherry-picked tests.
2026-02-20 18:04:11 +01:00
Alexander MingesandAlexander Minges 705d5738e3 feat(matrix): reply in threads with fallback relations
Propagate Matrix thread metadata from inbound events and attach
m.relates_to
(rel_type=m.thread, m.in_reply_to, is_falling_back=true) to outbound
messages
including attachments. Add tests for thread metadata and thread replies.
2026-02-20 18:03:26 +01:00
Alexander Minges 6a40665753 feat(matrix): support outbound attachments via message tool
- extend message tool with optional media paths for channel delivery

- switch Matrix uploads to stream providers and handle encrypted-room payloads

- add/expand tests for message tool media forwarding and Matrix upload edge cases
2026-02-20 18:02:40 +01:00
Alexander Minges d4d87bb4e5 fix(matrix): block outbound media when maxMediaBytes is zero 2026-02-20 18:02:15 +01:00
Alexander Minges a28ae51ce9 fix(matrix): handle matrix-nio upload tuple response 2026-02-20 18:02:14 +01:00
Alexander Minges 97cb85ee0b feat(matrix): add outbound media uploads and unify media limits with maxMediaBytes
- Use OutboundMessage.media for Matrix file/image/audio/video sends
- Apply effective media limit as min(m.upload.size, maxMediaBytes)
- Rename matrix config key maxInboundMediaBytes -> maxMediaBytes (no legacy fallback)
2026-02-20 18:02:13 +01:00
Alexander Minges bfd2018095 docs: update maxMediaBytes documentation to include blocking option
Add clarification that setting to 0 blocks all attachments
2026-02-20 18:01:21 +01:00
Alexander MingesandAlexander Minges 10de3bf329 refactor(matrix): use base media event filter for callbacks
- Replaces the explicit media event tuple with MATRIX_MEDIA_EVENT_FILTER
  based on
  media base classes: (RoomMessageMedia, RoomEncryptedMedia).
- Keeps MatrixMediaEvent as the static typing alias for media-specific
  handlers.
- Removes MatrixInboundEvent and uses RoomMessage in mention-related
  logic.
- Adds regression tests for:
  - callback registration using MATRIX_MEDIA_EVENT_FILTER
  - ensuring RoomMessageText is not matched by the media filter.
2026-02-20 17:58:37 +01:00
Alexander MingesandAlexander Minges 1103f000fc docs(matrix): clarify m.text body plaintext fallback note 2026-02-20 17:58:06 +01:00
Alexander Minges 9b06f682c3 docs(readme): document matrix e2eeEnabled option 2026-02-20 17:58:02 +01:00
Alexander Minges 566ad1dfc7 feat(matrix): make e2ee configurable with enabled default 2026-02-20 17:57:10 +01:00
Alexander Minges 085a311d4b docs(matrix): clarify typing keepalive spec notes 2026-02-20 17:56:28 +01:00
Alexander Minges 8b3171ca2b fix(matrix): include empty m.mentions in outgoing messages 2026-02-20 17:56:24 +01:00
Alexander Minges ca66ddb0bf feat(matrix): refresh typing indicator while processing 2026-02-20 17:56:15 +01:00
Alexander Minges a482a89df6 feat(matrix): support inbound media attachments 2026-02-20 17:56:11 +01:00
Alexander Minges 7b2adf9d9d docs(matrix): document raw html escaping in markdown renderer 2026-02-20 17:56:07 +01:00
Alexander Minges 6be7368a38 fix(matrix): sanitize formatted html with nh3 2026-02-20 17:55:59 +01:00
Alexander Minges 9b14869cb1 feat(matrix): support inline markdown html for url and super/subscript 2026-02-20 17:55:13 +01:00
Alexander Minges cc5cfe6847 test(matrix): cover mention policy and sender filtering 2026-02-20 17:55:09 +01:00
Alexander Minges fa2049fc60 feat(matrix): add group policy and strict mention gating 2026-02-20 17:55:05 +01:00
Alexander Minges 3200135f4b test(matrix): cover formatted body and markdown fallback 2026-02-20 17:54:42 +01:00
Alexander Minges e716c9caac feat(matrix): send markdown as formatted html messages 2026-02-20 17:54:39 +01:00
Alexander Minges 840ef7363f test(matrix): cover typing indicator lifecycle 2026-02-20 17:54:29 +01:00
Alexander Minges 45267b0730 feat(matrix): show typing while processing messages 2026-02-20 17:54:26 +01:00
Alexander Minges ffac42f9e5 refactor(matrix): replace logging depth magic number 2026-02-20 17:52:37 +01:00
Alexander Minges b294a682a8 chore(matrix): route matrix-nio logs through loguru 2026-02-20 17:52:36 +01:00
Alexander Minges b721f9f37d test(matrix): cover response callbacks and graceful shutdown 2026-02-20 17:52:34 +01:00
Alexander Minges 9d85393226 feat(matrix): add startup warnings and response error logging 2026-02-20 17:52:33 +01:00
Alexander Minges 7c33d3cbe2 feat(matrix): add configurable graceful sync shutdown 2026-02-20 17:52:32 +01:00
Re-bin 9a31571b6d fix: don't append interim assistant message before retry to avoid prefill errors 2026-02-20 16:51:37 +00:00
Alexander Minges 988b75624c test(matrix): add matrix channel behavior test 2026-02-20 17:48:16 +01:00
Alexander Minges c926569033 fix(matrix): guard store load without device id and allow invites by default 2026-02-20 17:48:15 +01:00
djmazeandAlexander Minges d3ddeb3067 fix: activate E2E and accept room invites in Matrix channels 2026-02-20 17:48:14 +01:00
Xubin RenandGitHub 21dd9e4112 Merge pull request #908 to route CLI interactive mode through message bus
refactor: route CLI interactive mode through message bus
2026-02-21 00:46:06 +08:00
Re-bin 7279ff0167 refactor: route CLI interactive mode through message bus for subagent support 2026-02-20 16:45:21 +00:00
Re-bin f8ffff98a5 Merge PR #892: fix MCP connection retry and concurrent connection guard 2026-02-20 16:09:13 +00:00
Re-bin 80b5e6cea0 Merge branch 'main' into pr-892 2026-02-20 16:06:17 +00:00
Re-bin 5ba3ee97a4 Merge PR #832: avoid duplicate reply when message tool already sent 2026-02-20 15:56:13 +00:00
Re-bin 132807a3fb refactor: simplify message tool turn tracking to a single boolean flag 2026-02-20 15:55:30 +00:00
Re-bin c8682512c9 Merge branch 'main' into pr-832 2026-02-20 15:47:16 +00:00
Re-bin b6610721f9 Merge PR #902: store session key in JSONL metadata to avoid lossy filename reconstruction 2026-02-20 15:43:06 +00:00
Re-bin d9cc144575 style: remove redundant comment in list_sessions 2026-02-20 15:42:24 +00:00
Re-bin 40867bff86 Merge branch 'main' into pr-902 2026-02-20 15:27:05 +00:00
Re-bin 5110b070dd Merge PR #900: split Discord messages exceeding 2000-character limit 2026-02-20 15:26:15 +00:00
Re-bin b853222c87 style: trim _send_payload docstring 2026-02-20 15:26:12 +00:00
Re-bin 9643b477da Merge branch 'main' into pr-900 2026-02-20 15:23:22 +00:00
Re-bin 44c2de2283 Merge PR #903: convert remaining f-string logger calls to loguru native format 2026-02-20 15:21:43 +00:00
Re-bin a33cb3e2dc Merge branch 'main' into pr-903 2026-02-20 15:21:11 +00:00
Re-bin ff0003de3f Merge PR #904: add media file upload support to Slack channel 2026-02-20 15:19:23 +00:00
Re-bin 6bcfbd9610 style: remove redundant comments and use loguru native format 2026-02-20 15:19:18 +00:00
Re-bin fe089abe5b Merge branch 'main' into pr-904 2026-02-20 15:17:04 +00:00
Re-bin 1d41dcd99a Merge PR #905: enable prompt caching for OpenRouter 2026-02-20 15:15:44 +00:00
Re-bin cc04bc4dd1 fix: check gateway's supports_prompt_caching instead of always returning False 2026-02-20 15:14:45 +00:00
tercerapersonaandGitHub b286457c85 add Openrouter prompt caching via cache_control 2026-02-20 11:34:50 -03:00
Nikolas de Hor 4cbd857250 fix: handle edge cases in message splitting and send failure
- _split_message: return empty list for empty/None content instead
  of a list with one empty string (Discord rejects empty content)
- _split_message: use pos <= 0 fallback to prevent empty chunks
  when content starts with a newline or space
- _send_payload: return bool to indicate success/failure
- send: abort remaining chunks when a chunk fails to send,
  preventing partial/corrupted message delivery
2026-02-20 10:09:04 -03:00
Nikolas de Hor f19baa8fc4 fix: convert remaining f-string logger calls to loguru native format
Follow-up to #864. Three f-string logger calls in base.py and dingtalk.py
were missed in the original sweep. These can cause KeyError if interpolated
values contain curly braces, since loguru interprets them as format placeholders.
2026-02-20 10:01:38 -03:00
Alexander Minges 426ef71ce7 style(loop): drop formatting-only churn against upstream main 2026-02-20 13:57:39 +01:00
Nikolas de Hor 73530d51ac fix: store session key in JSONL metadata to avoid lossy filename reconstruction
list_sessions() previously reconstructed the session key by replacing all
underscores in the filename with colons. This is lossy: a key like
'cli:user_name' became 'cli:user:name' after round-tripping.

Now the actual key is persisted in the metadata line during save() and read
back in list_sessions(). Legacy files without the key field fall back to
replacing only the first underscore, which handles the common channel:chat_id
pattern correctly.

Closes #899
2026-02-20 09:57:11 -03:00
Nikolas de Hor 4c75e1673f fix: split Discord messages exceeding 2000-character limit
Discord's API rejects messages longer than 2000 characters with HTTP 400.
Previously, long agent responses were silently lost after retries exhausted.

Adds _split_message() (matching Telegram's approach) to chunk content at
line boundaries before sending. Only the first chunk carries the reply
reference. Retry logic extracted to _send_payload() for reuse across chunks.

Closes #898
2026-02-20 09:55:22 -03:00
Nikolas de Hor 37222f9c0a fix: add connecting guard to prevent concurrent MCP connection attempts
Addresses Codex review: concurrent callers could both pass the
_mcp_connected guard and race through _connect_mcp(). Added
_mcp_connecting flag set immediately to serialize attempts.
2026-02-20 09:38:22 -03:00
Nikolas de Hor 45f33853cf fix: only apply interim fallback when no tools were used
Addresses Codex review: if the model sent interim text then used tools,
the interim text should not be used as fallback for the final response.
2026-02-20 09:37:42 -03:00
Alexander Minges df022febaf refactor(loop): drop redundant Any typing in /new snapshot 2026-02-20 13:33:51 +01:00
Alexander Minges c1b5e8c8d2 fix(loop): lock /new snapshot and prune stale consolidation locks 2026-02-20 13:32:57 +01:00
Kim 8cc54b188d style(logging): use loguru parameterized formatting in suppression log 2026-02-20 20:25:46 +08:00
Nikolas de Hor 44f44b305a fix: move MCP connected flag after successful connection to allow retry
The flag was set before the connection attempt, so if any MCP server
was temporarily unavailable, the flag stayed True and MCP tools were
permanently lost for the session.

Closes #889
2026-02-20 09:24:48 -03:00
Nikolas de Hor 4eb07c44b9 fix: preserve interim content as fallback when retry produces empty response
Fixes regression from #825 where models that respond with final text
directly (no tools) had their answer discarded by the retry mechanism.

Closes #878
2026-02-20 09:21:27 -03:00
Kim ddae3e9d5f fix(agent): avoid duplicate final send when message tool already replied 2026-02-20 20:16:45 +08:00
Alexander Minges 9ada8e6854 fix(loop): require successful archival before /new clear 2026-02-20 13:06:07 +01:00
Alexander Minges 5f9eca4664 style(loop): remove formatting-only changes from upstream PR 881 2026-02-20 12:46:11 +01:00
Alexander Minges 755e424127 fix(loop): serialize /new consolidation and track task refs 2026-02-20 12:40:59 +01:00
Re-bin c8089021a5 Merge PR #795: sanitize messages and ensure content key for strict LLM providers 2026-02-20 11:27:28 +00:00
Re-bin 5cc019bf1a style: trim verbose comments in _sanitize_messages 2026-02-20 11:27:21 +00:00
Re-bin 0c2fea6d33 Merge branch 'main' into pr-795 2026-02-20 11:25:51 +00:00
Re-bin ddf7f92275 Merge PR #833: always send tool hint even when model has preceding text 2026-02-20 11:19:00 +00:00
Re-bin 8db91f59e2 style: remove trailing space 2026-02-20 11:18:57 +00:00
Re-bin b73e847e89 Merge branch 'main' into pr-833 2026-02-20 11:16:49 +00:00
Xubin RenandGitHub cd0a5affd5 Merge pull request #879 to make Telegram reply-to-message behavior configurable (default false)
feat: make Telegram reply-to-message behavior configurable, default false
2026-02-20 19:14:15 +08:00
Re-bin e1854c4373 feat: make Telegram reply-to-message behavior configurable, default false 2026-02-20 11:13:10 +00:00
Paul e39bbaa9be feat(slack): add media file upload support
Use files_upload_v2 API to upload media attachments in Slack messages.
This enables the message tool's media parameter to work correctly
when sending images or other files through the Slack channel.

Requires files:write OAuth scope.
2026-02-20 09:54:21 +00:00
Re-bin 792f80ce0c Merge PR #821: make cron run command actually execute the agent 2026-02-20 09:04:41 +00:00
Re-bin b97b1a5e91 fix: pass full agent config including mcp_servers to cron run command 2026-02-20 09:04:33 +00:00
Re-bin 0b34a43779 Merge branch 'main' into pr-821 2026-02-20 08:59:51 +00:00
Re-bin 698b09b4e7 Merge PR #815: reply to original Telegram message using message_id 2026-02-20 08:57:13 +00:00
Re-bin 44eb1bdca2 Merge branch 'main' into pr-815 2026-02-20 08:57:02 +00:00
Re-bin 9f0928fde6 Merge PR #807: support custom headers for MCP HTTP authentication 2026-02-20 08:50:39 +00:00
Re-bin f5fe74f578 style: move httpx import to top-level and fix README example for MCP headers 2026-02-20 08:49:49 +00:00
Re-bin bbd76e8f5b Merge branch 'main' into pr-807 2026-02-20 08:47:13 +00:00
Re-bin d609eba7d6 Merge PR #812: add VolcEngine LLM provider support 2026-02-20 08:45:47 +00:00
Re-bin 25efd1bc54 docs: update docs for providers 2026-02-20 08:45:42 +00:00
Re-bin 82a318759f Merge branch 'main' into pr-812 2026-02-20 08:42:31 +00:00
Re-bin 72a622aea1 Merge PR #824: handle /help in Telegram directly, bypassing ACL 2026-02-20 08:40:32 +00:00
Re-bin 2f315ec567 style: trim _on_help docstring 2026-02-20 08:39:26 +00:00
Re-bin 7957f84e3d Merge branch 'main' into pr-824 2026-02-20 08:36:34 +00:00
Re-bin 7d7c1e3edf Merge PR #823: prevent duplicate memory consolidation tasks per session 2026-02-20 08:35:27 +00:00
Re-bin 686471bd8d Merge branch 'main' into pr-823 2026-02-20 08:33:45 +00:00
Re-bin ef0eef9f74 Merge PR #825: allow one retry for models that send interim text before tool calls 2026-02-20 08:31:57 +00:00
Re-bin 2383dcb3a8 style: use loguru native format and trim comments in interim retry 2026-02-20 08:31:48 +00:00
Re-bin 0660d614f6 Merge branch 'main' into pr-825 2026-02-20 08:24:26 +00:00
Re-bin 5855d92619 Merge PR #854: add Anthropic prompt caching via cache_control 2026-02-20 08:21:55 +00:00
Re-bin 9ffae47c13 refactor(litellm): remove redundant comments in cache_control methods 2026-02-20 08:21:02 +00:00
Re-bin afa0513243 Merge branch 'main' into pr-854 2026-02-20 08:17:32 +00:00
Re-bin 72f449e868 Merge PR #644: handle non-string values in memory consolidation 2026-02-20 08:13:07 +00:00
Re-bin 002de466d7 chore: remove test file for memory consolidation fix 2026-02-20 08:12:23 +00:00
Re-bin a9bffdc06f Merge branch 'main' into pr-644 2026-02-20 08:08:55 +00:00
Re-bin a79e56a44d Merge PR #763: add service-layer timezone validation for cron jobs 2026-02-20 08:06:36 +00:00
Re-bin 2b8c082428 Merge branch 'main' into pr-763 2026-02-20 08:04:48 +00:00
Re-bin 5d7a27ebf2 Merge PR #653: resolve relative file paths against workspace 2026-02-20 08:03:27 +00:00
Re-bin e17342ddfc fix: pass workspace to file tools in subagent 2026-02-20 08:03:24 +00:00
Re-bin 55ac4b729e Merge branch 'main' into pr-653 2026-02-20 08:01:08 +00:00
Re-bin ae0347042b Merge PR #455: fix UTF-8 encoding and ensure_ascii for non-ASCII support 2026-02-20 08:00:32 +00:00
Re-bin 73fdd0dd45 fix: complete ensure_ascii=False and UTF-8 encoding migration 2026-02-20 07:59:32 +00:00
Re-bin 4c2f64db14 Merge PR #864: use loguru native formatting to prevent KeyError on curly braces 2026-02-20 07:55:52 +00:00
Re-bin 37252a4226 fix: complete loguru native formatting migration across all files 2026-02-20 07:55:34 +00:00
Re-bin 0bde1d89fa Merge branch 'main' into pr-864 2026-02-20 07:47:48 +00:00
Re-bin 0e6683ad4b Merge PR #870: remove dead pub/sub code from MessageBus 2026-02-20 07:42:50 +00:00
Re-bin b26a2e1af1 Merge branch 'main' into pr-870 2026-02-20 07:41:17 +00:00
Tanish Rajput 0d3dc57a65 feat: add matrix (Element) chat channel support 2026-02-20 11:57:48 +05:30
AlexanderMerkelandClaude Opus 4.6 0001f286b5 fix: remove dead pub/sub code from MessageBus
`subscribe_outbound()`, `dispatch_outbound()`, and `stop()` have zero
callers — `ChannelManager._dispatch_outbound()` handles all outbound
routing via `consume_outbound()` directly. Remove the dead methods and
their unused imports (`Callable`, `Awaitable`, `logger`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:00:25 -07:00
dxtime f3c7337356 feat: Added custom headers for MCP Auth use, update README.md 2026-02-20 08:31:52 +08:00
Rudolfs Tilgass afca0278ad fix(memory): Enforce memory consolidation schema with a tool call 2026-02-19 22:14:51 +01:00
Nikolas de Hor 53b83a38e2 fix: use loguru native formatting to prevent KeyError on messages containing curly braces
Closes #857
2026-02-19 17:19:36 -03:00
Re-bin d22929305f Merge PR #820: fix safety guard false positive on 'format' in URLs 2026-02-19 17:48:37 +00:00
Re-bin fbfb030a6e chore: remove network-dependent test file for shell guard 2026-02-19 17:48:09 +00:00
Re-bin 1c51fbeeee Merge branch 'main' into pr-820 2026-02-19 17:44:30 +00:00
Re-bin c1296746e3 Merge PR #851: wait for killed process after shell timeout to prevent fd leaks 2026-02-19 17:43:05 +00:00
Re-bin fe7b0b64c1 Merge branch 'main' into pr-851 2026-02-19 17:42:23 +00:00
Re-bin 125524f5c2 Merge PR #836: fix Codex provider routing for GitHub Copilot models 2026-02-19 17:39:52 +00:00
Re-bin b11f0ce6a9 fix: prefer explicit provider prefix over keyword match to fix Codex routing 2026-02-19 17:39:44 +00:00
Re-bin d78368bb2f Merge branch 'main' into pr-836 2026-02-19 17:35:19 +00:00
Re-bin 9a00a274e5 Merge PR #844: support sending images, audio, and files for Feishu 2026-02-19 17:34:01 +00:00
Re-bin 3890f1a7dd refactor(feishu): clean up send() and remove dead code 2026-02-19 17:33:08 +00:00
Re-bin eea4942025 Merge branch 'main' into pr-844 2026-02-19 17:29:35 +00:00
Re-bin d748e6eca3 fix: pin dependency version ranges 2026-02-19 17:28:13 +00:00
tercerapersonaandClaude Sonnet 4.6 3b4763b3f9 feat: add Anthropic prompt caching via cache_control
Inject cache_control: {"type": "ephemeral"} on the system message and
last tool definition for providers that support prompt caching. Adds
supports_prompt_caching flag to ProviderSpec (enabled for Anthropic only)
and skips caching when routing through a gateway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:05:22 -03:00
Nikolas de Hor c86dbc9f45 fix: wait for killed process after shell timeout to prevent fd leaks
When a shell command times out, process.kill() is called but the
process object was never awaited after that. This leaves subprocess
pipes undrained and file descriptors open. If many commands time out,
fd leaks accumulate.

Add a bounded wait (5s) after kill to let the process fully terminate
and release its resources.
2026-02-19 10:27:11 -03:00
Nikolas de Hor 1b49bf9602 fix: avoid duplicate messages on retry and reset final_content
Address review feedback:
- Remove on_progress call for interim text to prevent duplicate
  messages when the model simply answers a direct question
- Reset final_content to None before continue to avoid stale
  interim text leaking as the final response on empty retry

Closes #705
2026-02-19 10:26:49 -03:00
Ubuntu d08c022255 feat(feishu): support sending images, audio, and files
- Add image upload via im.v1.image.create API
- Add file upload via im.v1.file.create API
- Support sending images (.png, .jpg, .gif, etc.) as image messages
- Support sending audio (.opus) as voice messages
- Support sending other files as file messages
- Refactor send() to handle media attachments before text content
2026-02-19 16:31:00 +08:00
PiEgg 9789307dd6 Fix Codex provider routing for GitHub Copilot models 2026-02-19 13:30:02 +08:00
Darye 523b2982f4 fix: fixed not logging tool uses if a think fragment had them attached.
if a think fragment had a tool attached, the tool use would not log. now it does
2026-02-19 05:22:00 +01:00
chtangwin 124c611426 Fix: Add ensure_ascii=False to WhatsApp send payload
The send() payload contains user message content (msg.content) which
may include non-ASCII characters (e.g. CJK, German umlauts, emoji).

The auth frame and Discord heartbeat/identify payloads are left
unchanged as they only carry ASCII protocol fields.
2026-02-18 18:46:23 -08:00
chtangwin a2379a08ac Fix: Ensure UTF-8 encoding and ensure_ascii=False for remaining file/JSON operations 2026-02-18 18:37:17 -08:00
chtangwin c7b5dd9350 Fix: Ensure UTF-8 encoding for all file operations 2026-02-18 18:28:54 -08:00
Nikolas de Hor 464352c664 fix: allow one retry for models that send interim text before tool calls
Some LLM providers (MiniMax, Gemini Flash, GPT-4.1, etc.) send an
initial text-only response like "Let me investigate..." before actually
making tool calls. The agent loop previously broke immediately on any
text response without tool calls, preventing these models from ever
using tools.

Now, when the model responds with text but hasn't used any tools yet,
the loop forwards the text as progress to the user and gives the model
one additional iteration to make tool calls. This is limited to a
single retry to prevent infinite loops.

Closes #705
2026-02-18 21:31:12 -03:00
Nikolas de Hor 33d760d312 fix: handle /help command directly in Telegram, bypassing ACL check
The /help command was routed through _forward_command → _handle_message
→ is_allowed(), which denied access to users not in the allowFrom list.
Since /help is purely informational, it should be accessible to all
users — similar to how /start already works with its own handler.

Add a dedicated _on_help handler that replies directly without going
through the message bus access control.

Closes #687
2026-02-18 21:31:11 -03:00
Nikolas de Hor 107a380e61 fix: prevent duplicate memory consolidation tasks per session
Add a `_consolidating` set to track which sessions have an active
consolidation task. Skip creating a new task if one is already in
progress for the same session key, and clean up the flag when done.

This prevents the excessive API calls reported when messages exceed
the memory_window threshold — previously every single message after
the threshold triggered a new background consolidation.

Closes #751
2026-02-18 21:31:09 -03:00
4367038a95 fix: make cron run command actually execute the agent
Wire up an AgentLoop with an on_job callback in the cron_run CLI
command so the job's message is sent to the agent and the response
is printed. Previously, CronService was created with no on_job
callback, causing _execute_job to skip execution silently and
always report success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 15:42:33 -06:00
ruby childsandClaude Opus 4.6 536ed60a05 Fix safety guard false positive on 'format' in URLs
The deny pattern `\b(format|mkfs|diskpart)\b` incorrectly blocked
commands containing "format" inside URLs (e.g. `curl https://wttr.in?format=3`)
because `\b` fires at the boundary between `?` (non-word) and `f` (word).

Split into two patterns:
- `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone
  command (start of line or after shell operators)
- `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:39:06 -05:00
Darye 3ac5513004 If given a message_id to telegram provider send, the bot will try to reply to that message 2026-02-18 20:27:48 +01:00
Darye c865b293a9 feat: enhance message context handling by adding message_id parameter 2026-02-18 20:18:27 +01:00
Your Name 1663517998 feat: Add VolcEngine LLM provider support
- Add VolcEngine ProviderSpec entry in registry.py
- Add volcengine to ProvidersConfig class in schema.py
- Update model providers table in README.md
- Add description about VolcEngine coding plan endpoint
2026-02-19 03:02:16 +08:00
4a85cd9a11 fix(cron): add service-layer timezone validation
Adds `_validate_schedule_for_add()` to `CronService.add_job` so that
invalid or misplaced `tz` values are rejected before a job is persisted,
regardless of which caller (CLI, tool, etc.) invoked the service.

Surfaces the resulting `ValueError` in `nanobot cron add` via a
`try/except` so the CLI exits cleanly with a readable error message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:33:23 +01:00
dxtime c5b4331e69 feature: Added custom headers for MCP Auth use. 2026-02-19 01:21:17 +08:00
Xubin RenandGitHub 8de36d398f docs: update news about release information 2026-02-18 23:09:55 +08:00
Re-bin 1f1f5b2d27 docs: update v0.1.4 release news 2026-02-18 14:41:13 +00:00
Ivan e44f14379a fix: sanitize messages and ensure 'content' for strict LLM providers
- Strip non-standard keys like 'reasoning_content' before sending to LLM
- Always include 'content' key in assistant messages (required by StepFun)
- Add _sanitize_messages to LiteLLMProvider to prevent 400 BadRequest errors
2026-02-18 11:57:58 +03:00
Harry Zhou 40f4834f30 Merge remote-tracking branch 'upstream/main' 2026-02-16 11:40:07 +08:00
Aleksander W. Oleszkiewicz (Alek)andGitHub d07e0c1f79 Update Slack message text fallback response
Slack doesn't accept an empty string in the `text` parameter. However, Nanobot sometimes sends an empty response. This may need a change in the bot's logic as well; still, it should also be handled by the channel. I suggest changing the default message to '<empty_response_from_the_bot>' when the content is empty, so the user will know that the bot was trying to respond with an empty message.
2026-02-15 13:51:17 +01:00
Oleg Medvedev fbbbdc727d fix(tools): resolve relative file paths against workspace
File tools now resolve relative paths (e.g., "test.txt") against the
workspace directory instead of the current working directory. This fixes
failures when models use simple filenames instead of full paths.

- Add workspace parameter to _resolve_path() in filesystem.py
- Update all file tools to accept workspace in constructor
- Pass workspace when registering tools in AgentLoop
2026-02-14 13:51:18 -06:00
Harry Zhou b523b277b0 fix(agent): handle non-string values in memory consolidation
Fix TypeError when LLM returns JSON objects instead of strings for
history_entry or memory_update.

Changes:
- Update prompt to explicitly require string values with example
- Add type checking and conversion for non-string values
- Use json.dumps() for consistent JSON formatting

Fixes potential memory consolidation failures when LLM interprets
the prompt loosely and returns structured objects instead of strings.
2026-02-14 23:48:21 +08:00
zhengliyuan cedde62201 Merge branch 'main' into feature/qq-groupmessage 2026-02-12 10:48:02 +08:00
zhengliyuan 039ab717fa update: Enable listening to both private and group messages. 2026-02-12 10:44:26 +08:00
eric 4d6f02ec0d fix(telegram): preserve file extension for generic documents 2026-02-09 21:12:16 -05:00
tao.jun 59017aa9bb feat(feishu): Add event handlers for reactions, message read, and p2p chat events
- Register handlers for message reaction created events
- Register handlers for message read events
- Register handlers for bot entering p2p chat events
- Prevent error logs for these common but unprocessed events
- Import required event types from lark_oapi
2026-02-08 13:03:32 +08:00
tao.jun 47a0628067 Merge tag 'v0.1.3.post5' into merge-upstream-v0.1.3.post5 2026-02-08 13:01:01 +08:00
Yaroslav Halchenko a25a24422d fix filename 2026-02-04 14:09:43 -05:00
Yaroslav Halchenko 5082a7732a [DATALAD RUNCMD] chore: run codespell throughout fixing few left typos automagically
=== Do not change lines below ===
{
 "chain": [],
 "cmd": "codespell -w",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
2026-02-04 14:08:41 -05:00
Yaroslav Halchenko b51ef6f886 Add rudimentary codespell config 2026-02-04 14:08:41 -05:00
Yaroslav Halchenko 50e0eee893 Add github action to codespell main on push and PRs 2026-02-04 14:08:41 -05:00
tao.jun 6968da3884 Merge remote-tracking branch 'upstream/main' 2026-02-04 18:09:50 +08:00
683 changed files with 240803 additions and 6916 deletions
+29
View File
@@ -0,0 +1,29 @@
# Design Constraints
These rules govern architectural decisions. When adding a feature or fixing a bug, prefer paths that respect these boundaries.
## Core stays small; extend at the edges
New capabilities should be added via `channels/`, `tools/`, skills, or MCP servers. The files `agent/loop.py` and `agent/runner.py` form the critical core path; changes there should be minimal and justified. If a feature can live in a channel adapter, a tool, or an external MCP server, it should not be inlined into the agent loop.
Runtime state fan-out follows the same boundary. `AgentLoop` may publish generic runtime events from `nanobot.bus.runtime_events` for turn/run/model/goal state changes, but WebUI/WebSocket wire details such as `_turn_end`, `_goal_status`, title refreshes, and goal-state sync belong in `nanobot.session.webui_turns.WebuiTurnCoordinator` or the relevant channel adapter.
## Less structure, more intelligence
Prefer simple, readable code over new framework layers and indirection. Add structure only when it removes real complexity, protects an important boundary, or matches an established local pattern. The best fix is often a smaller prompt, a tighter tool contract, a channel-local change, or one focused regression test.
## Prefer duplication over premature abstraction
Channels and providers are allowed to repeat similar logic (send retries, media handling, message splitting). Do not introduce complex base classes or shared helpers just to eliminate duplication across channel files. Each channel file should remain self-contained and readable on its own. The same applies to provider implementations.
## Minimal change that solves the real problem
Fix bugs by changing only what is necessary. Do not bundle unrelated refactors or clean-ups into a feature or bugfix PR. If a refactor is genuinely required, it should be a separate, clearly scoped PR.
## Keep PRs reviewable
A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review.
## Explicit over magical
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
+40
View File
@@ -0,0 +1,40 @@
# Common Gotchas
## Do not use `ruff format`
`CONTRIBUTING.md` mentions `ruff format`, but **do not run it** — it destroys git blame history. Only `ruff check` should be used.
## Config `${VAR}` References
`config/loader.py` resolves `${VAR}` patterns in `config.json` at load time. This is **not** a shell-like default-value syntax. If the environment variable is missing, `load_config` raises `ValueError` and the agent falls back to default configuration.
Example valid usage:
```json
{ "providers": { "openrouter": { "apiKey": "${OPENROUTER_KEY}" } } }
```
## Windows Compatibility
nanobot explicitly supports Windows. Key differences to keep in mind:
- `ExecTool` uses `cmd /c` on Windows instead of `sh -c` (`shell.py`).
- `cli/commands.py` forces `sys.stdout`/`stderr` to UTF-8 on startup to handle emoji and multilingual input.
- MCP stdio server commands are normalized for Windows path separators (`mcp.py`).
- Always use `pathlib.Path` for path manipulation; do not assume `/` separators.
## Prompt Templates
Agent system prompts and scenario-specific instructions live in `nanobot/templates/` as Jinja2 markdown files (`identity.md`, `platform_policy.md`, `HEARTBEAT.md`, `SOUL.md`, etc.). Changing these files alters agent behavior as directly as changing Python code. They are loaded by `utils/prompt_templates.py`.
Tool descriptions, skills, and replayed session history also shape model behavior. Treat changes to those surfaces like runtime code: keep them narrow, add a focused regression test when possible, and avoid teaching the model to repeat internal markers, local paths, or tool-call text.
## Context Pollution Persists
Anything written into memory, session history, or prompt inputs can be replayed into future LLM calls. Metadata such as timestamps, local media paths, tool-call echoes, and raw fallback dumps must be bounded and sanitized before they become examples for the model to imitate.
## Skills as Extension Point
Built-in skills live in `nanobot/skills/` (markdown + YAML frontmatter format). Agent capabilities that are "know-how" rather than code should be added as skills, not hardcoded into the agent loop. External skills can be published to and installed from ClawHub.
## Atomic Session Writes
`agent/memory.py` writes `history.jsonl` atomically (temp file + fsync + rename + directory fsync). This guarantees durability across crashes. Do not replace this with a plain `open(..., "w")` write.
+29
View File
@@ -0,0 +1,29 @@
# Security Boundaries
The agent operates with significant power (file system, shell, web). The following guards must not be bypassed when modifying related code.
## Workspace Restriction
Filesystem tools (`read_file`, `write_file`, `edit_file`, `list_dir`, `apply_patch`) resolve paths through the workspace path resolver (`agent/tools/filesystem.py` / `agent/tools/path_utils.py`), which enforces that the resolved path must lie under the active workspace when workspace restriction is enabled. The media upload directory is always an internal extra read root while restricted.
Additional filesystem roots must be capability-specific. `extra_allowed_dirs` is a legacy read-only alias. Use `extra_read_allowed_dirs` for read-only roots, `extra_write_allowed_dirs` only when a write-capable tool is intentionally allowed to modify an extra directory, and exact file allowlists when a tool may modify only specific files.
Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_workspace` as an application-level guard: if enabled and `working_dir` is outside the workspace, the command is rejected before execution, and command text is checked for obvious workspace escapes. This is not process-level isolation; use an exec sandbox backend for that.
**Rule**: Any new path-handling logic must go through the workspace path resolver or perform an equivalent containment check with explicit read/write capability semantics.
## SSRF Protection
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
The only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time.
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
**Rule**: Do not add direct `httpx.get` / `requests.get` calls in tools. Route through the existing web fetch utilities or replicate the `validate_url_target` check.
## Shell Sandbox
`tools/sandbox.py` provides optional command wrapping. The only backend currently shipped is `bwrap` (bubblewrap), intended for containerized deployments. On Windows and bare-metal Linux without `bwrap`, commands run in the native shell with workspace restriction as an application-level guard only.
**Rule**: If adding a new sandbox backend, implement `_wrap_<name>(command, workspace, cwd) -> str` and register it in `_BACKENDS`.
+1
View File
@@ -5,6 +5,7 @@ __pycache__
*.egg-info
dist/
build/
nanobot/web/dist/
.git
.env
.assets
+2
View File
@@ -0,0 +1,2 @@
# Ensure shell scripts always use LF line endings (Docker/Linux compat)
*.sh text eol=lf
+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.2.0
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.
+81
View File
@@ -0,0 +1,81 @@
name: Test Suite
on:
push:
branches: [main]
paths-ignore:
- docs/**
pull_request:
branches: [main]
paths-ignore:
- docs/**
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
python-version: ${{ fromJSON('["3.13","3.14"]') }}
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
- 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 dependencies
run: uv sync --all-extras --dev
- name: Lint with ruff
run: uv run ruff check nanobot --select F
- name: Run tests
run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered
webui:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.6
- name: Install WebUI dependencies
working-directory: webui
run: bun install
- name: Lint WebUI
working-directory: webui
run: bun run lint
- name: Test WebUI
working-directory: webui
run: bun run test
- name: Build WebUI
working-directory: webui
run: bun run build
+92 -12
View File
@@ -1,22 +1,102 @@
# Project-specific
.worktrees/
.worktree/
.assets
.docs
.env
*.pyc
dist/
build/
docs/
*.egg-info/
*.egg
.web
.orion
# Claude / AI assistant artifacts
docs/superpowers/
docs/plans/
# webui (monorepo frontend)
webui/node_modules/
webui/dist/
webui/coverage/
webui/.vite/
*.tsbuildinfo
# Python bytecode & caches
*.pyc
*.pyo
*.pyd
*.pyw
*.pyz
*.pywz
*.pyzz
__pycache__/
*.egg-info/
*.egg
.venv/
venv/
__pycache__/
poetry.lock
.pytest_cache/
botpy.log
tests/
.mypy_cache/
.ruff_cache/
.pytype/
.dmypy.json
dmypy.json
.tox/
.nox/
.hypothesis/
# Build & packaging
dist/
build/
*.manifest
*.spec
pip-wheel-metadata/
share/python-wheels/
# Test & coverage
.coverage
.coverage.*
htmlcov/
coverage.xml
*.cover
# Lock files (project policy)
poetry.lock
uv.lock
# Jupyter
.ipynb_checkpoints/
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Linux
.directory
# Editors & IDEs (local workspace / user settings)
.vscode/
.cursor/
.idea/
.fleet/
*.code-workspace
*.sublime-project
*.sublime-workspace
*.swp
*.swo
*~
nano.*.save
# Environment & secrets (keep examples tracked if needed)
.env.*
!.env.example
# Logs & temp
*.log
logs/
tmp/
temp/
*.tmp
exp/
.playwright-mcp/
bridge/node_modules/
+81
View File
@@ -0,0 +1,81 @@
This file provides guidance to AI coding agents working with this repository.
## Project Overview
nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory.
## Development Commands
```bash
# Python: run single test / lint
pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
cd webui && bun run build
cd webui && bun run test
# Gateway
nanobot gateway
```
## High-Level Architecture
### Core Data Flow
Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core:
1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus.
2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn.
3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses.
4. Responses are published as `OutboundMessage` events back to the appropriate channel.
### Key Subsystems
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed).
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context.
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
### Entry Points
- **CLI**: `nanobot/cli/commands.py`
- **Python SDK**: `nanobot/nanobot.py`
## Project-Specific Notes
- Architecture constraints: [`.agent/design.md`](.agent/design.md)
- Security boundaries: [`.agent/security.md`](.agent/security.md)
- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md)
## Contribution Flow
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for contribution flow and PR guidelines.
## Code Style
- Python 3.11+, asyncio throughout.
- Line length: 100.
- Linting: `ruff` with rules E, F, I, N, W (E501 ignored).
- pytest with `asyncio_mode = "auto"`.
## Common File Locations
- Config schema: `nanobot/config/schema.py`
- Provider base / new provider template: `nanobot/providers/base.py`
- Channel base / new channel template: `nanobot/channels/base.py`
- Tool registry: `nanobot/agent/tools/registry.py`
- WebUI dev proxy config: `webui/vite.config.ts`
- Tests mirror the `nanobot/` package structure.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+135
View File
@@ -0,0 +1,135 @@
# Contributing to nanobot
Thank you for being here.
nanobot is built with a simple belief: good tools should feel calm, clear, and humane.
We care deeply about useful features, but we also believe in achieving more with less:
solutions should be powerful without becoming heavy, and ambitious without becoming
needlessly complicated.
This guide is not only about how to open a PR. It is also about how we hope to build
software together: with care, clarity, and respect for the next person reading the code.
## Maintainers
Maintainers are community stewards who help review, organize, and maintain the project. The list below describes each maintainer's current open-source project responsibilities.
| Maintainer | Role |
|------------|------|
| [@re-bin](https://github.com/re-bin) | Project lead; reviews community PRs and handles merges |
| [@chengyongru](https://github.com/chengyongru) | Reviews community PRs and may approve them; merges are handled by the project lead |
## Contribution Flow
### What Should I Open a PR For?
PRs are welcome for:
- New features or functionality
- Bug fixes with no behavior changes
- Documentation improvements
- Minor tweaks that don't affect functionality
- Refactoring that is clearly scoped and easy to review
- Changes to APIs or configuration, when the impact is documented
For riskier or larger changes, please open an issue or draft PR early so the
shape of the work can be discussed before the implementation grows too large.
### Starting Work
Before making changes, sync your local checkout and create a topic branch.
```bash
git fetch upstream
git switch main
git pull --ff-only upstream main
git switch -c your-topic-branch
```
Use your primary HKUDS/nanobot remote in place of `upstream` if your checkout
uses a different remote name.
Keep unrelated local changes out of the topic branch. If your checkout already has
work in progress, use a separate worktree or finish that work before starting a
new branch.
## Development Setup
Keep setup boring and reliable. The goal is to get you into the code quickly:
```bash
# Clone the repository
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Lint code
ruff check nanobot/
# Format code — optional. The existing tree predates `ruff format`,
# so running it broadly produces large unrelated diffs.
# Do not mix mechanical formatting churn into a functional PR.
# Use formatting only for the exact code your change intentionally touches.
ruff format <files-you-changed>
```
## Contribution License
By submitting a contribution, you confirm that you have the right to submit it
and agree that it will be licensed under the project's MIT License.
## Code Style
We care about more than passing lint. We want nanobot to stay small, calm, and readable.
When contributing, please aim for code that feels:
- Simple: prefer the smallest change that solves the real problem
- Clear: optimize for the next reader, not for cleverness
- Decoupled: keep boundaries clean and avoid unnecessary new abstractions
- Honest: do not hide complexity, but do not create extra complexity either
- Durable: choose solutions that are easy to maintain, test, and extend
In practice:
- Line length: 100 characters (`ruff`)
- Target: Python 3.11+
- Linting: `ruff` with rules E, F, I, N, W (E501 ignored)
- Async: uses `asyncio` throughout; pytest with `asyncio_mode = "auto"`
- Prefer readable code over magical code
- Prefer focused patches over broad rewrites
- Do not mix mechanical formatting, line wrapping, import sorting, or quote churn
into a feature or bugfix PR. If formatting cleanup is needed, make it a
separate formatting-only PR.
- If a new abstraction is introduced, it should clearly reduce complexity rather than move it around
## Modifying CI Workflows
If your PR touches `.github/workflows/`, please keep the CI within
GitHub Actions' free tier:
- Use only standard GitHub-hosted runners (`ubuntu-latest`, `windows-latest`)
- Avoid macOS runners, larger runners (`*-cores`, `*-xlarge`, `*-gpu`),
and self-hosted runners
- Avoid uploading large artifacts or using long retention
- Avoid paid Marketplace actions
If your change genuinely needs to step outside this, please call it out
explicitly in the PR description so it can be discussed before merge.
## Questions?
If you have questions, ideas, or half-formed insights, you are warmly welcome here.
Please feel free to open an [issue](https://github.com/HKUDS/nanobot/issues), join the community, or simply reach out:
- [Discord](https://discord.gg/MnCvHqpUGB)
- [Feishu/WeChat](./COMMUNICATION.md)
- Email: Xubin Ren (@Re-bin) — <xubinrencs@gmail.com>
Thank you for spending your time and care on nanobot. We would love for more people to participate in this community, and we genuinely welcome contributions of all sizes.
+30 -25
View File
@@ -1,40 +1,45 @@
FROM node:24-bookworm-slim AS webui-builder
WORKDIR /app
COPY webui/package.json webui/package-lock.json ./webui/
WORKDIR /app/webui
RUN npm ci
COPY webui/ ./
RUN mkdir -p /app/nanobot/web && npm run build
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Install Node.js 20 for the WhatsApp bridge
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs && \
apt-get purge -y gnupg && \
apt-get autoremove -y && \
apt-get install -y --no-install-recommends ca-certificates git bubblewrap openssh-client libmagic1 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies first (cached layer)
COPY pyproject.toml README.md LICENSE ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \
rm -rf nanobot bridge
# Install Python dependencies first (cached layer). Hatch reads the custom build
# hook from hatch_build.py even for this metadata-only install.
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]" && \
rm -rf nanobot
# Copy the full source and install
COPY nanobot/ nanobot/
COPY bridge/ bridge/
RUN uv pip install --system --no-cache .
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]"
# Build the WhatsApp bridge
WORKDIR /app/bridge
RUN npm install && npm run build
WORKDIR /app
# Create non-root user and config directory
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
mkdir -p /home/nanobot/.nanobot && \
chown -R nanobot:nanobot /home/nanobot /app
# Create config directory
RUN mkdir -p /root/.nanobot
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
# Gateway default port
EXPOSE 18790
USER nanobot
ENV HOME=/home/nanobot
ENTRYPOINT ["nanobot"]
# Gateway health endpoint and optional WebUI/WebSocket channel ports
EXPOSE 18790 8765
ENTRYPOINT ["entrypoint.sh"]
CMD ["status"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 nanobot contributors
Copyright (c) 2025-present Xubin Ren and the nanobot 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
+434 -820
View File
File diff suppressed because it is too large Load Diff
+27 -21
View File
@@ -48,22 +48,23 @@ chmod 600 ~/.nanobot/config.json
},
"whatsapp": {
"enabled": true,
"allowFrom": ["+1234567890"]
"allowFrom": ["1234567890"]
}
}
}
```
**Security Notes:**
- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use)
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
- Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp
- Use WhatsApp sender IDs as full phone numbers with country code and no leading `+`
- Review access logs regularly for unauthorized access attempts
### 3. Shell Command Execution
The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should:
-**Enable the bwrap sandbox** (`"tools.exec.sandbox": "bwrap"`) for kernel-level isolation (Linux only)
- ✅ Review all tool usage in agent logs
- ✅ Understand what commands the agent is running
- ✅ Use a dedicated user account with limited privileges
@@ -71,6 +72,19 @@ The `exec` tool can execute shell commands. While dangerous command patterns are
- ❌ Don't disable security checks
- ❌ Don't run on systems with sensitive data without careful review
**Exec sandbox (bwrap):**
On Linux, set `"tools.exec.sandbox": "bwrap"` to wrap every shell command in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox. This uses Linux kernel namespaces to restrict what the process can see:
- Workspace directory → **read-write** (agent works normally)
- Media directory → **read-only** (can read uploaded attachments)
- System directories (`/usr`, `/bin`, `/lib`) → **read-only** (commands still work)
- Config files and API keys (`~/.nanobot/config.json`) → **hidden** (masked by tmpfs)
Requires `bwrap` installed (`apt install bubblewrap`). Pre-installed in the official Docker image. **Not available on macOS or Windows** — bubblewrap depends on Linux kernel namespaces.
Enabling the sandbox also automatically activates `restrictToWorkspace` for file tools.
**Blocked patterns:**
- `rm -rf /` - Root filesystem deletion
- Fork bombs
@@ -82,6 +96,7 @@ The `exec` tool can execute shell commands. While dangerous command patterns are
File operations have path traversal protection, but:
- ✅ Enable `restrictToWorkspace` or the bwrap sandbox to confine file access
- ✅ Run nanobot with a dedicated user account
- ✅ Use filesystem permissions to protect sensitive directories
- ✅ Regularly audit file operations in logs
@@ -94,10 +109,9 @@ File operations have path traversal protection, but:
- Timeouts are configured to prevent hanging requests
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:**
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
**WhatsApp:**
- Keep the neonize session database under `~/.nanobot/whatsapp-auth` secure (mode 0700).
- Use `nanobot channels login whatsapp --force` to remove and recreate the local session database when rotating linked devices.
### 6. Dependency Security
@@ -112,17 +126,9 @@ pip-audit
pip install --upgrade nanobot-ai
```
For Node.js dependencies (WhatsApp bridge):
```bash
cd bridge
npm audit
npm audit fix
```
**Important Notes:**
- Keep `litellm` updated to the latest version for security fixes
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
- Run `pip-audit` or `npm audit` regularly
- Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
- Subscribe to security advisories for nanobot and its dependencies
### 7. Production Deployment
@@ -212,9 +218,8 @@ If you suspect a security breach:
- Input length limits on HTTP requests
✅ **Authentication**
- Allow-list based access control
- Allow-list based access control — in `v0.1.4.post3` and earlier empty `allowFrom` allowed all; since `v0.1.4.post4` it denies all (`["*"]` explicitly allows all)
- Failed authentication attempt logging
- Open by default (configure allowFrom for production use)
✅ **Resource Protection**
- Command execution timeouts (60s default)
@@ -224,7 +229,7 @@ If you suspect a security breach:
✅ **Secure Communication**
- HTTPS for all external API calls
- TLS for Telegram API
- WhatsApp bridge: localhost-only binding + optional token auth
- WhatsApp session secrets stay in the local session database
## Known Limitations
@@ -233,7 +238,7 @@ If you suspect a security breach:
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
3. **No Session Management** - No automatic session expiry
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux)
5. **No Audit Trail** - Limited security event logging (enhance as needed)
## Security Checklist
@@ -244,6 +249,7 @@ Before deploying nanobot:
- [ ] Config file permissions set to 0600
- [ ] `allowFrom` lists configured for all channels
- [ ] Running as non-root user
- [ ] Exec sandbox enabled (`"tools.exec.sandbox": "bwrap"`) on Linux deployments
- [ ] File system permissions properly restricted
- [ ] Dependencies updated to latest secure versions
- [ ] Logs monitored for security events
@@ -253,7 +259,7 @@ Before deploying nanobot:
## Updates
**Last Updated**: 2026-02-03
**Last Updated**: 2026-04-05
For the latest security updates and announcements, check:
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
+175
View File
@@ -0,0 +1,175 @@
# Third-Party Notices
The following third-party components are redistributed as part of the packaged
nanobot Python distribution (`pip install nanobot-ai`).
---
## Tabler Icons — interface icons (MIT)
- **Source**: https://github.com/tabler/tabler-icons
- **Bundled**: `nanobot/web/dist/assets/index-*.js` (inline `arrow-fork` SVG)
```
MIT License
Copyright (c) 2020-2026 Paweł Kuna
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 — 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.
```
-26
View File
@@ -1,26 +0,0 @@
{
"name": "nanobot-whatsapp-bridge",
"version": "0.1.0",
"description": "WhatsApp bridge for nanobot using Baileys",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ws": "^8.17.1",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/ws": "^8.5.10",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=20.0.0"
}
}
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* nanobot WhatsApp Bridge
*
* This bridge connects WhatsApp Web to nanobot's Python backend
* via WebSocket. It handles authentication, message forwarding,
* and reconnection logic.
*
* Usage:
* npm run build && npm start
*
* Or with custom settings:
* BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
*/
// Polyfill crypto for Baileys in ESM
import { webcrypto } from 'crypto';
if (!globalThis.crypto) {
(globalThis as any).crypto = webcrypto;
}
import { BridgeServer } from './server.js';
import { homedir } from 'os';
import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
const TOKEN = process.env.BRIDGE_TOKEN || undefined;
console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n');
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\n\nShutting down...');
await server.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.stop();
process.exit(0);
});
// Start the server
server.start().catch((error) => {
console.error('Failed to start bridge:', error);
process.exit(1);
});
-129
View File
@@ -1,129 +0,0 @@
/**
* WebSocket server for Python-Node.js bridge communication.
* Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth.
*/
import { WebSocketServer, WebSocket } from 'ws';
import { WhatsAppClient, InboundMessage } from './whatsapp.js';
interface SendCommand {
type: 'send';
to: string;
text: string;
}
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
}
export class BridgeServer {
private wss: WebSocketServer | null = null;
private wa: WhatsAppClient | null = null;
private clients: Set<WebSocket> = new Set();
constructor(private port: number, private authDir: string, private token?: string) {}
async start(): Promise<void> {
// Bind to localhost only — never expose to external network
this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port });
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
if (this.token) console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client
this.wa = new WhatsAppClient({
authDir: this.authDir,
onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
onQR: (qr) => this.broadcast({ type: 'qr', qr }),
onStatus: (status) => this.broadcast({ type: 'status', status }),
});
// Handle WebSocket connections
this.wss.on('connection', (ws) => {
if (this.token) {
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
}
});
} else {
console.log('🔗 Python client connected');
this.setupClient(ws);
}
});
// Connect to WhatsApp
await this.wa.connect();
}
private setupClient(ws: WebSocket): void {
this.clients.add(ws);
ws.on('message', async (data) => {
try {
const cmd = JSON.parse(data.toString()) as SendCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
console.error('Error handling command:', error);
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
}
});
ws.on('close', () => {
console.log('🔌 Python client disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
this.clients.delete(ws);
});
}
private async handleCommand(cmd: SendCommand): Promise<void> {
if (cmd.type === 'send' && this.wa) {
await this.wa.sendMessage(cmd.to, cmd.text);
}
}
private broadcast(msg: BridgeMessage): void {
const data = JSON.stringify(msg);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
async stop(): Promise<void> {
// Close all client connections
for (const client of this.clients) {
client.close();
}
this.clients.clear();
// Close WebSocket server
if (this.wss) {
this.wss.close();
this.wss = null;
}
// Disconnect WhatsApp
if (this.wa) {
await this.wa.disconnect();
this.wa = null;
}
}
}
-3
View File
@@ -1,3 +0,0 @@
declare module 'qrcode-terminal' {
export function generate(text: string, options?: { small?: boolean }): void;
}
-187
View File
@@ -1,187 +0,0 @@
/**
* WhatsApp client wrapper using Baileys.
* Based on OpenClaw's working implementation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
const VERSION = '0.1.0';
export interface InboundMessage {
id: string;
sender: string;
pn: string;
content: string;
timestamp: number;
isGroup: boolean;
}
export interface WhatsAppClientOptions {
authDir: string;
onMessage: (msg: InboundMessage) => void;
onQR: (qr: string) => void;
onStatus: (status: string) => void;
}
export class WhatsAppClient {
private sock: any = null;
private options: WhatsAppClientOptions;
private reconnecting = false;
constructor(options: WhatsAppClientOptions) {
this.options = options;
}
async connect(): Promise<void> {
const logger = pino({ level: 'silent' });
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
const { version } = await fetchLatestBaileysVersion();
console.log(`Using Baileys version: ${version.join('.')}`);
// Create socket following OpenClaw's pattern
this.sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ['nanobot', 'cli', VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
// Handle WebSocket errors
if (this.sock.ws && typeof this.sock.ws.on === 'function') {
this.sock.ws.on('error', (err: Error) => {
console.error('WebSocket error:', err.message);
});
}
// Handle connection updates
this.sock.ev.on('connection.update', async (update: any) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
// Display QR code in terminal
console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
qrcode.generate(qr, { small: true });
this.options.onQR(qr);
}
if (connection === 'close') {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
this.options.onStatus('disconnected');
if (shouldReconnect && !this.reconnecting) {
this.reconnecting = true;
console.log('Reconnecting in 5 seconds...');
setTimeout(() => {
this.reconnecting = false;
this.connect();
}, 5000);
}
} else if (connection === 'open') {
console.log('✅ Connected to WhatsApp');
this.options.onStatus('connected');
}
});
// Save credentials on update
this.sock.ev.on('creds.update', saveCreds);
// Handle incoming messages
this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
if (type !== 'notify') return;
for (const msg of messages) {
// Skip own messages
if (msg.key.fromMe) continue;
// Skip status updates
if (msg.key.remoteJid === 'status@broadcast') continue;
const content = this.extractMessageContent(msg);
if (!content) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
this.options.onMessage({
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
content,
timestamp: msg.messageTimestamp as number,
isGroup,
});
}
});
}
private extractMessageContent(msg: any): string | null {
const message = msg.message;
if (!message) return null;
// Text message
if (message.conversation) {
return message.conversation;
}
// Extended text (reply, link preview)
if (message.extendedTextMessage?.text) {
return message.extendedTextMessage.text;
}
// Image with caption
if (message.imageMessage?.caption) {
return `[Image] ${message.imageMessage.caption}`;
}
// Video with caption
if (message.videoMessage?.caption) {
return `[Video] ${message.videoMessage.caption}`;
}
// Document with caption
if (message.documentMessage?.caption) {
return `[Document] ${message.documentMessage.caption}`;
}
// Voice/Audio message
if (message.audioMessage) {
return `[Voice Message]`;
}
return null;
}
async sendMessage(to: string, text: string): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
await this.sock.sendMessage(to, { text });
}
async disconnect(): Promise<void> {
if (this.sock) {
this.sock.end(undefined);
this.sock = null;
}
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Before

Width:  |  Height:  |  Size: 6.8 MiB

After

Width:  |  Height:  |  Size: 6.8 MiB

+81 -12
View File
@@ -1,21 +1,90 @@
#!/bin/bash
# Count core agent lines (excluding channels/, cli/, providers/ adapters)
set -euo pipefail
cd "$(dirname "$0")" || exit 1
echo "nanobot core agent line count"
echo "================================"
count_top_level_py_lines() {
local dir="$1"
if [ ! -d "$dir" ]; then
echo 0
return
fi
find "$dir" -maxdepth 1 -type f -name "*.py" -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' '
}
count_recursive_py_lines() {
local dir="$1"
if [ ! -d "$dir" ]; then
echo 0
return
fi
find "$dir" -type f -name "*.py" -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' '
}
count_skill_lines() {
local dir="$1"
if [ ! -d "$dir" ]; then
echo 0
return
fi
find "$dir" -type f \( -name "*.md" -o -name "*.py" -o -name "*.sh" \) -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' '
}
print_row() {
local label="$1"
local count="$2"
printf " %-16s %6s lines\n" "$label" "$count"
}
echo "nanobot line count"
echo "=================="
echo ""
for dir in agent agent/tools bus config cron heartbeat session utils; do
count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l)
printf " %-16s %5s lines\n" "$dir/" "$count"
done
echo "Core runtime"
echo "------------"
core_agent=$(count_top_level_py_lines "nanobot/agent")
core_bus=$(count_top_level_py_lines "nanobot/bus")
core_config=$(count_top_level_py_lines "nanobot/config")
core_cron=$(count_top_level_py_lines "nanobot/cron")
core_session=$(count_top_level_py_lines "nanobot/session")
root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
printf " %-16s %5s lines\n" "(root)" "$root"
print_row "agent/" "$core_agent"
print_row "bus/" "$core_bus"
print_row "config/" "$core_config"
print_row "cron/" "$core_cron"
print_row "session/" "$core_session"
core_total=$((core_agent + core_bus + core_config + core_cron + core_session))
echo ""
total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l)
echo " Core total: $total lines"
echo "Separate buckets"
echo "----------------"
extra_tools=$(count_recursive_py_lines "nanobot/agent/tools")
extra_skills=$(count_skill_lines "nanobot/skills")
extra_api=$(count_recursive_py_lines "nanobot/api")
extra_cli=$(count_recursive_py_lines "nanobot/cli")
extra_channels=$(count_recursive_py_lines "nanobot/channels")
extra_utils=$(count_recursive_py_lines "nanobot/utils")
print_row "tools/" "$extra_tools"
print_row "skills/" "$extra_skills"
print_row "api/" "$extra_api"
print_row "cli/" "$extra_cli"
print_row "channels/" "$extra_channels"
print_row "utils/" "$extra_utils"
extra_total=$((extra_tools + extra_skills + extra_api + extra_cli + extra_channels + extra_utils))
echo ""
echo " (excludes: channels/, cli/, providers/)"
echo "Totals"
echo "------"
print_row "core total" "$core_total"
print_row "extra total" "$extra_total"
echo ""
echo "Notes"
echo "-----"
echo " - agent/ only counts top-level Python files under nanobot/agent"
echo " - tools/ is counted separately from nanobot/agent/tools"
echo " - skills/ counts .md, .py, and .sh files"
echo " - not included here: command/, providers/, security/, templates/, nanobot.py, root files"
+28 -3
View File
@@ -3,7 +3,14 @@ x-common-config: &common-config
context: .
dockerfile: Dockerfile
volumes:
- ~/.nanobot:/root/.nanobot
- ~/.nanobot:/home/nanobot/.nanobot
cap_drop:
- ALL
cap_add:
- SYS_ADMIN
security_opt:
- apparmor=unconfined
- seccomp=unconfined
services:
nanobot-gateway:
@@ -13,13 +20,31 @@ services:
restart: unless-stopped
ports:
- 18790:18790
- 8765:8765
deploy:
resources:
limits:
cpus: '1'
cpus: "1"
memory: 1G
reservations:
cpus: '0.25'
cpus: "0.25"
memory: 256M
nanobot-api:
container_name: nanobot-api
<<: *common-config
command:
["serve", "--host", "0.0.0.0", "-w", "/home/nanobot/.nanobot/api-workspace"]
restart: unless-stopped
ports:
- 127.0.0.1:8900:8900
deploy:
resources:
limits:
cpus: "1"
memory: 1G
reservations:
cpus: "0.25"
memory: 256M
nanobot-cli:
+108
View File
@@ -0,0 +1,108 @@
# nanobot Docs
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md) and get one local `nanobot agent -m "Hello!"` reply working before connecting chat apps, WebUI, Docker, or custom tools.
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
Provider examples are concrete walkthroughs, not rankings or endorsements. Use the provider whose key, endpoint, and model ID you actually control.
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
## Pick a Track
| You are | Start with | Then use |
|---|---|---|
| New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails |
| Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups |
| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) |
| Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
## Start Here
| Goal | Read | Outcome |
|---|---|---|
| Start with no technical background | [`start-without-technical-background.md`](./start-without-technical-background.md) | One-command setup, terminal basics, config, API keys, and the first reply |
| Install and get the first reply | [`quick-start.md`](./quick-start.md) | A working CLI agent and a known-good config path |
| Understand how the pieces fit | [`concepts.md`](./concepts.md) | Mental model for config, workspace, gateway, channels, tools, memory, and sessions |
| Choose or change a model provider | [`providers.md`](./providers.md) | Correct provider/model pairing without reading the full config reference |
| Copy a provider setup recipe | [`provider-cookbook.md`](./provider-cookbook.md) | Pasteable OpenRouter, OpenAI, Anthropic, local model, fallback, and Langfuse setups |
| Fix a first-run or runtime problem | [`troubleshooting.md`](./troubleshooting.md) | A diagnosis order and targeted checks for common failures |
## After the First Reply Works
Do not configure everything at once. Pick one next surface:
If a local `nanobot agent` session can already answer normally, you can also ask nanobot to help configure itself: have it read the relevant docs, inspect your current config, make one specific next change, and tell you when to run `/restart`.
| Next goal | Read | First check |
|---|---|---|
| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
| Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn |
| Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean |
| Diagnose a new failure | [`troubleshooting.md`](./troubleshooting.md) | Start with `nanobot status`, then `nanobot agent -m "Hello!"` |
## Use nanobot
| Goal | Read | Outcome |
|---|---|---|
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
| Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls |
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
| Join agent communities | [`agent-social-network.md`](./agent-social-network.md) | External agent-community setup |
## Reference
| Area | Read | Best for |
|---|---|---|
| Full configuration schema | [`configuration.md`](./configuration.md) | Exact fields, defaults, provider tables, web tools, MCP, security, and runtime options |
| CLI commands | [`cli-reference.md`](./cli-reference.md) | Command names, common flags, and entrypoints |
| Architecture | [`architecture.md`](./architecture.md) | Source-level runtime map for core flow, providers, channels, tools, WebUI, memory, security, and extension points |
| Development | [`development.md`](./development.md) | Contributor notes for adding providers and transcription adapters |
| Memory | [`memory.md`](./memory.md) | Session history, Dream consolidation, memory files, and versioning |
| Observability | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | Langfuse tracing setup and required environment variables |
| WebSocket protocol | [`websocket.md`](./websocket.md) | Custom clients, token issuance, multiplexed chats, media, and protocol events |
| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | `/v1/chat/completions`, `/v1/models`, file uploads, and SDK-compatible usage |
| Python SDK | [`python-sdk.md`](./python-sdk.md) | SDK 101, sessions, streaming, model overrides, runtime helpers, and hooks |
| Runtime self-inspection | [`my-tool.md`](./my-tool.md) | Inspecting and tuning the current agent run |
## Fast Lookup
| Need | Jump to |
|---|---|
| Provider/model resolution order | [`providers.md#provider-resolution`](./providers.md#provider-resolution) |
| Model presets and fallback chains | [`providers.md#model-presets`](./providers.md#model-presets) and [`providers.md#fallback-models`](./providers.md#fallback-models) |
| Langfuse environment variables | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) |
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
| Python SDK usage | [`python-sdk.md`](./python-sdk.md) |
| Multiple configs, workspaces, and ports | [`multiple-instances.md`](./multiple-instances.md) |
| Security, sandboxing, and SSRF controls | [`configuration.md#security`](./configuration.md#security) |
| Channel plugin development | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
## Extend nanobot
| Goal | Read | Outcome |
|---|---|---|
| Add a provider or transcription adapter | [`development.md`](./development.md) | A registry/schema-aligned implementation path |
| Add a chat channel plugin | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | A packaged channel discovered through entry points |
| Add custom MCP servers | [`configuration.md#mcp-model-context-protocol`](./configuration.md#mcp-model-context-protocol) | External tools exposed to the agent through MCP |
| Tune tool safety | [`configuration.md#security`](./configuration.md#security) | Shell sandboxing, workspace restriction, and SSRF policy |
## Reading Strategy
Use the docs in this order when you are unsure where to go:
1. If terminal commands or config files are new to you, [`start-without-technical-background.md`](./start-without-technical-background.md) explains the setup words and uses one concrete provider example so there is only one decision at a time.
2. [`quick-start.md`](./quick-start.md) proves installation, config loading, and provider access.
3. [`concepts.md`](./concepts.md) explains the runtime model so later pages are easier to scan.
4. [`provider-cookbook.md`](./provider-cookbook.md) gives pasteable provider, fallback, local model, and Langfuse recipes.
5. A task guide, such as [`chat-apps.md`](./chat-apps.md), [`image-generation.md`](./image-generation.md), or [`deployment.md`](./deployment.md), gets one workflow working.
6. [`configuration.md`](./configuration.md) is the source of truth when you need a specific field, default value, or advanced option.
7. [`troubleshooting.md`](./troubleshooting.md) helps isolate whether a failure is install, config, provider, gateway, channel, or tool related.
+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.
+212
View File
@@ -0,0 +1,212 @@
# Architecture
This page maps nanobot's runtime behavior to source files. Use it when you are debugging internals, reviewing a PR, adding a provider/channel/tool, or trying to understand where a user-visible behavior comes from.
For the product-level mental model, read [`concepts.md`](./concepts.md) first.
## Core Flow
```mermaid
flowchart LR
Channel["Channel<br/>CLI, WebUI, chat apps"] --> Bus["MessageBus<br/>InboundMessage"]
Bus --> Loop["AgentLoop<br/>session, workspace, context"]
Loop --> Runner["AgentRunner<br/>provider/tool loop"]
Runner --> Provider["Provider<br/>LLM backend"]
Provider --> Runner
Runner --> Tools["Tools<br/>files, shell, web, MCP, cron"]
Tools --> Runner
Runner --> Loop
Loop --> Outbound["MessageBus<br/>OutboundMessage"]
Outbound --> Channel
Loop -. reads/writes .-> State["Session, memory,<br/>hooks, skills, templates"]
```
Main files:
| Area | Files |
|---|---|
| Message events and queue | `nanobot/bus/events.py`, `nanobot/bus/queue.py` |
| Turn orchestration | `nanobot/agent/loop.py` |
| Provider/tool conversation loop | `nanobot/agent/runner.py` |
| Context construction | `nanobot/agent/context.py` |
| Session storage and compaction | `nanobot/session/manager.py` |
| Long-term memory and Dream | `nanobot/agent/memory.py` |
## Agent Loop vs Agent Runner
`AgentLoop` owns the channel-facing turn:
- receives inbound messages;
- determines the effective session and workspace scope;
- builds context;
- wires hooks, progress, and channel metadata;
- publishes outbound messages.
`AgentRunner` owns the model-facing loop:
- sends messages to the selected provider;
- handles streaming deltas and reasoning blocks;
- executes tool calls;
- feeds tool results back into the model;
- stops when a final answer is produced or runtime limits are hit.
Keep this split in mind when debugging. If a problem is about channel routing, session keys, workspace selection, or outbound delivery, start in `agent/loop.py`. If it is about provider calls, tool calls, streaming, or iteration limits, start in `agent/runner.py`.
## Providers
Provider metadata is centralized in `nanobot/providers/registry.py`. Configuration fields live in `nanobot/config/schema.py`.
Provider selection uses:
- explicit `agents.defaults.provider` or preset provider;
- provider registry keywords;
- API key prefixes and API base URL hints;
- local provider fallback when `apiBase` is configured;
- gateway fallback for providers that can route many model families.
Provider implementations live in `nanobot/providers/`. Most hosted providers use the OpenAI-compatible implementation, while Anthropic, Azure OpenAI, AWS Bedrock, OpenAI Codex, and GitHub Copilot have specialized paths.
Useful docs:
- [`providers.md`](./providers.md) for practical setup;
- [`configuration.md#providers`](./configuration.md#providers) for exact provider reference.
## Channels
Channels translate external platforms into `InboundMessage` events and send `OutboundMessage` events back to the platform.
Main files:
| Area | Files |
|---|---|
| Base channel contract | `nanobot/channels/base.py` |
| Built-in channels | `nanobot/channels/*.py` |
| Discovery and lifecycle | `nanobot/channels/manager.py` |
| WebSocket/WebUI channel | `nanobot/channels/websocket.py` |
Channels are discovered through built-in module scanning and plugin entry points. A custom channel should follow [`channel-plugin-guide.md`](./channel-plugin-guide.md).
## WebUI and Gateway
`nanobot gateway` starts:
- enabled chat channels;
- the WebSocket channel when configured;
- workspace-scoped cron service;
- system jobs such as Dream and heartbeat;
- the health endpoint on `gateway.port`.
The packaged WebUI is served by the WebSocket channel, not the health endpoint:
| Surface | Default |
|---|---|
| Health endpoint | `http://127.0.0.1:18790/health` |
| WebUI/WebSocket | `http://127.0.0.1:8765` |
WebUI source lives in `webui/`. The production build is written to `nanobot/web/dist/` and bundled into the wheel.
Useful docs:
- [`webui.md`](./webui.md) for the WebUI user guide;
- [`../webui/README.md`](../webui/README.md) for frontend source development;
- [`websocket.md`](./websocket.md) for protocol details.
## Tools
Tools are discovered from `nanobot/agent/tools/` and plugin entry points.
Important files:
| Tool area | Files |
|---|---|
| Tool base and schema | `nanobot/agent/tools/base.py`, `nanobot/agent/tools/schema.py` |
| Discovery | `nanobot/agent/tools/registry.py` |
| Shell execution | `nanobot/agent/tools/shell.py` |
| Filesystem tools | `nanobot/agent/tools/filesystem.py` |
| Web search/fetch | `nanobot/agent/tools/web.py` |
| MCP tools | `nanobot/agent/tools/mcp.py` |
| Cron | `nanobot/agent/tools/cron.py`, `nanobot/cron/` |
| Image generation | `nanobot/agent/tools/image_generation.py` |
| Runtime self-inspection | `nanobot/agent/tools/self.py` |
Tool behavior is part of the model contract. Keep user-visible tool names, schemas, and error messages stable unless a change is intentional.
## Config and Paths
The config schema lives in `nanobot/config/schema.py`. Loading and saving live in `nanobot/config/loader.py`. Runtime path helpers live in `nanobot/config/paths.py`.
Defaults:
| Path | Default |
|---|---|
| Config | `~/.nanobot/config.json` |
| Workspace | `~/.nanobot/workspace/` |
| Sessions | `<workspace>/sessions/*.jsonl` |
| Memory | `<workspace>/memory/` |
| Cron store | `<workspace>/cron/jobs.json` |
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
## Memory and Sessions
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
| Store | File area |
|---|---|
| Session JSONL files | `<workspace>/sessions/` |
| Long-term memory | `<workspace>/memory/MEMORY.md` |
| Consolidation source history | `<workspace>/memory/history.jsonl` |
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
Dream is implemented in `nanobot/agent/memory.py` and scheduled by the runtime when enabled.
## Security Boundaries
Security-sensitive code paths include:
| Boundary | Files |
|---|---|
| Workspace scope | `nanobot/security/workspace_access.py`, `nanobot/security/workspace_policy.py` |
| Shell sandboxing | `nanobot/agent/tools/shell.py` |
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py` |
| PTH guard and CLI startup security | `nanobot/security/` and CLI entrypoints |
| Channel access control | channel config in `nanobot/channels/*.py` |
When changing tools, channels, file access, WebUI workspace behavior, or network fetching, treat security as part of the functional behavior and update docs if the user-facing boundary changes.
## Extension Points
| Extension | How |
|---|---|
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
| Channel | Implement `BaseChannel`, expose an entry point, follow [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
Prefer existing registry/discovery patterns over ad hoc wiring.
## Testing and Verification
Common checks:
```bash
pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/
cd webui && bun run test
cd webui && bun run build
```
Choose tests based on the changed surface:
| Change | Minimum useful verification |
|---|---|
| Provider behavior | Provider unit tests or a mocked API path; `nanobot agent -m "Hello!"` with safe config when possible |
| Channel behavior | Channel tests plus `nanobot gateway` startup path |
| WebUI behavior | WebUI tests/build and, for routing/settings/chat changes, browser-level verification through the gateway |
| Tool behavior | Tool unit tests and an agent-run path when schema or model-facing behavior changes |
| Docs | Link checks, command accuracy against CLI/schema, and `git diff --check` |
For user-facing flows, prefer at least one verification path through the public surface the user actually touches: CLI command, HTTP endpoint, WebSocket/WebUI, chat channel, or packaged import.
+550
View File
@@ -0,0 +1,550 @@
# Channel Plugin Guide
Build a custom nanobot channel in three steps: subclass, package, install.
> **Note:** We recommend developing channel plugins against a source checkout of nanobot (`python -m pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
## How It Works
nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
1. Built-in channels in `nanobot/channels/`
2. External packages registered under the `nanobot.channels` entry point group
If a matching config section has `"enabled": true`, the channel is instantiated and started.
## Quick Start
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
### Project Structure
```text
nanobot-channel-webhook/
├── nanobot_channel_webhook/
│ ├── __init__.py # re-export WebhookChannel
│ └── channel.py # channel implementation
└── pyproject.toml
```
### 1. Create Your Channel
```python
# nanobot_channel_webhook/__init__.py
from nanobot_channel_webhook.channel import WebhookChannel
__all__ = ["WebhookChannel"]
```
```python
# nanobot_channel_webhook/channel.py
import asyncio
from typing import Any
from aiohttp import web
from loguru import logger
from pydantic import Field
from nanobot.channels.base import BaseChannel
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
async def start(self) -> None:
"""Start an HTTP server that listens for incoming messages.
IMPORTANT: start() must block forever (or until stop() is called).
If it returns, the channel is considered dead.
"""
self._running = True
port = self.config.port
app = web.Application()
app.router.add_post("/message", self._on_request)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
logger.info("Webhook listening on :{}", port)
# Block until stopped
while self._running:
await asyncio.sleep(1)
await runner.cleanup()
async def stop(self) -> None:
self._running = False
async def send(self, msg: OutboundMessage) -> None:
"""Deliver an outbound message.
msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — may contain "_progress": True for streaming chunks
"""
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
# In a real plugin: POST to a callback URL, send via SDK, etc.
async def _on_request(self, request: web.Request) -> web.Response:
"""Handle an incoming HTTP POST."""
body = await request.json()
sender = body.get("sender", "unknown")
chat_id = body.get("chat_id", sender)
text = body.get("text", "")
media = body.get("media", []) # list of URLs
# This is the key call: validates allowFrom, then puts the
# message onto the bus for the agent to process.
await self._handle_message(
sender_id=sender,
chat_id=chat_id,
content=text,
media=media,
)
return web.json_response({"ok": True})
```
### 2. Register the Entry Point
```toml
# pyproject.toml
[project]
name = "nanobot-channel-webhook"
version = "0.1.0"
dependencies = ["nanobot-ai", "aiohttp"]
[project.entry-points."nanobot.channels"]
webhook = "nanobot_channel_webhook:WebhookChannel"
[build-system]
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.
### 3. Install & Configure
```bash
python -m pip install -e .
nanobot plugins list # verify "Webhook" shows as "plugin"
nanobot onboard # auto-adds default config for detected plugins
```
Edit `~/.nanobot/config.json`:
```json
{
"channels": {
"webhook": {
"enabled": true,
"port": 9000,
"allowFrom": ["*"]
}
}
}
```
### 4. Run & Test
```bash
nanobot gateway
```
In another terminal:
```bash
curl -X POST http://localhost:9000/message \
-H "Content-Type: application/json" \
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
```
The agent receives the message and processes it. Replies arrive in your `send()` method.
## BaseChannel API
### Required (abstract)
| Method | Description |
|--------|-------------|
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
### Interactive Login
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
```python
async def login(self, force: bool = False) -> bool:
"""
Perform channel-specific interactive login.
Args:
force: If True, ignore existing credentials and re-authenticate.
Returns True if already authenticated or login succeeds.
"""
# For QR-code-based login:
# 1. If force, clear saved credentials
# 2. Check if already authenticated (load from disk/state)
# 3. If not, show QR code and poll for confirmation
# 4. Save token on success
```
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
Users trigger interactive login via:
```bash
nanobot channels login <channel_name>
nanobot channels login <channel_name> --force # re-authenticate
```
### Provided by Base
| Method / Property | Description |
|-------------------|-------------|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. |
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
| `send_reasoning_delta(chat_id, delta, metadata?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional (streaming)
| Method | Description |
|--------|-------------|
| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
### Message Types
```python
@dataclass
class OutboundMessage:
channel: str # your channel name
chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
# "message_id" for reply threading
```
## Streaming Support
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
### How It Works
When **both** conditions are met, the agent streams content through your channel:
1. Config has `"streaming": true`
2. Your subclass overrides `send_delta()`
If either is missing, the agent falls back to the normal one-shot `send()` path.
### Implementing `send_delta`
Override `send_delta` to handle two types of calls:
```python
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
# Streaming finished — do final formatting, cleanup, etc.
return
# Regular delta — append text, update the message on screen
# delta contains a small chunk of text (a few tokens)
```
**Metadata flags:**
| Flag | Meaning |
|------|---------|
| `_stream_delta: True` | A content chunk (delta contains the new text) |
| `_stream_end: True` | Streaming finished (delta is empty) |
### Example: Webhook with Streaming
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._buffers: dict[str, str] = {}
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
text = self._buffers.pop(chat_id, "")
# Final delivery — format and send the complete message
await self._deliver(chat_id, text, final=True)
return
self._buffers.setdefault(chat_id, "")
self._buffers[chat_id] += delta
# Incremental update — push partial text to the client
await self._deliver(chat_id, self._buffers[chat_id], final=False)
async def send(self, msg: OutboundMessage) -> None:
# Non-streaming path — unchanged
await self._deliver(msg.chat_id, msg.content, final=True)
```
### Config
Enable streaming per channel:
```json
{
"channels": {
"webhook": {
"enabled": true,
"streaming": true,
"allowFrom": ["*"]
}
}
}
```
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
### BaseChannel Streaming API
| Method / Property | Description |
|-------------------|-------------|
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
## Progress, Tool Hints, and Reasoning
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
### Progress and Tool Hints
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering:
```python
async def send(self, msg: OutboundMessage) -> None:
meta = msg.metadata or {}
if meta.get("_tool_hint"):
# A short tool breadcrumb, e.g. read_file("config.json")
await self._send_trace(msg.chat_id, msg.content, kind="tool")
return
if meta.get("_progress"):
# Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress")
return
await self._send_message(msg.chat_id, msg.content, media=msg.media)
```
Tool hints are off by default for most channels. Users can enable them globally or per channel:
```json
{
"channels": {
"sendToolHints": true,
"webhook": {
"enabled": true,
"sendToolHints": true
}
}
}
```
### Reasoning Blocks
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._reasoning_buffers: dict[str, str] = {}
async def send_reasoning_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
) -> None:
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
) -> None:
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
text = self._reasoning_buffers.pop(stream_id, "")
if text:
await self._update_reasoning_block(chat_id, text, final=True)
```
**Reasoning metadata flags:**
| Flag | Meaning |
|------|---------|
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. |
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
Reasoning visibility is controlled by `showReasoning` globally or per channel:
```json
{
"channels": {
"showReasoning": true,
"webhook": {
"enabled": true,
"showReasoning": true
}
}
}
```
Recommended rendering:
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
## Config
### Why Pydantic model is required
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`**`dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
### Pattern
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
```python
from pydantic import Field
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
```
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
2. Convert `dict` → model in `__init__`:
```python
from typing import Any
from nanobot.bus.queue import MessageBus
class WebhookChannel(BaseChannel):
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
```
3. Access config as attributes (not `.get()`):
```python
async def start(self) -> None:
port = self.config.port
token = self.config.token
```
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
```python
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
```
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
If not overridden, the base class returns `{"enabled": false}`.
## Naming Convention
| What | Format | Example |
|------|--------|---------|
| PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
| Entry point key | `{name}` | `webhook` |
| Config section | `channels.{name}` | `channels.webhook` |
| Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
## Local Development
```bash
git clone https://github.com/you/nanobot-channel-webhook
cd nanobot-channel-webhook
python -m pip install -e .
nanobot plugins list # should show "Webhook" as "plugin"
nanobot gateway # test end-to-end
```
## Verify
```bash
$ nanobot plugins list
Name Source Enabled
telegram builtin yes
discord builtin no
webhook plugin yes
```
+905
View File
@@ -0,0 +1,905 @@
# Chat Apps
Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./channel-plugin-guide.md).
Before configuring a chat app, make sure the local CLI path works:
```bash
nanobot agent -m "Hello!"
```
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
Most examples below are snippets to merge into `~/.nanobot/config.json`.
## Common Setup Pattern
Every chat app uses the same shape:
1. Create or prepare the bot/account in the chat platform.
2. Copy the token, secret, QR login state, webhook URL, or account ID that platform gives you.
3. Merge that platform's JSON snippet into `~/.nanobot/config.json`.
4. Keep access control narrow at first with `allowFrom` or the platform-specific allow list.
5. Check that nanobot can see the configured channel:
```bash
nanobot channels status
```
6. Start the gateway and leave that terminal running:
```bash
nanobot gateway
```
7. Send a message from the allowed account. In group chats, follow that channel's `groupPolicy` behavior: many channels default to mention-only, while Matrix and WhatsApp default to open group replies.
If `nanobot channels status` does not show the channel as enabled, the config snippet is in the wrong place, the channel name is misspelled, or the config file you edited is not the one nanobot is reading. If the channel is enabled but messages do not arrive, run `nanobot gateway --verbose` and compare the platform-side credentials, event permissions, and allow lists.
> `["*"]` allows anyone who can reach that channel to talk to the bot. Use it only when that is intentional, or temporarily while testing in a private sandbox.
| 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** | QR code scan (`nanobot channels login feishu`) or 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 |
| **Napcat (QQ)** | Napcat Forward WebSocket URL + access token |
| **Wecom** | Bot ID + Bot Secret |
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
| **Mochat** | Claw token (auto-setup available) |
| **Signal** | signal-cli daemon + phone number |
<details>
<summary><b>Telegram</b></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.
>
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
**3. Run**
```bash
nanobot gateway
```
**Webhook mode (optional)**
Telegram uses long polling by default. To receive updates through a webhook, expose a public HTTPS URL that forwards to nanobot's local listener and set `mode` to `webhook`:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"mode": "webhook",
"webhookUrl": "https://example.com/telegram",
"webhookListenHost": "127.0.0.1",
"webhookListenPort": 8081,
"webhookPath": "/telegram",
"webhookSecretToken": "CHANGE_ME_RANDOM_SECRET",
"webhookMaxConnections": 4,
"allowFrom": ["YOUR_USER_ID"]
}
}
}
```
> `webhookSecretToken` is required in webhook mode. Do not expose the local webhook listener directly to the public internet without a reverse proxy or tunnel in front of it. TLS/Host policy is handled by your proxy; nanobot only listens on `webhookListenHost:webhookListenPort` and validates Telegram's webhook secret token. `webhookMaxConnections` defaults to `4`; nanobot still serializes Telegram updates per conversation before forwarding them to the agent.
>
> `webhookUrl` is the public HTTPS URL registered with Telegram. `webhookPath` is the local path nanobot listens on. They often use the same path, but may differ when a reverse proxy or tunnel rewrites the request path.
</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. Discord threads under an allowed parent channel are also allowed; for Forum channels, allowing the parent Forum channel allows all threads/posts in that forum.
> `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
python -m 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,
"sasVerification": 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. |
| `sasVerification` | Auto-complete SAS device verification requests from allowed users (default `false`). Useful for Element X, which does not expose manual trust for third-party devices. |
| `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 the WhatsApp optional dependencies:
```bash
pip install "nanobot-ai[whatsapp]"
# Source checkout:
python -m pip install -e ".[whatsapp]"
```
**1. Link device with QR**
```bash
nanobot channels login whatsapp
# Scan QR with WhatsApp → Settings → Linked Devices
```
**2. Configure**
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"allowFrom": ["1234567890"]
}
}
}
```
Optional session database path:
```json
{
"channels": {
"whatsapp": {
"databasePath": "~/.nanobot/whatsapp-auth/neonize.db"
}
}
}
```
**Migrating from the old bridge**
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.
- Re-run `nanobot channels login whatsapp`; old Baileys bridge auth data is not reused by neonize.
- Update `allowFrom` entries to the WhatsApp sender ID without a leading `+`.
**3. Run**
```bash
nanobot gateway
```
**Optional: static LID mappings**
Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot
learns LID to phone mappings at runtime when both identifiers are present, but you
can also seed mappings up front so the phone number resolves from the
very first message:
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"allowFrom": ["1234567890"],
"lidMappings": { "123456789012345": "1234567890" }
}
}
}
```
</details>
<details>
<summary><b>Feishu</b></summary>
Uses **WebSocket** long connection — no public IP required.
**Quick setup: QR login**
```bash
nanobot channels login feishu
# Use --force to create/sign in with a new bot
```
Open the printed URL or scan the QR code with Feishu/Lark on your phone. If the optional `qrcode` package is installed, nanobot shows a terminal QR code; otherwise it prints the login URL. nanobot writes `appId`, `appSecret`, `domain`, and `enabled` under `channels.feishu` in the active config file. Use `--config <path>` to update a non-default config.
If QR login is unavailable for your account, use manual setup below.
**Manual setup**
**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>Napcat (QQ via OneBot v11 支持群聊等功能)</b></summary>
Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **forward WebSocket** (OneBot v11). Use this when you have your own QQ account running through Napcat and want full private + group chat support.
**1. Set up Napcat**
- Install and log into Napcat, then enable a **Forward WebSocket** server. See the [official Napcat Docker tutorial](https://github.com/NapNeko/NapCat-Docker).
- In the webui, follow "网络配置" -> "新建" -> "Websocket 服务器" to create a forward websocket server. By default, the URL is `ws://127.0.0.1:3001`
- Copy the forward websocket server's token
- (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts
**2. Configure**
```json
{
"channels": {
"napcat": {
"enabled": true,
"wsUrl": "ws://127.0.0.1:3001",
"accessToken": "YOUR_WEBSOCKET_TOKEN",
"allowFrom": ["*"],
"groupPolicy": "mention",
"groupPolicyOverrides": {
"123456789": "open",
"987654321": 0.2
},
"welcomeNewMembers": true
}
}
}
```
| Option | What it does |
|--------|--------------|
| `wsUrl` | Napcat forward-WebSocket endpoint. Bearer auth via `accessToken` is sent in the `Authorization` header. |
| `allowFrom` | QQ numbers permitted to talk to the bot. `["*"]` = anyone. Required `["*"]` (or include the joining user) for `welcomeNewMembers` to fire. |
| `groupPolicy` | `"mention"` (default) — reply only when @-mentioned or replying to the bot's own message. `"open"` — reply to every group message. A float `p` in `[0.0, 1.0]`@mentions and replies-to-bot always reply; every other group message replies with probability `p` (so `0.0``"mention"`, `1.0``"open"`). Private chats always reply. |
| `groupPolicyOverrides` | Optional per-group overrides for `groupPolicy`, keyed by group id (as a string). Each value takes the same shape as `groupPolicy` (`"mention"`, `"open"`, or a float). Groups not listed fall back to `groupPolicy`. |
| `welcomeNewMembers` | When true, `notice.group_increase` events are pushed to the bus as a synthetic message so the agent can greet new joiners. |
| `maxImageBytes` | Hard cap (in bytes) for inbound image downloads. Defaults to 20 MB. Larger images are dropped with a warning. |
</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"],
"groupUserIsolation": false
}
}
}
```
> `allowFrom`: Add your staff ID. Use `["*"]` to allow all users.
>
> `groupUserIsolation`: Optional. Defaults to `false`, which keeps one shared session per group chat. Set it to `true` to give each sender in a DingTalk group chat a separate session while replies still go back to the same group.
**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`, `files:read`, `files:write`, `channels:history`, `groups:history`, `im:history`, `mpim:history`
- **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-...`)
> `files:read` is required to read files users send to nanobot. `files:write` is required for nanobot to send images, videos, and other file uploads. If you add either scope later, reinstall the Slack app to the workspace and restart nanobot so it uses the updated bot token.
**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 via `groupAllowFrom`).
> - `groupAllowFrom`: channel IDs the bot may respond in when `groupPolicy` is `"allowlist"`.
> - `groupRequireMention`: when `true` and `groupPolicy` is `"allowlist"`, the bot only replies to channels in `groupAllowFrom` **and** only when @mentioned (instead of every message). No effect for `"mention"`/`"open"`. Use this to scope the bot to approved channels while keeping mention-only behavior.
> - 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.
> - `postAction`: Optional post-processing for processed emails: `"delete"` or `"move"` (default `null`).
> This runs only after an accepted email is successfully delivered to the AI pipeline.
> - `postActionMoveMailbox`: Destination mailbox used when `postAction` is `"move"` (for example `"Processed"` or `"[Gmail]/Trash"`).
> - `postActionIgnoreSkipped`: If `true` (default), skipped emails are ignored for post-action and not moved/deleted.
> - `postActionExpunge`: When `true`, the channel allows a full-mailbox `EXPUNGE` fallback if UID-scoped expunge is unavailable or fails (default `false`). Enable only on very old IMAP servers that lack modern UIDPLUS support. Note that this fallback will expunge **all** messages marked as deleted in the mailbox, including ones not handled by the agent. Leaving this off is safe for all modern IMAP servers.
> - `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"],
"postAction": "move",
"postActionMoveMailbox": "[Gmail]/Trash",
"postActionIgnoreSkipped": true,
"postActionExpunge": false,
"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
python -m 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
python -m 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
python -m 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,
"refTtlDays": 30,
"pruneWebChatRefs": true,
"pruneNonPersonalRefs": true,
"refTouchIntervalS": 300
}
}
}
```
> - `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.
> - `refTtlDays` (default `30`) controls how old stored conversation refs can be before they are pruned.
> - `pruneWebChatRefs` (default `true`) drops refs with `webchat.botframework.com` service URLs.
> - `pruneNonPersonalRefs` (default `true`) drops refs whose `conversation_type` is not `personal`.
> - `refTouchIntervalS` (default `300`) throttles how often successful sends refresh `updated_at` for active refs.
**4. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>Signal</b></summary>
Uses **signal-cli** daemon in HTTP mode — receive messages via SSE, send via JSON-RPC.
**1. Install signal-cli**
Install [signal-cli](https://github.com/AsamK/signal-cli) and register a phone number:
```bash
signal-cli -u +1234567890 register
signal-cli -u +1234567890 verify <CODE>
```
Start the daemon:
```bash
signal-cli -a +1234567890 daemon --http localhost:8080
```
**2. Configure**
```json
{
"channels": {
"signal": {
"enabled": true,
"phoneNumber": "+1234567890",
"daemonHost": "localhost",
"daemonPort": 8080,
"dm": {
"enabled": true,
"policy": "open"
},
"group": {
"enabled": true,
"policy": "open",
"requireMention": true
}
}
}
}
```
> - `phoneNumber`: Your registered Signal phone number.
> - `daemonHost` / `daemonPort`: Where signal-cli daemon is listening (default `localhost:8080`).
> - `dm.policy`: `"open"` (anyone can DM) or `"allowlist"` (only listed numbers/UUIDs). When `"allowlist"`, unlisted DM senders receive a pairing code.
> - `dm.allowFrom`: List of allowed phone numbers or UUIDs (used when policy is `"allowlist"`).
> - `group.policy`: `"open"` (all groups) or `"allowlist"` (only listed group IDs).
> - `group.requireMention`: When `true` (default), the bot only responds in groups when @mentioned.
> - `group.allowFrom`: List of allowed group IDs (used when group policy is `"allowlist"`).
> - `attachmentsDir`: Override the directory where signal-cli stores inbound attachments. Defaults to `~/.local/share/signal-cli/attachments` (the Linux default). Set this if signal-cli runs with a custom `XDG_DATA_HOME` or on macOS/Windows.
> - `groupMessageBufferSize`: Number of recent group messages kept for context (default `20`, must be > 0).
**3. Run**
```bash
nanobot gateway
```
> [!TIP]
> The channel automatically reconnects to the signal-cli daemon with exponential backoff if the connection drops.
> Markdown in bot replies is automatically converted to Signal text styles (bold, italic, code, etc.).
</details>
+90
View File
@@ -0,0 +1,90 @@
# 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 |
| `/model` | Show the current model and available model presets |
| `/model <preset>` | Switch the runtime model preset for future turns |
| `/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 |
| `/skill` | List enabled skills and their descriptions |
| `/pairing` | List pending pairing requests |
| `/pairing approve <code>` | Approve a pairing code |
| `/pairing deny <code>` | Deny a pending pairing request |
| `/pairing revoke <user_id>` | Revoke a previously approved user on the current channel |
| `/pairing revoke <channel> <user_id>` | Revoke a previously approved user on a specific channel |
| `/help` | Show available in-chat commands |
## Pairing
When someone sends a DM to the bot and isn't on the allowlist — whether it's a new user or an existing user on a new channel — nanobot automatically replies with a **pairing code** (like `ABCD-EFGH`) that expires in 10 minutes. To grant them access:
```text
/pairing approve ABCD-EFGH
```
To see who's waiting, use `/pairing`. To remove someone later, use `/pairing revoke <user_id>` — you can find user IDs in the `/pairing list` output.
See [Configuration: Pairing](./configuration.md#pairing) for the full setup guide.
## Model Presets
Use `/model` to inspect the current runtime model:
```text
/model
```
The response shows the current model, the current preset, and the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
To switch presets for future turns:
```text
/model fast
/model deep
/model default
```
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
## Periodic Tasks
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
Use heartbeat for recurring checks that should usually stay quiet. User-created cron jobs are different: they run as scheduled turns in the chat/session where they were created and normally deliver the result back to that channel.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown
## Active Tasks
- Check weather forecast and notify me only if storms are expected
- Scan inbox for urgent emails and notify me if any are found
```
The agent can also manage this file itself - ask it to "add a periodic background check" or "check this periodically but only notify me if something changes" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
You can change the interval or disable the built-in heartbeat in `~/.nanobot/config.json`:
```json
{
"gateway": {
"heartbeat": {
"enabled": true,
"intervalS": 1800
}
}
}
```
The heartbeat job is visible in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. To stop it, set `gateway.heartbeat.enabled` to `false` and restart the gateway.
> **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.
+192
View File
@@ -0,0 +1,192 @@
# CLI Reference
Use this page when you know what you want to run and need the command shape. For a guided first run, start with [`quick-start.md`](./quick-start.md).
## Choose a Command
| Goal | Command | Notes |
|---|---|---|
| Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` |
| Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` |
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider |
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
## Global
```bash
nanobot --help
nanobot --version
python -m nanobot --help
python -m nanobot --version
```
`python -m nanobot ...` is useful when the package is installed but the `nanobot` script is not on `PATH`.
## Common Patterns
Most day-to-day commands use the default config and workspace. Advanced or multi-instance runs usually pass both paths explicitly:
```bash
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot serve --config ./bot-a/config.json --workspace ./bot-a/workspace
```
Use `--verbose` on long-running processes when you need startup or runtime logs:
```bash
nanobot gateway --verbose
nanobot serve --verbose
```
Long-running commands keep working until you stop them. Press `Ctrl+C` in that terminal
to stop foreground `nanobot gateway` or `nanobot serve`. If you started the gateway
with `--background`, use `nanobot gateway stop`.
## Setup
| Command | Description |
|---|---|
| `nanobot onboard` | Initialize or refresh the default config and workspace |
| `nanobot onboard --wizard` | Use the interactive setup wizard |
| `nanobot onboard --config <path> --workspace <path>` | Initialize or refresh a specific instance |
Default paths:
| Path | Default |
|---|---|
| Config | `~/.nanobot/config.json` |
| Workspace | `~/.nanobot/workspace/` |
## Agent CLI
| Command | Description |
|---|---|
| `nanobot agent -m "Hello!"` | Send one message and exit |
| `nanobot agent` | Start interactive terminal chat |
| `nanobot agent --session <id>` | Use a specific session key |
| `nanobot agent --workspace <path>` | Override workspace |
| `nanobot agent --config <path>` | Use a specific config file |
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
| `nanobot agent --logs` | Show runtime logs while chatting |
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
## Gateway
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
| Command | Description |
|---|---|
| `nanobot gateway` | Start the gateway in the foreground with config defaults |
| `nanobot gateway --verbose` | Show verbose runtime output |
| `nanobot gateway --port <port>` | Override `gateway.port` for the health endpoint |
| `nanobot gateway --workspace <path>` | Override workspace |
| `nanobot gateway --config <path>` | Use a specific config file |
| `nanobot gateway --background` | Start the gateway as a background process |
| `nanobot gateway status` | Show the recorded background gateway PID, state file, and log file |
| `nanobot gateway logs --no-follow` | Print recent background gateway logs and exit |
| `nanobot gateway logs` | Follow background gateway logs |
| `nanobot gateway restart` | Restart the recorded background gateway with the current config |
| `nanobot gateway stop` | Stop the recorded background gateway |
| `nanobot gateway install-service` | Install a systemd user service or macOS LaunchAgent |
| `nanobot gateway install-service --dry-run` | Preview the generated service file and system commands |
| `nanobot gateway uninstall-service` | Remove the installed system service |
For custom instances, pass the same selector flags to management commands:
```bash
nanobot gateway --background --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot gateway status --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot gateway stop --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot gateway install-service --config ./bot-a/config.json --workspace ./bot-a/workspace --name bot-a
```
`--background` is a lightweight detached process. `install-service` is for
login/startup integration: Linux uses a systemd user service; macOS uses a
LaunchAgent plist. System services run the foreground gateway under the OS
supervisor rather than nesting another background process.
Default health endpoint:
```text
http://127.0.0.1:18790/health
```
The bundled WebUI is served by the WebSocket channel, usually on port `8765`, not by the gateway health endpoint.
## OpenAI-Compatible API
| Command | Description |
|---|---|
| `nanobot serve` | Start `/v1/chat/completions`, `/v1/models`, and `/health` |
| `nanobot serve --host <host>` | Override API bind host |
| `nanobot serve --port <port>` | Override API port |
| `nanobot serve --timeout <seconds>` | Override per-request timeout |
| `nanobot serve --verbose` | Show runtime logs |
| `nanobot serve --workspace <path>` | Override workspace |
| `nanobot serve --config <path>` | Use a specific config file |
Default API endpoint:
```text
http://127.0.0.1:8900
```
See [`openai-api.md`](./openai-api.md) for request examples.
## Status
```bash
nanobot status
```
Shows the default config path, workspace path, active model, and provider summary. This command does not currently accept `--config`; use explicit `--config` and `--workspace` on `agent`, `gateway`, or `serve` when debugging a specific instance.
## Channels
| Command | Description |
|---|---|
| `nanobot channels status` | Show configured channel status |
| `nanobot channels status --config <path>` | Show channel status for a specific config |
| `nanobot channels login <channel>` | Run interactive login for supported channels |
| `nanobot channels login <channel> --force` | Re-authenticate even if credentials already exist |
| `nanobot channels login <channel> --config <path>` | Use a specific config file |
Examples:
```bash
nanobot channels login whatsapp
nanobot channels login weixin
nanobot channels status
```
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
## Provider OAuth
| Command | Description |
|---|---|
| `nanobot provider login openai-codex` | Authenticate OpenAI Codex provider |
| `nanobot provider login github-copilot` | Authenticate GitHub Copilot provider |
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection.
## Useful First Checks
```bash
nanobot --version
nanobot status
nanobot agent -m "Hello!"
```
If these fail, use [`troubleshooting.md`](./troubleshooting.md) before debugging WebUI, chat apps, Docker, systemd, or SDK integrations.
+151
View File
@@ -0,0 +1,151 @@
# Concepts
Use this page when you want to understand nanobot before changing advanced settings. It explains the moving parts without requiring you to read the source first.
If you want source-file ownership and extension points, read [`architecture.md`](./architecture.md) after this page.
## Runtime Shape
nanobot has one small core loop and several ways to enter it:
| Part | What it does |
|---|---|
| Agent loop | Builds context, selects the session, calls the provider, runs tools, and publishes replies |
| Providers | LLM backends such as OpenRouter, Anthropic, OpenAI, Bedrock, Ollama, vLLM, and other OpenAI-compatible APIs |
| Channels | User-facing transports such as CLI, WebUI/WebSocket, Telegram, Discord, Slack, Feishu, WeChat, Email, and others |
| Tools | Capabilities the model may call, including files, shell, web search/fetch, MCP, cron, image generation, and subagents |
| Memory | Workspace files and session history that keep useful context across turns |
| Gateway | Long-running process that connects enabled channels and serves the health endpoint |
The simplest path is `nanobot agent -m "Hello!"`: one inbound message goes through the agent loop and prints the reply in your terminal. The long-running path is `nanobot gateway`: channels receive messages from chat apps or the WebUI, publish them to the same agent loop, and send replies back to the originating channel.
## Config vs Workspace
The default instance lives under `~/.nanobot/`:
| Path | Meaning |
|---|---|
| `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options |
| `~/.nanobot/workspace/` | Agent workspace: memory, sessions, heartbeat tasks, cron jobs, skills, and generated artifacts |
You can override both with command flags:
```bash
nanobot onboard --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
```
The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance.
## Config Format
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
Most examples are partial snippets. Merge them into the existing file created by `nanobot onboard`; do not replace the whole file unless you want to reset the instance.
## One Agent Turn
A normal turn follows this flow:
1. A channel receives a user message and publishes it to the message bus.
2. The agent loop chooses a session key and builds context from the workspace, skills, memory, recent messages, channel metadata, and runtime settings.
3. The provider receives the model request.
4. If the model asks for tools, the runner executes them and feeds results back to the model.
5. The final reply is saved to the session and sent back through the channel.
That flow is the same whether the message starts in the CLI, WebUI, Telegram, Discord, or another channel.
## CLI, Gateway, API, and WebUI
| Entry point | Command | Use it for |
|---|---|---|
| CLI one-shot | `nanobot agent -m "..."` | First-run checks, scripts, and quick local questions |
| CLI interactive | `nanobot agent` | Terminal chat with persistent session history |
| Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode |
| OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` |
| WebUI | `nanobot gateway` plus WebSocket channel | Browser workbench served by the WebSocket channel on port `8765` |
The gateway health endpoint is on `gateway.port` (`18790` by default). The browser WebUI is served by the WebSocket channel (`8765` by default), not by the health endpoint.
## Provider and Model Selection
The active model should normally come from a named `modelPresets` entry selected by `agents.defaults.modelPreset`. Direct `agents.defaults.provider` and `agents.defaults.model` still form the implicit `default` preset for older or minimal configs. The active provider is resolved in this order:
1. If the active preset provider or implicit default provider is not `"auto"`, nanobot uses that provider.
2. If provider is `"auto"`, nanobot tries to infer the provider from the model name, configured API keys, local provider base URLs, or gateway providers.
3. OAuth providers such as OpenAI Codex and GitHub Copilot require explicit login and explicit provider/model selection inside the active preset.
Pin the provider inside the preset when setting up for the first time. It is easier to debug:
```json
{
"modelPresets": {
"primary": {
"provider": "openrouter",
"model": "anthropic/claude-opus-4.5"
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
See [`providers.md`](./providers.md) for practical examples and [`configuration.md#providers`](./configuration.md#providers) for the full provider reference.
## Channels and Sessions
Each channel maps inbound messages to a session key. That lets independent conversations keep separate history. The WebUI also supports multiple chats and workspace-scoped metadata for project workspaces.
`agents.defaults.unifiedSession` can intentionally share one session across channels for a single-user multi-device setup. Leave it off if you expect separate people, groups, channels, or projects to keep separate context.
## Memory, Sessions, and Dream
nanobot uses two related stores:
| Store | Location | Purpose |
|---|---|---|
| Sessions | `<workspace>/sessions/*.jsonl` | Recent conversation turns replayed into context |
| Memory | `<workspace>/memory/MEMORY.md` and `<workspace>/memory/history.jsonl` | Long-term facts and consolidated history |
Dream is a periodic consolidation job. It reads accumulated history and updates workspace memory so useful context can survive beyond short session replay.
See [`memory.md`](./memory.md) for the detailed design.
## Tools and Safety
Tools are discovered automatically from built-in modules and plugin entry points. Common tool groups include:
- file read/write/edit and patching;
- shell execution with configurable sandboxing;
- web search and web fetch with SSRF checks;
- MCP servers;
- cron reminders and heartbeat tasks;
- image generation;
- subagents and runtime self-inspection.
Security-sensitive controls live in [`configuration.md#security`](./configuration.md#security). For production or shared chat apps, also configure channel access controls such as `allowFrom`, pairing, or WebSocket tokens.
## Background Jobs
When `nanobot gateway` starts, it creates workspace-scoped cron storage at `<workspace>/cron/jobs.json` and registers system jobs:
- `dream`, when `agents.defaults.dream.enabled` is true;
- `heartbeat`, when `gateway.heartbeat.enabled` is true.
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
User-created reminders use the same cron service but are not the same as the protected heartbeat system job. They run as scheduled turns in their origin chat/session and normally deliver the result back to that channel.
## Where to Go Next
| Need | Read |
|---|---|
| First working install | [`quick-start.md`](./quick-start.md) |
| Provider/model setup | [`providers.md`](./providers.md) |
| Chat app setup | [`chat-apps.md`](./chat-apps.md) |
| Complete config reference | [`configuration.md`](./configuration.md) |
| Runtime debugging | [`troubleshooting.md`](./troubleshooting.md) |
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
# Deployment
Use this page after `nanobot agent -m "Hello!"` works locally. Deployment keeps long-running surfaces online: WebUI, chat apps, heartbeat, Dream, cron jobs, and channel connections.
## Before You Deploy
Check these once before Docker, systemd, or LaunchAgent:
| Check | Why it matters |
|---|---|
| `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run |
| `nanobot agent -m "Hello!"` works | Proves install, config, provider, model, and workspace writes before adding a service layer |
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
| Ports are planned | Gateway health defaults to `18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
Restart the deployed process after editing `config.json`. Long-running processes read config at startup.
## Choose a Runtime
| Runtime | Use it for | State location | Useful first command |
|---|---|---|---|
| Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` |
| Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` |
| systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` |
| macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` |
## 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 the non-root user `nanobot` (UID 1000) and reads config from `/home/nanobot/.nanobot`. Always mount your host config directory to `/home/nanobot/.nanobot`, not `/root/.nanobot`.
> 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.
>
> [!IMPORTANT]
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
> [!IMPORTANT]
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret:
>
> ```json
> {
> "gateway": { "host": "0.0.0.0" },
> "channels": {
> "websocket": {
> "enabled": true,
> "host": "0.0.0.0",
> "port": 8765,
> "tokenIssueSecret": "your-secret-here"
> }
> }
> }
> ```
>
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
### 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).
# Mirrors the security caps and port mappings declared in docker-compose.yml:
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
# endpoint on 18790.
docker run \
--cap-drop ALL --cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \
-v ~/.nanobot:/home/nanobot/.nanobot \
-p 18790:18790 -p 8765:8765 \
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.
Preview the generated unit first:
```bash
nanobot gateway install-service --manager systemd --dry-run
```
Install, enable, and start it:
```bash
nanobot gateway install-service --manager systemd
```
For a custom instance, pass the same config/workspace selector you use to run the gateway:
```bash
nanobot gateway install-service \
--manager systemd \
--name nanobot-telegram \
--config ~/.nanobot-telegram/config.json \
--workspace ~/.nanobot-telegram/workspace
```
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
nanobot gateway uninstall-service --manager systemd
```
The installer writes `~/.config/systemd/user/nanobot-gateway.service`, runs
`systemctl --user daemon-reload`, enables the unit, and restarts it. It uses the
current Python executable with `python -m nanobot gateway --foreground`, so the
service runs in the same environment you used to install nanobot.
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
>
> ```bash
> loginctl enable-linger $USER
> ```
## macOS LaunchAgent
Use a LaunchAgent when you want `nanobot gateway` to stay online after you log in, without keeping a terminal open.
Preview the generated plist first:
```bash
nanobot gateway install-service --manager launchd --dry-run
```
Install, load, enable, and start it:
```bash
nanobot gateway install-service --manager launchd
```
For a custom instance:
```bash
nanobot gateway install-service \
--manager launchd \
--name nanobot-telegram \
--config ~/.nanobot-telegram/config.json \
--workspace ~/.nanobot-telegram/workspace
```
Common operations:
```bash
launchctl list | grep ai.nanobot.gateway
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway
nanobot gateway uninstall-service --manager launchd
```
The installer writes `~/Library/LaunchAgents/ai.nanobot.gateway.plist`, uses the
current Python executable with `python -m nanobot gateway --foreground`, and
writes LaunchAgent logs under `~/.nanobot/logs/`.
> **Note:** if startup fails with "address already in use", stop the manually started `nanobot gateway` process first.
+121
View File
@@ -0,0 +1,121 @@
# Development
This page collects contributor-facing notes for extending nanobot. User-facing setup and runtime options live in [`configuration.md`](./configuration.md).
## Adding an LLM Provider
nanobot uses the provider registry in `nanobot/providers/registry.py` as the source of truth for LLM provider metadata. Most OpenAI-compatible providers need only two changes.
1. Add a `ProviderSpec` entry to `PROVIDERS`:
```python
ProviderSpec(
name="myprovider",
keywords=("myprovider", "mymodel"),
env_key="MYPROVIDER_API_KEY",
display_name="My Provider",
default_api_base="https://api.myprovider.com/v1",
)
```
2. Add a field to `ProvidersConfig` in `nanobot/config/schema.py`:
```python
class ProvidersConfig(BaseModel):
...
myprovider: ProviderConfig = Field(default_factory=ProviderConfig)
```
Environment variables, config matching, provider status, and WebUI credential display derive from those two entries.
Useful `ProviderSpec` options:
| Field | Description |
|---|---|
| `default_api_base` | Default OpenAI-compatible base URL. |
| `env_extras` | Additional environment variables derived from the provider config. |
| `model_overrides` | Per-model request parameter overrides. |
| `is_gateway` | Provider can route many model families, like OpenRouter. |
| `detect_by_key_prefix` | Match configured gateways by API-key prefix. |
| `detect_by_base_keyword` | Match configured gateways by API base URL. |
| `strip_model_prefix` | Strip `provider/` before sending the model to the upstream API. |
| `supports_max_completion_tokens` | Use `max_completion_tokens` instead of `max_tokens`. |
| `is_transcription_only` | Provider has credentials but cannot serve chat completions. |
## Adding a Transcription Provider
Transcription is intentionally split into two layers:
- `nanobot/audio/transcription_registry.py` owns provider names, aliases, default models, and adapter loading.
- `nanobot/providers/transcription.py` owns provider-specific HTTP behavior.
Credentials still live under `providers.<provider>` so chat channels and WebUI resolve API keys and API bases the same way.
1. Add provider credentials to `ProvidersConfig`.
```python
class ProvidersConfig(BaseModel):
...
my_stt: ProviderConfig = Field(default_factory=ProviderConfig)
```
2. Add a `ProviderSpec` in `nanobot/providers/registry.py`.
For transcription-only providers, set `is_transcription_only=True` so they show up in credential/settings surfaces but stay out of chat model selection.
```python
ProviderSpec(
name="my_stt",
keywords=("my_stt",),
env_key="MY_STT_API_KEY",
display_name="My STT",
default_api_base="https://api.example.com/v1",
is_transcription_only=True,
)
```
3. Add an adapter class in `nanobot/providers/transcription.py`.
Adapters receive resolved credentials and settings. They return an empty string for provider errors so channel voice messages fail quietly instead of crashing the agent loop.
```python
class MySTTTranscriptionProvider:
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
language: str | None = None,
model: str | None = None,
):
self.api_key = api_key or os.environ.get("MY_STT_API_KEY")
self.api_base = api_base or "https://api.example.com/v1"
self.language = language or None
self.model = model or "my-default-stt-model"
async def transcribe(self, file_path: str | Path) -> str:
...
```
4. Register the adapter in `nanobot/audio/transcription_registry.py`.
```python
TranscriptionProviderSpec(
name="my_stt",
default_model="my-default-stt-model",
adapter="nanobot.providers.transcription:MySTTTranscriptionProvider",
aliases=("mystt",),
)
```
5. Add tests.
At minimum, cover:
- config resolution in `tests/providers/test_transcription.py`
- adapter request/response behavior and retry/error handling
- WebUI settings payload/update behavior in `tests/webui/test_settings_api.py`
- provider brand mapping if the provider appears in Settings
6. Update user-facing docs.
Add the provider to [`configuration.md`](./configuration.md) where users choose `transcription.provider`, but keep implementation details in this development guide.
+372
View File
@@ -0,0 +1,372 @@
# Image Generation
nanobot can generate and edit images through the `generate_image` tool. In the WebUI, users can enable **Image Generation** from the composer, choose an aspect ratio, and keep iterating on generated images inside the same chat.
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
## Quick Setup
This snippet uses the current built-in image-generation default so the JSON has concrete names. It is not a provider recommendation; replace `provider` and `model` with any supported image provider and model you intend to use.
```json
{
"providers": {
"openrouter": {
"apiKey": "${OPENROUTER_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "openrouter",
"model": "openai/gpt-5.4-image-2"
}
}
}
```
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
## WebUI Usage
In the WebUI composer:
1. Click **Image Generation**.
2. Choose an aspect ratio: `Auto`, `1:1`, `3:4`, `9:16`, `4:3`, or `16:9`.
3. Describe the image or the edit you want.
4. Attach reference images when editing an existing image.
Generated images are rendered as assistant media in the chat. Follow-up prompts such as "make it warmer", "change the background", or "try a 16:9 version" can reuse the most recent generated artifact.
The WebUI hides provider storage details from the user. The agent sees the saved artifact path internally and can pass it back to `generate_image` as `reference_images` for iterative edits.
## Configuration Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
| `tools.imageGeneration.maxImagesPerTurn` | number | `4` | Maximum `count` accepted by one tool call. Valid range: `1` to `8` |
| `tools.imageGeneration.saveDir` | string | `"generated"` | Relative directory under nanobot's media directory for generated artifacts |
Provider settings reuse normal provider config fields:
| Option | Description |
|--------|-------------|
| `providers.<name>.apiKey` | Provider API key. Prefer `${ENV_VAR}` |
| `providers.<name>.apiBase` | Optional custom base URL |
| `providers.<name>.extraHeaders` | Headers merged into provider requests |
| `providers.<name>.extraBody` | Extra JSON fields merged into provider request bodies |
Both camelCase and snake_case config keys are accepted, but docs use camelCase to match `config.json`.
## Provider Notes
### OpenRouter
OpenRouter uses a chat-completions style image response. Configure:
```json
{
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "openrouter",
"model": "openai/gpt-5.4-image-2"
}
}
}
```
Use a model that supports image generation and image editing if you want reference-image edits.
### Custom (OpenAI-compatible)
The `custom` image provider fits services that implement the synchronous OpenAI Images API:
```text
POST /v1/images/generations
```
The response must include generated images in `data[].b64_json` or `data[].url`. Native prediction APIs, such as Replicate's `/v1/models/{owner}/{model}/predictions`, are not directly compatible unless you put an OpenAI-compatible gateway in front of them.
Configure:
```json
{
"providers": {
"custom": {
"apiKey": "${CUSTOM_IMAGE_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "custom",
"model": "your-model-name"
}
}
}
```
The `apiBase` is required. The provider sends requests to `{apiBase}/images/generations` using the OpenAI Images API format with `response_format: "b64_json"`. The `apiKey` is optional for local or unauthenticated endpoints. Reference-image edits are not supported by the generic `custom` provider.
`extraBody` can adapt provider-specific quirks because it is merged last into the request body. Examples:
- Agnes AI documents URL responses, so use `"extraBody": {"response_format": "url"}`.
- Together AI documents `"response_format": "base64"`, so override the default.
- Volcengine Ark Seedream models may require size hints such as `"2K"`, `"3K"`, `"4K"`, or explicit dimensions. Set `tools.imageGeneration.defaultImageSize` or `providers.custom.extraBody.size` to a value supported by the selected model.
For compatibility with the default nanobot setting, custom maps `defaultImageSize: "1K"` to `1024x1024`. Other explicit size hints are passed through unchanged.
### AIHubMix
AIHubMix `gpt-image-2-free` is supported through AIHubMix's unified predictions API. Internally nanobot calls:
```text
/v1/models/openai/gpt-image-2-free/predictions
```
Configure:
```json
{
"providers": {
"aihubmix": {
"apiKey": "${AIHUBMIX_API_KEY}",
"extraBody": {
"quality": "low"
}
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "aihubmix",
"model": "gpt-image-2-free"
}
}
}
```
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness.
### MiniMax
MiniMax `image-01` supports text-to-image and reference-image (subject reference) edits. Supported aspect ratios are `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, and `21:9`.
```json
{
"providers": {
"minimax": {
"apiKey": "${MINIMAX_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "minimax",
"model": "image-01",
"defaultAspectRatio": "1:1"
}
}
}
```
### Gemini
nanobot supports two Gemini image generation model families via Google's Generative Language API:
| Model | Endpoint | Reference images |
|-------|----------|-----------------|
| `imagen-4.0-generate-001` | `:predict` | Not supported by this integration |
| `gemini-2.5-flash-image` | `:generateContent` | Supported |
For reference-image edits, use a Gemini Flash image model:
```json
{
"providers": {
"gemini": {
"apiKey": "${GEMINI_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "gemini",
"model": "gemini-2.5-flash-image"
}
}
}
```
Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
### Ollama
Ollama's experimental native image generation API works with local servers and hosted ollama.com models. Local access at `http://localhost:11434/api` does not require an API key; set `providers.ollama.apiKey` only when targeting `https://ollama.com/api`.
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/api"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "ollama",
"model": "x/z-image-turbo",
"defaultAspectRatio": "16:9",
"defaultImageSize": "2K"
}
}
}
```
Ollama maps `defaultAspectRatio` and `defaultImageSize` to native `width` and `height` values. Reference images are not supported by this integration.
### StepFun
StepFun (阶跃星辰) `step-image-edit-2` supports text-to-image generation. The `step-1x-medium` variant additionally supports **style-reference** image edits, where a reference image guides the visual style of the output.
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes are specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1280x800`, `800x1280`).
```json
{
"providers": {
"stepfun": {
"apiKey": "${STEPFUN_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "stepfun",
"model": "step-image-edit-2"
}
}
}
```
> [!NOTE]
> The StepFun provider reuses the existing `providers.stepfun` config block (the same one used for StepFun's LLM API). Set `providers.stepfun.apiKey` once and it is shared between text and image generation.
>
> When `step-image-edit-2` is used, `reference_images` are ignored (the model does not support style reference). Switch to `step-1x-medium` to use reference-image-guided generation.
#### StepPlan (Subscription)
StepPlan is StepFun's subscription tier and uses a different API base URL. The image generation endpoint path is the same — just override `apiBase`:
```json
{
"providers": {
"stepfun": {
"apiKey": "${STEPFUN_API_KEY}",
"apiBase": "https://api.stepfun.ai/step_plan/v1"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "stepfun",
"model": "step-image-edit-2"
}
}
}
```
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.ai/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
### Zhipu
Zhipu (智谱) `glm-image` model supports text-to-image generation. The API returns temporary image URLs (valid for 30 days); nanobot downloads and re-encodes them as base64 data URLs.
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1280x1280`, `1728x960`) or using aspect ratio presets.
```json
{
"providers": {
"zhipu": {
"apiKey": "${ZAI_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "zhipu",
"model": "glm-image"
}
}
}
```
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
## Artifacts
Generated images are stored under the active nanobot instance's media directory:
```text
~/.nanobot/media/generated/YYYY-MM-DD/img_<id>.<ext>
~/.nanobot/media/generated/YYYY-MM-DD/img_<id>.json
```
For non-default config locations, the media directory is relative to the active config file's directory.
The JSON sidecar stores:
| Field | Meaning |
|-------|---------|
| `id` | Short generated image id, such as `img_ab12cd34ef56` |
| `path` | Local image path used internally for follow-up edits |
| `mime` | Detected image MIME type |
| `prompt` | Prompt used for the generation |
| `model` | Provider model |
| `provider` | Provider name |
| `source_images` | Reference image paths used for edits |
| `created_at` | Creation timestamp |
Do not paste base64 image payloads into chat. The agent should keep local artifact paths internal unless the user explicitly asks for debugging details.
## Prompting
Good image prompts include:
- Subject and scene.
- Composition, camera, or layout.
- Style, mood, lighting, and color palette.
- Exact text that must appear in the image, quoted.
- Constraints such as "keep the same character" or "preserve the logo".
Example:
```text
A minimal app icon for nanobot: friendly robot head, rounded square, soft blue and white palette, clean vector style, no text
```
For edits, describe what should change and what must stay fixed:
```text
Use the reference image. Keep the same robot and composition, change the palette to warm orange, and add a subtle sunrise background.
```
## Troubleshooting
| Symptom | Check |
|---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
+182
View File
@@ -0,0 +1,182 @@
# Memory in nanobot
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.
That is the shape of memory in nanobot.
## The Design
nanobot does not treat memory as one giant file.
It separates memory into layers, because different kinds of remembering deserve different tools:
- `session.messages` holds the living short-term conversation.
- `memory/history.jsonl` is the running archive of compressed past turns.
- `SOUL.md`, `USER.md`, and `memory/MEMORY.md` are the durable knowledge files.
- `GitStore` records how those durable files change over time.
This keeps the system light in the moment, but reflective over time.
## The Flow
Memory moves through nanobot in two stages.
### Stage 1: Consolidator
When a conversation grows large enough to pressure the context window, nanobot does not try to carry every old message forever.
Instead, the `Consolidator` summarizes the oldest safe slice of the conversation and appends that summary to `memory/history.jsonl`.
This file is:
- append-only
- cursor-based
- optimized for machine consumption first, human inspection second
Each line is a JSON object:
```json
{"cursor": 42, "timestamp": "2026-04-03 00:02", "content": "- User prefers dark mode\n- Decided to use PostgreSQL"}
```
It is not the final memory. It is the material from which final memory is shaped.
### Stage 2: Dream
`Dream` is the slower, more thoughtful layer. It runs on a cron schedule by default and can also be triggered manually.
Dream reads:
- new entries from `memory/history.jsonl`
- the current `SOUL.md`
- the current `USER.md`
- the current `memory/MEMORY.md`
Then it edits the long-term files surgically in a single pass — not by rewriting everything, but by making the smallest honest change that keeps memory coherent.
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
└── memory/
├── MEMORY.md # Project facts, decisions, and durable context
├── history.jsonl # Append-only history summaries
├── .cursor # Consolidator write cursor
├── .dream_cursor # Dream consumption cursor
└── .git/ # Version history for long-term memory files
```
These files play different roles:
- `SOUL.md` remembers how nanobot should sound.
- `USER.md` remembers who the user is and what they prefer.
- `MEMORY.md` remembers what remains true about the work itself.
- `history.jsonl` remembers what happened on the way there.
## Why `history.jsonl`
The old `HISTORY.md` format was pleasant for casual reading, but it was too fragile as an operational substrate.
`history.jsonl` gives nanobot:
- stable incremental cursors
- safer machine parsing
- easier batching
- cleaner migration and compaction
- a better boundary between raw history and curated knowledge
You can still search it with familiar tools:
```bash
# grep
grep -i "keyword" memory/history.jsonl
# jq
cat memory/history.jsonl | jq -r 'select(.content | test("keyword"; "i")) | .content' | tail -20
# Python
python -c "import json; [print(json.loads(l).get('content','')) for l in open('memory/history.jsonl','r',encoding='utf-8') if l.strip() and 'keyword' in l.lower()][-20:]"
```
The difference is philosophical as much as technical:
- `history.jsonl` is for structure
- `SOUL.md`, `USER.md`, and `MEMORY.md` are for meaning
## Commands
Memory is not hidden behind the curtain. Users can inspect and guide it.
| Command | What it does |
|---------|--------------|
| `/dream` | Run Dream immediately |
| `/dream-log` | Show the latest Dream memory change |
| `/dream-log <sha>` | Show a specific Dream change |
| `/dream-restore` | List recent Dream memory versions |
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
These commands exist for a reason: automatic memory is powerful, but users should always retain the right to inspect, understand, and restore it.
## Versioned Memory
After Dream changes long-term memory files, nanobot can record that change with `GitStore`.
This gives memory a history of its own:
- you can inspect what changed
- you can compare versions
- you can restore a previous state
That turns memory from a silent mutation into an auditable process.
## Configuration
Dream is configured under `agents.defaults.dream`:
```json
{
"agents": {
"defaults": {
"dream": {
"intervalH": 2,
"modelOverride": null,
"maxBatchSize": 20,
"maxIterations": 10
}
}
}
}
```
| Field | Meaning |
|-------|---------|
| `intervalH` | How often Dream runs, in hours |
| `cron` | Cron expression override (takes precedence over `intervalH`) |
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
| `maxBatchSize` | *(Deprecated — not used)* |
| `maxIterations` | *(Deprecated — not used)* |
In practical terms:
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
## In Practice
What this means in daily use is simple:
- conversations can stay fast without carrying infinite context
- durable facts can become clearer over time instead of noisier
- the user can inspect and restore memory when needed
Memory should not feel like a dump. It should feel like continuity.
That is what this design is trying to protect.
+124
View File
@@ -0,0 +1,124 @@
# 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** | workspace directory | `~/.nanobot-A/workspace/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 fragment:
```json
{
"agents": {
"defaults": {
"workspace": "~/.nanobot-telegram/workspace"
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_TELEGRAM_BOT_TOKEN"
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
}
}
```
The copied base config can keep using the same `modelPresets` and `agents.defaults.modelPreset`. If this instance needs a different model, add another preset and set `agents.defaults.modelPreset` to that preset name.
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 are stored in the active workspace; runtime media/state is derived from the config directory
+211
View File
@@ -0,0 +1,211 @@
# 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: 200000
# 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")` |
| "Which model preset is active?" | `check("model_preset")` |
| "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_preset", value="fast")
# → Switch to a configured model preset
my(action="set", key="model", value="fast-model")
# → Switch to a raw model and clear the active preset
my(action="set", key="context_window_tokens", value=262144)
# → 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 |
| `model_preset` | str | configured preset name | Named preset 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=262144)
```
### "Simple question, don't waste compute"
```text
Agent: This is a straightforward question, let me switch to the fast preset.
→ my(action="set", key="model_preset", value="fast")
```
### "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`).
+124
View File
@@ -0,0 +1,124 @@
# OpenAI-Compatible API
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
```bash
python -m pip install "nanobot-ai[api]"
nanobot agent -m "Hello!"
nanobot serve
```
Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or config setup before debugging the API server. By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`.
For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
## 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)
```
+626
View File
@@ -0,0 +1,626 @@
# Provider Cookbook
This page is for cases where you already know what you want to connect and need a pasteable setup. Each recipe shows what to set, what to run, and what a failure usually means.
If this is your first install and terminal commands are new to you, start with [`start-without-technical-background.md`](./start-without-technical-background.md). If you want the field-by-field explanation, read [`providers.md`](./providers.md) and then [`configuration.md#providers`](./configuration.md#providers).
Most examples below are snippets to merge into `~/.nanobot/config.json`. Keep any existing sections you still need, and replace placeholder keys such as `${OPENROUTER_API_KEY}` with environment-variable references or real values only on your own machine.
Recipes are examples, not rankings. Pick the recipe that matches the credential, endpoint, and model ID you already intend to use.
## Choose a Recipe
Match the recipe to the credential or endpoint you already have:
| What you have | Recipe | Must match |
|---|---|---|
| A gateway key and model IDs that include a model family path, such as `provider/model-name` | [OpenRouter Gateway](#recipe-openrouter-gateway) | API key, provider config key, preset provider, and gateway model ID |
| An OpenCode Zen or Go key | [OpenCode Zen or Go](#recipe-opencode-zen-or-go) | `OPENCODE_API_KEY`, the Zen/Go provider key, and a model ID from the matching OpenCode endpoint |
| An OpenAI platform API key and OpenAI model ID | [OpenAI Direct](#recipe-openai-direct) | `OPENAI_API_KEY`, `provider: "openai"`, and an OpenAI model available to that account |
| An Anthropic API key and Anthropic model ID | [Anthropic Direct](#recipe-anthropic-direct) | `ANTHROPIC_API_KEY`, `provider: "anthropic"`, and a non-gateway model ID |
| A Kimi Coding Plan key | [Kimi Coding Plan](#recipe-kimi-coding-plan) | `KIMI_CODING_API_KEY`, `provider: "kimi_coding"`, and `model: "kimi-for-coding"` |
| An OpenAI-compatible `/v1` endpoint that is not a named nanobot provider | [Custom OpenAI-Compatible Provider](#recipe-custom-openai-compatible-provider) | `apiBase`, optional API key, and the model ID served by that endpoint |
| Ollama already running locally | [Ollama Local Model](#recipe-ollama-local-model) | Ollama `apiBase`, pulled model name, and local server availability |
| vLLM, LM Studio, or another local OpenAI-compatible server | [vLLM or LM Studio](#recipe-vllm-or-lm-studio) | Local `/v1` base URL, any required key, and served model name |
| A primary model plus one or more backups | [Fallback Presets](#recipe-fallback-presets) | Named presets in `modelPresets`, referenced from `agents.defaults.fallbackModels` |
| A working agent and a Langfuse project | [Langfuse Tracing](#recipe-langfuse-tracing) | Langfuse env vars in the same process environment that starts nanobot |
## How to Use a Recipe
1. Install nanobot and run `nanobot onboard` once so `~/.nanobot/config.json` exists. Use `nanobot onboard --wizard` if you prefer prompts over hand-editing JSON.
2. Put secrets in environment variables when possible.
3. Merge the recipe snippet into `~/.nanobot/config.json`.
4. Run `nanobot status`.
5. Run `nanobot agent -m "Hello!"`.
6. If the CLI works, then connect WebUI, gateway, or chat apps.
The active model should normally come from `agents.defaults.modelPreset`, and that name should point to an entry in `modelPresets`. Direct `agents.defaults.provider` and `agents.defaults.model` still work for older configs, but presets are easier to switch and easier to reuse as fallbacks.
## Secret Setup
Environment variables keep API keys out of the config file.
Use the variable name shown by the recipe you picked. The commands below use `OPENROUTER_API_KEY` only as an example; an OpenAI direct recipe uses `OPENAI_API_KEY`, an Anthropic direct recipe uses `ANTHROPIC_API_KEY`, and a custom endpoint can use any variable name you reference in `config.json`.
**macOS / Linux**
```bash
export OPENROUTER_API_KEY="sk-or-v1-..."
nanobot agent -m "Hello!"
```
**Windows PowerShell**
```powershell
$env:OPENROUTER_API_KEY = "sk-or-v1-..."
nanobot agent -m "Hello!"
```
Environment variables set this way apply only to the current terminal. For long-running services such as systemd, Docker, LaunchAgent, or a remote shell, set the variables in that service environment before starting nanobot.
## Recipe: OpenRouter Gateway
This recipe applies when one API key routes many hosted model families.
```json
{
"providers": {
"openrouter": {
"apiKey": "${OPENROUTER_API_KEY}"
}
},
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Verify:
```bash
nanobot status
nanobot agent -m "Hello!"
```
If this fails with `401` or `unauthorized`, check that `OPENROUTER_API_KEY` is visible in the same terminal or service that starts nanobot. If it fails with `model not found`, choose a model ID that OpenRouter lists for your account.
## Recipe: OpenCode Zen or Go
This recipe applies when your credential comes from OpenCode Zen or OpenCode Go.
Both providers use `OPENCODE_API_KEY`; pick the provider block that matches the
subscription or balance you want to use.
OpenCode Zen:
```json
{
"providers": {
"opencodeZen": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"label": "OpenCode Zen",
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
OpenCode Go:
```json
{
"providers": {
"opencodeGo": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"label": "OpenCode Go",
"provider": "opencode_go",
"model": "opencode-go/deepseek-v4-flash",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Verify:
```bash
nanobot status
nanobot agent -m "Hello!"
```
OpenCode's docs list models across multiple endpoint types. The `opencode_zen`
and `opencode_go` providers in nanobot use the OpenAI-compatible
`chat/completions` path. If a model fails with `model not found` or an endpoint
shape error, choose a model that OpenCode lists under `chat/completions` for the
matching Zen or Go endpoint.
## Recipe: OpenAI Direct
This recipe applies when you have an OpenAI API key and want to call OpenAI directly instead of through a gateway.
```json
{
"providers": {
"openai": {
"apiKey": "${OPENAI_API_KEY}"
}
},
"modelPresets": {
"primary": {
"label": "OpenAI",
"provider": "openai",
"model": "gpt-5",
"maxTokens": 4096,
"contextWindowTokens": 128000,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Verify:
```bash
OPENAI_API_KEY="sk-..." nanobot agent -m "Hello!"
```
If your shell cannot use inline environment variables, set `OPENAI_API_KEY` first and then run `nanobot agent -m "Hello!"`. If the provider rejects `apiType`, remove `apiType` unless you are using a documented OpenAI-specific mode.
## Recipe: Anthropic Direct
This recipe applies when your key comes from Anthropic and your model name is an Anthropic model ID, not an OpenRouter model path.
```json
{
"providers": {
"anthropic": {
"apiKey": "${ANTHROPIC_API_KEY}"
}
},
"modelPresets": {
"primary": {
"label": "Anthropic",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"maxTokens": 4096,
"contextWindowTokens": 200000,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Verify:
```bash
ANTHROPIC_API_KEY="sk-ant-..." nanobot agent -m "Hello!"
```
If you copied a model name such as `anthropic/claude-sonnet-4.5`, that is a gateway-style model path and belongs under `provider: "openrouter"`, not `provider: "anthropic"`.
If you use an Anthropic-compatible proxy, keep the preset provider as `anthropic` and set `providers.anthropic.apiBase`:
```json
{
"providers": {
"anthropic": {
"apiKey": "${ANTHROPIC_API_KEY}",
"apiBase": "https://anthropic-proxy.example.com"
}
},
"modelPresets": {
"primary": {
"label": "Anthropic proxy",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"maxTokens": 4096,
"contextWindowTokens": 200000,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Do not configure Anthropic-compatible endpoints as arbitrary custom provider names; named custom providers use the OpenAI-compatible request format.
## Recipe: Kimi Coding Plan
This recipe applies when your key comes from Kimi's Coding Plan endpoint. Nanobot uses a dedicated `kimi_coding` provider for this Anthropic Messages API endpoint; do not configure it as a generic `custom` provider.
```json
{
"providers": {
"kimiCoding": {
"apiKey": "${KIMI_CODING_API_KEY}"
}
},
"modelPresets": {
"kimiCoding": {
"label": "Kimi Coding",
"provider": "kimi_coding",
"model": "kimi-for-coding",
"maxTokens": 4096,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "kimiCoding"
}
}
}
```
Verify:
```bash
nanobot status
nanobot agent -m "Hello!"
```
The default base URL is `https://api.kimi.com/coding/v1`. This endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default. If your account requires a different value, override it with `providers.kimiCoding.extraHeaders.User-Agent`.
## Recipe: Custom OpenAI-Compatible Provider
This recipe applies to an OpenAI-compatible service that is not a named nanobot provider.
```json
{
"providers": {
"custom": {
"apiKey": "${CUSTOM_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
},
"modelPresets": {
"primary": {
"label": "Custom",
"provider": "custom",
"model": "provider-model-name",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Verify the endpoint before blaming nanobot:
```bash
curl -sS https://api.example.com/v1/models
nanobot agent -m "Hello!"
```
`apiBase` is the HTTP base URL, not the model name. Include the version path when the service expects it, such as `/v1`. If the service requires a non-empty key but does not validate it, use a placeholder such as `"apiKey": "EMPTY"`.
For multiple custom endpoints, do not overload the single `custom` block. Name each endpoint under `providers` and reference that same name from the preset:
```json
{
"providers": {
"workProxy": {
"apiKey": "${WORK_PROXY_API_KEY}",
"apiBase": "https://proxy.example.com/v1"
},
"lab-local": {
"apiBase": "http://127.0.0.1:8000/v1"
}
},
"modelPresets": {
"work": {
"label": "Work proxy",
"provider": "workProxy",
"model": "gpt-4o-mini",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
},
"lab": {
"label": "Lab local",
"provider": "lab-local",
"model": "served-model-name",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "work"
}
}
}
```
These custom names behave like direct OpenAI-compatible providers: `apiBase` is required, `apiKey` is optional when the endpoint allows anonymous or placeholder credentials, and `apiType` should be left unset. They do not support Anthropic-compatible endpoints; use the `anthropic` provider with `apiBase` for that case.
## Recipe: Ollama Local Model
This recipe applies when Ollama is already installed and the model has been pulled locally.
```bash
ollama serve
ollama pull llama3.2
```
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1"
}
},
"modelPresets": {
"local": {
"label": "Local",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 2048,
"contextWindowTokens": 32768,
"temperature": 0.2
}
},
"agents": {
"defaults": {
"modelPreset": "local"
}
}
}
```
Verify:
```bash
curl -sS http://localhost:11434/v1/models
nanobot agent -m "Hello!"
```
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If the response is very slow, try a smaller local model or lower `contextWindowTokens`.
## Recipe: vLLM or LM Studio
This recipe applies when a local server exposes an OpenAI-compatible `/v1` API.
```json
{
"providers": {
"vllm": {
"apiBase": "http://127.0.0.1:8000/v1",
"apiKey": "EMPTY"
}
},
"modelPresets": {
"local": {
"label": "Local",
"provider": "vllm",
"model": "served-model-name",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.2
}
},
"agents": {
"defaults": {
"modelPreset": "local"
}
}
}
```
For LM Studio, use its local base URL and provider name:
```json
{
"providers": {
"lmStudio": {
"apiBase": "http://localhost:1234/v1"
}
},
"modelPresets": {
"local": {
"label": "LM Studio",
"provider": "lm_studio",
"model": "local-model",
"maxTokens": 2048,
"contextWindowTokens": 32768
}
},
"agents": {
"defaults": {
"modelPreset": "local"
}
}
}
```
The config key can be `lmStudio` or `lm_studio`, but the preset provider should use the registry name `lm_studio`.
## Recipe: Fallback Presets
This recipe applies when one provider sometimes rate-limits, one model is expensive, or you want a local backup.
```json
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
},
"deep": {
"label": "Deep",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"maxTokens": 4096,
"contextWindowTokens": 200000,
"temperature": 0.1
},
"local": {
"label": "Local",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 2048,
"contextWindowTokens": 32768,
"temperature": 0.2
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
"fallbackModels": ["deep", "local"]
}
}
}
```
`fallbackModels` belongs under `agents.defaults`. String entries are preset names, not raw model names. nanobot tries the active preset first, then the fallback presets in order.
Keep fallback candidates realistic. If the local fallback has a smaller context window, nanobot must build context that fits the smallest window in the active chain.
## Recipe: Langfuse Tracing
This recipe applies after the agent works and you want observability for OpenAI-compatible provider calls.
Install the optional package in the same Python environment that runs nanobot:
```bash
python -m pip install langfuse
```
Set the environment variables before starting nanobot:
```bash
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_BASE_URL="https://cloud.langfuse.com"
nanobot agent -m "Hello!"
```
PowerShell:
```powershell
$env:LANGFUSE_SECRET_KEY = "sk-lf-..."
$env:LANGFUSE_PUBLIC_KEY = "pk-lf-..."
$env:LANGFUSE_BASE_URL = "https://cloud.langfuse.com"
nanobot agent -m "Hello!"
```
Langfuse is not a model provider in `config.json`. It is configured through environment variables and traces supported OpenAI-compatible provider calls. Native providers that do not use that client path may not produce Langfuse OpenAI-wrapper traces.
## Recipe: Switch Models at Runtime
Use this after you have more than one preset and are chatting through a supported channel.
```json
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536
},
"local": {
"label": "Local",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 2048,
"contextWindowTokens": 32768
}
},
"agents": {
"defaults": {
"modelPreset": "fast"
}
}
}
```
In chat:
```text
/model
/model local
/model fast
```
`/model` switching is runtime-only. It does not rewrite `config.json`, and an in-progress turn keeps using the model it started with.
## Quick Failure Map
| Symptom | Usually means | First check |
|---|---|---|
| `401`, `unauthorized`, or `invalid API key` | The key is missing, wrong, expired, or under the wrong provider | Print or re-set the environment variable in the same terminal or service |
| `model not found` | The model ID does not belong to the selected provider or gateway | Compare `modelPresets.<name>.provider` and `modelPresets.<name>.model` |
| `connection refused` | Local server is not running or `apiBase` has the wrong port/path | Run `curl <apiBase>/models` |
| `provider not found` | Provider name is misspelled or uses the config key instead of registry name | Use names such as `openrouter`, `openai`, `anthropic`, `ollama`, `vllm`, `lm_studio` |
| Langfuse shows no traces | Env vars are missing, `langfuse` is not installed in the active Python environment, or the provider path is native | Run `python -m pip show langfuse` and restart nanobot from the same environment |
## Next References
| Need | Read |
|---|---|
| Field meanings and provider resolution | [`providers.md`](./providers.md) |
| Full schema and provider table | [`configuration.md#providers`](./configuration.md#providers) |
| Langfuse details | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) |
| First-run diagnosis | [`troubleshooting.md`](./troubleshooting.md) |
+604
View File
@@ -0,0 +1,604 @@
# Providers and Models
Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md).
For every setup, answer three questions:
1. Which provider owns the credential or endpoint?
2. What model name does that provider expect?
3. Does the provider need `apiKey`, `apiBase`, OAuth login, cloud credentials, or only a local server URL?
Prefer a named `modelPresets` entry for the model/provider pair, then select it with `agents.defaults.modelPreset`. Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but presets make runtime `/model` switching and fallback chains clearer. Pin `provider` inside the preset while setting up; you can switch back to `"auto"` later.
## Choose a Provider Without Guessing
The docs show concrete provider names so the JSON is copyable, not because nanobot ranks providers. Start from the service or endpoint you actually control:
| If you have... | Configure... |
|---|---|
| An API key from a hosted provider or gateway | That provider's `providers.<name>.apiKey`, then a preset with that provider name and a model ID from that service. |
| An OpenCode Zen or Go key | `providers.opencodeZen.apiKey` or `providers.opencodeGo.apiKey`, then a preset with `provider: "opencode_zen"` or `provider: "opencode_go"`. |
| A company proxy or regional endpoint | The matching provider block plus `apiBase` if the proxy gives you a URL. |
| A local OpenAI-compatible server | A local provider block such as `ollama`, `vllm`, `lmStudio`, or `custom`, usually with `apiBase`. |
| An OAuth-based account | Run the matching `nanobot provider login ...` command, then select that provider explicitly in a preset. |
| No provider yet | Pick one outside nanobot based on account access, pricing, regional availability, privacy requirements, and the model IDs you need. Then come back with its key and model ID. |
## Minimal Shape
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
},
"modelPresets": {
"primary": {
"provider": "openrouter",
"model": "anthropic/claude-opus-4.5",
"maxTokens": 8192,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
The provider config gives nanobot credentials and endpoint details. The model preset names the provider/model pair. The agent defaults choose which named preset to use for normal turns. Replace the example provider and model together; mixing an API key from one provider with a model ID from another is the most common first-run failure.
## Provider, Model, API Key, and Base URL
These fields answer different questions:
| Field | Where it lives | Meaning |
|---|---|---|
| `provider` | `modelPresets.<name>.provider` | Which nanobot provider adapter should send the request. |
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
## Common Provider Patterns
### OpenRouter Gateway
Gateway-style setup for model IDs served through OpenRouter.
```json
{
"providers": {
"openrouter": {
"apiKey": "${OPENROUTER_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "openrouter",
"model": "anthropic/claude-opus-4.5",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Use the model ID exactly as OpenRouter lists it.
### OpenCode Zen and Go
OpenCode Zen and OpenCode Go are OpenCode-managed gateways for coding-agent models.
They share `OPENCODE_API_KEY`, but use separate provider config keys and default base
URLs in nanobot.
```json
{
"providers": {
"opencodeZen": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
For OpenCode Go, switch the provider block and preset:
```json
{
"providers": {
"opencodeGo": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "opencode_go",
"model": "opencode-go/deepseek-v4-flash",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
}
}
```
OpenCode documents model IDs with `opencode/<model-id>` for Zen and
`opencode-go/<model-id>` for Go. nanobot accepts those prefixes and strips them
before sending the request to OpenCode. Use model IDs that OpenCode lists under
the `chat/completions` endpoint; models listed only under `responses`,
`messages`, or provider-specific endpoints are not handled by this
OpenAI-compatible provider path.
### Anthropic Direct
```json
{
"providers": {
"anthropic": {
"apiKey": "${ANTHROPIC_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "anthropic",
"model": "claude-opus-4-5",
"maxTokens": 8192,
"contextWindowTokens": 200000
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Anthropic direct uses the native Anthropic provider. Do not use an OpenRouter model ID unless the provider is OpenRouter.
If you use an Anthropic-compatible proxy, keep the provider as `anthropic` and override `apiBase`:
```json
{
"providers": {
"anthropic": {
"apiKey": "${ANTHROPIC_API_KEY}",
"apiBase": "https://anthropic-proxy.example.com"
}
},
"modelPresets": {
"primary": {
"provider": "anthropic",
"model": "claude-sonnet-4-5"
}
}
}
```
Arbitrary custom provider names are OpenAI-compatible only; they do not use the Anthropic Messages API request format.
### OpenAI Direct
```json
{
"providers": {
"openai": {
"apiKey": "${OPENAI_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "openai",
"model": "gpt-5",
"maxTokens": 8192,
"contextWindowTokens": 128000
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account.
### Custom OpenAI-Compatible Endpoint
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider.
```json
{
"providers": {
"custom": {
"apiKey": "${CUSTOM_API_KEY}",
"apiBase": "https://example.com/v1"
}
},
"modelPresets": {
"primary": {
"provider": "custom",
"model": "provider-model-name",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
`custom` does not infer a default base URL. Set `apiBase`.
If you have more than one custom OpenAI-compatible endpoint, give each endpoint its own provider key under `providers` and use that same key in the model preset. The key can be a name that makes sense in your environment, such as `companyProxy`, `tenant-a`, or `dev-local`.
```json
{
"providers": {
"companyProxy": {
"apiKey": "${COMPANY_PROXY_API_KEY}",
"apiBase": "https://llm-proxy.example.com/v1"
},
"tenant-a": {
"apiBase": "https://tenant-a.example.com/v1"
}
},
"modelPresets": {
"company": {
"provider": "companyProxy",
"model": "gpt-4o-mini",
"maxTokens": 8192,
"contextWindowTokens": 65536
},
"tenantA": {
"provider": "tenant-a",
"model": "served-model-name",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "company"
}
}
}
```
Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`.
If your custom endpoint documents a nonstandard thinking toggle, set `providers.<name>.thinkingStyle` to `thinking_type`, `enable_thinking`, or `reasoning_split`; nanobot then maps `reasoningEffort` onto that provider-specific request body. Leave it unset for ordinary OpenAI-compatible endpoints.
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
### Ollama
Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1"
}
},
"modelPresets": {
"primary": {
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 4096,
"contextWindowTokens": 32768
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Most Ollama setups do not require an API key.
### vLLM or Other Local OpenAI-Compatible Server
```json
{
"providers": {
"vllm": {
"apiBase": "http://127.0.0.1:8000/v1",
"apiKey": "EMPTY"
}
},
"modelPresets": {
"primary": {
"provider": "vllm",
"model": "served-model-name",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Some OpenAI-compatible local servers require any non-empty API key even when they do not validate it.
### LM Studio
```json
{
"providers": {
"lmStudio": {
"apiBase": "http://localhost:1234/v1"
}
},
"modelPresets": {
"primary": {
"provider": "lm_studio",
"model": "local-model",
"maxTokens": 4096,
"contextWindowTokens": 32768
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Config keys may be camelCase or snake_case. Provider names in model presets should use the registry name, such as `lm_studio`.
### AWS Bedrock
Bedrock can use the AWS credential chain, profile, region, or Bedrock bearer token depending on your AWS setup.
```json
{
"providers": {
"bedrock": {
"region": "us-east-1",
"profile": "default"
}
},
"modelPresets": {
"primary": {
"provider": "bedrock",
"model": "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
"maxTokens": 8192,
"contextWindowTokens": 200000
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
See [`configuration.md#providers`](./configuration.md#providers) for Bedrock-specific notes.
### OAuth Providers
Some providers do not use API keys in `config.json`.
```bash
nanobot provider login openai-codex
nanobot provider login github-copilot
```
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
For OpenAI Codex, add `providers.openai_codex.proxy` only when Codex OAuth/token refresh or Codex API requests must use a proxy:
```json
{
"providers": {
"openai_codex": {
"proxy": "http://127.0.0.1:7890"
}
},
"modelPresets": {
"codex": {
"provider": "openai_codex",
"model": "gpt-5.1-codex",
"reasoningEffort": "high"
}
},
"agents": {
"defaults": {
"modelPreset": "codex"
}
}
}
```
If you run the login command on a remote/headless machine and open the authorization URL in a local browser, paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted. See [`configuration.md#providers`](./configuration.md#providers) for the full OAuth provider notes.
## Provider Resolution
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
1. the named `modelPresets` entry referenced by `agents.defaults.modelPreset`;
2. otherwise the implicit `default` preset built from `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and related fields.
Provider selection follows this practical rule:
- Explicit `provider` in the active preset or implicit default config wins.
- `provider: "auto"` tries model-name keywords, configured keys, local base URLs, and gateway providers.
- Gateway providers such as OpenRouter and AiHubMix can route many model families, so the model name must be valid for that gateway.
- Local providers should normally be explicit because generic local model names such as `llama3.2` do not always contain provider keywords.
### Model Name Prefixes
`family/model-name` does not always select provider `family`. Prefix-based provider inference only runs when the active provider is `"auto"`.
- Explicit provider wins: `provider: "openrouter"` with `model: "anthropic/claude-sonnet-4.5"` calls OpenRouter, not Anthropic.
- With `provider: "auto"`, a prefix matching a configured built-in or named custom provider can select that provider. Named custom prefixes are stripped before request, so `companyProxy/gpt-4o-mini` is sent upstream as `gpt-4o-mini`.
- With an explicit named custom provider, the model is sent as written; `provider: "companyProxy"` with `model: "openai/gpt-4o-mini"` sends `openai/gpt-4o-mini` to `companyProxy`.
Pin `provider` in presets when using gateway catalog IDs such as `anthropic/claude-sonnet-4.5`.
## Model Presets
Model presets are the recommended model configuration surface. Use them when you want named model choices, runtime `/model` switching, or reusable fallback targets.
```json
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
},
"deep": {
"label": "Deep",
"provider": "anthropic",
"model": "claude-opus-4-5",
"maxTokens": 8192,
"contextWindowTokens": 200000,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "fast"
}
}
}
```
The preset name `default` is reserved for the implicit `agents.defaults` settings. Do not define `modelPresets.default`; use `/model default` to return to the direct `agents.defaults.*` fields in older configs.
## Fallback Models
Fallbacks are useful for transient provider failures, rate limits, or model availability issues. Keep fallbacks compatible with the task size and tool use. Prefer fallback presets so each candidate has a name and a complete provider, model, generation, and context-window configuration.
```json
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
},
"deep": {
"label": "Deep",
"provider": "anthropic",
"model": "claude-opus-4-5",
"maxTokens": 8192,
"contextWindowTokens": 200000,
"temperature": 0.1
},
"localSmall": {
"label": "Local Small",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 4096,
"contextWindowTokens": 32768,
"temperature": 0.2
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
"fallbackModels": ["deep", "localSmall"]
}
}
}
```
String entries in `fallbackModels` are preset names, not raw model names. nanobot tries them in order after the active preset. Each fallback preset uses its own `provider`, `model`, `maxTokens`, `contextWindowTokens`, `temperature`, and optional `reasoningEffort`.
Use inline fallback objects only when a model is not worth naming as a preset:
```json
{
"modelPresets": {
"fast": {
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
"fallbackModels": [
{
"provider": "deepseek",
"model": "deepseek-v4-pro",
"maxTokens": 4096,
"contextWindowTokens": 262144
}
]
}
}
}
```
`fallbackModels` belongs under `agents.defaults`, not inside each preset. If fallback candidates use smaller context windows, nanobot builds context using the smallest window in the active chain so every candidate can receive the same prompt. See [`configuration.md#model-fallbacks`](./configuration.md#model-fallbacks) for failure conditions.
## Quick Checks
Run these before debugging a chat app:
```bash
nanobot status
nanobot agent -m "Hello!"
```
If `nanobot agent -m "Hello!"` fails:
| Symptom | Likely cause |
|---|---|
| 401, unauthorized, invalid API key | Key is missing, expired, copied with whitespace, or stored under the wrong provider |
| model not found | Model ID does not exist for the selected provider or gateway |
| connection refused | Local provider server is not running or `apiBase` points to the wrong port |
| provider not found | The active preset uses a misspelled provider; use registry names such as `openrouter`, `anthropic`, `ollama`, `vllm`, `lm_studio` |
| works in CLI but not chat app | Provider is fine; debug gateway/channel setup in [`chat-apps.md`](./chat-apps.md) or [`troubleshooting.md`](./troubleshooting.md) |
For the complete provider table and advanced provider-specific notes, see [`configuration.md#providers`](./configuration.md#providers).
+754
View File
@@ -0,0 +1,754 @@
# Python SDK
Use nanobot as a Python library. The SDK gives you the same agent runtime used
by the CLI, but from code: model routing, tools, workspace access, conversation
history, memory, streaming events, and runtime helpers.
If you have used the OpenAI SDK before, the most important difference is this:
- OpenAI SDK calls a model.
- nanobot SDK runs an agent around a model.
That means one SDK call can read files, call tools, keep session history, use
memory, stream progress, and return structured runtime information.
```text
your Python code
-> Nanobot SDK
-> agent runtime
-> configured model provider
-> tools
-> workspace
-> session history
-> memory
```
## Before You Start
Install and configure nanobot first. If you have not done that yet, follow the
[Quick Start](quick-start.md) and complete the setup wizard. For SDK-only Python
environments, install the package with:
```bash
python -m pip install nanobot-ai
```
`Nanobot.from_config()` reuses your normal `~/.nanobot/config.json` and
`~/.nanobot/workspace/`. Provider, model, tools, memory, and session behavior
match the CLI unless you override them. For the difference between config and
workspace, see [Concepts: Config vs Workspace](concepts.md#config-vs-workspace).
Before writing SDK code, run the same first-run checks from the main
[Install and Quick Start](quick-start.md):
```bash
nanobot status
```
`nanobot status` should show the config path, workspace path, active model or
preset, and provider summary. Then send one real message:
```bash
nanobot agent -m "Hello!"
```
A normal assistant reply means install, config, provider/model selection, and
workspace access are all usable. Once that works, the SDK should see the same
runtime.
## 5-Minute Quick Start
### Ask One Question
```python
import asyncio
from nanobot import Nanobot
async def main() -> None:
async with Nanobot.from_config() as bot:
result = await bot.run("What time is it in Tokyo?")
print(result.content)
asyncio.run(main())
```
Use `async with` when possible so tool connections and background cleanup are
closed before the event loop exits. If you manage the instance manually, call
`await bot.aclose()` in a `finally` block.
The SDK is async-first because agent runs may stream tokens, execute tools, and
wait on external services. In a normal Python script, wrap your async function
with `asyncio.run(...)` as shown above. In a notebook or another async app, call
`await bot.run(...)` directly from your existing event loop.
### Inspect What Happened
`bot.run(...)` returns a `RunResult`, not just a string:
```python
result = await bot.run("Review this repository")
print(result.content) # final answer
print(result.tools_used) # tools the agent used
print(result.usage) # token usage when available
print(result.stop_reason) # why the run stopped
```
### Continue A Conversation
Use a `session_key` when you want history to carry across turns. Different
session keys are isolated from each other:
```python
await bot.run("My name is Alice.", session_key="user:alice")
result = await bot.run("What is my name?", session_key="user:alice")
print(result.content)
```
This is the SDK equivalent of giving each user, task, eval case, or workflow
its own conversation thread.
### Stream A Long Answer
For live output, use `bot.stream(...)`:
```python
from nanobot import STREAM_EVENT_TEXT_DELTA
async for event in bot.stream("Write a migration plan"):
if event.type == STREAM_EVENT_TEXT_DELTA:
print(event.delta, end="", flush=True)
```
Streaming returns structured events, so you can also observe tool calls,
reasoning chunks, completion, and failures.
## Complete Starter Script
Save this as `sdk_demo.py` after `nanobot agent -m "Hello!"` works:
```python
import asyncio
import sys
from nanobot import (
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_STARTED,
Nanobot,
)
async def main() -> None:
prompt = " ".join(sys.argv[1:]) or "Explain what nanobot is in one paragraph."
session_key = "sdk:demo"
async with Nanobot.from_config() as bot:
print(f"model: {bot.runtime.model}")
print(f"workspace: {bot.runtime.workspace}")
print()
final_result = None
async for event in bot.stream(prompt, session_key=session_key):
if event.type == STREAM_EVENT_TEXT_DELTA:
print(event.delta, end="", flush=True)
elif event.type == STREAM_EVENT_TOOL_STARTED:
print(f"\n[tool] {event.name}", flush=True)
elif event.type == STREAM_EVENT_RUN_COMPLETED:
final_result = event.result
elif event.type == STREAM_EVENT_RUN_FAILED:
raise RuntimeError(event.error or "nanobot run failed")
print()
if final_result is not None:
print(f"\nstop_reason: {final_result.stop_reason}")
print(f"tools_used: {final_result.tools_used}")
print(f"usage: {final_result.usage}")
if __name__ == "__main__":
asyncio.run(main())
```
Run it:
```bash
python sdk_demo.py "List the top-level files in the current workspace."
```
You should see the configured model, workspace path, streamed assistant text,
and final run metadata. The exact answer depends on your config and workspace,
but a file-listing prompt may look like this:
```text
model: openai/gpt-4.1-mini
workspace: /Users/alice/.nanobot/workspace
[tool] list_dir
Here are the top-level files I found...
stop_reason: completed
tools_used: ['list_dir']
usage: {'prompt_tokens': ..., 'completion_tokens': ..., 'total_tokens': ...}
```
This script shows the usual production shape: create one `Nanobot`, choose a
stable `session_key`, stream events, keep the final `RunResult`, and let
`async with` close runtime resources.
## Core Concepts
| Concept | Meaning |
|---------|---------|
| `Nanobot` | The SDK object that owns one configured agent runtime. |
| Run | One call to `bot.run(...)`, `bot.run_streamed(...)`, or `bot.stream(...)`. |
| `session_key` | The conversation history key. Reuse it to continue a thread; change it to isolate a thread. |
| Workspace | The local directory where file tools and shell tools operate. |
| Tools | Capabilities the agent may call, such as file access, shell, web, or custom tools from your config. |
| Memory | Long-term memory files managed by nanobot. |
| Stream event | A typed event such as `text.delta`, `tool.started`, or `run.completed`. |
| Model override | A temporary model or model preset used for one SDK instance or one run. |
For most users, the mental model is:
1. Create a `Nanobot` from config.
2. Pick a `session_key`.
3. Call `run` or `stream`.
4. Read `RunResult` or stream events.
5. Use session/memory/runtime helpers only when you need more control.
## SDK Or OpenAI-Compatible API?
nanobot has two programming surfaces:
| Use | Choose | Why |
|-----|--------|-----|
| Python code running in the same process as nanobot | Python SDK | Direct access to `RunResult`, sessions, memory, runtime helpers, hooks, and stream events. |
| Existing OpenAI-compatible clients, another language, or a separate process | [OpenAI-Compatible API](openai-api.md) | HTTP `/v1/chat/completions` compatibility with familiar client libraries. |
The Python SDK is best when you are writing evals, notebooks, benchmark
runners, product backends, local scripts, or integrations that should control
nanobot directly.
The OpenAI-compatible API is best when you already have an HTTP client, want
process isolation, or need to call nanobot from a non-Python service.
## Common Patterns
### Use a specific config or workspace
Set the workspace when your agent should work inside a specific project:
```python
from nanobot import Nanobot
async with Nanobot.from_config(workspace="/my/project") as bot:
result = await bot.run("Explain the project structure")
```
Use a custom config when you run multiple nanobot instances or test an isolated
setup:
```python
async with Nanobot.from_config(
config_path="./bot-a/config.json",
workspace="./bot-a/workspace",
) as bot:
result = await bot.run("Hello from bot A")
```
The config controls what nanobot may use. The workspace is where nanobot keeps
state for that instance. See [multiple-instances.md](multiple-instances.md) for
multi-instance CLI and gateway examples.
### Choose a default or per-run model
Set the SDK instance default model when you create the bot:
```python
bot = Nanobot.from_config(model="openai/gpt-4.1")
```
Override the model for one run without changing the instance default:
```python
result = await bot.run("Summarize this file", model="openai/gpt-4.1-mini")
```
Model presets from `config.json` work the same way:
```python
bot = Nanobot.from_config(model_preset="fast")
result = await bot.run("Think deeply about this bug", model_preset="reasoning")
```
`model` and `model_preset` are mutually exclusive.
For first setup, prefer named presets in `config.json`. Mixing an API key from
one provider with a model ID from another is the most common first-run failure.
For the exact difference between `provider`, `model`, `apiKey`, and `apiBase`,
see [Providers: Provider, Model, API Key, and Base URL](providers.md#provider-model-api-key-and-base-url).
If a run fails before the SDK does anything interesting, confirm the same
provider and model work with `nanobot agent -m "Hello!"` first.
### 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")
```
Use stable keys in product code:
```python
session_key = f"user:{user_id}"
result = await bot.run(user_message, session_key=session_key)
```
Avoid using the default `"sdk:default"` for multiple users or unrelated
workflows. It is convenient for local experiments, but stable product code
should choose explicit keys such as `user:<id>`, `project:<id>`, or
`eval:<case-id>`.
### Handle failures
For a normal non-streamed run, catch exceptions around `bot.run(...)` and inspect
`RunResult.error` when the runtime returns a structured failure:
```python
try:
result = await bot.run("Review this repo", session_key="project:demo")
except Exception as exc:
print(f"SDK call failed before a result was returned: {exc}")
else:
if result.error:
print(f"Agent run failed: {result.error}")
else:
print(result.content)
```
For streamed runs, either consume the stream to completion or close it:
```python
run = await bot.run_streamed("Write a long answer", session_key="task:123")
try:
async for event in run.stream_events():
...
finally:
if not run.done:
await run.aclose()
```
Use `await run.cancel()` when the user presses a stop button or leaves the page
before the stream finishes.
### Stream long-running output
Use `bot.stream()` when you want Cursor/OpenAI-style live events instead of
waiting for the final `RunResult`:
```python
from nanobot import (
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_STARTED,
)
async for event in bot.stream("Review this repository"):
if event.type == STREAM_EVENT_TEXT_DELTA:
print(event.delta, end="", flush=True)
elif event.type == STREAM_EVENT_TOOL_STARTED:
print(f"\nusing {event.name}")
elif event.type == STREAM_EVENT_RUN_COMPLETED:
print("\nfinal:", event.result.content)
```
Use `run_streamed()` when you also want a handle you can wait on:
```python
from nanobot import STREAM_EVENT_TEXT_DELTA
run = await bot.run_streamed("Write a detailed migration plan")
async for event in run.stream_events():
if event.type == STREAM_EVENT_TEXT_DELTA:
print(event.delta, end="", flush=True)
result = await run.wait()
```
Always either consume the stream, call `await run.wait()` / `await run.text()`,
or close it with `await run.cancel()` / `await run.aclose()`. Exiting
`stream_events()` or `bot.stream()` early cancels the underlying run so a
half-consumed stream cannot leave a background task stuck behind backpressure.
### Import an existing transcript
This is useful for evals, benchmark runners, migrations, and tests.
Use `bot.sessions.ingest()` when you already have a transcript and want it to
become nanobot session history. Ingesting a transcript does not call the model,
execute tools, update memory, or compact automatically.
```python
await bot.sessions.ingest(
"eval:case-1",
[
{
"role": "user",
"content": "I graduated with a degree in Business Administration.",
"timestamp": "2023/05/30 (Tue) 17:27",
"source_session_id": "answer_280352e9",
},
{
"role": "assistant",
"content": "Congratulations on your degree.",
"timestamp": "2023/05/30 (Tue) 17:27",
},
],
source="longmemeval",
)
await bot.runtime.compact_session("eval:case-1")
result = await bot.run(
"Current Date: 2023/05/30 (Tue) 23:40\n"
"Question: What degree did I graduate with?",
session_key="eval:case-1",
)
print(result.content)
```
### Attach hooks for observability
Hooks are an advanced escape hatch. Use them when you want custom logging,
metrics, tracing, or output post-processing 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()])
```
## Where To Go Next
The SDK page is the programming entry point. The fuller conceptual and
configuration docs remain the source of truth for the runtime around it:
| Need | Read |
|------|------|
| First working install and config | [Install and Quick Start](quick-start.md) |
| Mental model for config, workspace, sessions, tools, and memory | [Concepts](concepts.md) |
| Provider/model/API key/base URL matching | [Providers and Models](providers.md) |
| Pasteable provider recipes | [Provider Cookbook](provider-cookbook.md) |
| Complete configuration reference | [Configuration](configuration.md) |
| Long-term memory design | [Memory](memory.md) |
| HTTP API instead of Python SDK | [OpenAI-Compatible API](openai-api.md) |
| Debugging install, config, provider, or runtime failures | [Troubleshooting](troubleshooting.md) |
## API Reference
### `Nanobot.from_config(config_path=None, *, workspace=None, model=None, model_preset=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. |
| `model` | `str \| None` | `None` | Override the instance default model. |
| `model_preset` | `str \| None` | `None` | Override the instance default model preset from `config.json`. |
Raises `FileNotFoundError` if an explicit config path does not exist.
Raises `ValueError` if both `model` and `model_preset` are provided.
### `await bot.run(...)`
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. |
| `channel` | `str` | `"cli"` | Logical channel label used in runtime context. |
| `chat_id` | `str` | `"direct"` | Logical chat identifier used in runtime context. |
| `sender_id` | `str` | `"user"` | Logical sender identifier used in runtime context. |
| `media` | `list[str] \| None` | `None` | Optional local media paths attached to the message. |
| `ephemeral` | `bool` | `False` | Run without persisting the turn or compacting session history. |
| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. |
| `model` | `str \| None` | `None` | Override the model for this run only. |
| `model_preset` | `str \| None` | `None` | Override the model preset for this run only. |
`model` and `model_preset` are per-run overrides and do not change
`bot.runtime.model` after the run completes. They are mutually exclusive.
### `await bot.run_streamed(...)`
Start a streamed agent turn and return a `RunStream`. It accepts the same
parameters as `bot.run(...)`.
```python
run = await bot.run_streamed("Generate a long answer")
async for event in run.stream_events():
...
result = await run.wait()
```
### `bot.stream(...)`
Convenience wrapper around `run_streamed()` for direct event iteration. It
accepts the same parameters as `bot.run(...)`.
```python
async for event in bot.stream("Generate a long answer"):
...
```
### `RunStream`
| Method | Description |
|--------|-------------|
| `stream_events()` | Single-consumer async iterator of `StreamEvent` objects. |
| `await wait()` | Wait for the run to finish and return `RunResult`. |
| `await text()` | Wait for the run to finish and return `RunResult.content`. |
| `await cancel()` | Cancel the run and release stream resources. |
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
Normal SDK runs with different session keys may overlap. Runs that use per-run
`model` or `model_preset` overrides are exclusive while the override is active,
because the current `AgentLoop` provider/model state is mutable.
### `StreamEvent`
| Field | Type | Description |
|-------|------|-------------|
| `type` | `StreamEventType` | Event type, such as `text.delta` or `run.completed`. |
| `delta` | `str` | Incremental text or reasoning chunk. |
| `content` | `str` | Completed text segment or final content. |
| `result` | `RunResult \| None` | Present on `run.completed`. |
| `name` | `str \| None` | Tool name for tool events. |
| `tool_call_id` | `str \| None` | Provider tool call id when available. |
| `arguments` | `dict \| None` | Tool arguments when available. |
| `iteration` | `int \| None` | Agent loop iteration when available. |
| `resuming` | `bool \| None` | Whether a text segment ended before more tool work. |
| `usage` | `dict[str, int]` | Token usage on completion events. |
| `error` | `str \| None` | Error text on failed events. |
| `metadata` | `dict` | Additional event metadata. |
Use the exported constants instead of hard-coded strings when possible:
| Constant | Value |
|----------|-------|
| `STREAM_EVENT_RUN_STARTED` | `run.started` |
| `STREAM_EVENT_TEXT_DELTA` | `text.delta` |
| `STREAM_EVENT_TEXT_COMPLETED` | `text.completed` |
| `STREAM_EVENT_REASONING_DELTA` | `reasoning.delta` |
| `STREAM_EVENT_REASONING_COMPLETED` | `reasoning.completed` |
| `STREAM_EVENT_TOOL_STARTED` | `tool.started` |
| `STREAM_EVENT_TOOL_COMPLETED` | `tool.completed` |
| `STREAM_EVENT_TOOL_FAILED` | `tool.failed` |
| `STREAM_EVENT_RUN_COMPLETED` | `run.completed` |
| `STREAM_EVENT_RUN_FAILED` | `run.failed` |
`STREAM_EVENT_TYPES` contains all stable v1 event values.
### `await bot.aclose()`
Release resources held by the SDK instance, including tool connections. The async context manager calls this automatically:
```python
async with Nanobot.from_config() as bot:
result = await bot.run("Summarize this repo")
```
### `RunResult`
| Field | Type | Description |
|-------|------|-------------|
| `content` | `str` | The agent's final text response. |
| `tools_used` | `list[str]` | Tool names used during the run. |
| `messages` | `list[dict]` | Final message list from the run. |
| `usage` | `dict[str, int]` | Token usage reported or estimated by the runtime. |
| `stop_reason` | `str \| None` | Why the run stopped, such as `"completed"` or `"max_iterations"`. |
| `error` | `str \| None` | Error text when the run failed inside the agent runtime. |
| `metadata` | `dict` | Outbound metadata such as latency. |
## Session, Memory, And Runtime Helpers
### `bot.sessions`
| Method | Description |
|--------|-------------|
| `await ingest(session_key, messages, metadata=None, source=None, save=True)` | Import existing transcript messages without running the model. |
| `get(session_key)` | Return a `SessionSnapshot`, or `None` if missing. |
| `list()` | Return compact `SessionInfo` rows. |
| `export(session_key)` | Return a full `SessionSnapshot` suitable for JSON serialization. |
| `clear(session_key)` | Clear and persist one session. |
| `delete(session_key)` | Delete one session from disk and cache. |
| `flush()` | Flush cached sessions to durable storage. |
Ingested messages must include `role` and `content`. Roles may be `user`,
`assistant`, `tool`, or `system`. Other fields, such as `timestamp`,
`source_session_id`, or `source_date`, are persisted as message metadata.
### `bot.memory`
| Method | Description |
|--------|-------------|
| `read()` | Read `memory/MEMORY.md`. |
| `write(text)` | Overwrite `memory/MEMORY.md`. |
| `append_history(text, session_key=None)` | Append one `memory/history.jsonl` entry and return its cursor. |
| `read_history(session_key=None)` | Read memory history entries, optionally filtered by session key. |
### `bot.runtime`
| Method / Property | Description |
|-------------------|-------------|
| `model` | Current runtime model name. |
| `workspace` | Current runtime workspace path. |
| `await compact_session(session_key)` | Run token/replay-window consolidation for a session. |
| `await compact_idle_session(session_key, max_suffix=8)` | Run idle-session compaction and return its summary. |
## 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:
async with Nanobot.from_config(workspace="/my/project") as bot:
result = await bot.run(
"Explain the main function",
session_key="sdk:demo",
hooks=[TimingHook()],
)
print(result.content)
asyncio.run(main())
```
+347
View File
@@ -0,0 +1,347 @@
# Install and Quick Start
This page gets one local nanobot reply working. After that, you can add the WebUI, chat apps, local models, web search, MCP, deployment, or custom plugins.
If you have never used a terminal or edited a config file before, use [`start-without-technical-background.md`](./start-without-technical-background.md) first. This page assumes you are comfortable pasting commands and editing JSON snippets.
## Before You Start
You need:
- Python 3.11 or newer.
- One LLM provider, company endpoint, subscription endpoint, or local model server you can call. The examples below use a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service; any supported provider works when the key, provider name, and model ID match.
- Git only if you install from source.
- Node.js or Bun only if you are developing the WebUI itself.
> [!IMPORTANT]
> Repository docs may describe features that are available first in source. Install from PyPI or `uv` for the stable day-to-day release; install from source when you want the newest repository behavior or plan to contribute.
## 1. Install
Pick one install method.
**One-command setup:**
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
```
On Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, go straight to [Open the WebUI](#5-open-the-webui).
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
```
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
```
To install the current `main` branch instead, pass `--dev`:
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
```
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
```
If `curl` or `irm` is unavailable, or GitHub raw downloads are blocked on your network, use one of the manual install methods below.
If you prefer to inspect the script first, open [`../scripts/install.sh`](../scripts/install.sh) or [`../scripts/install.ps1`](../scripts/install.ps1).
**Stable release with `uv`:**
```bash
uv tool install nanobot-ai
nanobot --version
```
**Stable release with pip:**
```bash
python -m pip install nanobot-ai
nanobot --version
```
Use pip only inside an environment you control. If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment first.
**Latest source checkout:**
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install -e .
nanobot --version
```
If your shell cannot find `nanobot` after a pip install, run the module form:
```bash
python -m nanobot --version
python -m nanobot onboard
```
On Windows, `~` in the docs means your user profile directory, for example `C:\Users\you`.
The docs use `python` in commands. If your system exposes Python 3.11+ as `python3` or `py`, use that command in the same place, for example `python3 -m pip install nanobot-ai` or `py -m nanobot --version`.
## 2. Initialize
Skip this section if the one-command setup already started the wizard and Quick Start finished there.
```bash
nanobot onboard
```
Use the wizard if you prefer prompts instead of editing JSON by hand:
```bash
nanobot onboard --wizard
```
Initialization creates:
| Path | What it is |
|------|------------|
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API |
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values.
## 3. Configure a Provider
Skip this section if you already configured provider and model settings in the wizard.
Open `~/.nanobot/config.json`. Add or merge these blocks into the file created by `nanobot onboard`; do not replace the whole file unless you want to reset the config.
**API key:**
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
}
}
```
**Model preset:**
```json
{
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 8192,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
The provider and model inside a preset must match. The snippet above is only an example. For another provider, replace these values together:
| Replace | Where |
|---|---|
| Provider config key, such as `custom` | `providers.<provider>` |
| API key or environment variable | `providers.<provider>.apiKey` |
| Preset provider name | `modelPresets.primary.provider` |
| Model ID | `modelPresets.primary.model` |
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and fallback chains. For provider-specific examples across direct, gateway, OAuth, cloud, and local setups, see [`providers.md`](./providers.md).
**What about `apiBase` / base URL?**
`apiBase` is the HTTP base URL of the provider endpoint, not the model name. Most hosted providers in nanobot already know their default endpoint, so you usually only set `apiKey` and a model preset. Set `apiBase` when you are using:
- `custom` for a third-party or self-hosted OpenAI-compatible API;
- a local OpenAI-compatible server such as Ollama, vLLM, or LM Studio;
- a provider-specific alternate endpoint, regional endpoint, proxy, or subscription endpoint.
Examples:
```json
{
"providers": {
"custom": {
"apiKey": "${CUSTOM_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
```
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1"
}
}
}
```
If the provider's docs say the endpoint is `/v1`, include `/v1` in `apiBase`. The model ID still belongs in the active `modelPresets` entry.
If you prefer not to store secrets in `config.json`, reference an environment variable and set it before starting nanobot:
```json
{
"providers": {
"custom": {
"apiKey": "${PROVIDER_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
```
## 4. Check the Setup
```bash
nanobot status
```
This should show the config path, workspace path, active model or preset, and provider summary. It does not send a message to the model, so use it as a quick config check before the first real request.
Read it like this:
| Status line | What you want |
|---|---|
| `Config` | A check mark. |
| `Workspace` | A check mark. |
| `Model` | The model or preset you expect. |
| Provider list | Most providers can say `not set`; the provider used by the active preset should show a check mark, OAuth status, or local URL. |
## 5. Open the WebUI
If Quick Start enabled the WebSocket channel, start the gateway:
```bash
nanobot gateway
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
## 6. Test One CLI Message
Use this path if you skipped Quick Start, declined the WebSocket channel, or want a terminal-only check.
Run a one-shot CLI message:
```bash
nanobot agent -m "Hello!"
```
A successful first run proves that:
- the `nanobot` command is installed;
- `~/.nanobot/config.json` can be loaded;
- the selected provider and model can answer;
- the default workspace can be created and used.
The reply text itself will vary. Any normal assistant answer means the install, config, provider, model, and workspace path are all usable.
If that works, start an interactive CLI chat:
```bash
nanobot agent
```
After the interactive session can answer normally, nanobot can help with its own next setup step. Ask it to read the relevant docs, inspect your current `~/.nanobot/config.json`, and make one concrete change such as enabling WebUI, adding a provider preset, or configuring one chat channel. When nanobot says the config is updated, run `/restart` in the chat or restart the nanobot process manually so long-running processes reload `config.json`.
Example prompt:
```text
Read docs/quick-start.md, docs/providers.md, and docs/configuration.md in this checkout.
Then update ~/.nanobot/config.json to add a model preset named "primary" for my provider.
Tell me exactly what changed and whether I need to run /restart.
```
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
## 7. Choose Your Next Step
| Want to... | Go to |
|---|---|
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
| Understand provider/model matching | [`providers.md`](./providers.md) |
| Open the bundled browser UI | [`webui.md`](./webui.md) |
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [`chat-apps.md`](./chat-apps.md) |
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
## Updating
**pip:**
```bash
python -m pip install -U nanobot-ai
nanobot --version
```
If pip reports `externally-managed-environment`, upgrade with the same isolated method you used to install nanobot, such as `uv tool upgrade nanobot-ai`, `pipx upgrade nanobot-ai`, or the managed venv created by the one-command installer.
**uv:**
```bash
uv tool upgrade nanobot-ai
nanobot --version
```
**pipx:**
```bash
pipx upgrade nanobot-ai
nanobot --version
```
**Source checkout:**
```bash
git pull
python -m pip install -e .
nanobot --version
```
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
```bash
python -m pip install -e ".[whatsapp]"
```
## First-Run Troubleshooting
| Symptom | What to check |
|---------|---------------|
| `nanobot: command not found` | Use `python -m nanobot ...`, or add your Python scripts directory to `PATH`. |
| `ModuleNotFoundError: nanobot` | Confirm you installed into the same Python environment that is running the command. |
| JSON parse errors | Check commas and braces in `~/.nanobot/config.json`; examples above are partial snippets to merge. |
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
| WebUI does not open | Enable the WebSocket channel and open port `8765`, not the gateway health port `18790`. |
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).
+421
View File
@@ -0,0 +1,421 @@
# Start Without Technical Background
This page is for you if you have never used a terminal, edited a JSON file, or configured an AI model before.
The goal is small: get one local nanobot reply in your browser. Do not connect Telegram, Discord, Docker, local models, or deployment yet. Those are easier after the first reply works.
## What You Are Setting Up
You only need these words for Quick Start:
| Word | Plain meaning |
|---|---|
| Terminal | A text window where you paste commands and press Enter. |
| Command | One line of text you run in the terminal. |
| API key | A password-like token from an AI provider. Do not share it publicly. |
| Config file | The settings file nanobot reads when it starts. |
| Wizard | An interactive terminal menu that edits the config file for you. |
| Browser UI | The local web page where you chat with nanobot. |
## 1. Open a Terminal
You will paste commands into a terminal. Copy only the command text inside each code block; do not copy the ``` marks.
| System | How to open it |
|---|---|
| Windows | Press `Win`, type `PowerShell`, then open **Windows PowerShell**. |
| macOS | Press `Command` + `Space`, type `Terminal`, then press `Enter`. |
| Linux | Open your app launcher, search for `Terminal`, then open it. |
When the terminal opens, click inside it, paste the command, and press `Enter`. If a command prints text and returns to a prompt, that is usually normal.
## 2. Install Python
Install Python 3.11 or newer from [python.org](https://www.python.org/downloads/).
On Windows, enable **Add python.exe to PATH** during installation if the installer shows that option.
In that terminal, check Python:
```bash
python --version
```
If Windows says `python` is not found, close and reopen PowerShell. If it still does not work, try:
```bash
py --version
```
If `py` works but `python` does not, replace `python` with `py` in the commands below.
If macOS or Linux says `python` is not found, try:
```bash
python3 --version
```
If `python3` works but `python` does not, replace `python` with `python3` in the manual commands below. The one-command installer already checks both `python3` and `python`.
## 3. Get a Provider API Key
nanobot does not create AI accounts or API keys for you. Use an AI provider account, company endpoint, subscription endpoint, or local model server that you already control. If the provider has an OpenAI-compatible base URL in its docs, keep that nearby too.
For the setup path:
1. Open your provider's API key page.
2. Create or copy an API key.
3. Keep the key private.
4. Keep the provider's base URL nearby if the provider docs show one.
## 4. Install nanobot
The easiest path is the one-command installer. It installs or upgrades nanobot, then starts the setup wizard. On macOS and Linux it avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`.
**macOS / Linux**
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
```
**Windows PowerShell**
```powershell
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`:
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
```
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
```
Use the development installer only when a maintainer asks you to test the current `main` branch:
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
```
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
```
If the command says `curl` or `irm` is not found, or it cannot download from GitHub, use one of the manual install commands below.
If `uv` is installed, use:
```bash
uv tool install nanobot-ai
```
If you prefer pip, use it only inside an environment you control:
```bash
python -m pip install nanobot-ai
```
If pip reports `externally-managed-environment` on macOS or Linux, go back to the one-command installer, use `uv tool install nanobot-ai`, use `pipx install nanobot-ai`, or create a virtual environment first.
Then check that nanobot is installed:
```bash
nanobot --version
```
If the terminal cannot find `nanobot`, use the module form:
```bash
python -m nanobot --version
```
Use `python3 -m nanobot --version` or `py -m nanobot --version` if that is the Python command that worked in step 2.
## 5. Run the Setup Wizard
The one-command installer starts this for you after installation. If you installed manually, run:
```bash
nanobot onboard --wizard
```
If `nanobot` is not found, run:
```bash
python -m nanobot onboard --wizard
```
Use `python3 -m nanobot onboard --wizard` or `py -m nanobot onboard --wizard` if that is the Python command that worked in step 2.
The wizard is a terminal menu. It is not a graphical app, but it lets you choose options instead of hand-editing every JSON field.
You will see a menu like this:
```text
> What would you like to do?
[Q] Quick Start
[A] Advanced Settings
[X] Exit
```
Move through the wizard like this:
| When you see | Do this |
|---|---|
| A menu | Use the arrow keys to highlight an option, then press `Enter`. |
| The provider menu | Choose the company or service you want to use. |
| An endpoint menu | Choose the standard API or subscription plan endpoint that matches your key. |
| An API key field | Paste the key, then press `Enter`. |
| A provider base URL field | Paste the provider base URL from its docs, then press `Enter`. |
| The Model ID field | Paste a model name from your provider, then press `Enter`. |
| A back option in Advanced Settings | Choose it to return to the previous menu. |
For the first setup, choose `[Q] Quick Start`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a chat app, a tool setup, or provider-specific fields.
1. Choose `[Q] Quick Start`.
2. Choose the provider you want to use.
3. Choose the endpoint if the wizard asks, such as Standard API, Coding Plan, Token Plan, or Step Plan.
4. Paste your API key if the wizard asks for one.
5. Paste the provider base URL if the wizard asks for one.
6. Paste a model ID that provider can run.
7. Confirm that Quick Start should enable the WebSocket channel for the local WebUI.
8. Set the WebUI password when prompted.
9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
The recommended path enables `channels.websocket` for the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
The wizard creates or updates:
| Path | Meaning |
|---|---|
| `~/.nanobot/config.json` | Settings file. |
| `~/.nanobot/workspace/` | Working folder for memory, sessions, and generated files. |
If Quick Start finished successfully, skip to [Open the WebUI](#7-open-the-webui). The next two sections are only for manual setup.
## Manual Setup: How to Merge JSON Snippets
Most docs examples are snippets, not whole files. Your `config.json` has one outer `{ ... }`. Add new top-level sections such as `providers`, `modelPresets`, `agents`, or `channels` inside that same outer object.
Do not paste two separate JSON objects into one file:
```text
{
"providers": { "...": "..." }
}
{
"channels": { "...": "..." }
}
```
Merge them into one object:
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
},
"channels": {
"websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard --wizard` whenever possible.
## 6. Manual Setup: Config Fallback
Use this only if the wizard is unavailable or you prefer opening the file yourself.
Run `nanobot onboard` first if `~/.nanobot/config.json` does not exist yet.
Use one of these commands:
**Windows PowerShell**
```powershell
notepad "$env:USERPROFILE\.nanobot\config.json"
```
**macOS**
```bash
open -e ~/.nanobot/config.json
```
**Linux**
```bash
xdg-open ~/.nanobot/config.json
```
If this is a brand-new install and you have not configured anything else yet, replace the file with this minimal config:
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
},
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
},
"channels": {
"websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
Replace `your-api-key`, `https://api.example.com/v1`, `model-id-from-your-provider`, and `your-webui-password` with your own values.
For copyable provider-specific examples, use [`provider-cookbook.md`](./provider-cookbook.md).
Save the file.
## 7. Open the WebUI
First check that nanobot can read the saved setup:
```bash
nanobot status
```
This should show the config file path, workspace path, and the active model or preset. If `nanobot` is not found, use `python -m nanobot status`, `python3 -m nanobot status`, or `py -m nanobot status`, matching the Python command that worked in step 2.
It is normal for most providers to say `not set`. Only the provider you selected for the active preset needs to look configured.
Start the local browser UI:
```bash
nanobot gateway
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard or the `tokenIssueSecret` value from your manual config.
Send this first message in the browser:
```text
Hello!
```
If that works, nanobot is installed and can call the model. You should see a normal assistant reply in the browser. The exact words will differ, but it should look like this shape:
```text
Hello! How can I help you today?
```
If `nanobot` is not found, run:
```bash
python -m nanobot gateway
```
Use `python3 -m nanobot gateway` or `py -m nanobot gateway` if that is the Python command that worked in step 2.
Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app.
## 8. If Something Fails
Do not change many things at once. Check the exact error:
| Error or symptom | What it usually means |
|---|---|
| `JSON parse error` | The config file has a missing comma, extra comma, or mismatched brace. Copy the example again. |
| `401`, `unauthorized`, or `invalid API key` | The API key is wrong, expired, has extra spaces, or was pasted under the wrong provider. |
| `model not found` | Your account cannot use the default model. Return to `nanobot onboard --wizard`, choose `Advanced Settings`, then edit `Model Presets`. |
| `nanobot: command not found` | The install worked in Python, but your shell cannot find the script. Use `python -m nanobot ...`, `python3 -m nanobot ...`, or `py -m nanobot ...`, matching the Python command that worked earlier. |
| No response after editing config | Restart the command. Long-running processes read config when they start. |
For a fuller diagnosis path, see [`troubleshooting.md`](./troubleshooting.md).
## What Not to Configure Yet
Skip these until the first local message works:
- `apiBase`: hosted built-in providers often already have default endpoints. You only need `apiBase` for local models, proxies, custom OpenAI-compatible providers, or special regional/subscription endpoints.
- chat apps: first prove the local browser UI can answer.
- fallback models: useful later, but not needed for the first reply.
- Langfuse: useful for observability, but not needed for first setup.
## Next Steps
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot gateway` open whenever you use the WebUI or a chat app.
### Open the Browser UI Again
Run:
```bash
nanobot gateway
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser.
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
### Connect a Chat App
1. Read the section for one app in [`chat-apps.md`](./chat-apps.md).
2. Add only that app's config snippet. Merge it into the existing file instead of replacing the whole file.
3. Run:
```bash
nanobot channels status
nanobot gateway
```
4. Leave the gateway terminal open, then send a message from the allowed account.
Start with a private chat or a test server. Do not set `allowFrom` to `["*"]` unless you intentionally want anyone who can reach that channel to talk to the bot.
### Change Models or Add Backups
Use [`providers.md`](./providers.md) when a provider/model pair fails, and [`provider-cookbook.md`](./provider-cookbook.md) when you want copyable snippets. Keep model choices in `modelPresets`, then select the active one with `agents.defaults.modelPreset`.
### Ask for Help
When you ask for help, include:
- your operating system;
- the command you ran;
- `nanobot --version`;
- `nanobot status`;
- whether the browser UI can answer `Hello!`;
- the exact error text;
- a config snippet with API keys and tokens removed.
Never paste real API keys, bot tokens, OAuth tokens, or private chat IDs into a public issue or chat.
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
+266
View File
@@ -0,0 +1,266 @@
# Troubleshooting
Use this page to isolate where a failure lives. Start with the smallest surface that proves the most: local CLI first, then gateway, then WebUI or chat apps.
## Fast Diagnosis Order
Run these in order:
```bash
nanobot --version
nanobot status
nanobot agent -m "Hello!"
```
Then, only if the CLI works:
```bash
nanobot gateway
```
This separates failures into layers:
| Layer | What it proves |
|---|---|
| `nanobot --version` | Install and shell command discovery |
| `nanobot status` | Config path, workspace path, active model, and provider summary |
| `nanobot agent -m "Hello!"` | Config loading, provider/model access, workspace writes, and agent loop |
| `nanobot gateway` | Channel startup, cron system jobs, heartbeat, WebUI/WebSocket, and health endpoint |
If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram, Discord, Docker, systemd, or any chat app.
## How to Read `nanobot status`
`nanobot status` does not call a model. It only checks whether nanobot can find the default config, default workspace, active model or preset, and provider setup summary.
The output has this shape:
```text
nanobot Status
Config: /path/to/config.json ✓
Workspace: /path/to/workspace ✓
Model: provider/model-name (preset: primary)
Provider A: not set
Provider B: ✓
Local Provider: ✓ http://localhost:11434/v1
OAuth Provider: ✓ (OAuth)
```
Read it like this:
| Line | Good sign | What to do if it looks wrong |
|---|---|---|
| `Config` | It points to the config file you meant to use and shows `✓`. | Run `nanobot onboard`, or pass `--config` to `nanobot agent`, `gateway`, or `serve` when testing a non-default instance. |
| `Workspace` | It points to the workspace you meant to use and shows `✓`. | Run `nanobot onboard`, create the folder, fix permissions, or pass `--workspace` on commands that support it. |
| `Model` | It shows the active model or the preset name you expect. | Set `agents.defaults.modelPreset` to the intended preset, or check `/model` if you changed models during a chat session. |
| Provider rows | The provider used by the active preset shows `✓`, an OAuth marker, or a local URL. | Configure only the active provider first. It is normal for unused providers to say `not set`. |
If `nanobot status` looks right but `nanobot agent -m "Hello!"` fails, the install and config paths are probably fine. Continue with [Provider and Model Problems](#provider-and-model-problems).
## Installation Problems
Use the same Python command for install checks and module fallback. On macOS/Linux that may be `python3`; on Windows it may be `python` or `py`.
| Symptom | Check |
|---|---|
| `python: command not found` | Try `python3 --version` on macOS/Linux or `py --version` on Windows. Then replace `python` in docs commands with the command that worked. |
| `curl: command not found` | The macOS/Linux one-command installer could not download the script. Install curl, or use a manual isolated install such as `uv tool install nanobot-ai` or `pipx install nanobot-ai`. |
| `irm` is not recognized | PowerShell could not run the download helper. Use manual install: `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or `py -m pip install nanobot-ai` inside an environment you control. |
| Could not download `raw.githubusercontent.com` | Your network, proxy, or firewall blocked the installer script download. Use manual install from PyPI, or configure your proxy and rerun the command. |
| `nanobot: command not found` | Use the module form, for example `python -m nanobot ...`, `python3 -m nanobot ...`, or `py -m nanobot ...`. Reinstall with the same Python command, or add that Python's scripts directory to `PATH`. |
| `No module named nanobot` | You are running a different Python than the one used for installation. Run `python -m pip show nanobot-ai`, `python3 -m pip show nanobot-ai`, or `py -m pip show nanobot-ai`, matching the command that installed nanobot. |
| `pip is not available` | When the installer uses a virtual environment, it tries `python -m ensurepip --upgrade`. If that fails, install pip for that Python, or use a Python installer/distribution that includes pip. |
| `externally-managed-environment` | Your system Python blocks global pip installs. Use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment; do not add `--break-system-packages` for nanobot. |
| Installer chose the wrong Python | Set `PYTHON` before running the installer, such as `curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | PYTHON=python3 sh` or `$env:PYTHON="py"` before the PowerShell command. |
| Editable source install does not update | From the repo root, run `python -m pip install -e .` again with the Python command used for development, then check `python -m nanobot --version` or `nanobot --version`. |
| WebUI build tools missing | They are only needed for WebUI development. Packaged installs already include the WebUI bundle. |
## Config Problems
Default config path:
```text
~/.nanobot/config.json
```
Default workspace path:
```text
~/.nanobot/workspace/
```
`nanobot status` reads the default config. Use explicit paths on commands that support them when debugging multiple instances:
```bash
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
```
Common config mistakes:
| Symptom | Check |
|---|---|
| JSON parse error | Validate commas, braces, and quotes. Most docs examples are partial snippets to merge. |
| Unknown or missing provider | Use provider registry names such as `openrouter`, `anthropic`, `openai`, `ollama`, `vllm`, `lm_studio`, or define a custom OpenAI-compatible provider key under `providers` and reference that exact key from the active preset. |
| snake_case vs camelCase confusion | Both are accepted, but docs use camelCase because nanobot writes config with aliases such as `apiKey`, `modelPresets`, `intervalS`. |
| Environment variable error | `${VAR_NAME}` references are resolved at startup. Set the variable before running nanobot. |
| Edited config but behavior did not change | Restart `nanobot gateway`; long-running processes read config at startup. |
To refresh missing defaults without overwriting existing settings, run:
```bash
nanobot onboard
```
When prompted about overwriting the config, choose the option that keeps current values and merges missing defaults.
## Provider and Model Problems
First prove the provider in the CLI:
```bash
nanobot agent -m "Hello!"
```
Then compare your config against [`providers.md`](./providers.md).
If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.md`](./provider-cookbook.md).
| Symptom | Likely cause |
|---|---|
| 401, unauthorized, invalid API key | Key is missing, expired, pasted with whitespace, or under the wrong provider key. |
| Model not found | The model ID belongs to a different provider or gateway. |
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
| OAuth provider fails | Run `nanobot provider login openai-codex` or `nanobot provider login github-copilot`, then select the provider explicitly. |
## Langfuse Problems
Langfuse tracing is optional and controlled by environment variables.
| Symptom | Check |
|---|---|
| `LANGFUSE_SECRET_KEY is set but langfuse is not installed` | Install `langfuse` in the same Python environment that runs nanobot, then restart the process. |
| No traces appear | Set `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, and `LANGFUSE_BASE_URL` before starting nanobot. |
| Wrong Langfuse project or region | Check that the key pair and `LANGFUSE_BASE_URL` come from the same Langfuse project/region. |
| Only some providers trace | Langfuse tracing applies to OpenAI-compatible provider calls; native providers may not use that client path. |
See [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) for setup commands.
## Gateway Problems
`nanobot gateway` is required for WebUI, chat apps, heartbeat, Dream, and long-running channel connections.
Default ports:
| Surface | Default |
|---|---|
| Gateway health endpoint | `http://127.0.0.1:18790/health` |
| WebUI/WebSocket channel | `http://127.0.0.1:8765` |
| OpenAI-compatible API (`nanobot serve`) | `http://127.0.0.1:8900` |
Common gateway checks:
```bash
nanobot gateway --verbose
```
| Symptom | Check |
|---|---|
| Port already in use | Change `gateway.port`, `channels.websocket.port`, or the `--port` CLI flag for the relevant command. |
| WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. |
| Config changes ignored | Restart the gateway. |
| Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. |
| Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. |
## WebUI Problems
The packaged WebUI is served by the WebSocket channel.
Minimal config:
```json
{
"channels": {
"websocket": {
"enabled": true
}
}
}
```
Then run:
```bash
nanobot gateway
```
Open:
```text
http://127.0.0.1:8765
```
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
## Chat App Problems
Before debugging a chat app:
```bash
nanobot agent -m "Hello!"
nanobot channels status
nanobot gateway
```
Then check:
| Symptom | Check |
|---|---|
| Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. |
| Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. |
| Telegram fails | Confirm the BotFather token and `allowFrom` user ID. |
| Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. |
| WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. |
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
## Tool and Workspace Problems
| Symptom | Check |
|---|---|
| File access denied | Check `tools.restrictToWorkspace` and whether the target path is inside the active workspace. |
| Shell commands fail in Docker | Sandbox settings may need Linux capabilities; see [`deployment.md`](./deployment.md). |
| Web fetch blocked | SSRF protection blocks unsafe targets; use `tools.ssrfWhitelist` only for trusted private networks. |
| MCP tools missing | Check `tools.mcpServers`, server startup command, environment variables, and tool allow list. |
| Generated artifacts are missing | Check the active workspace and channel media directory. |
## Memory and Session Problems
| Symptom | Check |
|---|---|
| Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. |
| Memory does not update immediately | Dream consolidation is periodic; recent turns still live in session history. |
| Old sessions appear after moving config | Session files are stored under `<workspace>/sessions/`; verify the workspace path. |
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
## Collect Useful Evidence
When opening an issue or asking for help, include:
- install method and `nanobot --version`;
- operating system and Python version;
- the command you ran;
- relevant `nanobot status` output;
- sanitized config snippets, especially provider, model, channel, and tool settings;
- gateway logs from `nanobot gateway --verbose`;
- whether `nanobot agent -m "Hello!"` works.
Never paste real API keys, bot tokens, OAuth tokens, or private chat IDs into public issues.
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
+432
View File
@@ -0,0 +1,432 @@
# WebSocket Server Channel
Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs, scripts) to interact with the agent in real time via persistent connections.
## Features
- Bidirectional real-time communication over WebSocket
- Streaming support — receive agent responses token by token
- Token-based authentication (static tokens and short-lived issued tokens)
- 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
## Quick Start
### 1. Configure
Add to `config.json` under `channels.websocket`:
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "127.0.0.1",
"port": 8765,
"path": "/",
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true,
"allowFrom": ["*"],
"streaming": true
}
}
}
```
### 2. Start nanobot
```bash
nanobot gateway
```
You should see:
```text
WebSocket server listening on ws://127.0.0.1:8765/
```
### 3. Connect a client
```bash
# Using websocat
websocat ws://127.0.0.1:8765/?client_id=alice
# Using Python
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8765/?client_id=alice") as ws:
ready = json.loads(await ws.recv())
print(ready) # {"event": "ready", "chat_id": "...", "client_id": "alice"}
await ws.send(json.dumps({"content": "Hello nanobot!"}))
reply = json.loads(await ws.recv())
print(reply["text"])
asyncio.run(main())
```
## Connection URL
```text
ws://{host}:{port}{path}?client_id={id}&token={token}
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. |
| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured. |
## Wire Protocol
All frames are JSON text. Each message has an `event` field.
### Server → Client
**`ready`** — sent immediately after connection is established:
```json
{
"event": "ready",
"chat_id": "uuid-v4",
"client_id": "alice"
}
```
**`message`** — full agent response:
```json
{
"event": "message",
"chat_id": "uuid-v4",
"text": "Hello! How can I help?",
"media": ["/tmp/image.png"],
"reply_to": "msg-id"
}
```
`media` and `reply_to` are only present when applicable.
**`delta`** — streaming text chunk (only when `streaming: true`):
```json
{
"event": "delta",
"chat_id": "uuid-v4",
"text": "Hello",
"stream_id": "s1"
}
```
**`stream_end`** — signals the end of a streaming segment:
```json
{
"event": "stream_end",
"chat_id": "uuid-v4",
"stream_id": "s1"
}
```
**`reasoning_delta`** — incremental model reasoning / thinking chunk for the active assistant turn. Mirrors `delta` but targets the reasoning bubble above the answer rather than the answer body:
```json
{
"event": "reasoning_delta",
"chat_id": "uuid-v4",
"text": "Let me decompose ",
"stream_id": "r1"
}
```
**`reasoning_end`** — close marker for the active reasoning stream. WebUI uses this to lock the in-place bubble and switch from the shimmer header to a static collapsed state:
```json
{
"event": "reasoning_end",
"chat_id": "uuid-v4",
"stream_id": "r1"
}
```
Reasoning frames only flow when the channel's `showReasoning` is `true` (default) and the model returns reasoning content (DeepSeek-R1 / Kimi / MiMo / OpenAI reasoning models, Anthropic extended thinking, or inline `<think>` / `<thought>` tags). Models without reasoning produce zero `reasoning_delta` frames.
**`runtime_model_updated`** — broadcast when the gateway runtime model changes, for example after `/model <preset>`:
```json
{
"event": "runtime_model_updated",
"model_name": "openai/gpt-4.1-mini",
"model_preset": "fast"
}
```
`model_preset` is omitted when no named preset is active. WebUI clients use this event to keep the displayed model badge in sync across slash commands, config reloads, and settings changes.
**`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
**Legacy (default chat):** send a plain string, or a JSON object with a recognized text field:
```json
"Hello nanobot!"
```
```json
{"content": "Hello nanobot!"}
```
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
All fields go under `channels.websocket` in `config.json`.
### Connection
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable the WebSocket server. |
| `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
| `port` | int | `8765` | Listen port. |
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
| `maxMessageBytes` | int | `37748736` | Maximum inbound message size in bytes (1 KB 40 MB). Default (36 MB) is sized to accept up to 4 base64-encoded image attachments at 8 MB each; lower it if the channel only carries text. |
### Authentication
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). |
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 86,400). |
### Access Control
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `allowFrom` | list of string | `["*"]` | Allowed `client_id` values. `"*"` allows all; `[]` denies all. |
### Streaming
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `streaming` | bool | `true` | Enable streaming mode. The agent sends `delta` + `stream_end` frames instead of a single `message`. |
### Keep-alive
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `pingIntervalS` | float | `20.0` | WebSocket ping interval in seconds (5 300). |
| `pingTimeoutS` | float | `20.0` | Time to wait for a pong before closing the connection (5 300). |
### TLS/SSL
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `sslCertfile` | string | `""` | Path to the TLS certificate file (PEM). Both `sslCertfile` and `sslKeyfile` must be set to enable WSS. |
| `sslKeyfile` | string | `""` | Path to the TLS private key file (PEM). Minimum TLS version is enforced as TLSv1.2. |
## Token Issuance
For production deployments where `websocketRequiresToken: true`, use short-lived tokens instead of embedding static secrets in clients.
### How it works
1. Client sends `GET {tokenIssuePath}` with `Authorization: Bearer {tokenIssueSecret}` (or `X-Nanobot-Auth` header).
2. Server responds with a one-time-use token:
```json
{"token": "nbwt_aBcDeFg...", "expires_in": 300}
```
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
4. The token is consumed (single use) and cannot be reused.
### Example setup
```json
{
"channels": {
"websocket": {
"enabled": true,
"port": 8765,
"path": "/ws",
"tokenIssuePath": "/auth/token",
"tokenIssueSecret": "your-secret-here",
"tokenTtlS": 300,
"websocketRequiresToken": true,
"allowFrom": ["*"],
"streaming": true
}
}
}
```
Client flow:
```bash
# 1. Obtain a token
curl -H "Authorization: Bearer your-secret-here" http://127.0.0.1:8765/auth/token
# 2. Connect using the token
websocat "ws://127.0.0.1:8765/ws?client_id=alice&token=nbwt_aBcDeFg..."
```
### Limits
- Issued tokens are single-use — each token can only complete one handshake.
- 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.
- **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.
## Media Files
Outbound `message` events may include a `media` field containing local filesystem paths. Remote clients cannot access these files directly — they need either:
- A shared filesystem mount, or
- An HTTP file server serving the nanobot media directory
## Common Patterns
### Trusted local network (no auth)
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"websocketRequiresToken": false,
"allowFrom": ["*"],
"streaming": true
}
}
}
```
### Static token (simple auth)
```json
{
"channels": {
"websocket": {
"enabled": true,
"token": "my-shared-secret",
"allowFrom": ["alice", "bob"]
}
}
}
```
Clients connect with `?token=my-shared-secret&client_id=alice`.
### Public endpoint with issued tokens
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"path": "/ws",
"tokenIssuePath": "/auth/token",
"tokenIssueSecret": "production-secret",
"websocketRequiresToken": true,
"sslCertfile": "/etc/ssl/certs/server.pem",
"sslKeyfile": "/etc/ssl/private/server-key.pem",
"allowFrom": ["*"]
}
}
}
```
### Custom path
```json
{
"channels": {
"websocket": {
"enabled": true,
"path": "/chat/ws",
"allowFrom": ["*"]
}
}
}
```
Clients connect to `ws://127.0.0.1:8765/chat/ws?client_id=...`. Trailing slashes are normalized, so `/chat/ws/` works the same.
+189
View File
@@ -0,0 +1,189 @@
# WebUI
The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already
works, when you want a persistent chat workspace, visible agent activity,
workspace controls, Apps, Skills, settings, and Automations in one place.
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
the `webui/` source directory when you are changing the frontend itself.
## Open the WebUI
First confirm your provider and model can answer:
```bash
nanobot agent -m "Hello!"
```
Then merge the WebSocket channel into your existing `~/.nanobot/config.json`.
Set `tokenIssueSecret` to the password you will enter in the WebUI login form:
```json
{
"channels": {
"websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
If you are new to JSON snippets, see
[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets).
Start the gateway:
```bash
nanobot gateway
```
Leave the gateway running and open
[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the
WebSocket channel on port `8765` by default. The gateway health endpoint,
`18790` by default, is not the browser UI.
Enter `tokenIssueSecret` when the WebUI asks for a password.
## What It Is For
| Area | Use it for |
|---|---|
| Chat | Start, switch, search, fork, and delete browser sessions |
| Agent activity | See thinking, tool calls, file activity, command output, and generated artifacts in context |
| Workspace | Pick the project workspace before asking for file or shell work |
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
| Skills | Inspect available built-in and workspace skills before relying on them |
| Automations | Review, search, run, pause, edit, and delete scheduled agent turns |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
## Chat Workspace
The sidebar is the session switcher. A session keeps its own history, title,
workspace metadata, and linked automations. Use a new session when you want a
separate context; use fork when you want to continue from an existing point
without changing the original thread.
The message timeline shows both user-visible replies and agent activity. Long
tool or reasoning sections can be expanded when you need the details.
## Workspace and Access
Use the workspace picker before starting project-specific work. This gives the
agent the right project context for file paths, shell commands, and session
metadata.
The access control in the composer controls the local capability level for the
chat. It does not bypass your gateway, provider, shell sandbox, or operating
system configuration; it only selects among the capabilities that are already
available to this WebUI session.
## Composer
The composer supports plain messages, image attachments, voice input when
transcription is configured, slash commands, and `@` mentions for installed Apps
or MCP presets. The model badge shows the current model or preset and links back
to model settings when setup is incomplete.
For image generation, configure an image provider first and then use the WebUI
image mode from the composer. See [`image-generation.md`](./image-generation.md)
for provider setup and output behavior.
## Apps
Open Apps from the sidebar or settings navigation to manage integrations that
nanobot can call from a chat. CLI Apps install local adapters that nanobot runs
on your machine; they do not modify the native apps themselves. MCP presets add
predefined MCP server configurations.
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
extraction tools without requiring an API key. This does not replace nanobot's
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
turn needs Firecrawl's richer web data tools.
After an App or MCP preset is available, mention it from the composer with `@`
to attach that capability to the next message.
## Skills
The Skills view shows the skill instructions available to the agent, including
built-in skills and workspace-provided skills. Check this view when you want to
know whether nanobot already has a focused workflow for a task before you ask it
to perform that task.
## Automations
Automations are scheduled agent turns. They should be created from the chat,
channel, or session where they are supposed to run so nanobot keeps the correct
target context. When an automation runs, it normally delivers the result back to
that linked chat.
For recurring background checks that should stay quiet unless there is something
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
instead of creating a chat automation.
Use the Automations view to:
- Filter by all, active, paused, needs-attention, or system jobs.
- Search by task name, message, linked chat, schedule, or status.
- Sort by next run, last run, updated time, or name.
- Run now, pause or resume, edit, or delete user-created automations.
- Inspect protected system automations without changing them.
Search accepts plain text and field filters such as `name:backup`,
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`.
An automation without a linked chat cannot be enabled or run from the WebUI,
because nanobot would not know where to deliver the scheduled turn. Recreate it
from the target chat or channel so the automation has complete context.
## Settings
Settings is the control surface for the browser session and gateway-backed
runtime configuration. Use it to review or adjust model presets, provider
visibility, image generation, voice transcription, web tools, Apps, Automations,
Skills, runtime identity, and advanced safety controls.
Some settings take effect immediately. Runtime settings that affect the gateway
or agent process may require a restart; the WebUI shows that requirement next to
the relevant control.
## LAN Access
To open the WebUI from another device on the same network, bind the WebSocket
channel to all interfaces and set a token or token issue secret:
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"tokenIssueSecret": "your-secret-here"
}
}
}
```
The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
`tokenIssueSecret` is configured. After the gateway starts, open
`http://<your-ip>:8765` from the other device and enter the secret in the login
form.
## Troubleshooting
If the page does not open, check these in order:
1. `nanobot agent -m "Hello!"` works in the same Python environment.
2. The WebSocket channel is enabled in `~/.nanobot/config.json`.
3. `nanobot gateway` is still running.
4. You are opening port `8765`, not the gateway health port.
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
For detailed diagnostics, see
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
For frontend development, see [`../webui/README.md`](../webui/README.md).
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
dir="$HOME/.nanobot"
if [ -d "$dir" ] && [ ! -w "$dir" ]; then
owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null)
cat >&2 <<EOF
Error: $dir is not writable (owned by UID $owner_uid, running as UID $(id -u)).
Fix (pick one):
Host: sudo chown -R 1000:1000 ~/.nanobot
Docker: docker run --user \$(id -u):\$(id -g) ...
Podman: podman run --userns=keep-id ...
EOF
exit 1
fi
exec nanobot "$@"
+101
View File
@@ -0,0 +1,101 @@
"""Hatch build hook that bundles the webui (Vite) into nanobot/web/dist.
Triggered automatically by `python -m build` (and any other hatch-driven build)
so published wheels and sdists ship a fresh webui without requiring developers
to remember `cd webui && bun run build` beforehand.
Behaviour:
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
do not need a packaged `dist/`.
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
already contains a prebuilt `nanobot/web/dist/`).
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
- Skips when `nanobot/web/dist/index.html` already exists, unless
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
performs `install` followed by `run build`.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class WebUIBuildHook(BuildHookInterface):
PLUGIN_NAME = "webui-build"
def initialize(self, version: str, build_data: dict) -> None: # noqa: D401
root = Path(self.root)
webui_dir = root / "webui"
package_json = webui_dir / "package.json"
dist_dir = root / "nanobot" / "web" / "dist"
index_html = dist_dir / "index.html"
# `pip install -e .` builds an editable wheel; skip the (slow) webui
# bundle since editable installs target Python development and webui
# work uses `bun run dev` instead.
if self.target_name == "wheel" and version == "editable":
self.app.display_info(
"[webui-build] skipped for editable install "
"(use `cd webui && bun run build` to bundle webui manually)"
)
return
if os.environ.get("NANOBOT_SKIP_WEBUI_BUILD") == "1":
self.app.display_info("[webui-build] skipped via NANOBOT_SKIP_WEBUI_BUILD=1")
return
if not package_json.is_file():
self.app.display_info(
"[webui-build] no webui/ source tree, assuming prebuilt nanobot/web/dist/"
)
return
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
if index_html.is_file() and not force:
self.app.display_info(
f"[webui-build] reusing existing build at {dist_dir} "
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
)
return
runner = self._pick_runner()
if runner is None:
raise RuntimeError(
"[webui-build] neither `bun` nor `npm` is available on PATH; "
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
)
self.app.display_info(f"[webui-build] using {runner} to build webui")
self._run([runner, "install"], cwd=webui_dir)
self._run([runner, "run", "build"], cwd=webui_dir)
if not index_html.is_file():
raise RuntimeError(
f"[webui-build] build finished but {index_html} is missing; "
"check webui/vite.config.ts outDir."
)
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
@staticmethod
def _pick_runner() -> str | None:
for candidate in ("bun", "npm"):
if shutil.which(candidate):
return candidate
return None
def _run(self, cmd: list[str], *, cwd: Path) -> None:
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
try:
subprocess.run(cmd, cwd=cwd, check=True)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
) from exc
Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+78 -1
View File
@@ -2,5 +2,82 @@
nanobot - A lightweight AI agent framework
"""
__version__ = "0.1.4"
import tomllib
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from pathlib import Path
def _read_pyproject_version() -> str | None:
"""Read the source-tree version when package metadata is unavailable."""
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
if not pyproject.exists():
return None
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
return data.get("project", {}).get("version")
def _resolve_version() -> str:
try:
return _pkg_version("nanobot-ai")
except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.2.2"
__version__ = _resolve_version()
__logo__ = "🐈"
_LAZY_EXPORTS = {
"Nanobot": ".nanobot",
"RunStream": ".nanobot",
"RunResult": ".nanobot",
"SessionInfo": ".nanobot",
"SessionSnapshot": ".nanobot",
"STREAM_EVENT_REASONING_COMPLETED": ".nanobot",
"STREAM_EVENT_REASONING_DELTA": ".nanobot",
"STREAM_EVENT_RUN_COMPLETED": ".nanobot",
"STREAM_EVENT_RUN_FAILED": ".nanobot",
"STREAM_EVENT_RUN_STARTED": ".nanobot",
"STREAM_EVENT_TEXT_COMPLETED": ".nanobot",
"STREAM_EVENT_TEXT_DELTA": ".nanobot",
"STREAM_EVENT_TOOL_COMPLETED": ".nanobot",
"STREAM_EVENT_TOOL_FAILED": ".nanobot",
"STREAM_EVENT_TOOL_STARTED": ".nanobot",
"STREAM_EVENT_TYPES": ".nanobot",
"StreamEvent": ".nanobot",
"StreamEventType": ".nanobot",
}
def __getattr__(name: str):
module_path = _LAZY_EXPORTS.get(name)
if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
mod = import_module(module_path, __name__)
val = getattr(mod, name)
globals()[name] = val
return val
__all__ = [
"Nanobot",
"RunResult",
"RunStream",
"SessionInfo",
"SessionSnapshot",
"STREAM_EVENT_REASONING_COMPLETED",
"STREAM_EVENT_REASONING_DELTA",
"STREAM_EVENT_RUN_COMPLETED",
"STREAM_EVENT_RUN_FAILED",
"STREAM_EVENT_RUN_STARTED",
"STREAM_EVENT_TEXT_COMPLETED",
"STREAM_EVENT_TEXT_DELTA",
"STREAM_EVENT_TOOL_COMPLETED",
"STREAM_EVENT_TOOL_FAILED",
"STREAM_EVENT_TOOL_STARTED",
"STREAM_EVENT_TYPES",
"StreamEvent",
"StreamEventType",
]
+14 -2
View File
@@ -1,8 +1,20 @@
"""Agent core module."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.context import ContextBuilder
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext, CompositeHook
from nanobot.agent.loop import AgentLoop
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.agent.subagent import SubagentManager
__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]
__all__ = [
"AgentHook",
"AgentHookContext",
"AgentRunHookContext",
"AgentLoop",
"CompositeHook",
"ContextBuilder",
"MemoryStore",
"SkillsLoader",
"SubagentManager",
]
+117
View File
@@ -0,0 +1,117 @@
"""Auto compact: proactive compression of idle sessions to reduce token cost and latency."""
from __future__ import annotations
from collections.abc import Collection
from datetime import datetime
from typing import TYPE_CHECKING, Callable, Coroutine
from loguru import logger
from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator
class AutoCompact:
_RECENT_SUFFIX_MESSAGES = 8
_INTERNAL_SESSION_PREFIXES = ("dream:",)
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
session_ttl_minutes: int = 0):
self.sessions = sessions
self.consolidator = consolidator
self._ttl = session_ttl_minutes
self._archiving: set[str] = set()
self._summaries: dict[str, tuple[str, datetime]] = {}
def _is_expired(self, ts: datetime | str | None,
now: datetime | None = None) -> bool:
if self._ttl <= 0 or not ts:
return False
if isinstance(ts, str):
ts = datetime.fromisoformat(ts)
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
def _has_compactable_idle_tail(self, key: str) -> bool:
session = self.sessions.get_or_create(key)
tail = list(session.messages[session.last_consolidated:])
if not tail:
return False
probe = Session(
key=session.key,
messages=tail,
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
result = probe.retain_recent_legal_suffix(
self._RECENT_SUFFIX_MESSAGES,
extend_to_user=True,
)
messages_to_remove = result.dropped[result.already_consolidated_count:]
return bool(messages_to_remove)
@staticmethod
def _format_summary(text: str, last_active: datetime) -> str:
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
@classmethod
def _is_internal_session(cls, key: str) -> bool:
return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
def check_expired(self, schedule_background: Callable[[Coroutine], None],
active_session_keys: Collection[str] = ()) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
now = datetime.now()
for info in self.sessions.list_sessions():
key = info.get("key", "")
if not key or self._is_internal_session(key) or key in self._archiving:
continue
if key in active_session_keys:
continue
updated_at = info.get("updated_at")
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
self._archiving.add(key)
schedule_background(self._archive(key))
async def _archive(self, key: str) -> None:
if self._is_internal_session(key):
self._archiving.discard(key)
return
try:
summary = await self.consolidator.compact_idle_session(
key, self._RECENT_SUFFIX_MESSAGES,
)
if summary and summary != "(nothing)":
session = self.sessions.get_or_create(key)
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
self._summaries[key] = (
meta["text"],
datetime.fromisoformat(meta["last_active"]),
)
except Exception:
logger.exception("Auto-compact: failed for {}", key)
finally:
self._archiving.discard(key)
def prepare_session(self, session: Session, key: str) -> tuple[Session, str | None]:
if self._is_internal_session(key):
self._archiving.discard(key)
self._summaries.pop(key, None)
return session, None
if key in self._archiving or self._is_expired(session.updated_at):
logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving)
session = self.sessions.get_or_create(key)
# Hot path: summary from in-memory dict (process hasn't restarted).
entry = self._summaries.pop(key, None)
if entry:
return session, self._format_summary(entry[0], entry[1])
# Cold path: summary persisted in session metadata (process restarted).
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
return session, None
+199 -161
View File
@@ -4,123 +4,186 @@ import base64
import mimetypes
import platform
from pathlib import Path
from typing import Any
from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.cli import utils as cli_app_utils
from nanobot.bus.events import InboundMessage
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import (
current_time_str,
detect_image_mime,
load_bundled_template,
truncate_text_to_tokens,
)
from nanobot.utils.prompt_templates import render_template
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted kwargs for turn-attached capabilities."""
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
"""Return model-visible runtime annotations for turn-attached capabilities."""
return [
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
*mcp_tools.runtime_lines(
msg,
configured_server_names=set(state._mcp_servers),
connected_server_names=set(state._mcp_stacks),
skip=skip,
),
]
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools)
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
return await mcp_tools.handle_runtime_control(state, msg, tools)
class ContextBuilder:
"""
Builds the context (system prompt + messages) for the agent.
"""Builds the context (system prompt + messages) for the agent."""
Assembles bootstrap files, memory, skills, and conversation history
into a coherent prompt for the LLM.
"""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
def __init__(self, workspace: Path):
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
self.workspace = workspace
self.timezone = timezone
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace)
self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
"""
Build the system prompt from bootstrap files, memory, and skills.
def build_system_prompt(
self,
skill_names: list[str] | None = None,
channel: str | None = None,
session_summary: str | None = None,
workspace: Path | None = None,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
root = workspace or self.workspace
parts = [self._get_identity(channel=channel, workspace=root)]
Args:
skill_names: Optional list of skills to include.
Returns:
Complete system prompt.
"""
parts = []
# Core identity
parts.append(self._get_identity())
# Bootstrap files
bootstrap = self._load_bootstrap_files()
bootstrap = self._load_bootstrap_files(root)
if bootstrap:
parts.append(bootstrap)
# Memory context
parts.append(render_template("agent/tool_contract.md"))
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}")
# Skills - progressive loading
# 1. Always-loaded skills: include full content
always_skills = self.skills.get_always_skills()
if always_skills:
always_content = self.skills.load_skills_for_context(always_skills)
if always_content:
parts.append(f"# Active Skills\n\n{always_content}")
# 2. Available skills: only show summary (agent uses read_file to load)
skills_summary = self.skills.build_skills_summary()
skills_summary = self.skills.build_skills_summary(exclude=set(always_skills))
if skills_summary:
parts.append(f"""# Skills
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
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.
if include_memory_recent_history:
entries = self.memory.read_recent_history_for_prompt(
since_cursor=self.memory.get_last_dream_cursor(),
session_key=session_key,
unified_session=unified_session,
)
if entries:
capped = entries[-self._MAX_RECENT_HISTORY:]
history_text = "\n".join(
f"- [{e['timestamp']}] {e['content']}" for e in capped
)
history_text = truncate_text_to_tokens(history_text, self._MAX_HISTORY_TOKENS)
parts.append("# Recent History\n\n" + history_text)
{skills_summary}""")
if session_summary:
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
"""Get the core identity section."""
from datetime import datetime
import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
workspace_path = str(self.workspace.expanduser().resolve())
root = workspace or self.workspace
workspace_path = str(root.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return f"""# nanobot 🐈
return render_template(
"agent/identity.md",
workspace_path=workspace_path,
runtime=runtime,
platform_policy=render_template("agent/platform_policy.md", system=system),
channel=channel or "",
)
You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
- Read, write, and edit files
- Execute shell commands
- Search the web and fetch web pages
- Send messages to users on chat channels
- Spawn subagents for complex background tasks
@staticmethod
def _build_runtime_context(
channel: str | None,
chat_id: str | None,
timezone: str | None = None,
sender_id: str | None = None,
supplemental_lines: Sequence[str] | None = None,
) -> str:
"""Build untrusted runtime metadata block appended after user content."""
lines = [f"Current Time: {current_time_str(timezone)}"]
if channel and chat_id:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
if sender_id:
lines += [f"Sender ID: {sender_id}"]
if supplemental_lines:
lines.extend(supplemental_lines)
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
## Current Time
{now} ({tz})
@staticmethod
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
if isinstance(left, str) and isinstance(right, str):
return f"{left}\n\n{right}" if left else right
## Runtime
{runtime}
def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value]
if value is None:
return []
return [{"type": "text", "text": str(value)}]
## Workspace
Your workspace is at: {workspace_path}
- Long-term memory: {workspace_path}/memory/MEMORY.md
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
return _to_blocks(left) + _to_blocks(right)
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
When remembering something important, write to {workspace_path}/memory/MEMORY.md
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
def _load_bootstrap_files(self) -> str:
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load all bootstrap files from workspace."""
parts = []
root = workspace or self.workspace
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
file_path = root / filename
if file_path.exists():
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
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)."""
tpl = load_bundled_template(template_path)
if tpl is not None:
return content.strip() == tpl.strip()
return False
def build_messages(
self,
history: list[dict[str, Any]],
@@ -129,36 +192,66 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
current_role: str = "user",
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
current_runtime_lines: Sequence[str] | None = None,
workspace: Path | None = None,
runtime_state: Any | None = None,
inbound_message: Any | None = None,
skip_runtime_lines: bool = False,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
) -> list[dict[str, Any]]:
"""
Build the complete message list for an LLM call.
Args:
history: Previous conversation messages.
current_message: The new user message.
skill_names: Optional skills to include.
media: Optional list of local file paths for images/media.
channel: Current channel (telegram, feishu, etc.).
chat_id: Current chat/user ID.
Returns:
List of messages including system prompt.
"""
messages = []
# System prompt
system_prompt = self.build_system_prompt(skill_names)
if channel and chat_id:
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
messages.append({"role": "system", "content": system_prompt})
# History
messages.extend(history)
# Current message (with optional image attachments)
"""Build the complete message list for an LLM call."""
root = workspace or self.workspace
extra = [
*goal_state_runtime_lines(session_metadata),
]
if runtime_state is not None and inbound_message is not None:
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line)
runtime_ctx = self._build_runtime_context(
channel,
chat_id,
self.timezone,
sender_id=sender_id,
supplemental_lines=extra or None,
)
user_content = self._build_user_content(current_message, media)
messages.append({"role": "user", "content": user_content})
# Merge runtime context and user content into a single user message
# to avoid consecutive same-role messages that some providers reject.
# Runtime context is appended to keep the user-content prefix stable
# for prompt-cache hits (the context changes every turn due to time).
if isinstance(user_content, str):
merged = f"{user_content}\n\n{runtime_ctx}"
else:
merged = user_content + [{"type": "text", "text": runtime_ctx}]
messages = [
{
"role": "system",
"content": self.build_system_prompt(
skill_names,
channel=channel,
session_summary=session_summary,
workspace=root,
include_memory_recent_history=include_memory_recent_history,
session_key=session_key,
unified_session=unified_session,
),
},
*history,
]
if messages[-1].get("role") == current_role:
last = dict(messages[-1])
last["content"] = self._merge_message_content(last.get("content"), merged)
messages[-1] = last
return messages
messages.append({"role": current_role, "content": merged})
return messages
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
@@ -169,74 +262,19 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
images = []
for path in media:
p = Path(path)
mime, _ = mimetypes.guess_type(path)
if not p.is_file() or not mime or not mime.startswith("image/"):
if not p.is_file():
continue
b64 = base64.b64encode(p.read_bytes()).decode()
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
raw = p.read_bytes()
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if not mime or not mime.startswith("image/"):
continue
b64 = base64.b64encode(raw).decode()
images.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
if not images:
return text
return images + [{"type": "text", "text": text}]
def add_tool_result(
self,
messages: list[dict[str, Any]],
tool_call_id: str,
tool_name: str,
result: str
) -> list[dict[str, Any]]:
"""
Add a tool result to the message list.
Args:
messages: Current message list.
tool_call_id: ID of the tool call.
tool_name: Name of the tool.
result: Tool execution result.
Returns:
Updated message list.
"""
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result
})
return messages
def add_assistant_message(
self,
messages: list[dict[str, Any]],
content: str | None,
tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None,
) -> list[dict[str, Any]]:
"""
Add an assistant message to the message list.
Args:
messages: Current message list.
content: Message content.
tool_calls: Optional tool calls.
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
Returns:
Updated message list.
"""
msg: dict[str, Any] = {"role": "assistant"}
# Omit empty content — some backends reject empty text blocks
if content:
msg["content"] = content
if tool_calls:
msg["tool_calls"] = tool_calls
# Include reasoning content when provided (required by some thinking models)
if reasoning_content:
msg["reasoning_content"] = reasoning_content
messages.append(msg)
return messages
+503
View File
@@ -0,0 +1,503 @@
"""Model-message governance for agent runner requests.
This module owns model-facing message shaping and tool-result content normalization.
It may return copied messages or persisted-result placeholders, but it must not
mutate an existing session history list in place.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.utils.helpers import (
estimate_message_tokens,
estimate_prompt_tokens_chain,
find_legal_message_start,
maybe_persist_tool_result,
truncate_text,
)
from nanobot.utils.runtime import ensure_nonempty_tool_result
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
SNIP_SAFETY_BUFFER = 1024
MICROCOMPACT_KEEP_RECENT = 10
MICROCOMPACT_MIN_CHARS = 500
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
})
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
PLACEHOLDER_TEXTS = frozenset({
"[Previous assistant message omitted.]",
})
def _tool_call_name_is_valid(tool_call: Any) -> bool:
"""Whether a persisted OpenAI-style tool_call carries a usable name.
Mirrors ``ToolCallRequest.has_valid_name`` for the dict shape stored in
message history: a degenerate call with ``name=None`` / ``""`` cannot be
executed and is rejected by upstream APIs if replayed.
"""
if not isinstance(tool_call, dict):
return False
fn = tool_call.get("function")
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
return isinstance(name, str) and bool(name)
@dataclass(slots=True)
class ContextGovernanceConfig:
provider: LLMProvider
model: str
tools: Any
workspace: Path | None
session_key: str | None
max_tool_result_chars: int
context_window_tokens: int | None = None
context_block_limit: int | None = None
max_tokens: int | None = None
inflight_start_index: int = 0
class ContextGovernor:
"""Prepare model-copy messages while preserving persisted history."""
def prepare_for_model(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
updated = self.strip_placeholder_assistant_messages(messages)
updated = self.strip_malformed_tool_calls(updated)
updated = self.drop_orphan_tool_results(updated)
updated = self.backfill_missing_tool_results(updated)
updated = self.apply_tool_result_budget(config, updated)
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
updated = self.snip_history(config, updated)
updated = self.drop_orphan_tool_results(updated)
return self.backfill_missing_tool_results(updated)
@staticmethod
def input_budget(config: ContextGovernanceConfig) -> int:
if not config.context_window_tokens:
return 0
provider_max_tokens = getattr(
getattr(config.provider, "generation", None),
"max_tokens",
4096,
)
max_output = config.max_tokens if isinstance(config.max_tokens, int) else (
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
)
budget = config.context_block_limit or (
config.context_window_tokens - max_output - SNIP_SAFETY_BUFFER
)
return budget if budget > 0 else 0
@staticmethod
def normalize_tool_result(
config: ContextGovernanceConfig,
tool_call_id: str,
tool_name: str,
result: Any,
) -> Any:
result = ensure_nonempty_tool_result(tool_name, result)
if tool_name in TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
return result
try:
content = maybe_persist_tool_result(
config.workspace,
config.session_key,
tool_call_id,
result,
max_chars=config.max_tool_result_chars,
)
except Exception:
logger.exception(
"Tool result persist failed for {} in {}; using raw result",
tool_call_id,
config.session_key or "default",
)
content = result
if isinstance(content, str) and len(content) > config.max_tool_result_chars:
return truncate_text(content, config.max_tool_result_chars)
return content
@staticmethod
def strip_placeholder_assistant_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Remove assistant messages that are compaction placeholders.
Messages like ``[Previous assistant message omitted.]`` carry no useful
context for the model and can cause it to repeatedly attempt tool calls
that previously failed, producing malformed responses in a loop.
Consecutive same-role messages that result from removal are handled
downstream by the provider's merge-consecutive logic. Only the
model-facing copy is repaired; the persisted transcript is untouched
(a copy is returned, or the same list object when nothing changes).
"""
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
if msg.get("role") != "assistant":
if updated is not None:
updated.append(msg)
continue
content = msg.get("content", "")
text = content if isinstance(content, str) else ""
is_placeholder = text.strip() in PLACEHOLDER_TEXTS
has_tool_calls = bool(msg.get("tool_calls"))
if is_placeholder and not has_tool_calls:
if updated is None:
updated = list(messages[:idx])
logger.debug(
"Stripping placeholder assistant message from history: {!r}",
text[:60],
)
continue
if updated is not None:
updated.append(msg)
if updated is None:
return messages
return updated
@staticmethod
def strip_malformed_tool_calls(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop persisted assistant tool_calls whose name is missing/non-string.
A degenerate tool call (``name=None`` or ``""``) that slipped into the
saved history before this guard existed gets replayed on every turn and
makes upstream APIs reject the whole request
(``messages.content.N.tool_use.name: Input should be a valid string``),
permanently wedging the session. Removing the bad call here lets the
existing orphan-result cleanup drop its now-dangling tool result, so a
polluted session self-heals on its next turn. The persisted transcript
is left untouched; only the model-facing copy is repaired (a copy is
returned, or the same list object when nothing changes).
"""
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
if msg.get("role") != "assistant":
if updated is not None:
updated.append(msg)
continue
calls = msg.get("tool_calls")
if not calls:
if updated is not None:
updated.append(msg)
continue
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
if len(kept) == len(calls):
if updated is not None:
updated.append(msg)
continue
if updated is None:
updated = [dict(m) for m in messages[:idx]]
logger.warning(
"Stripping {} malformed tool_call(s) with missing/non-string "
"name from assistant history before request",
len(calls) - len(kept),
)
repaired = dict(msg)
if kept:
repaired["tool_calls"] = kept
else:
repaired.pop("tool_calls", None)
# An assistant turn with neither content nor any valid tool call is
# itself invalid upstream; drop it entirely in that case.
has_content = bool(repaired.get("content"))
if not kept and not has_content:
continue
updated.append(repaired)
if updated is None:
return messages
return updated
@staticmethod
def drop_orphan_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop tool results that have no matching assistant tool_call earlier in history."""
declared: set[str] = set()
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
declared.add(str(tc["id"]))
if role == "tool":
tid = msg.get("tool_call_id")
if tid and str(tid) not in declared:
if updated is None:
updated = [dict(m) for m in messages[:idx]]
continue
if updated is not None:
updated.append(dict(msg))
if updated is None:
return messages
return updated
@staticmethod
def backfill_missing_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Insert synthetic error results for assistant tool_calls with missing tool outputs."""
declared: list[tuple[int, str, str]] = []
fulfilled: set[str] = set()
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
name = ""
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", "")
declared.append((idx, str(tc["id"]), name))
elif role == "tool":
tid = msg.get("tool_call_id")
if tid:
fulfilled.add(str(tid))
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
if not missing:
return messages
updated = list(messages)
offset = 0
for assistant_idx, call_id, name in missing:
insert_at = assistant_idx + 1 + offset
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
insert_at += 1
updated.insert(insert_at, {
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": BACKFILL_CONTENT,
})
offset += 1
return updated
def apply_tool_result_budget(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
updated = messages
for idx, message in enumerate(messages):
if message.get("role") != "tool":
continue
normalized = self.normalize_tool_result(
config,
str(message.get("tool_call_id") or f"tool_{idx}"),
str(message.get("name") or "tool"),
message.get("content"),
)
if normalized != message.get("content"):
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = normalized
return updated
def compact_inflight_overflow(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
"""Compact in-flight tool results only when the request would overflow."""
budget = self.input_budget(config)
if budget <= 0:
return messages
tools = config.tools.get_definitions()
updated = self._apply_recorded_compactions(messages, compacted_tool_call_ids)
estimate, source = estimate_prompt_tokens_chain(
config.provider,
config.model,
updated,
tools,
)
if estimate <= budget:
return updated
target = int(budget * INFLIGHT_COMPACT_TARGET_RATIO)
candidates = self._inflight_compaction_candidates(
config,
updated,
compacted_tool_call_ids,
)
if not candidates:
return updated
for candidate_idx, (idx, tool_call_id) in enumerate(candidates):
is_newest_candidate = candidate_idx == len(candidates) - 1
if is_newest_candidate and estimate <= budget:
break
if tool_call_id in compacted_tool_call_ids:
continue
if updated is messages:
updated = [dict(m) for m in messages]
compacted_tool_call_ids.add(tool_call_id)
self._compact_tool_result_at(updated, idx)
estimate, source = estimate_prompt_tokens_chain(
config.provider,
config.model,
updated,
tools,
)
if estimate <= target:
break
logger.debug(
"In-flight context compaction for {}: prompt={} budget={} target={} via {}, ids={}",
config.session_key or "default",
estimate,
budget,
target,
source,
len(compacted_tool_call_ids),
)
return updated
def snip_history(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not messages or not config.context_window_tokens:
return messages
budget = self.input_budget(config)
if budget <= 0:
return messages
tools = config.tools.get_definitions()
estimate, _ = estimate_prompt_tokens_chain(
config.provider,
config.model,
messages,
tools,
)
if estimate <= budget:
return messages
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
if not non_system:
return messages
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
fixed_tokens, _ = estimate_prompt_tokens_chain(
config.provider,
config.model,
system_messages,
tools,
)
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
kept: list[dict[str, Any]] = []
kept_tokens = 0
for message in reversed(non_system):
msg_tokens = estimate_message_tokens(message)
if kept and kept_tokens + msg_tokens > remaining_budget:
break
kept.append(message)
kept_tokens += msg_tokens
kept.reverse()
return system_messages + self._legal_history_tail(kept, non_system)
@staticmethod
def _summary_for(message: dict[str, Any]) -> str:
name = message.get("name", "tool")
return f"[{name} result omitted from context]"
def _legal_history_tail(
self,
kept: list[dict[str, Any]],
non_system: list[dict[str, Any]],
) -> list[dict[str, Any]]:
fallback = kept if kept else (non_system[-1:] if non_system else [])
kept = self._user_tail(kept) or self._user_tail(non_system, last=True) or fallback
start = find_legal_message_start(kept)
return kept[start:] if start else kept
@staticmethod
def _user_tail(messages: list[dict[str, Any]], *, last: bool = False) -> list[dict[str, Any]]:
indexes = range(len(messages) - 1, -1, -1) if last else range(len(messages))
for idx in indexes:
if messages[idx].get("role") == "user":
return messages[idx:]
return []
def _apply_recorded_compactions(
self,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
if not compacted_tool_call_ids:
return messages
updated = messages
for idx, msg in enumerate(messages):
if msg.get("role") != "tool":
continue
tool_call_id = msg.get("tool_call_id")
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
continue
summary = self._summary_for(msg)
if msg.get("content") == summary:
continue
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = summary
return updated
def _inflight_compaction_candidates(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[tuple[int, str]]:
compactable: list[tuple[int, str]] = []
for idx, msg in enumerate(messages):
if idx < config.inflight_start_index:
continue
if msg.get("role") != "tool" or msg.get("name") not in COMPACTABLE_TOOLS:
continue
tool_call_id = msg.get("tool_call_id")
if not tool_call_id or str(tool_call_id) in compacted_tool_call_ids:
continue
content = msg.get("content")
if not isinstance(content, str) or len(content) < MICROCOMPACT_MIN_CHARS:
continue
compactable.append((idx, str(tool_call_id)))
if not compactable:
return []
primary_count = max(0, len(compactable) - MICROCOMPACT_KEEP_RECENT)
primary = compactable[:primary_count]
# Hard overflow beats the keep-recent preference. Return recent results
# after stale ones so the newest result is naturally last.
fallback = compactable[primary_count:]
return primary + fallback
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
messages[idx]["content"] = self._summary_for(messages[idx])
+142
View File
@@ -0,0 +1,142 @@
"""Coordination for scheduled cron turns."""
from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cron.session_turns import (
cron_run_id,
cron_trigger,
defer_cron_until_session_idle,
)
class CronTurnCoordinator:
"""Manage scheduled cron turns without mixing them into live injections."""
def __init__(
self,
*,
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
) -> None:
self._publish_inbound = publish_inbound
self._dispatch = dispatch
self._is_running = is_running
self.deferred_queues: dict[str, list[InboundMessage]] = {}
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
self._pending_messages_by_run_id: dict[str, InboundMessage] = {}
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit a scheduled cron turn and wait for its session response."""
run_id = cron_run_id(msg.metadata)
if not run_id:
raise ValueError("cron turn metadata must include a run_id")
if run_id in self._waiters:
raise RuntimeError(f"cron run {run_id!r} is already pending")
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[run_id] = future
self._pending_messages_by_run_id[run_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
return await future
finally:
self._waiters.pop(run_id, None)
self._pending_messages_by_run_id.pop(run_id, None)
def should_defer(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
return (
defer_cron_until_session_idle(msg.metadata)
and session_key in active_session_keys
)
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer a cron turn when its target session is already active."""
if not self.should_defer(
msg,
session_key=session_key,
active_session_keys=active_session_keys,
):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.defer(session_key, pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
run_id = cron_run_id(msg.metadata)
if not run_id:
return
future = self._waiters.get(run_id)
if future is None or future.done():
return
if error is not None:
future.set_exception(error)
else:
future.set_result(response)
def defer(self, session_key: str, msg: InboundMessage) -> None:
self.deferred_queues.setdefault(session_key, []).append(msg)
def pending_job_ids_for_session(self, session_key: str) -> set[str]:
"""Return cron jobs that are waiting for or running in *session_key*."""
job_ids: set[str] = set()
for msg in self.deferred_queues.get(session_key, []):
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
for msg in self._pending_messages_by_run_id.values():
if msg.session_key != session_key:
continue
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
return job_ids
async def publish_next_deferred(self, session_key: str) -> None:
queue = self.deferred_queues.get(session_key)
if not queue:
return
msg = queue.pop(0)
if not queue:
self.deferred_queues.pop(session_key, None)
await self._publish_inbound(msg)
def _cron_job_id(msg: InboundMessage) -> str | None:
trigger = cron_trigger(msg.metadata)
if not trigger:
return None
value = trigger.get("job_id")
return value if isinstance(value, str) and value else None
+201
View File
@@ -0,0 +1,201 @@
"""Shared lifecycle hook primitives for agent runs."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
from nanobot.providers.base import LLMResponse, ToolCallRequest
@dataclass(slots=True)
class AgentHookContext:
"""Mutable per-iteration state exposed to runner hooks."""
iteration: int
messages: list[dict[str, Any]]
response: LLMResponse | None = None
usage: dict[str, int] = field(default_factory=dict)
tool_calls: list[ToolCallRequest] = field(default_factory=list)
tool_results: list[Any] = field(default_factory=list)
tool_events: list[dict[str, str]] = field(default_factory=list)
streamed_content: bool = False
streamed_reasoning: bool = False
final_content: str | None = None
stop_reason: str | None = None
error: str | None = None
session_key: str | None = None
@dataclass(slots=True)
class AgentRunHookContext:
"""Run-level state snapshot exposed to runner hooks."""
messages: list[dict[str, Any]]
final_content: str | None = None
tools_used: list[str] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
stop_reason: str | None = None
error: str | None = None
tool_events: list[dict[str, str]] = field(default_factory=list)
had_injections: bool = False
exception: BaseException | None = None
class AgentHook:
"""Minimal lifecycle surface for shared runner customization."""
def __init__(self, reraise: bool = False) -> None:
self._reraise = reraise
def wants_streaming(self) -> bool:
return False
async def before_run(self, context: AgentRunHookContext) -> None:
pass
async def after_run(self, context: AgentRunHookContext) -> None:
pass
async def on_error(self, context: AgentRunHookContext) -> None:
pass
async def on_finally(self, context: AgentRunHookContext) -> None:
pass
async def before_iteration(self, context: AgentHookContext) -> None:
pass
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
pass
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
pass
async def before_execute_tools(self, context: AgentHookContext) -> None:
pass
async def emit_reasoning(self, reasoning_content: str | None) -> None:
pass
async def emit_reasoning_end(self) -> None:
"""Mark the end of an in-flight reasoning stream.
Hooks that buffer ``emit_reasoning`` chunks (for in-place UI updates)
flush and freeze the rendered group here. One-shot hooks ignore.
"""
pass
async def after_iteration(self, context: AgentHookContext) -> None:
pass
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
return content
class CompositeHook(AgentHook):
"""Fan-out hook that delegates to an ordered list of hooks.
Error isolation: async methods catch and log per-hook exceptions
so a faulty custom hook cannot crash the agent loop.
``finalize_content`` is a pipeline (no isolation — bugs should surface).
"""
__slots__ = ("_hooks",)
def __init__(self, hooks: list[AgentHook]) -> None:
super().__init__()
self._hooks = list(hooks)
def wants_streaming(self) -> bool:
return any(h.wants_streaming() for h in self._hooks)
async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None:
for h in self._hooks:
if getattr(h, "_reraise", False):
await getattr(h, method_name)(*args, **kwargs)
continue
try:
await getattr(h, method_name)(*args, **kwargs)
except Exception:
logger.exception("AgentHook.{} error in {}", method_name, type(h).__name__)
async def before_iteration(self, context: AgentHookContext) -> None:
await self._for_each_hook_safe("before_iteration", context)
async def before_run(self, context: AgentRunHookContext) -> None:
await self._for_each_hook_safe("before_run", context)
async def after_run(self, context: AgentRunHookContext) -> None:
await self._for_each_hook_safe("after_run", context)
async def on_error(self, context: AgentRunHookContext) -> None:
await self._for_each_hook_safe("on_error", context)
async def on_finally(self, context: AgentRunHookContext) -> None:
await self._for_each_hook_safe("on_finally", context)
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
await self._for_each_hook_safe("on_stream", context, delta)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
async def before_execute_tools(self, context: AgentHookContext) -> None:
await self._for_each_hook_safe("before_execute_tools", context)
async def emit_reasoning(self, reasoning_content: str | None) -> None:
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
async def emit_reasoning_end(self) -> None:
await self._for_each_hook_safe("emit_reasoning_end")
async def after_iteration(self, context: AgentHookContext) -> None:
await self._for_each_hook_safe("after_iteration", context)
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
for h in self._hooks:
content = h.finalize_content(context, content)
return content
class SDKCaptureHook(AgentHook):
"""Record tool names and the final message list for ``RunResult``.
The runner mutates ``context.messages`` in place across iterations, so the
snapshot is refreshed on every ``after_iteration`` call; the last call
reflects the end-of-turn state the SDK caller cares about. The run-level
snapshot is authoritative when available and covers paths without a final
per-iteration callback.
"""
def __init__(self) -> None:
super().__init__()
self.tools_used: list[str] = []
self.messages: list[dict[str, Any]] = []
self.usage: dict[str, int] = {}
self.stop_reason: str | None = None
self.error: str | None = None
self.tool_events: list[dict[str, str]] = []
self.had_injections: bool = False
async def after_iteration(self, context: AgentHookContext) -> None:
for call in context.tool_calls:
self.tools_used.append(call.name)
self.messages = list(context.messages)
self.usage = dict(context.usage)
self.stop_reason = context.stop_reason
self.error = context.error
self.tool_events = list(context.tool_events)
async def after_run(self, context: AgentRunHookContext) -> None:
self.tools_used = list(context.tools_used)
self.messages = list(context.messages)
self.usage = dict(context.usage)
self.stop_reason = context.stop_reason
self.error = context.error
self.tool_events = list(context.tool_events)
self.had_injections = context.had_injections
+1739 -370
View File
File diff suppressed because it is too large Load Diff
+1040 -13
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
"""Helpers for runtime model preset selection."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
PresetSnapshotLoader = Callable[[str], ProviderSnapshot]
def default_selection_signature(signature: tuple[object, ...] | None) -> tuple[object, ...] | None:
return signature[:2] if signature else None
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
return {**config.model_presets, "default": config.resolve_default_preset()}
def make_preset_snapshot_loader(
config: Any,
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
) -> PresetSnapshotLoader:
if provider_snapshot_loader is not None:
return lambda name: provider_snapshot_loader(preset_name=name)
return lambda name: build_provider_snapshot(config, preset_name=name)
def build_static_preset_snapshot(
provider: LLMProvider,
name: str,
preset: ModelPresetConfig,
) -> ProviderSnapshot:
provider.generation = preset.to_generation_settings()
return ProviderSnapshot(
provider=provider,
model=preset.model,
context_window_tokens=preset.context_window_tokens,
signature=("model_preset", name, preset.model_dump_json()),
)
def build_runtime_preset_snapshot(
*,
name: str,
presets: dict[str, ModelPresetConfig],
provider: LLMProvider,
loader: PresetSnapshotLoader | None,
) -> ProviderSnapshot:
if loader is not None:
return loader(name)
return build_static_preset_snapshot(provider, name, presets[name])
def normalize_preset_name(name: str | None, presets: dict[str, ModelPresetConfig]) -> str:
if not isinstance(name, str) or not name.strip():
raise ValueError("model_preset must be a non-empty string")
name = name.strip()
if name not in presets:
raise KeyError(f"model_preset {name!r} not found. Available: {', '.join(presets) or '(none)'}")
return name
+178
View File
@@ -0,0 +1,178 @@
"""Agent hook that adapts runner events into channel progress UI."""
from __future__ import annotations
import inspect
import json
from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think
from nanobot.utils.progress_events import (
build_tool_event_finish_payloads,
build_tool_event_start_payload,
invoke_on_progress,
on_progress_accepts_tool_events,
)
from nanobot.utils.tool_hints import format_tool_hints
class AgentProgressHook(AgentHook):
"""Translate runner lifecycle events into user-visible progress signals."""
def __init__(
self,
on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None,
*,
channel: str = "cli",
chat_id: str = "direct",
message_id: str | None = None,
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
tool_hint_max_length: int = 40,
set_tool_context: Callable[..., None] | None = None,
on_iteration: Callable[[int], None] | None = None,
) -> None:
super().__init__(reraise=True)
self._on_progress = on_progress
self._on_stream = on_stream
self._on_stream_end = on_stream_end
self._channel = channel
self._chat_id = chat_id
self._message_id = message_id
self._metadata = metadata or {}
self._session_key = session_key
self._tool_hint_max_length = tool_hint_max_length
self._set_tool_context = set_tool_context
self._on_iteration = on_iteration
self._stream_buf = ""
self._think_extractor = IncrementalThinkExtractor()
self._reasoning_open = False
def wants_streaming(self) -> bool:
return self._on_stream is not None
@staticmethod
def _strip_think(text: str | None) -> str | None:
if not text:
return None
return strip_think(text) or None
def _tool_hint(self, tool_calls: list[Any]) -> str:
return format_tool_hints(tool_calls, max_length=self._tool_hint_max_length)
@staticmethod
def _on_progress_accepts(cb: Callable[..., Any], name: str) -> bool:
try:
sig = inspect.signature(cb)
except (TypeError, ValueError):
return False
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
return True
return name in sig.parameters
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
prev_clean = strip_think(self._stream_buf)
self._stream_buf += delta
new_clean = strip_think(self._stream_buf)
incremental = new_clean[len(prev_clean) :]
if await self._think_extractor.feed(self._stream_buf, self.emit_reasoning):
context.streamed_reasoning = True
if incremental:
# Answer text has started; close the reasoning segment so the UI can
# lock the bubble before the answer renders below it.
await self.emit_reasoning_end()
if self._on_stream:
await self._on_stream(incremental)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
await self.emit_reasoning_end()
if self._on_stream_end:
await self._on_stream_end(resuming=resuming)
self._stream_buf = ""
self._think_extractor.reset()
async def before_iteration(self, context: AgentHookContext) -> None:
if self._on_iteration:
self._on_iteration(context.iteration)
logger.debug(
"Starting agent loop iteration {} for session {}",
context.iteration,
self._session_key,
)
async def before_execute_tools(self, context: AgentHookContext) -> None:
if self._on_progress:
if not self._on_stream and not context.streamed_content:
thought = self._strip_think(context.response.content if context.response else None)
if thought:
await self._on_progress(thought)
tool_hint = self._strip_think(self._tool_hint(context.tool_calls))
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
await invoke_on_progress(
self._on_progress,
tool_hint,
tool_hint=True,
tool_events=tool_events,
)
for tc in context.tool_calls:
args_str = json.dumps(tc.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tc.name, args_str[:200])
if self._set_tool_context:
self._set_tool_context(
self._channel,
self._chat_id,
self._message_id,
self._metadata,
session_key=self._session_key,
)
async def emit_reasoning(self, reasoning_content: str | None) -> None:
"""Publish a reasoning chunk; channel plugins decide whether to render."""
if (
self._on_progress
and reasoning_content
and self._on_progress_accepts(self._on_progress, "reasoning")
):
self._reasoning_open = True
await self._on_progress(reasoning_content, reasoning=True)
async def emit_reasoning_end(self) -> None:
"""Close the current reasoning stream segment, if any was open."""
if self._reasoning_open and self._on_progress:
self._reasoning_open = False
await self._on_progress("", reasoning_end=True)
else:
self._reasoning_open = False
async def after_iteration(self, context: AgentHookContext) -> None:
if (
self._on_progress
and context.tool_calls
and context.tool_events
and on_progress_accepts_tool_events(self._on_progress)
):
tool_events = build_tool_event_finish_payloads(context)
if tool_events:
await invoke_on_progress(
self._on_progress,
"",
tool_hint=False,
tool_events=tool_events,
)
u = context.usage or {}
logger.debug(
"LLM usage: prompt={} completion={} cached={}",
u.get("prompt_tokens", 0),
u.get("completion_tokens", 0),
u.get("cached_tokens", 0),
)
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
return self._strip_think(content)
File diff suppressed because it is too large Load Diff
+138 -106
View File
@@ -6,9 +6,17 @@ 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"
# Opening ---, YAML body (group 1), closing --- on its own line; supports CRLF.
_STRIP_SKILL_FRONTMATTER = re.compile(
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
re.DOTALL,
)
class SkillsLoader:
"""
@@ -18,10 +26,27 @@ class SkillsLoader:
specific tools or perform certain tasks.
"""
def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None):
def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None, disabled_skills: set[str] | None = None):
self.workspace = workspace
self.workspace_skills = workspace / "skills"
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
self.disabled_skills = disabled_skills or set()
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
if not base.exists():
return []
entries: list[dict[str, str]] = []
for skill_dir in base.iterdir():
if not skill_dir.is_dir():
continue
skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
continue
name = skill_dir.name
if skip_names is not None and name in skip_names:
continue
entries.append({"name": name, "path": str(skill_file), "source": source})
return entries
def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]:
"""
@@ -33,27 +58,18 @@ class SkillsLoader:
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
skills = []
# Workspace skills (highest priority)
if self.workspace_skills.exists():
for skill_dir in self.workspace_skills.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"})
# Built-in skills
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
workspace_names = {entry["name"] for entry in skills}
if self.builtin_skills and self.builtin_skills.exists():
for skill_dir in self.builtin_skills.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills):
skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"})
skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
)
if self.disabled_skills:
skills = [s for s in skills if s["name"] not in self.disabled_skills]
# Filter by requirements
if filter_unavailable:
return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))]
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
return skills
def load_skill(self, name: str) -> str | None:
@@ -66,17 +82,13 @@ class SkillsLoader:
Returns:
Skill content or None if not found.
"""
# Check workspace first
workspace_skill = self.workspace_skills / name / "SKILL.md"
if workspace_skill.exists():
return workspace_skill.read_text(encoding="utf-8")
# Check built-in
roots = [self.workspace_skills]
if self.builtin_skills:
builtin_skill = self.builtin_skills / name / "SKILL.md"
if builtin_skill.exists():
return builtin_skill.read_text(encoding="utf-8")
roots.append(self.builtin_skills)
for root in roots:
path = root / name / "SKILL.md"
if path.exists():
return path.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
@@ -89,67 +101,73 @@ class SkillsLoader:
Returns:
Formatted skills content.
"""
parts = []
for name in skill_names:
content = self.load_skill(name)
if content:
content = self._strip_frontmatter(content)
parts.append(f"### Skill: {name}\n\n{content}")
parts = [
f"### Skill: {name}\n\n{self._strip_frontmatter(markdown)}"
for name in skill_names
if (markdown := self.load_skill(name))
]
return "\n\n---\n\n".join(parts)
return "\n\n---\n\n".join(parts) if parts else ""
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 ""
def escape_xml(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines = ["<skills>"]
for s in all_skills:
name = escape_xml(s["name"])
path = s["path"]
desc = escape_xml(self._get_skill_description(s["name"]))
skill_meta = self._get_skill_meta(s["name"])
available = self._check_requirements(skill_meta)
lines.append(f" <skill available=\"{str(available).lower()}\">")
lines.append(f" <name>{name}</name>")
lines.append(f" <description>{desc}</description>")
lines.append(f" <location>{path}</location>")
# Show missing requirements for unavailable skills
if not available:
missing = self._get_missing_requirements(skill_meta)
if missing:
lines.append(f" <requires>{escape_xml(missing)}</requires>")
lines.append(f" </skill>")
lines.append("</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)
desc = self._get_skill_description(skill_name)
if available:
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
else:
missing = self._get_missing_requirements(meta)
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:
"""Get a description of missing requirements."""
missing = []
requires = skill_meta.get("requires", {})
for b in requires.get("bins", []):
if not shutil.which(b):
missing.append(f"CLI: {b}")
for env in requires.get("env", []):
if not os.environ.get(env):
missing.append(f"ENV: {env}")
return ", ".join(missing)
required_bins = requires.get("bins", [])
required_env_vars = requires.get("env", [])
return ", ".join(
[f"CLI: {command_name}" for command_name in required_bins if not shutil.which(command_name)]
+ [f"ENV: {env_name}" for env_name in required_env_vars if not os.environ.get(env_name)]
)
def get_skill_availability(self, name: str) -> tuple[bool, str]:
"""Return whether a skill can run and why not when it cannot."""
meta = self._get_skill_meta(name)
available = self._check_requirements(meta)
return available, "" if available else self._get_missing_requirements(meta)
def get_skill_requirements(self, name: str) -> dict[str, list[str]]:
"""Return explicit command/env requirements and currently missing entries."""
requires = self._get_skill_meta(name).get("requires", {})
bins = [str(value) for value in requires.get("bins", [])]
env = [str(value) for value in requires.get("env", [])]
return {
"bins": bins,
"env": env,
"missing_bins": [value for value in bins if not shutil.which(value)],
"missing_env": [value for value in env if not os.environ.get(value)],
}
def _get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter."""
@@ -160,45 +178,57 @@ class SkillsLoader:
def _strip_frontmatter(self, content: str) -> str:
"""Remove YAML frontmatter from markdown content."""
if content.startswith("---"):
match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL)
if not content.startswith("---"):
return content
match = _STRIP_SKILL_FRONTMATTER.match(content)
if match:
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)."""
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)
return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
else:
return {}
if not isinstance(data, dict):
return {}
payload = data.get("nanobot", data.get("openclaw", {}))
return payload if isinstance(payload, dict) else {}
def _check_requirements(self, skill_meta: dict) -> bool:
"""Check if skill requirements are met (bins, env vars)."""
requires = skill_meta.get("requires", {})
for b in requires.get("bins", []):
if not shutil.which(b):
return False
for env in requires.get("env", []):
if not os.environ.get(env):
return False
return True
required_bins = requires.get("bins", [])
required_env_vars = requires.get("env", [])
return all(shutil.which(cmd) for cmd in required_bins) and all(
os.environ.get(var) for var in required_env_vars
)
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."""
result = []
for s in self.list_skills(filter_unavailable=True):
meta = self.get_skill_metadata(s["name"]) or {}
skill_meta = self._parse_nanobot_metadata(meta.get("metadata", ""))
if skill_meta.get("always") or meta.get("always"):
result.append(s["name"])
return result
return [
entry["name"]
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")
or meta.get("always")
)
]
def get_skill_metadata(self, name: str) -> dict | None:
"""
@@ -211,18 +241,20 @@ class SkillsLoader:
Metadata dict or None.
"""
content = self.load_skill(name)
if not content:
if not content or not content.startswith("---"):
return None
if content.startswith("---"):
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if match:
# Simple YAML parsing
metadata = {}
for line in match.group(1).split("\n"):
if ":" in line:
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip().strip('"\'')
match = _STRIP_SKILL_FRONTMATTER.match(content)
if not match:
return None
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
return None
+321 -158
View File
@@ -2,53 +2,173 @@
import asyncio
import json
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, Callable
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig
from nanobot.providers.base import LLMProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.security.workspace_access import (
WorkspaceScope,
bind_workspace_scope,
reset_workspace_scope,
workspace_sandbox_status,
)
from nanobot.utils.prompt_templates import render_template
@dataclass(slots=True)
class SubagentStatus:
"""Real-time status of a running subagent."""
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:
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.debug(
"Subagent [{}] executing: {} with arguments: {}",
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.
Subagents are lightweight agent instances that run in the background
to handle specific tasks. They share the same LLM provider but have
isolated context and a focused system prompt.
"""
"""Manages background subagent execution."""
def __init__(
self,
provider: LLMProvider,
workspace: Path,
bus: MessageBus,
max_tool_result_chars: int,
model: str | None = None,
temperature: float = 0.7,
max_tokens: int = 4096,
brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None,
tools_config: ToolsConfig | None = None,
restrict_to_workspace: bool = False,
disabled_skills: list[str] | None = None,
max_iterations: int | None = None,
max_concurrent_subagents: int | None = None,
fail_on_tool_error: bool | None = None,
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
):
from nanobot.config.schema import ExecToolConfig
defaults = AgentDefaults()
self.provider = provider
self.workspace = workspace
self.bus = bus
self.model = model or provider.get_default_model()
self.temperature = temperature
self.max_tokens = max_tokens
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.tools_config = tools_config or ToolsConfig()
self.max_tool_result_chars = max_tool_result_chars
self.restrict_to_workspace = restrict_to_workspace
self.disabled_skills = set(disabled_skills or [])
self.max_iterations = (
max_iterations
if max_iterations is not None
else defaults.max_tool_iterations
)
self.max_concurrent_subagents = (
max_concurrent_subagents
if max_concurrent_subagents is not None
else defaults.max_concurrent_subagents
)
self.fail_on_tool_error = (
fail_on_tool_error
if fail_on_tool_error is not None
else defaults.fail_on_tool_error
)
self.runner = AgentRunner(provider)
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
self._direct_result_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
def set_direct_result_queue(
self,
session_key: str,
queue: asyncio.Queue[InboundMessage],
) -> None:
self._direct_result_queues[session_key] = queue
def clear_direct_result_queue(
self,
session_key: str,
queue: asyncio.Queue[InboundMessage],
) -> None:
if self._direct_result_queues.get(session_key) is queue:
self._direct_result_queues.pop(session_key, None)
def _subagent_tools_config(self) -> ToolsConfig:
"""Build a ToolsConfig scoped for subagent use."""
return ToolsConfig(
exec=self.tools_config.exec,
web=self.tools_config.web,
file=self.tools_config.file,
restrict_to_workspace=self.restrict_to_workspace,
)
def _build_tools(
self,
workspace: Path | None = None,
tools_config: ToolsConfig | None = None,
) -> ToolRegistry:
"""Build an isolated subagent tool registry via ToolLoader."""
root = self.workspace if workspace is None else workspace
registry = ToolRegistry()
cfg = tools_config if tools_config is not None else self._subagent_tools_config()
ctx = ToolContext(
config=cfg,
workspace=str(root.resolve()),
file_state_store=FileStates(),
workspace_sandbox=workspace_sandbox_status(
restrict_to_workspace=cfg.restrict_to_workspace,
workspace=root,
),
)
ToolLoader().load(ctx, registry, scope="subagent")
return registry
def set_provider(self, provider: LLMProvider, model: str) -> None:
self.provider = provider
self.model = model
self.runner.provider = provider
async def spawn(
self,
@@ -56,37 +176,51 @@ class SubagentManager:
label: str | None = None,
origin_channel: str = "cli",
origin_chat_id: str = "direct",
session_key: str | None = None,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> str:
"""
Spawn a subagent to execute a task in the background.
Args:
task: The task description for the subagent.
label: Optional human-readable label for the task.
origin_channel: The channel to announce results to.
origin_chat_id: The chat ID to announce results to.
Returns:
Status message indicating the subagent was started.
"""
"""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, "session_key": session_key}
origin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
}
status = SubagentStatus(
task_id=task_id,
label=display_label,
task_description=task,
started_at=time.monotonic(),
)
self._task_statuses[task_id] = status
# Create background task
bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin)
self._run_subagent(
task_id,
task,
display_label,
origin,
status,
origin_message_id,
temperature,
workspace_scope,
)
)
self._running_tasks[task_id] = bg_task
if session_key:
self._session_tasks.setdefault(session_key, set()).add(task_id)
# Cleanup when done
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
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:
del self._session_tasks[session_key]
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
bg_task.add_done_callback(_cleanup)
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
async def _run_subagent(
@@ -95,93 +229,85 @@ class SubagentManager:
task: str,
label: str,
origin: dict[str, str],
status: SubagentStatus,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> None:
"""Execute the subagent task and announce the result."""
logger.info(f"Subagent [{task_id}] starting task: {label}")
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()
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir))
tools.register(WriteFileTool(allowed_dir=allowed_dir))
tools.register(EditFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool(allowed_dir=allowed_dir))
tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
))
tools.register(WebSearchTool(api_key=self.brave_api_key))
tools.register(WebFetchTool())
# Build messages with subagent-specific prompt
system_prompt = self._build_subagent_prompt(task)
root = workspace_scope.project_path if workspace_scope is not None else self.workspace
cfg = None
if workspace_scope is not None:
cfg = self._subagent_tools_config()
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
tools = self._build_tools(workspace=root, tools_config=cfg)
system_prompt = self._build_subagent_prompt(workspace=root)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": task},
]
# Run agent loop (limited iterations)
max_iterations = 15
iteration = 0
final_result: str | None = None
while iteration < max_iterations:
iteration += 1
response = await self.provider.chat(
messages=messages,
tools=tools.get_definitions(),
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
sess_key = origin.get("session_key")
llm_timeout = (
self._llm_wall_timeout_for_session(sess_key)
if self._llm_wall_timeout_for_session
else None
)
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
try:
result = await self.runner.run(AgentRunSpec(
initial_messages=messages,
tools=tools,
model=self.model,
temperature=temperature,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id, status),
max_iterations_message="Task completed but no final response was generated.",
finalize_on_max_iterations=False,
error_message=None,
fail_on_tool_error=self.fail_on_tool_error,
checkpoint_callback=_on_checkpoint,
session_key=sess_key,
workspace=root,
llm_timeout_s=llm_timeout,
))
finally:
if token is not None:
reset_workspace_scope(token)
status.phase = "done"
status.stop_reason = result.stop_reason
if response.has_tool_calls:
# Add assistant message with tool calls
tool_call_dicts = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments),
},
}
for tc in response.tool_calls
]
messages.append({
"role": "assistant",
"content": response.content or "",
"tool_calls": tool_call_dicts,
})
# Execute tools
for tool_call in response.tool_calls:
args_str = json.dumps(tool_call.arguments)
logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}")
result = await tools.execute(tool_call.name, tool_call.arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_call.name,
"content": result,
})
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", origin_message_id,
)
elif result.stop_reason == "error":
await self._announce_result(
task_id, label, task,
result.error or "Error: subagent execution failed.",
origin, "error", origin_message_id,
)
else:
final_result = response.content
break
if final_result is None:
final_result = "Task completed but no final response was generated."
logger.info(f"Subagent [{task_id}] completed successfully")
await self._announce_result(task_id, label, task, final_result, origin, "ok")
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", origin_message_id)
except Exception as e:
error_msg = f"Error: {str(e)}"
logger.error(f"Subagent [{task_id}] failed: {e}")
await self._announce_result(task_id, label, task, error_msg, origin, "error")
status.phase = "error"
status.error = str(e)
logger.exception("Subagent [{}] failed", task_id)
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
async def _announce_result(
self,
@@ -191,67 +317,104 @@ class SubagentManager:
result: str,
origin: dict[str, str],
status: str,
origin_message_id: str | None = None,
) -> None:
"""Announce the subagent result to the main agent via the message bus."""
status_text = "completed successfully" if status == "ok" else "failed"
announce_content = f"""[Subagent '{label}' {status_text}]
announce_content = render_template(
"agent/subagent_announce.md",
label=label,
status_text=status_text,
task=task,
result=result,
)
Task: {task}
Result:
{result}
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
# 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']}"
metadata: dict[str, Any] = {
"injected_event": "subagent_result",
"subagent_task_id": task_id,
}
if origin_message_id:
metadata["origin_message_id"] = origin_message_id
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id=f"{origin['channel']}:{origin['chat_id']}",
content=announce_content,
session_key_override=override,
metadata=metadata,
)
if queue := self._direct_result_queues.get(override):
await queue.put(msg)
logger.debug("Subagent [{}] queued result directly for {}", task_id, override)
return
await self.bus.publish_inbound(msg)
logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
def _build_subagent_prompt(self, task: str) -> str:
@staticmethod
def _format_partial_progress(result) -> str:
completed = [e for e in result.tool_events if e["status"] == "ok"]
failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None)
lines: list[str] = []
if completed:
lines.append("Completed steps:")
for event in completed[-3:]:
lines.append(f"- {event['name']}: {event['detail']}")
if failure:
if lines:
lines.append("")
lines.append("Failure:")
lines.append(f"- {failure['name']}: {failure['detail']}")
if result.error and not failure:
if lines:
lines.append("")
lines.append("Failure:")
lines.append(f"- {result.error}")
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
"""Build a focused system prompt for the subagent."""
from datetime import datetime
import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
from nanobot.agent.context import ContextBuilder
from nanobot.agent.skills import SkillsLoader
return f"""# Subagent
time_ctx = ContextBuilder._build_runtime_context(None, None)
root = workspace or self.workspace
skills_summary = SkillsLoader(
root,
disabled_skills=self.disabled_skills,
).build_skills_summary()
return render_template(
"agent/subagent_system.md",
time_ctx=time_ctx,
workspace=str(root),
skills_summary=skills_summary or "",
)
## Current Time
{now} ({tz})
You are a subagent spawned by the main agent to complete a specific task.
## Rules
1. Stay focused - complete only the assigned task, nothing else
2. Your final response will be reported back to the main agent
3. Do not initiate conversations or take on side tasks
4. Be concise but informative in your findings
## What You Can Do
- Read and write files in the workspace
- Execute shell commands
- Search the web and fetch web pages
- Complete the task thoroughly
## What You Cannot Do
- Send messages directly to users (no message tool available)
- Spawn other subagents
- Access the main agent's conversation history
## Workspace
Your workspace is at: {self.workspace}
Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
When you have completed the task, provide a clear summary of your findings or actions."""
async def cancel_by_session(self, session_key: str) -> int:
"""Cancel all subagents for the given session. Returns count cancelled."""
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, [])
if tid in self._running_tasks and not self._running_tasks[tid].done()]
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
return len(tasks)
def get_running_count(self) -> int:
"""Return the number of currently running subagents."""
return len(self._running_tasks)
def get_running_count_by_session(self, session_key: str) -> int:
"""Return the number of currently running subagents for a session."""
tids = self._session_tasks.get(session_key, set())
return sum(
1 for tid in tids
if tid in self._running_tasks and not self._running_tasks[tid].done()
)
+27 -2
View File
@@ -1,6 +1,31 @@
"""Agent tools module."""
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.base import Schema, Tool, tool_parameters
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
IntegerSchema,
NumberSchema,
ObjectSchema,
StringSchema,
tool_parameters_schema,
)
__all__ = ["Tool", "ToolRegistry"]
__all__ = [
"Schema",
"ArraySchema",
"BooleanSchema",
"IntegerSchema",
"NumberSchema",
"ObjectSchema",
"StringSchema",
"Tool",
"ToolContext",
"ToolLoader",
"ToolRegistry",
"tool_parameters",
"tool_parameters_schema",
]
+296
View File
@@ -0,0 +1,296 @@
"""Apply file edits by providing structured edit instructions."""
from __future__ import annotations
import difflib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
ObjectSchema,
StringSchema,
tool_parameters_schema,
)
@dataclass(slots=True)
class _PatchSummary:
action: str
path: str
added: int = 0
deleted: int = 0
class _PatchError(ValueError):
pass
def _validate_patch_path(path: str) -> str:
normalized = path.strip()
if not normalized:
raise _PatchError("patch path cannot be empty")
if "\0" in normalized:
raise _PatchError(f"patch path contains a null byte: {path!r}")
return normalized
def _lines_to_text(lines: list[str]) -> str:
if not lines:
return ""
return "\n".join(lines) + "\n"
def _text_line_count(text: str) -> int:
if not text:
return 0
return len(text.splitlines())
def _line_diff_stats(before: str, after: str) -> tuple[int, int]:
before_lines = before.replace("\r\n", "\n").splitlines()
after_lines = after.replace("\r\n", "\n").splitlines()
added = 0
deleted = 0
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
if tag in ("replace", "delete"):
deleted += i2 - i1
if tag in ("replace", "insert"):
added += j2 - j1
return added, deleted
def _append_text(content: str, addition: str) -> str:
"""Append text without merging it into an unterminated final line."""
base = content.replace("\r\n", "\n")
extra = addition.replace("\r\n", "\n")
if base and extra and not base.endswith("\n") and not extra.startswith("\n"):
base += "\n"
combined = base + extra
if combined and not combined.endswith("\n"):
combined += "\n"
return combined
def _format_summary(summary: _PatchSummary) -> str:
stats = ""
if summary.added or summary.deleted:
stats = f" (+{summary.added}/-{summary.deleted})"
return f"- {summary.action} {summary.path}{stats}"
@tool_parameters(
tool_parameters_schema(
edits=ArraySchema(
items=ObjectSchema(
path=StringSchema(
"Path to the file to edit. Relative paths resolve against the "
"workspace; absolute paths and '..' obey the workspace access policy."
),
action=StringSchema(
"Operation type: replace or add.",
enum=["replace", "add"],
),
old_text=StringSchema(
"Exact text to search for in the file. Required for replace.",
nullable=True,
),
new_text=StringSchema(
"Text to replace with or append. Required for replace and add.",
nullable=True,
),
required=["path", "action"],
),
description="List of edits to apply. Each edit specifies a file and the change to make.",
min_items=1,
max_items=20,
),
dry_run=BooleanSchema(
description="Validate and summarize the patch without writing files.",
default=False,
),
required=["edits"],
)
)
class ApplyPatchTool(_FsTool):
"""Apply file edits by providing structured edit instructions."""
_scopes = {"core", "subagent"}
@property
def name(self) -> str:
return "apply_patch"
@property
def description(self) -> str:
return (
"Default tool for code edits. Supports multi-file changes in a single call. "
"Provide a list of structured edits, each specifying a file path, action "
"(replace/add), and the exact text to change. "
"Paths are resolved by the current workspace access policy. "
"Set dry_run=true to validate and preview without writing files. "
"Use edit_file only for small exact replacements on a single file."
)
async def execute(
self,
edits: list[dict] | None = None,
dry_run: bool = False,
**kwargs: Any,
) -> str:
try:
if not edits:
raise _PatchError("must provide edits")
writes: dict[Path, str] = {}
summaries: list[_PatchSummary] = []
for edit in edits:
if not isinstance(edit, dict):
raise _PatchError("each edit must be an object")
raw_path = edit.get("path")
if not isinstance(raw_path, str):
raise _PatchError("path required for edit")
path = _validate_patch_path(raw_path)
action = edit.get("action")
if not isinstance(action, str):
raise _PatchError(f"action required for edit: {path}")
source = self._resolve_write(path)
if action == "add":
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for add: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
exists = True
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
exists = True
else:
content = ""
exists = False
if exists:
uses_crlf = "\r\n" in content
new_norm = _append_text(content, new_text)
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
added, deleted = _line_diff_stats(content, new_norm)
action_name = "update"
else:
new_norm = new_text.replace("\r\n", "\n")
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
writes[source] = new_norm
added = _text_line_count(new_norm)
deleted = 0
action_name = "add"
summaries.append(
_PatchSummary(
action=action_name, path=path, added=added, deleted=deleted
)
)
elif action == "replace":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for replace: {path}")
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for replace: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
new_norm = (
norm_content[:pos]
+ new_text.replace("\r\n", "\n")
+ norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
else:
raise _PatchError(f"unknown action: {action}")
if dry_run:
return "Patch dry-run succeeded:\n" + "\n".join(
_format_summary(summary) for summary in summaries
)
backups: dict[Path, bytes | None] = {}
for path in writes:
backups[path] = path.read_bytes() if path.exists() else None
try:
for path, content in writes.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="")
except Exception:
for path, data in backups.items():
if data is None:
if path.exists():
path.unlink()
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
raise
for path in writes:
self._file_states.record_write(path)
return "Patch applied:\n" + "\n".join(
_format_summary(summary) for summary in summaries
)
except PermissionError as exc:
return f"Error: {exc}"
except _PatchError as exc:
return f"Error applying patch: {exc}"
except Exception as exc:
return f"Error applying patch: {exc}"
+259 -49
View File
@@ -1,18 +1,21 @@
"""Base class for agent tools."""
from __future__ import annotations
import typing
from abc import ABC, abstractmethod
from typing import Any
from collections.abc import Callable
from copy import deepcopy
from typing import Any, TypeVar
if typing.TYPE_CHECKING:
from pydantic import BaseModel
class Tool(ABC):
"""
Abstract base class for agent tools.
from nanobot.agent.tools.context import ToolContext
Tools are capabilities that the agent can use to interact with
the environment, such as reading files, executing commands, etc.
"""
_ToolT = TypeVar("_ToolT", bound="Tool")
_TYPE_MAP = {
# Matches :meth:`Tool._cast_value` / :meth:`Schema.validate_json_schema_value` behavior
_JSON_TYPE_MAP: dict[str, type | tuple[type, ...]] = {
"string": str,
"integer": int,
"number": (int, float),
@@ -21,50 +24,49 @@ class Tool(ABC):
"object": dict,
}
@property
@abstractmethod
def name(self) -> str:
"""Tool name used in function calls."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Description of what the tool does."""
pass
class Schema(ABC):
"""Abstract base for JSON Schema fragments describing tool parameters.
@property
@abstractmethod
def parameters(self) -> dict[str, Any]:
"""JSON Schema for tool parameters."""
pass
@abstractmethod
async def execute(self, **kwargs: Any) -> str:
Concrete types live in :mod:`nanobot.agent.tools.schema`; all implement
:meth:`to_json_schema` and :meth:`validate_value`. Class methods
:meth:`validate_json_schema_value` and :meth:`fragment` are the shared validation and normalization entry points.
"""
Execute the tool with given parameters.
Args:
**kwargs: Tool-specific parameters.
@staticmethod
def resolve_json_schema_type(t: Any) -> str | None:
"""Resolve the non-null type name from JSON Schema ``type`` (e.g. ``['string','null']`` -> ``'string'``)."""
if isinstance(t, list):
return next((x for x in t if x != "null"), None)
return t # type: ignore[return-value]
Returns:
String result of the tool execution.
@staticmethod
def subpath(path: str, key: str) -> str:
return f"{path}.{key}" if path else key
@staticmethod
def validate_json_schema_value(val: Any, schema: dict[str, Any], path: str = "") -> list[str]:
"""Validate ``val`` against a JSON Schema fragment; returns error messages (empty means valid).
Used by :class:`Tool` and each concrete Schema's :meth:`validate_value`.
"""
pass
raw_type = schema.get("type")
nullable = (isinstance(raw_type, list) and "null" in raw_type) or schema.get("nullable", False)
t = Schema.resolve_json_schema_type(raw_type)
label = path or "parameter"
def validate_params(self, params: dict[str, Any]) -> list[str]:
"""Validate tool parameters against JSON schema. Returns error list (empty if valid)."""
schema = self.parameters or {}
if schema.get("type", "object") != "object":
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
return self._validate(params, {**schema, "type": "object"}, "")
def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
t, label = schema.get("type"), path or "parameter"
if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]):
if nullable and val is None:
return []
if t == "integer" and (not isinstance(val, int) or isinstance(val, bool)):
return [f"{label} should be integer"]
if t == "number" and (
not isinstance(val, _JSON_TYPE_MAP["number"]) or isinstance(val, bool)
):
return [f"{label} should be number"]
if t in _JSON_TYPE_MAP and t not in ("integer", "number") and not isinstance(val, _JSON_TYPE_MAP[t]):
return [f"{label} should be {t}"]
errors = []
errors: list[str] = []
if "enum" in schema and val not in schema["enum"]:
errors.append(f"{label} must be one of {schema['enum']}")
if t in ("integer", "number"):
@@ -81,22 +83,230 @@ class Tool(ABC):
props = schema.get("properties", {})
for k in schema.get("required", []):
if k not in val:
errors.append(f"missing required {path + '.' + k if path else k}")
errors.append(f"missing required {Schema.subpath(path, k)}")
additional = schema.get("additionalProperties", True)
for k, v in val.items():
if k in props:
errors.extend(self._validate(v, props[k], path + '.' + k if path else k))
if t == "array" and "items" in schema:
errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k)))
elif additional is False:
errors.append(f"unexpected parameter {Schema.subpath(path, k)}")
elif isinstance(additional, dict):
errors.extend(
Schema.validate_json_schema_value(v, additional, Schema.subpath(path, k))
)
if t == "array":
if "minItems" in schema and len(val) < schema["minItems"]:
errors.append(f"{label} must have at least {schema['minItems']} items")
if "maxItems" in schema and len(val) > schema["maxItems"]:
errors.append(f"{label} must be at most {schema['maxItems']} items")
if "items" in schema:
prefix = f"{path}[{{}}]" if path else "[{}]"
for i, item in enumerate(val):
errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]"))
errors.extend(
Schema.validate_json_schema_value(item, schema["items"], prefix.format(i))
)
return errors
@staticmethod
def fragment(value: Any) -> dict[str, Any]:
"""Normalize a Schema instance or an existing JSON Schema dict to a fragment dict."""
# Try to_json_schema first: Schema instances must be distinguished from dicts that are already JSON Schema
to_js = getattr(value, "to_json_schema", None)
if callable(to_js):
return to_js()
if isinstance(value, dict):
return value
raise TypeError(f"Expected schema object or dict, got {type(value).__name__}")
@abstractmethod
def to_json_schema(self) -> dict[str, Any]:
"""Return a fragment dict compatible with :meth:`validate_json_schema_value`."""
...
def validate_value(self, value: Any, path: str = "") -> list[str]:
"""Validate a single value; returns error messages (empty means pass). Subclasses may override for extra rules."""
return Schema.validate_json_schema_value(value, self.to_json_schema(), path)
class Tool(ABC):
"""Agent capability: read files, run commands, etc."""
_TYPE_MAP = _JSON_TYPE_MAP
_BOOL_TRUE = frozenset(("true", "1", "yes"))
_BOOL_FALSE = frozenset(("false", "0", "no"))
@staticmethod
def _resolve_type(t: Any) -> str | None:
"""Pick first non-null type from JSON Schema unions like ``['string','null']``."""
return Schema.resolve_json_schema_type(t)
@property
@abstractmethod
def name(self) -> str:
"""Tool name used in function calls."""
...
@property
@abstractmethod
def description(self) -> str:
"""Description of what the tool does."""
...
@property
@abstractmethod
def parameters(self) -> dict[str, Any]:
"""JSON Schema for tool parameters."""
...
@property
def read_only(self) -> bool:
"""Whether this tool is side-effect free and safe to parallelize."""
return False
@property
def concurrency_safe(self) -> bool:
"""Whether this tool can run alongside other concurrency-safe tools."""
return self.read_only and not self.exclusive
@property
def exclusive(self) -> bool:
"""Whether this tool should run alone even if concurrency is enabled."""
return False
# --- Plugin metadata ---
config_key: str = ""
_plugin_discoverable: bool = True
_scopes: set[str] = {"core"}
@classmethod
def config_cls(cls) -> type[BaseModel] | None:
return None
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
return True
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
return cls()
@abstractmethod
async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; returns a string or list of content blocks."""
...
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
if not isinstance(obj, dict):
return obj
props = schema.get("properties", {})
additional = schema.get("additionalProperties")
casted: dict[str, Any] = {}
for k, v in obj.items():
if k in props:
casted[k] = self._cast_value(v, props[k])
elif isinstance(additional, dict):
casted[k] = self._cast_value(v, additional)
else:
casted[k] = v
return casted
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
"""Apply safe schema-driven casts before validation."""
schema = self.parameters or {}
if schema.get("type", "object") != "object":
return params
return self._cast_object(params, schema)
def _cast_value(self, val: Any, schema: dict[str, Any]) -> Any:
t = self._resolve_type(schema.get("type"))
if t == "boolean" and isinstance(val, bool):
return val
if t == "integer" and isinstance(val, int) and not isinstance(val, bool):
return val
if t in self._TYPE_MAP and t not in ("boolean", "integer", "array", "object"):
expected = self._TYPE_MAP[t]
if isinstance(val, expected):
return val
if isinstance(val, str) and t in ("integer", "number"):
try:
return int(val) if t == "integer" else float(val)
except ValueError:
return val
if t == "string":
return val if val is None else str(val)
if t == "boolean" and isinstance(val, str):
low = val.lower()
if low in self._BOOL_TRUE:
return True
if low in self._BOOL_FALSE:
return False
return val
if t == "array" and isinstance(val, list):
items = schema.get("items")
return [self._cast_value(x, items) for x in val] if items else val
if t == "object" and isinstance(val, dict):
return self._cast_object(val, schema)
return val
def validate_params(self, params: dict[str, Any]) -> list[str]:
"""Validate against JSON schema; empty list means valid."""
if not isinstance(params, dict):
return [f"parameters must be an object, got {type(params).__name__}"]
schema = self.parameters or {}
if schema.get("type", "object") != "object":
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
return Schema.validate_json_schema_value(params, {**schema, "type": "object"}, "")
def to_schema(self) -> dict[str, Any]:
"""Convert tool to OpenAI function schema format."""
"""OpenAI function schema."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
}
def tool_parameters(schema: dict[str, Any]) -> Callable[[type[_ToolT]], type[_ToolT]]:
"""Class decorator: attach JSON Schema and inject a concrete ``parameters`` property.
Use on ``Tool`` subclasses instead of writing ``@property def parameters``. The
schema is stored on the class and returned as a fresh copy on each access.
Example::
@tool_parameters({
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
})
class ReadFileTool(Tool):
...
"""
def decorator(cls: type[_ToolT]) -> type[_ToolT]:
frozen = deepcopy(schema)
@property
def parameters(self: Any) -> dict[str, Any]:
return deepcopy(frozen)
cls.parameters = parameters # type: ignore[assignment]
abstract = getattr(cls, "__abstractmethods__", None)
if abstract is not None and "parameters" in abstract:
cls.__abstractmethods__ = frozenset(abstract - {"parameters"}) # type: ignore[misc]
return cls
return decorator
+139
View File
@@ -0,0 +1,139 @@
"""Controlled runner for installed CLI Apps."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config_base import Base
from nanobot.security.workspace_access import current_tool_workspace
class CliAppsToolConfig(Base):
"""CLI Apps tool configuration."""
enable: bool = True
install_timeout: int = Field(default=300, ge=1, le=3600)
run_timeout: int = Field(default=60, ge=1, le=600)
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
@tool_parameters(
tool_parameters_schema(
required=["name"],
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
args=ArraySchema(
StringSchema("One command-line argument."),
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
nullable=True,
),
json=BooleanSchema(
description="Whether to prepend --json when supported by the CLI.",
default=False,
nullable=True,
),
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
timeout=IntegerSchema(
description="Timeout in seconds for this CLI call.",
minimum=1,
maximum=600,
nullable=True,
),
)
)
class CliAppsTool(Tool):
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
config_key = "cli_apps"
_scopes = {"core", "subagent"}
@classmethod
def config_cls(cls):
return CliAppsToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.cli_apps.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.cli_apps
return cls(
workspace=Path(ctx.workspace),
restrict_to_workspace=ctx.config.restrict_to_workspace,
runtime=CliAppsRuntimeConfig(
install_timeout=cfg.install_timeout,
run_timeout=cfg.run_timeout,
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
),
)
def __init__(
self,
*,
workspace: Path,
restrict_to_workspace: bool = False,
runtime: CliAppsRuntimeConfig | None = None,
) -> None:
self.workspace = workspace
self.restrict_to_workspace = restrict_to_workspace
self.runtime = runtime or CliAppsRuntimeConfig()
@property
def name(self) -> str:
return "run_cli_app"
@property
def description(self) -> str:
try:
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
except Exception:
installed = []
installed_note = (
f" Installed Settings CLI Apps: {', '.join(installed)}."
if installed
else " No Settings CLI Apps are currently installed."
)
return (
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
"unknown names are rejected. Execution uses argv, not shell."
+ installed_note
)
async def execute(
self,
name: str,
args: list[str] | None = None,
json: bool | None = False,
working_dir: str | None = None,
timeout: int | None = None,
) -> str:
access = current_tool_workspace(
self.workspace,
restrict_to_workspace=self.restrict_to_workspace,
)
workspace = access.project_path or self.workspace
manager = CliAppManager(workspace=workspace, runtime=self.runtime)
try:
return manager.run(
name,
args=args or [],
json_output=bool(json),
working_dir=working_dir,
timeout=timeout,
restrict_to_workspace=access.restrict_to_workspace,
)
except CliAppError as exc:
return f"Error: {exc.message}"
+60
View File
@@ -0,0 +1,60 @@
"""Runtime context for tool construction."""
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, runtime_checkable
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
"nanobot_tool_request_context",
default=None,
)
@dataclass(frozen=True)
class RequestContext:
"""Per-request context injected into tools at message-processing time."""
channel: str
chat_id: str
message_id: str | None = None
session_key: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class ContextAware(Protocol):
def set_context(self, ctx: RequestContext) -> None:
...
def bind_request_context(ctx: RequestContext) -> Token[RequestContext | None]:
return _CURRENT_REQUEST_CONTEXT.set(ctx)
def reset_request_context(token: Token[RequestContext | None]) -> None:
_CURRENT_REQUEST_CONTEXT.reset(token)
def current_request_context() -> RequestContext | None:
return _CURRENT_REQUEST_CONTEXT.get()
def current_request_session_key() -> str | None:
ctx = current_request_context()
return ctx.session_key if ctx else None
@dataclass
class ToolContext:
config: Any
workspace: str
bus: Any | None = None
subagent_manager: Any | None = None
cron_service: Any | None = None
sessions: Any | None = None
file_state_store: Any = field(default=None)
provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None
timezone: str = "UTC"
workspace_sandbox: Any | None = None
runtime_events: Any | None = None
+217 -66
View File
@@ -1,24 +1,117 @@
"""Cron tool for scheduling reminders and tasks."""
from __future__ import annotations
from contextvars import ContextVar
from datetime import datetime
from typing import Any
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import (
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
from nanobot.cron.types import CronJob, CronJobState, CronSchedule
from nanobot.session.keys import UNIFIED_SESSION_KEY
_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."
),
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."
),
)
class CronTool(Tool):
@tool_parameters(_CRON_PARAMETERS)
class CronTool(Tool, ContextAware):
"""Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService):
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
self._cron = cron_service
self._channel = ""
self._chat_id = ""
self._default_timezone = default_timezone
self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="")
self._origin_channel: ContextVar[str] = ContextVar("cron_origin_channel", default="")
self._origin_chat_id: ContextVar[str] = ContextVar("cron_origin_chat_id", default="")
self._origin_metadata: ContextVar[dict[str, Any] | None] = ContextVar(
"cron_origin_metadata",
default=None,
)
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
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.cron_service is not None
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
def set_context(self, ctx: RequestContext) -> None:
"""Set the current session context for scheduled cron job ownership."""
raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
self._session_key.set(
raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
)
self._origin_channel.set(ctx.channel or "")
self._origin_chat_id.set(ctx.chat_id or "")
self._origin_metadata.set(dict(ctx.metadata or {}))
def set_cron_context(self, active: bool):
"""Mark whether the tool is executing inside a cron job callback."""
return self._in_cron_context.set(active)
def reset_cron_context(self, token) -> None:
"""Restore previous cron context."""
self._in_cron_context.reset(token)
@staticmethod
def _validate_timezone(tz: str) -> str | None:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
return None
def _display_timezone(self, schedule: CronSchedule) -> str:
"""Pick the most human-meaningful timezone for display."""
return schedule.tz or self._default_timezone
@staticmethod
def _format_timestamp(ms: int, tz_name: str) -> str:
from zoneinfo import ZoneInfo
dt = datetime.fromtimestamp(ms / 1000, tz=ZoneInfo(tz_name))
return f"{dt.isoformat()} ({tz_name})"
@property
def name(self) -> str:
@@ -26,59 +119,37 @@ class CronTool(Tool):
@property
def description(self) -> str:
return "Schedule reminders and recurring tasks. Actions: add, list, remove."
return (
"Schedule reminders and recurring tasks. Actions: add, list, remove. "
f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "Action to perform"
},
"message": {
"type": "string",
"description": "Reminder message (for add)"
},
"every_seconds": {
"type": "integer",
"description": "Interval in seconds (for recurring tasks)"
},
"cron_expr": {
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
"tz": {
"type": "string",
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
},
"at": {
"type": "string",
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
},
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
}
},
"required": ["action"]
}
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,
name: str | None = None,
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None,
job_id: str | None = None,
**kwargs: Any
deliver: bool = True,
**kwargs: Any,
) -> str:
if action == "add":
return self._add_job(message, every_seconds, cron_expr, tz, at)
if self._in_cron_context.get():
return "Error: cannot schedule new jobs from within a cron job execution"
return self._add_job(name, message, every_seconds, cron_expr, tz, at)
elif action == "list":
return self._list_jobs()
elif action == "remove":
@@ -87,6 +158,7 @@ class CronTool(Tool):
def _add_job(
self,
name: str | None,
message: str,
every_seconds: int | None,
cron_expr: str | None,
@@ -94,27 +166,44 @@ class CronTool(Tool):
at: str | None,
) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/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=\"...\"."
)
session_key = self._session_key.get()
if not session_key:
return "Error: scheduled cron jobs must be created from a chat session"
origin_channel = self._origin_channel.get()
origin_chat_id = self._origin_chat_id.get()
if not origin_channel or not origin_chat_id:
return "Error: scheduled cron jobs must be created from a chat session"
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
if tz:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
if err := self._validate_timezone(tz):
return err
# Build schedule
delete_after = False
if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
effective_tz = tz or self._default_timezone
if err := self._validate_timezone(effective_tz):
return err
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=effective_tz)
elif at:
from datetime import datetime
from zoneinfo import ZoneInfo
try:
dt = datetime.fromisoformat(at)
except ValueError:
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS"
if dt.tzinfo is None:
if err := self._validate_timezone(self._default_timezone):
return err
dt = dt.replace(tzinfo=ZoneInfo(self._default_timezone))
at_ms = int(dt.timestamp() * 1000)
schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True
@@ -122,26 +211,88 @@ class CronTool(Tool):
return "Error: either every_seconds, cron_expr, or at is required"
job = self._cron.add_job(
name=message[:30],
name=name or message[:30],
schedule=schedule,
message=message,
deliver=True,
channel=self._channel,
to=self._chat_id,
delete_after_run=delete_after,
session_key=session_key,
origin_channel=origin_channel,
origin_chat_id=origin_chat_id,
origin_metadata=dict(self._origin_metadata.get() or {}),
)
return f"Created job '{job.name}' (id: {job.id})"
def _format_timing(self, schedule: CronSchedule) -> str:
"""Format schedule as a human-readable timing string."""
if schedule.kind == "cron":
tz = f" ({schedule.tz})" if schedule.tz else ""
return f"cron: {schedule.expr}{tz}"
if schedule.kind == "every" and schedule.every_ms:
ms = schedule.every_ms
if ms % 3_600_000 == 0:
return f"every {ms // 3_600_000}h"
if ms % 60_000 == 0:
return f"every {ms // 60_000}m"
if ms % 1000 == 0:
return f"every {ms // 1000}s"
return f"every {ms}ms"
if schedule.kind == "at" and schedule.at_ms:
return f"at {self._format_timestamp(schedule.at_ms, self._display_timezone(schedule))}"
return schedule.kind
def _format_state(self, state: CronJobState, schedule: CronSchedule) -> list[str]:
"""Format job run state as display lines."""
lines: list[str] = []
display_tz = self._display_timezone(schedule)
if state.last_run_at_ms:
info = (
f" Last run: {self._format_timestamp(state.last_run_at_ms, display_tz)}"
f"{state.last_status or 'unknown'}"
)
if state.last_error:
info += f" ({state.last_error})"
lines.append(info)
if state.next_run_at_ms:
lines.append(f" Next run: {self._format_timestamp(state.next_run_at_ms, display_tz)}")
return lines
@staticmethod
def _system_job_purpose(job: CronJob) -> str:
if job.name == "dream":
return "Dream memory consolidation for long-term memory."
return "System-managed internal job."
def _list_jobs(self) -> str:
jobs = self._cron.list_jobs()
if not jobs:
return "No scheduled jobs."
lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs]
lines = []
for j in jobs:
timing = self._format_timing(j.schedule)
parts = [f"- {j.name} (id: {j.id}, {timing})"]
if j.payload.kind == "system_event":
parts.append(f" Purpose: {self._system_job_purpose(j)}")
parts.append(" Protected: visible for inspection, but cannot be removed.")
parts.extend(self._format_state(j.state, j.schedule))
lines.append("\n".join(parts))
return "Scheduled jobs:\n" + "\n".join(lines)
def _remove_job(self, job_id: str | None) -> str:
if not job_id:
return "Error: job_id is required for remove"
if self._cron.remove_job(job_id):
result = self._cron.remove_job(job_id)
if result == "removed":
return f"Removed job {job_id}"
if result == "protected":
job = self._cron.get_job(job_id)
if job and job.name == "dream":
return (
"Cannot remove job `dream`.\n"
"This is a system-managed Dream memory consolidation job for long-term memory.\n"
"It remains visible so you can inspect it, but it cannot be removed."
)
return (
f"Cannot remove job `{job_id}`.\n"
"This is a protected system-managed cron job."
)
return f"Job {job_id} not found"
+609
View File
@@ -0,0 +1,609 @@
"""Session support for long-running exec workflows."""
from __future__ import annotations
import asyncio
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
DEFAULT_YIELD_MS = 1000
MAX_YIELD_MS = 30_000
DEFAULT_WAIT_FOR_MS = 10_000
MAX_WAIT_FOR_MS = 120_000
DEFAULT_MAX_OUTPUT_CHARS = 10_000
MAX_OUTPUT_CHARS = 50_000
OUTPUT_DRAIN_GRACE_S = 0.1
@dataclass(slots=True)
class _SessionPoll:
output: str
done: bool
exit_code: int | None
elapsed_s: float = 0.0
timed_out: bool = False
terminated: bool = False
stdin_closed: bool = False
truncated_chars: int = 0
@dataclass(slots=True)
class ExecSessionInfo:
session_id: str
command: str
cwd: str
elapsed_s: float
idle_s: float
remaining_s: float
returncode: int | None
owner_session_key: str | None = None
class _ExecSession:
def __init__(
self,
*,
session_id: str,
process: asyncio.subprocess.Process,
command: str,
cwd: str,
timeout: int | None,
owner_session_key: str | None = None,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.owner_session_key = owner_session_key
self.started_at = time.monotonic()
# timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf")
self.last_access = time.monotonic()
self._chunks: list[str] = []
self._lock = asyncio.Lock()
self._timed_out = False
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, ""))
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n"))
async def _read_stream(
self,
stream: asyncio.StreamReader | None,
prefix: str,
) -> None:
if stream is None:
return
first = True
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace")
if prefix and first:
text = prefix + text
first = False
async with self._lock:
self._chunks.append(text)
async def write(self, chars: str) -> str | None:
if self.process.returncode is not None:
return "session has already exited"
if self.process.stdin is None:
return "session stdin is not available"
try:
self.process.stdin.write(chars.encode("utf-8"))
await self.process.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
return "session stdin is closed"
return None
async def close_stdin(self) -> str | None:
if self.process.returncode is not None:
return "session has already exited"
if self.process.stdin is None:
return "session stdin is not available"
self.process.stdin.close()
with suppress(BrokenPipeError, ConnectionResetError):
await self.process.stdin.wait_closed()
return None
async def poll(
self,
yield_time_ms: int,
max_output_chars: int,
*,
terminated: bool = False,
stdin_closed: bool = False,
) -> _SessionPoll:
self.last_access = time.monotonic()
if yield_time_ms > 0 and self.process.returncode is None:
await asyncio.sleep(min(yield_time_ms, MAX_YIELD_MS) / 1000)
if self.process.returncode is None and time.monotonic() >= self.deadline:
self._timed_out = True
await self.kill()
if self.process.returncode is not None:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(
asyncio.gather(self._stdout_task, self._stderr_task),
timeout=2.0,
)
elif yield_time_ms > 0:
await self._wait_for_buffered_output()
async with self._lock:
output = "".join(self._chunks)
self._chunks.clear()
output, truncated = _truncate_output(output, max_output_chars)
return _SessionPoll(
output=output,
done=self.process.returncode is not None,
exit_code=self.process.returncode,
elapsed_s=max(0.0, time.monotonic() - self.started_at),
timed_out=self._timed_out,
terminated=terminated,
stdin_closed=stdin_closed,
truncated_chars=truncated,
)
async def kill(self) -> None:
if self.process.returncode is not None:
return
self.process.kill()
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.process.wait(), timeout=5.0)
async def _wait_for_buffered_output(self) -> None:
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
while time.monotonic() < deadline:
async with self._lock:
if self._chunks:
return
await asyncio.sleep(0.01)
class ExecSessionManager:
def __init__(self, *, max_sessions: int = 8, idle_timeout: int = 1800) -> None:
self.max_sessions = max_sessions
self.idle_timeout = idle_timeout
self._sessions: dict[str, _ExecSession] = {}
self._lock = asyncio.Lock()
async def start(
self,
*,
command: str,
cwd: str,
env: dict[str, str],
timeout: int | None,
shell_program: str | None,
login: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> tuple[str, _SessionPoll]:
async with self._lock:
await self._cleanup_locked()
if len(self._sessions) >= self.max_sessions:
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
process = await self._spawn(command, cwd, env, shell_program, login)
session_id = uuid.uuid4().hex[:12]
session = _ExecSession(
session_id=session_id,
process=process,
command=command,
cwd=cwd,
timeout=timeout,
owner_session_key=owner_session_key,
)
self._sessions[session_id] = session
poll = await session.poll(yield_time_ms, max_output_chars)
if poll.done:
async with self._lock:
self._sessions.pop(session_id, None)
return session_id, poll
async def write(
self,
*,
session_id: str,
chars: str | None,
close_stdin: bool,
terminate: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> _SessionPoll:
async with self._lock:
await self._cleanup_locked()
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
if (
owner_session_key
and session.owner_session_key
and session.owner_session_key != owner_session_key
):
raise KeyError(session_id)
if chars:
error = await session.write(chars)
if error:
raise RuntimeError(error)
stdin_closed = False
if close_stdin:
error = await session.close_stdin()
if error:
raise RuntimeError(error)
stdin_closed = True
if terminate:
await session.kill()
poll = await session.poll(
yield_time_ms,
max_output_chars,
terminated=terminate,
stdin_closed=stdin_closed,
)
if poll.done:
async with self._lock:
self._sessions.pop(session_id, None)
return poll
async def list(self, *, owner_session_key: str | None = None) -> list[ExecSessionInfo]:
async with self._lock:
await self._cleanup_locked()
now = time.monotonic()
return [
ExecSessionInfo(
session_id=session_id,
command=session.command,
cwd=session.cwd,
elapsed_s=max(0.0, now - session.started_at),
idle_s=max(0.0, now - session.last_access),
remaining_s=max(0.0, session.deadline - now),
returncode=session.process.returncode,
owner_session_key=session.owner_session_key,
)
for session_id, session in sorted(self._sessions.items())
if not owner_session_key
or not session.owner_session_key
or session.owner_session_key == owner_session_key
]
async def _cleanup_locked(self) -> None:
now = time.monotonic()
stale = [
session_id
for session_id, session in self._sessions.items()
if now - session.last_access > self.idle_timeout
]
for session_id in stale:
session = self._sessions.pop(session_id)
await session.kill()
async def _spawn(
self,
command: str,
cwd: str,
env: dict[str, str],
shell_program: str | None,
login: bool,
) -> asyncio.subprocess.Process:
from nanobot.agent.tools.shell import ExecTool
return await ExecTool._spawn(
command, cwd, env, shell_program, login,
stdin=asyncio.subprocess.PIPE,
)
DEFAULT_EXEC_SESSION_MANAGER = ExecSessionManager()
def clamp_session_int(value: int | None, default: int, minimum: int, maximum: int) -> int:
if value is None:
return default
return min(max(value, minimum), maximum)
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
if len(output) <= max_output_chars:
return output, 0
half = max_output_chars // 2
omitted = len(output) - max_output_chars
return (
output[:half]
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
+ output[-half:],
omitted,
)
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
parts = [poll.output] if poll.output else []
if poll.truncated_chars:
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)")
if poll.timed_out:
parts.append("Error: Command timed out; session was terminated.")
if poll.terminated and not poll.timed_out:
parts.append("Session terminated.")
if poll.stdin_closed:
parts.append("Stdin closed.")
if poll.done:
parts.append(f"Exit code: {poll.exit_code}")
else:
parts.append(f"Process running. session_id: {session_id}")
parts.append(f"Elapsed: {poll.elapsed_s:.1f}s")
return "\n".join(parts) if parts else "(no output yet)"
@tool_parameters(
tool_parameters_schema(
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
chars=StringSchema(
"Bytes/text to write to stdin. Omit or pass an empty string to only poll recent output.",
nullable=True,
),
close_stdin=BooleanSchema(
description="Close stdin after writing chars. Useful for commands waiting for EOF.",
default=False,
),
terminate=BooleanSchema(
description="Terminate the running exec session.",
default=False,
),
yield_time_ms=IntegerSchema(
DEFAULT_YIELD_MS,
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
minimum=0,
maximum=MAX_YIELD_MS,
),
wait_for=StringSchema(
"Optional text to wait for in output before returning. "
"Useful for interactive commands and dev servers.",
nullable=True,
),
wait_timeout_ms=IntegerSchema(
DEFAULT_WAIT_FOR_MS,
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
minimum=0,
maximum=MAX_WAIT_FOR_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Maximum output characters to return from this poll (default 10000, max 50000).",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
),
max_output_tokens=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
required=["session_id"],
)
)
class WriteStdinTool(Tool):
"""Write to or poll a running exec session."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
from nanobot.agent.tools.shell import ExecToolConfig
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
self,
*,
manager: ExecSessionManager | None = None,
) -> None:
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
@property
def exclusive(self) -> bool:
return True
@property
def name(self) -> str:
return "write_stdin"
@property
def description(self) -> str:
return (
"Interact with a running exec session created by exec with "
"yield_time_ms. Use chars='' to poll without writing, chars to send "
"stdin, close_stdin=true to send EOF, or terminate=true to stop the "
"process. Use wait_for with wait_timeout_ms for dev servers, test "
"watchers, and prompts where you need to wait for expected output. "
"Do not use this to start new commands; start them with exec."
)
async def execute(
self,
session_id: str,
chars: str | None = None,
close_stdin: bool = False,
terminate: bool = False,
yield_time_ms: int | None = None,
wait_for: str | None = None,
wait_timeout_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any,
) -> str:
try:
if max_output_chars is None:
max_output_chars = max_output_tokens
output_limit = clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
1000,
MAX_OUTPUT_CHARS,
)
if wait_for:
return await self._wait_for_output(
session_id=session_id,
chars=chars,
close_stdin=close_stdin,
terminate=terminate,
wait_for=wait_for,
wait_timeout_ms=clamp_session_int(
wait_timeout_ms,
DEFAULT_WAIT_FOR_MS,
0,
MAX_WAIT_FOR_MS,
),
max_output_chars=output_limit,
)
poll = await self._manager.write(
session_id=session_id,
chars=chars,
close_stdin=close_stdin,
terminate=terminate,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=output_limit,
owner_session_key=current_request_session_key(),
)
return format_session_poll(session_id, poll)
except KeyError:
return f"Error: exec session not found: {session_id}"
except Exception as exc:
return f"Error writing to exec session: {exc}"
async def _wait_for_output(
self,
*,
session_id: str,
chars: str | None,
close_stdin: bool,
terminate: bool,
wait_for: str,
wait_timeout_ms: int,
max_output_chars: int,
) -> str:
deadline = time.monotonic() + (wait_timeout_ms / 1000)
aggregate: list[str] = []
first = True
poll: _SessionPoll | None = None
while True:
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
step_ms = min(500, remaining_ms)
poll = await self._manager.write(
session_id=session_id,
chars=chars if first else None,
close_stdin=close_stdin if first else False,
terminate=terminate if first else False,
yield_time_ms=step_ms,
max_output_chars=max_output_chars,
owner_session_key=current_request_session_key(),
)
first = False
if poll.output:
aggregate.append(poll.output)
joined = "".join(aggregate)
if wait_for in joined:
poll.output = joined
return format_session_poll(session_id, poll)
if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate)
result = format_session_poll(session_id, poll)
if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}"
return result
@tool_parameters(tool_parameters_schema())
class ListExecSessionsTool(Tool):
"""List active exec sessions."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
from nanobot.agent.tools.shell import ExecToolConfig
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
self,
*,
manager: ExecSessionManager | None = None,
) -> None:
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
@property
def name(self) -> str:
return "list_exec_sessions"
@property
def description(self) -> str:
return (
"List active long-running exec sessions, including session_id, cwd, "
"elapsed time, idle time, remaining timeout, and command preview. "
"Use this to recover a session_id after context shifts before "
"polling, writing stdin, or terminating with write_stdin."
)
@property
def read_only(self) -> bool:
return True
async def execute(self, **kwargs: Any) -> str:
try:
sessions = await self._manager.list(
owner_session_key=current_request_session_key(),
)
if not sessions:
return "No active exec sessions."
lines = []
for info in sessions:
command = " ".join(info.command.split())
if len(command) > 120:
command = command[:119] + "..."
status = "exited" if info.returncode is not None else "running"
lines.append(
f"{info.session_id} | {status} | elapsed={info.elapsed_s:.1f}s "
f"| idle={info.idle_s:.1f}s | remaining={info.remaining_s:.1f}s "
f"| cwd={info.cwd} | {command}"
)
return "\n".join(lines)
except Exception as exc:
return f"Error listing exec sessions: {exc}"
+205
View File
@@ -0,0 +1,205 @@
"""Track file-read state for read-before-edit warnings and read deduplication."""
from __future__ import annotations
import hashlib
import os
from contextvars import ContextVar, Token
from dataclasses import dataclass
from pathlib import Path
@dataclass(slots=True)
class ReadState:
mtime: float
offset: int
limit: int | None
content_hash: str | None
can_dedup: bool
def _hash_file(p: str) -> str | None:
try:
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
except OSError:
return None
class FileStates:
"""Per-session read/write tracker.
Owns its own state dict so read-dedup ("File unchanged since last read")
and read-before-edit warnings stay scoped to one agent session and do
not leak across sessions sharing this process.
"""
__slots__ = ("_state",)
def __init__(self) -> None:
self._state: dict[str, ReadState] = {}
def record_read(self, path: str | Path, offset: int = 1, limit: int | None = None) -> None:
"""Record that a file was read (called after successful read)."""
p = str(Path(path).resolve())
try:
mtime = os.path.getmtime(p)
except OSError:
return
self._state[p] = ReadState(
mtime=mtime,
offset=offset,
limit=limit,
content_hash=_hash_file(p),
can_dedup=True,
)
def record_write(self, path: str | Path) -> None:
"""Record that a file was written (updates mtime in state)."""
p = str(Path(path).resolve())
try:
mtime = os.path.getmtime(p)
except OSError:
self._state.pop(p, None)
return
self._state[p] = ReadState(
mtime=mtime,
offset=1,
limit=None,
content_hash=_hash_file(p),
can_dedup=False,
)
def check_read(self, path: str | Path) -> str | None:
"""Check if a file has been read and is fresh.
Returns None if OK, or a warning string.
When mtime changed but file content is identical (e.g. touch, editor save),
the check passes to avoid false-positive staleness warnings.
"""
p = str(Path(path).resolve())
entry = self._state.get(p)
if entry is None:
return "Warning: file has not been read yet. Read it first to verify content before editing."
try:
current_mtime = os.path.getmtime(p)
except OSError:
return None
if current_mtime != entry.mtime:
if entry.content_hash and _hash_file(p) == entry.content_hash:
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(self, path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
"""Return True if file was previously read with same params and content is unchanged."""
p = str(Path(path).resolve())
entry = self._state.get(p)
if entry is None:
return False
if not entry.can_dedup:
return False
if entry.offset != offset or entry.limit != limit:
return False
try:
current_mtime = os.path.getmtime(p)
except OSError:
return False
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 get(self, path: str | Path) -> ReadState | None:
"""Return the raw ReadState entry for a path, or None."""
return self._state.get(str(Path(path).resolve()))
def clear(self) -> None:
"""Clear all tracked state (useful for testing)."""
self._state.clear()
class FileStateStore:
"""Lookup table for per-session file read/write state."""
__slots__ = ("_states_by_key",)
def __init__(self) -> None:
self._states_by_key: dict[str, FileStates] = {}
def for_session(self, session_key: str | None) -> FileStates:
key = session_key or "__default__"
states = self._states_by_key.get(key)
if states is None:
states = FileStates()
self._states_by_key[key] = states
return states
def clear(self) -> None:
self._states_by_key.clear()
_current_file_states: ContextVar[FileStates | None] = ContextVar(
"nanobot_file_states",
default=None,
)
def current_file_states(default: FileStates) -> FileStates:
"""Return the FileStates bound to the current agent task, or a fallback."""
return _current_file_states.get() or default
def bind_file_states(file_states: FileStates) -> Token[FileStates | None]:
"""Bind file read/write state for the current async task."""
return _current_file_states.set(file_states)
def reset_file_states(token: Token[FileStates | None]) -> None:
_current_file_states.reset(token)
# Module-level default instance, retained for backward compatibility with
# tests and callers that reach in directly. Per-session callers should hold
# their own FileStates instance instead of touching this one.
_default = FileStates()
def record_read(path: str | Path, offset: int = 1, limit: int | None = None) -> None:
_default.record_read(path, offset=offset, limit=limit)
def record_write(path: str | Path) -> None:
_default.record_write(path)
def check_read(path: str | Path) -> str | None:
return _default.check_read(path)
def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
return _default.is_unchanged(path, offset=offset, limit=limit)
def clear() -> None:
_default.clear()
# Legacy attribute for callers that reached into the module-level dict
# directly (filesystem.py used to do this). Kept as a property-like accessor
# so existing imports keep working.
def __getattr__(name: str):
if name == "_state":
return _default._state
raise AttributeError(name)
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
"""Image generation tool."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import (
ArraySchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base
from nanobot.providers.image_generation import (
ImageGenerationError,
ImageGenerationProvider,
get_image_gen_provider,
)
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
from nanobot.utils.artifacts import (
ArtifactError,
generated_image_tool_result,
store_generated_image_artifact,
)
from nanobot.utils.helpers import detect_image_mime
if TYPE_CHECKING:
from nanobot.config.schema import ProviderConfig
class ImageGenerationToolConfig(Base):
"""Image generation tool configuration."""
enabled: bool = False
provider: str = "openrouter"
model: str = "openai/gpt-5.4-image-2"
default_aspect_ratio: str = "1:1"
default_image_size: str = "1K"
max_images_per_turn: int = Field(default=4, ge=1, le=8)
save_dir: str = "generated"
@tool_parameters(
tool_parameters_schema(
prompt=StringSchema(
"Detailed image generation or edit prompt. Include style, subject, composition, colors, and constraints.",
min_length=1,
),
reference_images=ArraySchema(
StringSchema("Local path of an existing image artifact or user-provided image to use as an edit reference."),
description="Optional local image paths. Use generated artifact paths for iterative edits.",
),
aspect_ratio=StringSchema(
"Optional output aspect ratio, e.g. 1:1, 16:9, 9:16, 4:3.",
),
image_size=StringSchema(
"Optional output size hint supported by the configured provider, e.g. 1K, 2K, 4K, or 1024x1024.",
),
count=IntegerSchema(
description="Number of images to generate in this turn.",
minimum=1,
maximum=8,
),
required=["prompt"],
)
)
class ImageGenerationTool(Tool):
"""Generate persistent image artifacts through the configured image provider."""
config_key = "image_generation"
@classmethod
def config_cls(cls):
return ImageGenerationToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.image_generation.enabled
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(
workspace=ctx.workspace,
config=ctx.config.image_generation,
provider_configs=ctx.image_generation_provider_configs,
)
def __init__(
self,
*,
workspace: str | Path,
config: ImageGenerationToolConfig,
provider_config: ProviderConfig | None = None,
provider_configs: dict[str, ProviderConfig] | None = None,
) -> None:
self.workspace = Path(workspace).expanduser()
self.config = config
self.provider_configs = dict(provider_configs or {})
if provider_config is not None and "openrouter" not in self.provider_configs:
self.provider_configs["openrouter"] = provider_config
@property
def name(self) -> str:
return "generate_image"
@property
def description(self) -> str:
return (
"Generate or edit images and store them as persistent artifacts. "
"Returns artifact ids and local paths. For edits, pass prior generated image paths "
"or user image paths as reference_images."
)
def _provider_config(self) -> ProviderConfig | None:
return self.provider_configs.get(self.config.provider)
def _provider_client(self) -> ImageGenerationProvider | None:
provider = self._provider_config()
cls = get_image_gen_provider(self.config.provider)
if cls is None:
return None
kwargs = {
"api_key": provider.api_key if provider else None,
"api_base": provider.api_base if provider else None,
"extra_headers": provider.extra_headers if provider else None,
"extra_body": provider.extra_body if provider else None,
}
return cls(**kwargs)
def _resolve_reference_image(self, value: str) -> str:
access = current_tool_workspace(self.workspace, restrict_to_workspace=True)
workspace = access.project_path or self.workspace
try:
resolved = resolve_allowed_path(
value,
workspace=workspace,
allowed_root=access.allowed_root,
extra_allowed_roots=[get_media_dir()] if access.allowed_root is not None else None,
strict=True,
)
except WorkspaceBoundaryError as exc:
raise ImageGenerationError(
"reference_images must be inside the workspace or nanobot media directory"
) from exc
except OSError as exc:
raise ImageGenerationError(f"reference image not found: {value}") from exc
if not resolved.is_file():
raise ImageGenerationError(f"reference image is not a file: {value}")
raw = resolved.read_bytes()
if detect_image_mime(raw) is None:
raise ImageGenerationError(f"unsupported reference image: {value}")
return str(resolved)
def _resolve_reference_images(self, values: list[str] | None) -> list[str]:
if not values:
return []
return [self._resolve_reference_image(value) for value in values if value]
async def execute(
self,
prompt: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
count: int | None = None,
**kwargs: Any,
) -> str:
client = self._provider_client()
if client is None:
return f"Error: unsupported image generation provider '{self.config.provider}'"
requested = count or 1
if requested > self.config.max_images_per_turn:
return (
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
f"({self.config.max_images_per_turn})"
)
try:
refs = self._resolve_reference_images(reference_images)
artifacts: list[dict[str, Any]] = []
while len(artifacts) < requested:
response = await client.generate(
prompt=prompt,
model=self.config.model,
reference_images=refs,
aspect_ratio=aspect_ratio or self.config.default_aspect_ratio,
image_size=image_size or self.config.default_image_size,
)
for image_data_url in response.images:
artifact = store_generated_image_artifact(
image_data_url,
prompt=prompt,
model=self.config.model,
source_images=refs,
save_dir=self.config.save_dir,
provider=self.config.provider,
)
artifacts.append(artifact)
if len(artifacts) >= requested:
break
return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc:
return f"Error: {exc}"
+116
View File
@@ -0,0 +1,116 @@
"""Tool discovery and registration via package scanning."""
from __future__ import annotations
import importlib
import pkgutil
from importlib.metadata import entry_points
from typing import Any
from loguru import logger
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
_SKIP_MODULES = frozenset({
"base", "schema", "registry", "context", "loader", "config",
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
})
class ToolLoader:
def __init__(self, package: Any = None, *, test_classes: list[type[Tool]] | None = None):
if package is None:
import nanobot.agent.tools as _pkg
package = _pkg
self._package = package
self._test_classes = test_classes
self._discovered: list[type[Tool]] | None = None
self._plugins: dict[str, type[Tool]] | None = None
def discover(self) -> list[type[Tool]]:
if self._test_classes is not None:
return list(self._test_classes)
if self._discovered is not None:
return self._discovered
seen: set[int] = set()
results: list[type[Tool]] = []
for _importer, module_name, _ispkg in pkgutil.iter_modules(self._package.__path__):
if module_name.startswith("_") or module_name in _SKIP_MODULES:
continue
try:
module = importlib.import_module(f".{module_name}", self._package.__name__)
except Exception:
logger.exception("Failed to import tool module: %s", module_name)
continue
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, Tool)
and attr is not Tool
and not attr_name.startswith("_")
and not getattr(attr, "__abstractmethods__", None)
and getattr(attr, "_plugin_discoverable", True)
and id(attr) not in seen
):
seen.add(id(attr))
results.append(attr)
results.sort(key=lambda cls: cls.__name__)
self._discovered = results
return results
def _discover_plugins(self) -> dict[str, type[Tool]]:
"""Discover external tool plugins registered via entry_points."""
if self._plugins is not None:
return self._plugins
plugins: dict[str, type[Tool]] = {}
try:
eps = entry_points(group="nanobot.tools")
except Exception:
return plugins
for ep in eps:
try:
cls = ep.load()
if (
isinstance(cls, type)
and issubclass(cls, Tool)
and not getattr(cls, "__abstractmethods__", None)
and getattr(cls, "_plugin_discoverable", True)
):
plugins[ep.name] = cls
except Exception:
logger.exception("Failed to load tool plugin: %s", ep.name)
self._plugins = plugins
return plugins
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
registered: list[str] = []
builtin_names: set[str] = set()
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
for source, is_plugin_source in sources:
for tool_cls in source:
cls_label = tool_cls.__name__
try:
if scope not in getattr(tool_cls, "_scopes", {"core"}):
continue
if not tool_cls.enabled(ctx):
continue
tool = tool_cls.create(ctx)
if registry.has(tool.name):
if is_plugin_source and tool.name in builtin_names:
logger.warning(
"Plugin %s skipped: conflicts with built-in tool %s",
cls_label, tool.name,
)
continue
logger.warning(
"Tool name collision: %s from %s overwrites existing",
tool.name, cls_label,
)
registry.register(tool)
registered.append(tool.name)
if not is_plugin_source:
builtin_names.add(tool.name)
except Exception:
logger.exception("Failed to register tool: %s", cls_label)
return registered
+254
View File
@@ -0,0 +1,254 @@
"""Sustained goal tools on the main agent (Codex-style).
Follow the built-in **long-goal** skill for lifecycle rules and how to phrase
objectives (especially **idempotent**, compaction-safe goals). Load that skill
from the skills listing (path shown there) before composing ``long_task.goal`` text.
``long_task`` registers an objective on the session (JSON-serializable metadata).
Active objectives are mirrored each turn into the Runtime Context block (see
``nanobot.session.goal_state.goal_state_runtime_lines``) so compaction cannot hide them.
Work proceeds in ordinary agent turns (same runner, compaction as configured).
Call ``complete_goal`` when the sustained objective should stop being tracked:
finished successfully, or cancelled / superseded / redirectedin every case the recap should match reality.
There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui`` stream.
"""
from __future__ import annotations
from contextvars import ContextVar
from datetime import datetime
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
discard_legacy_goal_state_key,
goal_state_raw,
parse_goal_state,
)
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
def _iso_now() -> str:
return datetime.now().isoformat()
class _GoalToolsMixin(ContextAware):
"""Shared routing context + Session lookup."""
def __init__(
self,
sessions: SessionManager,
runtime_events: RuntimeEventBus | None = None,
) -> None:
self._sessions = sessions
self._runtime_events = runtime_events
# Each subclass gets its own ContextVar so concurrent tasks across
# different tool types (LongTaskTool vs CompleteGoalTool) do not
# interfere with each other.
self._request_ctx: ContextVar[RequestContext | None] = ContextVar(
f"{self.__class__.__name__}_request_ctx",
default=None,
)
def set_context(self, ctx: RequestContext) -> None:
self._request_ctx.set(ctx)
def _session(self):
request_ctx = self._request_ctx.get()
if request_ctx is None:
return None
key = request_ctx.session_key
if not key:
return None
return self._sessions.get_or_create(key)
async def _publish_goal_state_changed(self, metadata: dict[str, Any]) -> None:
"""Publish authoritative goal metadata as a runtime event."""
runtime_events = self._runtime_events
rc = self._request_ctx.get()
if runtime_events is None or rc is None:
return
cid = (rc.chat_id or "").strip()
if not cid:
return
await runtime_events.publish(
GoalStateChanged(
context=RuntimeEventContext(
channel=rc.channel,
chat_id=cid,
session_key=rc.session_key or f"{rc.channel}:{cid}",
metadata=dict(rc.metadata or {}),
),
session_metadata=dict(metadata),
)
)
@tool_parameters(
tool_parameters_schema(
goal=StringSchema(
"Sustained objective for this chat thread. First read the built-in **long-goal** skill, "
"especially its Start fast section, then call this promptly once the user's intent is clear. "
"The goal must still be idempotent, self-contained, bounded, and explicit about done-ness; "
"do not delay this tool call to over-plan, research, or decide execution details. "
"Do not use this for a single current-turn answer, including one that uses spawn subagents.",
max_length=12_000,
),
ui_summary=StringSchema(
"Optional one-line label for session lists / logs (≤120 chars).",
max_length=120,
nullable=True,
),
required=["goal"],
)
)
class LongTaskTool(Tool, _GoalToolsMixin):
"""Begin or replace focus on a long-running objective stored on the session."""
def __init__(
self,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None # guarded by enabled()
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
return "long_task"
@property
def description(self) -> str:
return (
"Mark this thread as a sustained long-running task. "
"Use only when the user wants work to persist across future turns or background check-ins; "
"do not use for a single current-turn answer, including one that uses spawn subagents. "
"First read the built-in **long-goal** skill, especially its Start fast section; then call this "
"as soon as the user's intent is clear. Write a good idempotent goal, but do not delay the tool "
"call with long planning, research, or execution-detail thinking. "
"The active goal is mirrored in Runtime Context each turn. Use normal tools until done, then call "
"complete_goal when the objective is satisfied, cancelled, or replaced. "
"If a goal is already active, finish it or call complete_goal before registering another."
)
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return (
"Error: long_task requires an active chat session (missing routing context)."
)
prior = parse_goal_state(goal_state_raw(sess.metadata))
if isinstance(prior, dict) and prior.get("status") == "active":
return (
"Error: a sustained goal is already active. "
"Use complete_goal when finished, or ask the user before replacing it."
)
summary = (ui_summary or "").strip()[:120]
blob = {
"status": "active",
"objective": goal.strip(),
"ui_summary": summary,
"started_at": _iso_now(),
}
sess.metadata[GOAL_STATE_KEY] = blob
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
await self._publish_goal_state_changed(sess.metadata)
extra = f"\nSummary line: {summary}" if summary else ""
return (
"Goal recorded. Keep working toward the objective using ordinary tools. "
"When fully done (verified against what was asked), call complete_goal with a "
f"short recap.{extra}"
)
@tool_parameters(
tool_parameters_schema(
recap=StringSchema(
"Brief recap for the user (plain text). When the goal succeeded, confirm outcomes; "
"if the user cancelled, pivoted, or replaced the objective, say so honestly.",
max_length=8000,
nullable=True,
),
required=[],
)
)
class CompleteGoalTool(Tool, _GoalToolsMixin):
"""Mark the active sustained goal finished after all required work is verified."""
def __init__(
self,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
return "complete_goal"
@property
def description(self) -> str:
return (
"End bookkeeping for the active sustained goal. "
"Use when the objective is fully achieved and verified—recap what was delivered. "
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
"what actually happened (not necessarily success). "
"If no goal is active, the tool reports that and leaves metadata unchanged."
)
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return "Error: complete_goal requires an active chat session."
prior = parse_goal_state(goal_state_raw(sess.metadata))
if not isinstance(prior, dict) or prior.get("status") != "active":
return "No active goal to complete."
ended = _iso_now()
sess.metadata[GOAL_STATE_KEY] = {
**prior,
"status": "completed",
"completed_at": ended,
"recap": (recap or "").strip(),
}
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
await self._publish_goal_state_changed(sess.metadata)
tail = (recap or "").strip()
if tail:
return f"Goal marked complete ({ended}). Recap:\n{tail}"
return f"Goal marked complete ({ended})."
+1281 -32
View File
File diff suppressed because it is too large Load Diff
+219 -40
View File
@@ -1,77 +1,232 @@
"""Message tool for sending messages to users."""
from typing import Any, Callable, Awaitable
from contextvars import ContextVar
from pathlib import Path
from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool
from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path
from nanobot.security.workspace_access import current_tool_workspace
class MessageTool(Tool):
@tool_parameters(
tool_parameters_schema(
content=StringSchema(
"Message content for proactive or cross-channel delivery. "
"Do not use this for a normal reply in the current chat."
),
channel=StringSchema(
"Optional target channel for cross-channel/proactive delivery. "
"Do not set this to the current runtime channel for a normal reply."
),
chat_id=StringSchema(
"Optional target chat/user ID for cross-channel/proactive delivery. "
"On WebSocket/WebUI turns: omit chat_id to use the server's conversation id "
"(never pass client_id values like anon-…). "
"Do not set this to the current runtime chat for a normal reply."
),
media=ArraySchema(
StringSchema(""),
description=(
"Optional list of existing file paths to attach. "
"Use artifact paths returned by generate_image here when delivering generated images."
),
),
buttons=ArraySchema(
ArraySchema(StringSchema("Button label")),
description="Optional: inline keyboard buttons as list of rows, each row is list of button labels.",
),
required=["content"],
)
)
class MessageTool(Tool, ContextAware):
"""Tool to send messages to users on chat channels."""
def __init__(
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
default_chat_id: str = ""
default_chat_id: str = "",
default_message_id: str | None = None,
workspace: str | Path | None = None,
restrict_to_workspace: bool = False,
):
self._send_callback = send_callback
self._default_channel = default_channel
self._default_chat_id = default_chat_id
self._workspace = (
Path(workspace).expanduser() if workspace is not None else get_workspace_path()
)
self._restrict_to_workspace = restrict_to_workspace
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._default_metadata: ContextVar[dict[str, Any]] = ContextVar(
"message_default_metadata",
default={},
)
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
"message_turn_delivered_media",
default=(),
)
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
"message_record_channel_delivery",
default=False,
)
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
"message_suppress_delivery",
default=False,
)
def set_context(self, channel: str, chat_id: str) -> None:
@classmethod
def create(cls, ctx: Any) -> Tool:
send_callback = ctx.bus.publish_outbound if ctx.bus else None
return cls(
send_callback=send_callback,
workspace=ctx.workspace,
restrict_to_workspace=ctx.config.restrict_to_workspace,
)
def set_context(self, ctx: RequestContext) -> None:
"""Set the current message context."""
self._default_channel = channel
self._default_chat_id = chat_id
self._default_channel.set(ctx.channel)
self._default_chat_id.set(ctx.chat_id)
self._default_message_id.set(ctx.message_id)
self._default_metadata.set(dict(ctx.metadata or {}))
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages."""
self._send_callback = callback
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
self._turn_delivered_media_var.set(())
def turn_delivered_media_paths(self) -> list[str]:
"""Absolute paths attached via this tool to the active chat in the current turn."""
return list(self._turn_delivered_media_var.get())
def set_record_channel_delivery(self, active: bool):
"""Mark tool-sent messages as proactive channel deliveries."""
return self._record_channel_delivery_var.set(active)
def reset_record_channel_delivery(self, token) -> None:
"""Restore previous proactive delivery recording state."""
self._record_channel_delivery_var.reset(token)
def set_suppress_delivery(self, active: bool):
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
return self._suppress_delivery_var.set(active)
def reset_suppress_delivery(self, token) -> None:
"""Restore previous delivery-suppression state."""
self._suppress_delivery_var.reset(token)
@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"
@property
def description(self) -> str:
return "Send a message to the user. Use this when you want to communicate something."
return (
"Proactively send a message to a user/channel, optionally with file attachments. "
"Use this for reminders, cross-channel delivery, or explicit proactive sends. "
"Do not use this for the normal reply in the current chat: answer naturally instead. "
"If channel/chat_id would target the current runtime conversation, do not call this tool "
"unless the user explicitly asked you to proactively send an existing file attachment. "
"When generate_image creates images in the current chat, use the message tool "
"with the artifact paths in the media parameter to deliver the images to the user. "
"For proactive attachment delivery, use the 'media' parameter with file paths. "
"Do NOT use read_file to send files — that only reads content for your own analysis."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The message content to send"
},
"channel": {
"type": "string",
"description": "Optional: target channel (telegram, discord, etc.)"
},
"chat_id": {
"type": "string",
"description": "Optional: target chat/user ID"
},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of file paths to attach (images, audio, documents)"
}
},
"required": ["content"]
}
def _resolve_media(self, media: list[str]) -> list[str]:
"""Resolve local media attachments and enforce workspace restriction when enabled."""
resolved: list[str] = []
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
)
workspace = access.project_path or self._workspace
for p in media:
if p.startswith(("http://", "https://")):
resolved.append(p)
elif not access.restrict_to_workspace:
path = Path(p).expanduser()
resolved.append(p if path.is_absolute() else str(workspace / path))
else:
resolved.append(str(resolve_workspace_path(p, workspace, access.allowed_root)))
return resolved
async def execute(
self,
content: str,
channel: str | None = None,
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
**kwargs: Any
buttons: list[list[str]] | None = None,
**kwargs: Any,
) -> str:
channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id
from nanobot.utils.helpers import strip_think
content = strip_think(content)
if buttons is not None:
if not isinstance(buttons, list) or any(
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons
):
return "Error: buttons must be a list of list of strings"
default_channel = self._default_channel.get()
default_chat_id = self._default_chat_id.get()
channel = channel or default_channel
explicit_chat_id = chat_id
if (
default_channel == "websocket"
and channel == "websocket"
and explicit_chat_id is not None
and str(explicit_chat_id).strip() != ""
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
):
return (
"Error: chat_id does not match the active WebSocket conversation. "
"Omit chat_id (and usually channel) so delivery uses the current "
"conversation id from context — WebSocket client_id strings "
"(e.g. anon-…) are not chat ids."
)
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.
same_target = channel == default_channel and chat_id == default_chat_id
if same_target:
message_id = message_id or self._default_message_id.get()
else:
message_id = None
if not channel or not chat_id:
return "Error: No target channel/chat specified"
@@ -79,16 +234,40 @@ class MessageTool(Tool):
if not self._send_callback:
return "Error: Message sending not configured"
if media:
try:
media = self._resolve_media(media)
except (OSError, PermissionError, ValueError) as e:
return f"Error: media path is not allowed: {str(e)}"
metadata = dict(self._default_metadata.get()) if same_target else {}
if message_id:
metadata["message_id"] = message_id
if self._record_channel_delivery_var.get() or media:
metadata["_record_channel_delivery"] = True
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
media=media or []
media=media or [],
buttons=buttons or [],
metadata=metadata,
)
if self._suppress_delivery_var.get():
logger.debug("MessageTool: delivery suppressed during internal check")
return f"Message acknowledged for {channel}:{chat_id} (not delivered)"
try:
await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True
if media:
prev = self._turn_delivered_media_var.get()
self._turn_delivered_media_var.set(prev + tuple(str(p) for p in media))
media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}"
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e:
return f"Error sending message: {str(e)}"
+34
View File
@@ -0,0 +1,34 @@
"""Shared path helpers for workspace-scoped tools."""
from pathlib import Path
from nanobot.config.paths import get_media_dir
from nanobot.security.workspace_policy import (
is_path_within,
resolve_allowed_path,
)
def is_under(path: Path, directory: Path) -> bool:
"""Return True when path resolves under directory."""
return is_path_within(path, directory)
def resolve_workspace_path(
path: str,
workspace: Path | None = None,
allowed_dir: Path | None = None,
extra_allowed_dirs: list[Path] | None = None,
extra_allowed_files: list[Path] | None = None,
include_media_dir: bool = True,
) -> Path:
"""Resolve path against workspace and enforce allowed directory containment."""
media_roots = [get_media_dir()] if include_media_dir else []
extra_roots = [*media_roots, *(extra_allowed_dirs or [])] if allowed_dir else None
return resolve_allowed_path(
path,
workspace=workspace,
allowed_root=allowed_dir,
extra_allowed_roots=extra_roots,
extra_allowed_files=extra_allowed_files,
)
+127 -18
View File
@@ -1,5 +1,6 @@
"""Tool registry for dynamic tool management."""
import json
from typing import Any
from nanobot.agent.tools.base import Tool
@@ -14,52 +15,160 @@ 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."""
return self._tools.get(name)
@staticmethod
def _lookup_key(name: str) -> str:
"""Normalize names for suggestions only; never for execution."""
return "".join(ch.lower() for ch in name if ch.isalnum())
def _suggest_name(self, name: str) -> str | None:
key = self._lookup_key(str(name or ""))
if not key:
return None
matches = [
registered
for registered in self._tools
if self._lookup_key(registered) == key
]
if len(matches) == 1:
return matches[0]
return None
def has(self, name: str) -> bool:
"""Check if a tool is registered."""
return name in self._tools
@staticmethod
def _schema_name(schema: dict[str, Any]) -> str:
"""Extract a normalized tool name from either OpenAI or flat schemas."""
fn = schema.get("function")
if isinstance(fn, dict):
name = fn.get("name")
if isinstance(name, str):
return name
name = schema.get("name")
return name if isinstance(name, str) else ""
def get_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions in OpenAI format."""
return [tool.to_schema() for tool in self._tools.values()]
"""Get tool definitions with stable ordering for cache-friendly prompts.
async def execute(self, name: str, params: dict[str, Any]) -> str:
Built-in tools are sorted first as a stable prefix, then MCP tools are
sorted and appended. The result is cached until the next
register/unregister call.
"""
Execute a tool by name with given parameters.
if self._cached_definitions is not None:
return self._cached_definitions
Args:
name: Tool name.
params: Tool parameters.
definitions = [tool.to_schema() for tool in self._tools.values()]
builtins: list[dict[str, Any]] = []
mcp_tools: list[dict[str, Any]] = []
for schema in definitions:
name = self._schema_name(schema)
if name.startswith("mcp_"):
mcp_tools.append(schema)
else:
builtins.append(schema)
Returns:
Tool execution result as string.
builtins.sort(key=self._schema_name)
mcp_tools.sort(key=self._schema_name)
self._cached_definitions = builtins + mcp_tools
return self._cached_definitions
Raises:
KeyError: If tool not found.
"""
def prepare_call(
self,
name: str,
params: Any,
) -> tuple[Tool | None, Any, str | None]:
"""Resolve, cast, and validate one tool call."""
tool = self._tools.get(name)
if not tool:
return f"Error: Tool '{name}' not found"
suggestion = self._suggest_name(str(name))
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
return None, params, (
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
)
params = self._coerce_params(tool, params)
if not isinstance(params, dict):
return tool, params, (
f"Error: Tool '{name}' parameters must be a JSON object, got "
f"{type(params).__name__}. Use named parameters like "
'tool_name(param1="value1", param2="value2") matching the tool schema.'
)
cast_params = tool.cast_params(params)
errors = tool.validate_params(cast_params)
if errors:
return tool, cast_params, (
f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
)
return tool, cast_params, None
@classmethod
def _coerce_argument_value(cls, value: Any) -> Any:
if value is None:
return {}
if not isinstance(value, str):
return value
stripped = value.strip()
if not stripped:
return {}
if not stripped.startswith(("{", "[")):
return value
try:
errors = tool.validate_params(params)
if errors:
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
return await tool.execute(**params)
parsed = json.loads(stripped)
except Exception:
return value
return parsed
@classmethod
def _coerce_params(cls, tool: Tool, params: Any) -> Any:
params = cls._coerce_argument_value(params)
return cls._unwrap_arguments_payload(tool, params)
@classmethod
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
if not isinstance(params, dict) or set(params) != {"arguments"}:
return params
properties = (tool.parameters or {}).get("properties", {})
if isinstance(properties, dict) and "arguments" in properties:
return params
return cls._coerce_argument_value(params.get("arguments"))
async def execute(self, name: str, params: Any) -> Any:
"""Execute a tool by name with given parameters."""
hint = "\n\n[Analyze the error above and try a different approach.]"
tool, params, error = self.prepare_call(name, params)
if error:
return error + hint
try:
assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params)
if isinstance(result, str) and result.startswith("Error"):
return result + hint
return result
except Exception as e:
return f"Error executing {name}: {str(e)}"
return f"Error executing {name}: {str(e)}" + hint
@property
def tool_names(self) -> list[str]:
+62
View File
@@ -0,0 +1,62 @@
"""RuntimeState protocol: agent loop state exposed to MyTool."""
from typing import Any, Protocol
class RuntimeState(Protocol):
"""Minimum contract that MyTool requires from its runtime state provider.
In practice, this is always satisfied by ``AgentLoop``. MyTool also
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
for dot-path inspection and modification; those paths are validated at
runtime rather than by this protocol.
"""
@property
def model(self) -> str: ...
@property
def max_iterations(self) -> int: ...
@property
def current_iteration(self) -> int: ...
@property
def tool_names(self) -> list[str]: ...
@property
def workspace(self) -> str: ...
@property
def provider_retry_mode(self) -> str: ...
@property
def max_tool_result_chars(self) -> int: ...
@property
def context_window_tokens(self) -> int: ...
@property
def web_config(self) -> Any: ...
@property
def exec_config(self) -> Any: ...
@property
def workspace_sandbox(self) -> Any: ...
@property
def subagents(self) -> Any: ...
@property
def _runtime_vars(self) -> dict[str, Any]: ...
@property
def _last_usage(self) -> Any: ...
def _sync_subagent_runtime_limits(self) -> None: ...
@property
def model_preset(self) -> str | None: ...
_active_preset: str | None
+64
View File
@@ -0,0 +1,64 @@
"""Sandbox backends for shell command execution.
To add a new backend, implement a function with the signature:
_wrap_<name>(command: str, workspace: str, cwd: str) -> str
and register it in _BACKENDS below.
"""
import shlex
from pathlib import Path
from nanobot.config.paths import get_media_dir
def _bwrap(command: str, workspace: str, cwd: str) -> str:
"""Wrap command in a bubblewrap sandbox (requires bwrap in container).
Only the workspace is bind-mounted read-write; its parent dir (which holds
config.json) is hidden behind a fresh tmpfs. The media directory is
bind-mounted read-only so exec commands can read uploaded attachments.
"""
ws = Path(workspace).resolve()
media = get_media_dir().resolve()
try:
sandbox_cwd = str(ws / Path(cwd).resolve().relative_to(ws))
except ValueError:
sandbox_cwd = str(ws)
required = ["/usr"]
optional = [
"/bin",
"/lib",
"/lib64",
"/etc/alternatives",
"/etc/ssl/certs",
"/etc/resolv.conf",
"/etc/ld.so.cache",
]
args = ["bwrap", "--new-session", "--die-with-parent", "--setenv", "HOME", str(ws)]
for p in required:
args += ["--ro-bind", p, p]
for p in optional:
args += ["--ro-bind-try", p, p]
args += [
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
"--tmpfs", str(ws.parent), # mask config dir
"--dir", str(ws), # recreate workspace mount point
"--bind", str(ws), str(ws),
"--ro-bind-try", str(media), str(media), # read-only access to media
"--chdir", sandbox_cwd,
"--", "sh", "-c", command,
]
return shlex.join(args)
_BACKENDS = {"bwrap": _bwrap}
def wrap_command(sandbox: str, command: str, workspace: str, cwd: str) -> str:
"""Wrap *command* using the named sandbox backend."""
if backend := _BACKENDS.get(sandbox):
return backend(command, workspace, cwd)
raise ValueError(f"Unknown sandbox backend {sandbox!r}. Available: {list(_BACKENDS)}")
+239
View File
@@ -0,0 +1,239 @@
"""JSON Schema fragment types: all subclass :class:`~nanobot.agent.tools.base.Schema` for descriptions and constraints on tool parameters.
- ``to_json_schema()``: returns a dict compatible with :meth:`~nanobot.agent.tools.base.Schema.validate_json_schema_value` /
:class:`~nanobot.agent.tools.base.Tool`.
- ``validate_value(value, path)``: validates a single value against this schema; returns a list of error messages (empty means valid).
Shared validation and fragment normalization are on the class methods of :class:`~nanobot.agent.tools.base.Schema`.
Note: Python does not allow subclassing ``bool``, so booleans use :class:`BooleanSchema`.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from nanobot.agent.tools.base import Schema
class StringSchema(Schema):
"""String parameter: ``description`` documents the field; optional length bounds and enum."""
def __init__(
self,
description: str = "",
*,
min_length: int | None = None,
max_length: int | None = None,
enum: tuple[Any, ...] | list[Any] | None = None,
nullable: bool = False,
) -> None:
self._description = description
self._min_length = min_length
self._max_length = max_length
self._enum = tuple(enum) if enum is not None else None
self._nullable = nullable
def to_json_schema(self) -> dict[str, Any]:
t: Any = "string"
if self._nullable:
t = ["string", "null"]
d: dict[str, Any] = {"type": t}
if self._description:
d["description"] = self._description
if self._min_length is not None:
d["minLength"] = self._min_length
if self._max_length is not None:
d["maxLength"] = self._max_length
if self._enum is not None:
d["enum"] = list(self._enum)
return d
class IntegerSchema(Schema):
"""Integer parameter: optional placeholder int (legacy ctor signature), description, and bounds."""
def __init__(
self,
value: int = 0,
*,
description: str = "",
minimum: int | None = None,
maximum: int | None = None,
enum: tuple[int, ...] | list[int] | None = None,
nullable: bool = False,
) -> None:
self._value = value
self._description = description
self._minimum = minimum
self._maximum = maximum
self._enum = tuple(enum) if enum is not None else None
self._nullable = nullable
def to_json_schema(self) -> dict[str, Any]:
t: Any = "integer"
if self._nullable:
t = ["integer", "null"]
d: dict[str, Any] = {"type": t}
if self._description:
d["description"] = self._description
if self._minimum is not None:
d["minimum"] = self._minimum
if self._maximum is not None:
d["maximum"] = self._maximum
if self._enum is not None:
d["enum"] = list(self._enum)
return d
class NumberSchema(Schema):
"""Numeric parameter (JSON number): description and optional bounds."""
def __init__(
self,
value: float = 0.0,
*,
description: str = "",
minimum: float | None = None,
maximum: float | None = None,
enum: tuple[float, ...] | list[float] | None = None,
nullable: bool = False,
) -> None:
self._value = value
self._description = description
self._minimum = minimum
self._maximum = maximum
self._enum = tuple(enum) if enum is not None else None
self._nullable = nullable
def to_json_schema(self) -> dict[str, Any]:
t: Any = "number"
if self._nullable:
t = ["number", "null"]
d: dict[str, Any] = {"type": t}
if self._description:
d["description"] = self._description
if self._minimum is not None:
d["minimum"] = self._minimum
if self._maximum is not None:
d["maximum"] = self._maximum
if self._enum is not None:
d["enum"] = list(self._enum)
return d
class BooleanSchema(Schema):
"""Boolean parameter (standalone class because Python forbids subclassing ``bool``)."""
def __init__(
self,
*,
description: str = "",
default: bool | None = None,
nullable: bool = False,
) -> None:
self._description = description
self._default = default
self._nullable = nullable
def to_json_schema(self) -> dict[str, Any]:
t: Any = "boolean"
if self._nullable:
t = ["boolean", "null"]
d: dict[str, Any] = {"type": t}
if self._description:
d["description"] = self._description
if self._default is not None:
d["default"] = self._default
return d
class ArraySchema(Schema):
"""Array parameter: element schema is given by ``items``."""
def __init__(
self,
items: Any | None = None,
*,
description: str = "",
min_items: int | None = None,
max_items: int | None = None,
nullable: bool = False,
) -> None:
self._items_schema: Any = items if items is not None else StringSchema("")
self._description = description
self._min_items = min_items
self._max_items = max_items
self._nullable = nullable
def to_json_schema(self) -> dict[str, Any]:
t: Any = "array"
if self._nullable:
t = ["array", "null"]
d: dict[str, Any] = {
"type": t,
"items": Schema.fragment(self._items_schema),
}
if self._description:
d["description"] = self._description
if self._min_items is not None:
d["minItems"] = self._min_items
if self._max_items is not None:
d["maxItems"] = self._max_items
return d
class ObjectSchema(Schema):
"""Object parameter: ``properties`` or keyword args are field names; values are child Schema or JSON Schema dicts."""
def __init__(
self,
properties: Mapping[str, Any] | None = None,
*,
required: list[str] | None = None,
description: str = "",
additional_properties: bool | dict[str, Any] | None = None,
nullable: bool = False,
**kwargs: Any,
) -> None:
self._properties = dict(properties or {}, **kwargs)
self._required = list(required or [])
self._root_description = description
self._additional_properties = additional_properties
self._nullable = nullable
def to_json_schema(self) -> dict[str, Any]:
t: Any = "object"
if self._nullable:
t = ["object", "null"]
props = {k: Schema.fragment(v) for k, v in self._properties.items()}
out: dict[str, Any] = {"type": t, "properties": props}
if self._required:
out["required"] = self._required
if self._root_description:
out["description"] = self._root_description
if self._additional_properties is not None:
out["additionalProperties"] = self._additional_properties
return out
def tool_parameters_schema(
*,
required: list[str] | None = None,
description: str = "",
additional_properties: bool | dict[str, Any] | None = False,
**properties: Any,
) -> dict[str, Any]:
"""Build root tool parameters ``{"type": "object", "properties": ...}`` for :meth:`Tool.parameters`.
Built-in tools default to strict parameter objects so misspelled tool-call
arguments are reported before execution instead of being silently ignored.
Pass ``additional_properties=None`` to omit the JSON Schema keyword.
"""
return ObjectSchema(
required=required,
description=description,
additional_properties=additional_properties,
**properties,
).to_json_schema()
+584
View File
@@ -0,0 +1,584 @@
"""Search tools: file discovery and grep."""
from __future__ import annotations
import fnmatch
import os
import re
from contextlib import suppress
from pathlib import Path, PurePosixPath
from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250
_DEFAULT_FILE_HEAD_LIMIT = 200
T = TypeVar("T")
_TYPE_GLOB_MAP = {
"py": ("*.py", "*.pyi"),
"python": ("*.py", "*.pyi"),
"js": ("*.js", "*.jsx", "*.mjs", "*.cjs"),
"ts": ("*.ts", "*.tsx", "*.mts", "*.cts"),
"tsx": ("*.tsx",),
"jsx": ("*.jsx",),
"json": ("*.json",),
"md": ("*.md", "*.mdx"),
"markdown": ("*.md", "*.mdx"),
"go": ("*.go",),
"rs": ("*.rs",),
"rust": ("*.rs",),
"java": ("*.java",),
"sh": ("*.sh", "*.bash"),
"yaml": ("*.yaml", "*.yml"),
"yml": ("*.yaml", "*.yml"),
"toml": ("*.toml",),
"sql": ("*.sql",),
"html": ("*.html", "*.htm"),
"css": ("*.css", "*.scss", "*.sass"),
}
def _normalize_pattern(pattern: str) -> str:
return pattern.strip().replace("\\", "/")
def _match_glob(rel_path: str, name: str, pattern: str) -> bool:
normalized = _normalize_pattern(pattern)
if not normalized:
return False
if "/" in normalized or normalized.startswith("**"):
return PurePosixPath(rel_path).match(normalized)
return fnmatch.fnmatch(name, normalized)
def _is_binary(raw: bytes) -> bool:
if b"\x00" in raw:
return True
sample = raw[:4096]
if not sample:
return False
non_text = sum(byte < 9 or 13 < byte < 32 for byte in sample)
return (non_text / len(sample)) > 0.2
def _paginate(items: list[T], limit: int | None, offset: int) -> tuple[list[T], bool]:
if limit is None:
return items[offset:], False
sliced = items[offset : offset + limit]
truncated = len(items) > offset + limit
return sliced, truncated
def _pagination_note(limit: int | None, offset: int, truncated: bool) -> str | None:
if truncated:
if limit is None:
return f"(pagination: offset={offset})"
return f"(pagination: limit={limit}, offset={offset})"
if offset > 0:
return f"(pagination: offset={offset})"
return None
def _matches_type(name: str, file_type: str | None) -> bool:
if not file_type:
return True
lowered = file_type.strip().lower()
if not lowered:
return True
patterns = _TYPE_GLOB_MAP.get(lowered, (f"*.{lowered}",))
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
def _matches_query(rel_path: str, query: str | None) -> bool:
if not query:
return True
haystack = rel_path.lower()
terms = [part for part in query.lower().split() if part]
return all(term in haystack for term in terms)
class _SearchTool(_FsTool):
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
def _display_path(self, target: Path, root: Path) -> str:
workspace = self._display_workspace()
if workspace:
with suppress(ValueError):
return target.relative_to(workspace).as_posix()
return target.relative_to(root).as_posix()
def _iter_files(self, root: Path) -> Iterable[Path]:
if root.is_file():
yield root
return
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
current = Path(dirpath)
for filename in sorted(filenames):
yield current / filename
class FindFilesTool(_SearchTool):
"""Find files by path fragment, glob, or type."""
_scopes = {"core", "subagent"}
@property
def name(self) -> str:
return "find_files"
@property
def description(self) -> str:
return (
"Find files by path fragment, glob, or file type. "
"Use this before read_file when you need to locate files, and "
"prefer it over shell find/ls for ordinary workspace discovery. "
"Returns workspace-relative paths and skips common dependency/build "
"directories."
)
@property
def read_only(self) -> bool:
return True
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory or file to search in (default '.')",
},
"query": {
"type": "string",
"description": (
"Optional case-insensitive path fragment search. "
"Whitespace-separated terms must all be present."
),
},
"glob": {
"type": "string",
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
},
"type": {
"type": "string",
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
},
"include_dirs": {
"type": "boolean",
"description": "Include matching directories as well as files (default false)",
},
"sort": {
"type": "string",
"enum": ["path", "modified"],
"description": "Sort by path or most recently modified first (default path)",
},
"head_limit": {
"type": "integer",
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
"minimum": 0,
"maximum": 1000,
},
"offset": {
"type": "integer",
"description": "Skip the first N results before applying head_limit",
"minimum": 0,
"maximum": 100000,
},
},
}
def _iter_paths(self, root: Path, *, include_dirs: bool) -> Iterable[Path]:
if root.is_file():
yield root
return
if include_dirs:
yield root
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
current = Path(dirpath)
if include_dirs and current != root:
yield current
for filename in sorted(filenames):
yield current / filename
async def execute(
self,
path: str = ".",
query: str | None = None,
glob: str | None = None,
type: str | None = None,
include_dirs: bool = False,
sort: str = "path",
head_limit: int | None = None,
offset: int = 0,
**kwargs: Any,
) -> str:
try:
target = self._resolve(path or ".")
if not target.exists():
return f"Error: Path not found: {path}"
if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}"
if sort not in {"path", "modified"}:
return "Error: sort must be 'path' or 'modified'"
limit = (
_DEFAULT_FILE_HEAD_LIMIT
if head_limit is None
else None if head_limit == 0 else head_limit
)
root = target if target.is_dir() else target.parent
matches: list[tuple[str, float]] = []
for candidate in self._iter_paths(target, include_dirs=include_dirs):
if candidate.is_dir() and not include_dirs:
continue
rel_path = candidate.relative_to(root).as_posix()
display_path = self._display_path(candidate, root)
name = candidate.name
if glob and not _match_glob(rel_path, name, glob):
continue
if candidate.is_file() and not _matches_type(name, type):
continue
if candidate.is_dir() and type:
continue
if not _matches_query(display_path, query):
continue
try:
mtime = candidate.stat().st_mtime
except OSError:
mtime = 0.0
suffix = "/" if candidate.is_dir() else ""
matches.append((display_path + suffix, mtime))
if sort == "modified":
matches.sort(key=lambda item: (-item[1], item[0]))
else:
matches.sort(key=lambda item: item[0])
paths = [item[0] for item in matches]
paged, truncated = _paginate(paths, limit, offset)
if not paged:
return "No files found"
result = "\n".join(paged)
note = _pagination_note(limit, offset, truncated)
if note:
result += "\n\n" + note
return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error finding files: {e}"
class GrepTool(_SearchTool):
"""Search file contents using a regex-like pattern."""
_scopes = {"core", "subagent"}
_MAX_RESULT_CHARS = 128_000
_MAX_FILE_BYTES = 2_000_000
@property
def name(self) -> str:
return "grep"
@property
def description(self) -> str:
return (
"Search file contents with a regex pattern. "
"Default output_mode is files_with_matches (file paths only); "
"use content mode for matching lines with context. Prefer this "
"over shell grep for ordinary workspace searches. "
"Skips binary and files >2 MB. Supports glob/type filtering."
)
@property
def read_only(self) -> bool:
return True
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regex or plain text pattern to search for",
"minLength": 1,
},
"path": {
"type": "string",
"description": "File or directory to search in (default '.')",
},
"glob": {
"type": "string",
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
},
"type": {
"type": "string",
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
},
"case_insensitive": {
"type": "boolean",
"description": "Case-insensitive search (default false)",
},
"fixed_strings": {
"type": "boolean",
"description": "Treat pattern as plain text instead of regex (default false)",
},
"output_mode": {
"type": "string",
"enum": ["content", "files_with_matches", "count"],
"description": (
"content: matching lines with optional context; "
"files_with_matches: only matching file paths; "
"count: matching line counts per file. "
"Default: files_with_matches"
),
},
"context_before": {
"type": "integer",
"description": "Number of lines of context before each match",
"minimum": 0,
"maximum": 20,
},
"context_after": {
"type": "integer",
"description": "Number of lines of context after each match",
"minimum": 0,
"maximum": 20,
},
"max_matches": {
"type": "integer",
"description": (
"Legacy alias for head_limit in content mode"
),
"minimum": 1,
"maximum": 1000,
},
"max_results": {
"type": "integer",
"description": (
"Legacy alias for head_limit in files_with_matches or count mode"
),
"minimum": 1,
"maximum": 1000,
},
"head_limit": {
"type": "integer",
"description": (
"Maximum number of results to return. In content mode this limits "
"matching line blocks; in other modes it limits file entries. "
"Default 250"
),
"minimum": 0,
"maximum": 1000,
},
"offset": {
"type": "integer",
"description": "Skip the first N results before applying head_limit",
"minimum": 0,
"maximum": 100000,
},
},
"required": ["pattern"],
}
@staticmethod
def _format_block(
display_path: str,
lines: list[str],
match_line: int,
before: int,
after: int,
) -> str:
start = max(1, match_line - before)
end = min(len(lines), match_line + after)
block = [f"{display_path}:{match_line}"]
for line_no in range(start, end + 1):
marker = ">" if line_no == match_line else " "
block.append(f"{marker} {line_no}| {lines[line_no - 1]}")
return "\n".join(block)
async def execute(
self,
pattern: str,
path: str = ".",
glob: str | None = None,
type: str | None = None,
case_insensitive: bool = False,
fixed_strings: bool = False,
output_mode: str = "files_with_matches",
context_before: int = 0,
context_after: int = 0,
max_matches: int | None = None,
max_results: int | None = None,
head_limit: int | None = None,
offset: int = 0,
**kwargs: Any,
) -> str:
try:
target = self._resolve(path or ".")
if not target.exists():
return f"Error: Path not found: {path}"
if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}"
flags = re.IGNORECASE if case_insensitive else 0
try:
needle = re.escape(pattern) if fixed_strings else pattern
regex = re.compile(needle, flags)
except re.error as e:
return f"Error: invalid regex pattern: {e}"
if head_limit is not None:
limit = None if head_limit == 0 else head_limit
elif output_mode == "content" and max_matches is not None:
limit = max_matches
elif output_mode != "content" and max_results is not None:
limit = max_results
else:
limit = _DEFAULT_HEAD_LIMIT
blocks: list[str] = []
result_chars = 0
seen_content_matches = 0
truncated = False
size_truncated = False
skipped_binary = 0
skipped_large = 0
matching_files: list[str] = []
counts: dict[str, int] = {}
file_mtimes: dict[str, float] = {}
root = target if target.is_dir() else target.parent
for file_path in self._iter_files(target):
rel_path = file_path.relative_to(root).as_posix()
if glob and not _match_glob(rel_path, file_path.name, glob):
continue
if not _matches_type(file_path.name, type):
continue
raw = file_path.read_bytes()
if len(raw) > self._MAX_FILE_BYTES:
skipped_large += 1
continue
if _is_binary(raw):
skipped_binary += 1
continue
try:
mtime = file_path.stat().st_mtime
except OSError:
mtime = 0.0
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
skipped_binary += 1
continue
lines = content.splitlines()
display_path = self._display_path(file_path, root)
file_had_match = False
for idx, line in enumerate(lines, start=1):
if not regex.search(line):
continue
file_had_match = True
if output_mode == "count":
counts[display_path] = counts.get(display_path, 0) + 1
continue
if output_mode == "files_with_matches":
if display_path not in matching_files:
matching_files.append(display_path)
file_mtimes[display_path] = mtime
break
seen_content_matches += 1
if seen_content_matches <= offset:
continue
if limit is not None and len(blocks) >= limit:
truncated = True
break
block = self._format_block(
display_path,
lines,
idx,
context_before,
context_after,
)
extra_sep = 2 if blocks else 0
if result_chars + extra_sep + len(block) > self._MAX_RESULT_CHARS:
size_truncated = True
break
blocks.append(block)
result_chars += extra_sep + len(block)
if output_mode == "count" and file_had_match:
if display_path not in matching_files:
matching_files.append(display_path)
file_mtimes[display_path] = mtime
if output_mode in {"count", "files_with_matches"} and file_had_match:
continue
if truncated or size_truncated:
break
if output_mode == "files_with_matches":
if not matching_files:
result = f"No matches found for pattern '{pattern}' in {path}"
else:
ordered_files = sorted(
matching_files,
key=lambda name: (-file_mtimes.get(name, 0.0), name),
)
paged, truncated = _paginate(ordered_files, limit, offset)
result = "\n".join(paged)
elif output_mode == "count":
if not counts:
result = f"No matches found for pattern '{pattern}' in {path}"
else:
ordered_files = sorted(
matching_files,
key=lambda name: (-file_mtimes.get(name, 0.0), name),
)
ordered, truncated = _paginate(ordered_files, limit, offset)
lines = [f"{name}: {counts[name]}" for name in ordered]
result = "\n".join(lines)
else:
if not blocks:
result = f"No matches found for pattern '{pattern}' in {path}"
else:
result = "\n\n".join(blocks)
notes: list[str] = []
if output_mode == "content" and truncated:
notes.append(
f"(pagination: limit={limit}, offset={offset})"
)
elif output_mode == "content" and size_truncated:
notes.append("(output truncated due to size)")
elif truncated and output_mode in {"count", "files_with_matches"}:
notes.append(
f"(pagination: limit={limit}, offset={offset})"
)
elif output_mode in {"count", "files_with_matches"} and offset > 0:
notes.append(f"(pagination: offset={offset})")
elif output_mode == "content" and offset > 0 and blocks:
notes.append(f"(pagination: offset={offset})")
if skipped_binary:
notes.append(f"(skipped {skipped_binary} binary/unreadable files)")
if skipped_large:
notes.append(f"(skipped {skipped_large} large files)")
if output_mode == "count" and counts:
notes.append(
f"(total matches: {sum(counts.values())} in {len(counts)} files)"
)
if notes:
result += "\n\n" + "\n".join(notes)
return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error searching files: {e}"
+503
View File
@@ -0,0 +1,503 @@
"""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.tools.base import Tool
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus
class MyToolConfig(Base):
"""Self-inspection tool configuration."""
enable: bool = True
allow_set: bool = False
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
def _is_subagent_status(value: Any) -> bool:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
class MyTool(Tool, ContextAware):
"""Check and set the agent loop's runtime configuration."""
_plugin_discoverable = False # Requires AgentLoop reference; registered manually
config_key = "my"
@classmethod
def config_cls(cls):
return MyToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.my.enable
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
"workspace_sandbox", # read-only view of workspace enforcement level
})
_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, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
self._runtime_state = runtime_state
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._runtime_state = self._runtime_state
result._modify_allowed = self._modify_allowed
result._channel = self._channel
result._chat_id = self._chat_id
return result
def set_context(self, ctx: RequestContext) -> None:
self._channel = ctx.channel
self._chat_id = ctx.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"
"- User asks to switch to a named model preset → set model_preset to that preset name.\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'. "
"Use 'model_preset' to switch named model presets. 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/model_preset)."},
},
"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._runtime_state
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 _is_subagent_status(val):
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 _is_subagent_status(next(iter(val.values()))):
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._runtime_state._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._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: {err}"
# Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return f"Error: '{key}' not found"
return self._format_value(obj, key)
def _inspect_all(self) -> str:
state = self._runtime_state
parts: list[str] = []
# RESTRICTED keys
for k in self.RESTRICTED:
parts.append(self._format_value(getattr(state, k, None), k))
parts.append(self._format_value(state.model_preset, "model_preset"))
# 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", "workspace_sandbox", "subagents"):
if _has_real_attr(state, k):
parts.append(self._format_value(getattr(state, k, None), k))
# Token usage
usage = state._last_usage
if usage:
parts.append(self._format_value(usage, "_last_usage"))
rv = state._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 == "model_preset":
return self._modify_model_preset(value)
if key in self.RESTRICTED:
return self._modify_restricted(key, value)
return self._modify_free(key, value)
def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return "Error: 'model_preset' must be a non-empty string"
name = value.strip()
result = self._modify_free("model_preset", name)
if result.startswith("Error:"):
return result if result.endswith((".", "!", "?")) else f"{result}."
return (
f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
)
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._runtime_state, 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._runtime_state, key, value)
if key == "model":
self._runtime_state._active_preset = None
sync_replay = getattr(self._runtime_state, "_sync_replay_max_messages", None)
if key == "context_window_tokens" and callable(sync_replay):
sync_replay()
if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"):
self._runtime_state._sync_subagent_runtime_limits()
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_free(self, key: str, value: Any) -> str:
if _has_real_attr(self._runtime_state, key):
old = getattr(self._runtime_state, 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__}"
try:
setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}")
return f"Error: {message}"
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._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first."
old = self._runtime_state._runtime_vars.get(key)
self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
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__}"
+596 -52
View File
@@ -1,16 +1,162 @@
"""Shell execution tool."""
from __future__ import annotations
import asyncio
import os
import re
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER,
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_YIELD_MS,
MAX_OUTPUT_CHARS,
MAX_YIELD_MS,
clamp_session_int,
format_session_poll,
)
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
from nanobot.security.workspace_policy import is_path_within
_IS_WINDOWS = sys.platform == "win32"
# Policy note appended to recoverable workspace-boundary guard errors.
_WORKSPACE_BOUNDARY_NOTE = (
"\n\nNote: this is a hard policy boundary, not a transient failure. "
"Do NOT retry with shell tricks (symlinks, base64 piping, alternative "
"tools, working_dir overrides). If the user genuinely needs this "
"resource, tell them you cannot reach it under the current "
"restrict_to_workspace policy and ask how to proceed."
)
class ExecToolConfig(Base):
"""Shell exec tool configuration."""
enable: bool = True
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
path_prepend: str = ""
path_append: str = ""
sandbox: str = ""
allowed_env_keys: list[str] = Field(default_factory=list)
allow_patterns: list[str] = Field(default_factory=list)
deny_patterns: list[str] = Field(default_factory=list)
@dataclass(slots=True)
class _PreparedCommand:
command: str
cwd: str
env: dict[str, str]
timeout: int | None
shell_program: str | None
login: bool
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
cmd=StringSchema("Compatibility alias for command"),
working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema(
60,
description=(
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
),
minimum=1,
maximum=600,
),
shell=StringSchema(
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
nullable=True,
),
login=BooleanSchema(
description="Whether to run bash/zsh with login shell semantics (default false).",
default=False,
nullable=True,
),
yield_time_ms=IntegerSchema(
description=(
"Optional milliseconds to wait before returning output. "
"When set, a still-running command returns a session_id that "
"can be polled or written to with write_stdin. Omit this field "
"to keep one-shot exec behavior."
),
minimum=0,
maximum=MAX_YIELD_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
description=(
"Maximum output characters to return when yield_time_ms is used "
"(default 10000, max 50000)."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
max_output_tokens=IntegerSchema(
description=(
"Compatibility alias for max_output_chars. The current runtime "
"uses a character budget."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
)
)
class ExecTool(Tool):
"""Tool to execute shell commands."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.exec
return cls(
working_dir=ctx.workspace,
timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox,
path_prepend=cfg.path_prepend,
path_append=cfg.path_append,
allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns,
deny_patterns=cfg.deny_patterns,
)
def __init__(
self,
@@ -19,69 +165,128 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "",
path_prepend: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None,
):
self.timeout = timeout
self.working_dir = working_dir
self.deny_patterns = deny_patterns or [
self.sandbox = sandbox
self.deny_patterns = (deny_patterns or []) + [
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
r"\brmdir\s+/s\b", # rmdir /s
r"\b(format|mkfs|diskpart)\b", # disk operations
r"(?:^|[;&|]\s*)format(?!=)\b", # format (as standalone command only)
r"\b(mkfs|diskpart)\b", # disk operations
r"\bdd\s+if=", # dd
r">\s*/dev/sd", # write to disk
r"\b(shutdown|reboot|poweroff)\b", # system power
r":\(\)\s*\{.*\};\s*:", # fork bomb
# Block writes to nanobot internal state files (#2989).
# history.jsonl / .dream_cursor are managed by append_history();
# direct writes corrupt the cursor format and crash /dream.
r">>?\s*\S*(?:history\.jsonl|\.dream_cursor)", # > / >> redirect
r"\btee\b[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # tee / tee -a
r"\b(?:cp|mv)\b(?:\s+[^\s|;&<>]+)+\s+\S*(?:history\.jsonl|\.dream_cursor)", # cp/mv target
r"\bdd\b[^|;&<>]*\bof=\S*(?:history\.jsonl|\.dream_cursor)", # dd of=
r"\bsed\s+-i[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # sed -i
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
if allow_local_preview_access is not None:
webui_allow_local_service_access = allow_local_preview_access
self.webui_allow_local_service_access = webui_allow_local_service_access
self.path_prepend = path_prepend
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@property
def name(self) -> str:
return "exec"
_MAX_TIMEOUT = 600
_MAX_OUTPUT = 10_000
# Kernel device files safe as stdio redirect targets (#3599).
_BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/random",
"/dev/urandom",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
})
@property
def description(self) -> str:
return "Execute a shell command and return its output. Use with caution."
return (
"Execute a shell command and return its output. "
"Use this for tests, builds, package commands, git commands, and "
"other process execution. Prefer read_file/find_files/grep for "
"inspection and apply_patch/write_file/edit_file for file changes "
"instead of cat, shell find/grep, echo, or sed. "
"Use -y or --yes flags to avoid interactive prompts. "
"For long-running or interactive commands, pass yield_time_ms; "
"if the command keeps running, exec returns a session_id that can "
"be polled or written to with write_stdin. Output is truncated at "
"10 000 chars; timeout defaults to 60s."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"working_dir": {
"type": "string",
"description": "Optional working directory for the command"
}
},
"required": ["command"]
}
def exclusive(self) -> bool:
return True
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
cwd = working_dir or self.working_dir or os.getcwd()
guard_error = self._guard_command(command, cwd)
if guard_error:
return guard_error
async def execute(
self, command: str | None = None, cmd: str | None = None,
working_dir: str | None = None, workdir: str | None = None,
timeout: int | None = None, shell: str | None = None,
login: bool | None = None, yield_time_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any,
) -> str:
command = command or cmd
working_dir = working_dir or workdir
if not command:
return "Error: Missing command. Provide command or cmd."
if max_output_chars is None:
max_output_chars = max_output_tokens
prepared = self._prepare_command(command, working_dir, timeout, shell, login)
if isinstance(prepared, str):
return prepared
if yield_time_ms is not None:
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
process = await self._spawn(
prepared.command,
prepared.cwd,
prepared.env,
prepared.shell_program,
prepared.login,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=self.timeout
timeout=prepared.timeout,
)
except asyncio.TimeoutError:
process.kill()
return f"Error: Command timed out after {self.timeout} seconds"
await self._kill_process(process)
return f"Error: Command timed out after {prepared.timeout} seconds"
except asyncio.CancelledError:
await self._kill_process(process)
raise
output_parts = []
@@ -93,52 +298,391 @@ class ExecTool(Tool):
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
if process.returncode != 0:
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
# Truncate very long output
max_len = 10000
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
if len(result) > max_len:
result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)"
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
return result
except Exception as e:
return f"Error executing command: {str(e)}"
def _guard_command(self, command: str, cwd: str) -> str | None:
async def _execute_session(
self,
prepared: _PreparedCommand,
yield_time_ms: int | None,
max_output_chars: int | None,
) -> str:
try:
session_id, poll = await self._session_manager.start(
command=prepared.command,
cwd=prepared.cwd,
env=prepared.env,
timeout=prepared.timeout,
shell_program=prepared.shell_program,
login=prepared.login,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
owner_session_key=current_request_session_key(),
max_output_chars=clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
1000,
MAX_OUTPUT_CHARS,
),
)
return format_session_poll(session_id, poll)
except Exception as exc:
return f"Error executing command: {exc}"
def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit).
A per-call timeout supplied by the model stays capped at _MAX_TIMEOUT so
the LLM cannot request unbounded execution. The config-level default
(self.timeout) may exceed that cap, and 0 disables the limit entirely
for trusted long-running tasks (#3595).
"""
if timeout:
return min(timeout, self._MAX_TIMEOUT)
if self.timeout and self.timeout > 0:
return self.timeout
return None
def _prepare_command(
self,
command: str,
working_dir: str | None = None,
timeout: int | None = None,
shell: str | None = None,
login: bool | None = None,
) -> _PreparedCommand | str:
access = current_tool_workspace(
self.working_dir,
restrict_to_workspace=self.restrict_to_workspace,
sandbox_restricts_workspace=bool(self.sandbox),
)
workspace_root = str(access.project_path) if access.project_path is not None else self.working_dir
cwd = working_dir or workspace_root or os.getcwd()
# Prevent an LLM-supplied working_dir from escaping the configured
# workspace when restrict_to_workspace is enabled (#2826). Without
# this, a caller can pass working_dir="/etc" and then all absolute
# paths under /etc would pass the _guard_command check that anchors
# on cwd.
if access.restrict_to_workspace and workspace_root:
try:
requested = Path(cwd).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve()
except Exception:
return (
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if not is_path_within(requested, resolved_root):
return (
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
guard_error = self._guard_command(
command,
cwd,
restrict_to_workspace=access.restrict_to_workspace,
workspace_root=workspace_root,
)
if guard_error:
return guard_error
if self.sandbox:
if _IS_WINDOWS:
logger.warning(
"Sandbox '{}' is not supported on Windows; running unsandboxed",
self.sandbox,
)
else:
workspace = workspace_root or cwd
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
effective_timeout = self._resolve_timeout(timeout)
env = self._build_env()
if self.path_prepend or self.path_append:
if _IS_WINDOWS:
env["PATH"] = self._compose_path(env.get("PATH", ""))
else:
command = self._wrap_path_export(command, env)
shell_program, shell_error = self._resolve_shell(shell)
if shell_error:
return shell_error
return _PreparedCommand(
command=command,
cwd=cwd,
env=env,
timeout=effective_timeout,
shell_program=shell_program,
login=False if login is None else login,
)
def _compose_path(self, current_path: str) -> str:
parts = []
if self.path_prepend:
parts.append(self.path_prepend)
if current_path:
parts.append(current_path)
if self.path_append:
parts.append(self.path_append)
return os.pathsep.join(parts)
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
segments = []
if self.path_prepend:
env["NANOBOT_PATH_PREPEND"] = self.path_prepend
segments.append("$NANOBOT_PATH_PREPEND")
segments.append("$PATH")
if self.path_append:
env["NANOBOT_PATH_APPEND"] = self.path_append
segments.append("$NANOBOT_PATH_APPEND")
path_expr = os.pathsep.join(segments)
return f'export PATH="{path_expr}"; {command}'
@staticmethod
async def _spawn(
command: str, cwd: str, env: dict[str, str],
shell_program: str | None = None,
login: bool = False,
*,
stdin: int = asyncio.subprocess.DEVNULL,
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
if "\n" in command:
return await asyncio.create_subprocess_exec(
"powershell", "-NoProfile", "-Command", command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
return await asyncio.create_subprocess_shell(
command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
shell_name = Path(shell_program).name.lower()
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
args.append("-l")
args.extend(["-c", command])
return await asyncio.create_subprocess_exec(
*args,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
@staticmethod
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
if not shell:
return None, None
if _IS_WINDOWS:
return None, "Error: shell parameter is not supported on Windows"
if "\0" in shell or "\n" in shell or "\r" in shell:
return None, "Error: shell contains invalid characters"
allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser()
if path.is_absolute():
if path.name not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
if not path.is_file() or not os.access(path, os.X_OK):
return None, f"Error: shell is not executable: {shell}"
return str(path), None
if "/" in shell or "\\" in shell:
return None, "Error: shell must be a shell name or absolute path"
if shell not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
resolved = shutil.which(shell)
if not resolved:
return None, f"Error: shell not found: {shell}"
return resolved, None
@staticmethod
async def _kill_process(process: asyncio.subprocess.Process) -> None:
"""Kill a subprocess and reap it to prevent zombies."""
process.kill()
try:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(process.wait(), timeout=5.0)
finally:
if not _IS_WINDOWS:
try:
os.waitpid(process.pid, os.WNOHANG)
except (ProcessLookupError, ChildProcessError) as e:
logger.debug("Process already reaped or not found: {}", e)
def _build_env(self) -> dict[str, str]:
"""Build a minimal environment for subprocess execution.
On Unix, only HOME/LANG/TERM are passed by default. If callers request
``login=True``, bash/zsh may source the user's profile and add PATH or
other variables.
On Windows, ``cmd.exe`` has no login-profile mechanism, so a curated
set of system variables (including PATH) is forwarded. API keys and
other secrets are still excluded.
"""
if _IS_WINDOWS:
sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
env = {
"SYSTEMROOT": sr,
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
"USERPROFILE": os.environ.get("USERPROFILE", ""),
"HOMEDRIVE": os.environ.get("HOMEDRIVE", "C:"),
"HOMEPATH": os.environ.get("HOMEPATH", "\\"),
"TEMP": os.environ.get("TEMP", f"{sr}\\Temp"),
"TMP": os.environ.get("TMP", f"{sr}\\Temp"),
"PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"),
"PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"),
"PYTHONUNBUFFERED": "1",
"APPDATA": os.environ.get("APPDATA", ""),
"LOCALAPPDATA": os.environ.get("LOCALAPPDATA", ""),
"ProgramData": os.environ.get("ProgramData", ""),
"ProgramFiles": os.environ.get("ProgramFiles", ""),
"ProgramFiles(x86)": os.environ.get("ProgramFiles(x86)", ""),
"ProgramW6432": os.environ.get("ProgramW6432", ""),
}
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
home = os.environ.get("HOME", "/tmp")
env = {
"HOME": home,
"LANG": os.environ.get("LANG", "C.UTF-8"),
"TERM": os.environ.get("TERM", "dumb"),
"PYTHONUNBUFFERED": "1",
}
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
def _guard_command(
self,
command: str,
cwd: str,
*,
restrict_to_workspace: bool | None = None,
workspace_root: str | None = None,
) -> str | None:
"""Best-effort safety guard for potentially destructive commands."""
cmd = command.strip()
lower = cmd.lower()
# allow_patterns take priority over deny_patterns so that users can
# exempt specific commands (e.g. "rm -rf" inside a build directory)
# from the hardcoded deny list via configuration.
explicitly_allowed = bool(self.allow_patterns) and any(
re.fullmatch(p, lower) for p in self.allow_patterns
)
if not explicitly_allowed:
for pattern in self.deny_patterns:
if re.search(pattern, lower):
return "Error: Command blocked by safety guard (dangerous pattern detected)"
return "Error: Command blocked by deny pattern filter"
if self.allow_patterns:
if not any(re.search(p, lower) for p in self.allow_patterns):
return "Error: Command blocked by safety guard (not in allowlist)"
return "Error: Command blocked by allowlist filter (not in allowlist)"
if self.restrict_to_workspace:
from nanobot.security.network import contains_internal_url
if contains_internal_url(
cmd,
allow_loopback=current_scope_allows_loopback(
enabled=self.webui_allow_local_service_access,
),
):
# The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)"
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
if should_restrict:
if "..\\" in cmd or "../" in cmd:
return "Error: Command blocked by safety guard (path traversal detected)"
return (
"Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE
)
cwd_path = Path(cwd).resolve()
resolved_workspace = (
Path(workspace_root).expanduser().resolve()
if workspace_root
else None
)
win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd)
# Only match absolute paths — avoid false positives on relative
# paths like ".venv/bin/python" where "/bin/python" would be
# incorrectly extracted by the old pattern.
posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", cmd)
for raw in win_paths + posix_paths:
for raw in self._extract_absolute_paths(cmd):
try:
p = Path(raw.strip()).resolve()
expanded = os.path.expandvars(raw.strip())
# Match against the un-resolved path first. On Linux,
# /dev/stderr is a symlink to /proc/self/fd/2 and
# ``Path.resolve()`` would mask the device-file intent.
if self._is_benign_device_path(expanded):
continue
p = Path(expanded).expanduser().resolve()
except Exception:
continue
if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
return "Error: Command blocked by safety guard (path outside working dir)"
if self._is_benign_device_path(str(p)):
continue
media_path = get_media_dir().resolve()
allowed = (
is_path_within(p, cwd_path)
or is_path_within(p, media_path)
)
if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace)
if p.is_absolute() and not allowed:
return (
"Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
return None
@classmethod
def _is_benign_device_path(cls, path: str) -> bool:
"""Return True for kernel device files that should never be workspace-blocked."""
if path in cls._BENIGN_DEVICE_PATHS:
return True
return path.startswith("/dev/fd/")
@staticmethod
def _extract_absolute_paths(command: str) -> list[str]:
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command
)
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths
+68 -34
View File
@@ -1,30 +1,58 @@
"""Spawn tool for creating background subagents."""
from typing import Any, TYPE_CHECKING
from __future__ import annotations
from nanobot.agent.tools.base import Tool
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
class SpawnTool(Tool):
"""
Tool to spawn a subagent for background task execution.
The subagent runs asynchronously and announces its result back
to the main agent when complete.
"""
@tool_parameters(
tool_parameters_schema(
task=StringSchema("The task for the subagent to complete"),
label=StringSchema("Optional short label for the task (for display)"),
temperature=NumberSchema(
description=(
"Optional sampling temperature for the subagent "
"(0.0 = deterministic, higher = more creative). "
"Defaults to the provider's configured temperature."
),
minimum=0.0,
maximum=2.0,
),
required=["task"],
)
)
class SpawnTool(Tool, ContextAware):
"""Tool to spawn a subagent for background task execution."""
def __init__(self, manager: "SubagentManager"):
self._manager = manager
self._origin_channel = "cli"
self._origin_chat_id = "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")
self._origin_message_id: ContextVar[str | None] = ContextVar(
"spawn_origin_message_id",
default=None,
)
def set_context(self, channel: str, chat_id: str) -> None:
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(manager=ctx.subagent_manager)
def set_context(self, ctx: RequestContext) -> None:
"""Set the origin context for subagent announcements."""
self._origin_channel = channel
self._origin_chat_id = chat_id
self._origin_channel.set(ctx.channel)
self._origin_chat_id.set(ctx.chat_id)
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}")
self._origin_message_id.set(ctx.message_id)
@property
def name(self) -> str:
@@ -35,31 +63,37 @@ class SpawnTool(Tool):
return (
"Spawn a subagent to handle a task in the background. "
"Use this for complex or time-consuming tasks that can run independently. "
"For MapReduce-style work, spawn only independent map slices with clear "
"boundaries; keep reduction, conflict resolution, and final user-facing "
"synthesis in the main agent. "
"The subagent will complete the task and report back when done. "
"For deliverables or existing projects, inspect the workspace first "
"and use a dedicated subdirectory when helpful."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The task for the subagent to complete",
},
"label": {
"type": "string",
"description": "Optional short label for the task (for display)",
},
},
"required": ["task"],
}
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
async def execute(
self,
task: str,
label: str | None = None,
temperature: float | None = None,
**kwargs: Any,
) -> str:
"""Spawn a subagent to execute the given task."""
running = self._manager.get_running_count()
limit = self._manager.max_concurrent_subagents
if running >= limit:
return (
f"Cannot spawn subagent: concurrency limit reached "
f"({running}/{limit} running). Wait for a running subagent "
f"to complete before spawning a new one."
)
return await self._manager.spawn(
task=task,
label=label,
origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id,
origin_channel=self._origin_channel.get(),
origin_chat_id=self._origin_chat_id.get(),
session_key=self._session_key.get(),
origin_message_id=self._origin_message_id.get(),
temperature=temperature,
workspace_scope=current_workspace_scope(),
)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""OpenAI-compatible HTTP API for nanobot."""
+413
View File
@@ -0,0 +1,413 @@
"""OpenAI-compatible HTTP API server for a fixed nanobot session.
Provides /v1/chat/completions and /v1/models endpoints.
All requests route to a single persistent API session.
"""
from __future__ import annotations
import asyncio
import contextlib
import json as _json
import time
import uuid
from typing import Any
from aiohttp import web
from loguru import logger
from nanobot.config.paths import get_media_dir
from nanobot.utils.helpers import safe_filename
from nanobot.utils.media_decode import (
MAX_FILE_SIZE,
)
from nanobot.utils.media_decode import (
FileSizeExceeded as _FileSizeExceeded,
)
from nanobot.utils.media_decode import (
save_base64_data_url as _save_base64_data_url,
)
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
__all__ = (
"MAX_FILE_SIZE",
"_FileSizeExceeded",
"_save_base64_data_url",
"create_app",
"handle_chat_completions",
)
API_SESSION_KEY = "api:default"
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}},
status=status,
)
def _chat_completion_response(
content: str,
model: str,
usage: dict[str, int] | None = None,
) -> dict[str, Any]:
prompt = (usage or {}).get("prompt_tokens", 0)
completion = (usage or {}).get("completion_tokens", 0)
total = (usage or {}).get("total_tokens", 0) or prompt + completion
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": total,
},
}
def _response_text(value: Any) -> str:
"""Normalize process_direct output to plain assistant text."""
if value is None:
return ""
if hasattr(value, "content"):
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 _parse_json_content(body: dict) -> tuple[str, list[str]]:
"""Parse JSON request body. Returns (text, media_paths)."""
messages = body.get("messages")
if not isinstance(messages, list) or len(messages) != 1:
raise ValueError("Only a single user message is supported")
message = messages[0]
if not isinstance(message, dict) or message.get("role") != "user":
raise ValueError("Only a single user message is supported")
user_content = message.get("content", "")
media_dir = get_media_dir("api")
media_paths: list[str] = []
if isinstance(user_content, list):
text_parts: list[str] = []
for part in user_content:
if not isinstance(part, dict):
continue
if part.get("type") == "text":
text_parts.append(part.get("text", ""))
elif part.get("type") == "image_url":
url = part.get("image_url", {}).get("url", "")
if url.startswith("data:"):
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
else:
raise ValueError("Invalid content format")
return text, media_paths
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:
part = await reader.next()
if part is None:
break
if part.name == "message":
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"
)
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))
if not text:
text = "请分析上传的文件"
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 ""
if not isinstance(content_type, str):
content_type = ""
agent_loop = request.app["agent_loop"]
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, requested_model = await _parse_multipart(request)
else:
try:
body = await request.json()
except Exception:
return _error_json(400, "Invalid JSON body")
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:
return _error_json(400, str(e))
except _FileSizeExceeded as e:
return _error_json(413, str(e), err_type="invalid_request_error")
except Exception:
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={} 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"
await resp.prepare(request)
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
queue: asyncio.Queue[str | None] = asyncio.Queue()
stream_failed = False
emitted_content = False
async def _on_stream(token: str) -> None:
nonlocal emitted_content
if token:
emitted_content = True
await queue.put(token)
async def _on_stream_end(*_a: Any, **_kw: Any) -> None:
# Agent stream-end callbacks mark generation segment boundaries.
# Tool-backed requests may continue after a segment ends, so the
# HTTP SSE stream is closed only when process_direct returns.
return None
async def _run() -> None:
nonlocal stream_failed
try:
async with session_lock:
response = 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,
)
if not emitted_content:
response_text = _response_text(response)
if response_text.strip():
await queue.put(response_text)
except Exception:
stream_failed = True
logger.exception("Streaming error for session {}", session_key)
finally:
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:
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
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:
async with session_lock:
try:
response = 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,
),
timeout=timeout_s,
)
response_text = _response_text(response)
if not response_text or not response_text.strip():
logger.warning("Empty response for session {}, retrying", session_key)
retry_response = 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,
persist_user_message=False,
),
timeout=timeout_s,
)
response_text = _response_text(retry_response)
if not response_text or not response_text.strip():
logger.warning("Empty response after retry, using fallback")
response_text = fallback
except asyncio.TimeoutError:
return _error_json(504, f"Request timed out after {timeout_s}s")
except Exception:
logger.exception("Error processing request for session {}", session_key)
return _error_json(500, "Internal server error", err_type="server_error")
except Exception:
logger.exception("Unexpected API lock error for session {}", session_key)
return _error_json(500, "Internal server error", err_type="server_error")
return web.json_response(
_chat_completion_response(response_text, model_name, getattr(agent_loop, "_last_usage", None))
)
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",
}
],
}
)
async def handle_health(request: web.Request) -> web.Response:
"""GET /health"""
return web.json_response({"status": "ok"})
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def create_app(
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0
) -> web.Application:
"""Create the aiohttp application.
Args:
agent_loop: An initialized AgentLoop instance.
model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds.
"""
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
app["agent_loop"] = agent_loop
app["model_name"] = model_name
app["request_timeout"] = request_timeout
app["session_locks"] = {} # per-user locks, keyed by session_key
app.router.add_post("/v1/chat/completions", handle_chat_completions)
app.router.add_get("/v1/models", handle_models)
app.router.add_get("/health", handle_health)
return app
+5
View File
@@ -0,0 +1,5 @@
"""Shared app protocol helpers."""
from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest
__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"]
+13
View File
@@ -0,0 +1,13 @@
"""CLI app adapter for the unified Apps domain."""
from nanobot.apps.cli.service import (
CliAppError,
CliAppManager,
CliAppsRuntimeConfig,
)
__all__ = [
"CliAppError",
"CliAppManager",
"CliAppsRuntimeConfig",
]

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