Compare commits

..
232 Commits
Author SHA1 Message Date
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
335 changed files with 62806 additions and 8289 deletions
-4
View File
@@ -31,10 +31,6 @@ Tool descriptions, skills, and replayed session history also shape model behavio
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. 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.
## Heartbeat Virtual Tool Call
The heartbeat service (`heartbeat/service.py`) does not parse free-text LLM output. Instead, it injects a virtual `heartbeat` tool with `action: skip | run` into the conversation. Phase 1 is a structured decision; Phase 2 executes only on `run`. When adding new periodic background checks, follow this virtual-tool-call pattern rather than string matching.
## Skills as Extension Point ## 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. 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.
+1
View File
@@ -5,6 +5,7 @@ __pycache__
*.egg-info *.egg-info
dist/ dist/
build/ build/
nanobot/web/dist/
.git .git
.env .env
.assets .assets
+1 -1
View File
@@ -49,7 +49,7 @@ body:
attributes: attributes:
label: nanobot Version label: nanobot Version
description: Run `nanobot --version` or `pip show nanobot-ai` description: Run `nanobot --version` or `pip show nanobot-ai`
placeholder: e.g., 0.1.5 placeholder: e.g., 0.2.0
validations: validations:
required: true required: true
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest","windows-latest"]') }} os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python). # CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
python-version: ${{ fromJSON('["3.13","3.14"]') }} python-version: ${{ fromJSON('["3.13","3.14"]') }}
+4
View File
@@ -6,6 +6,8 @@
.env .env
.web .web
.orion .orion
nanobot-desktop/
desktop/
# Claude / AI assistant artifacts # Claude / AI assistant artifacts
docs/superpowers/ docs/superpowers/
@@ -97,3 +99,5 @@ logs/
tmp/ tmp/
temp/ temp/
*.tmp *.tmp
exp/
.playwright-mcp/
+82
View File
@@ -0,0 +1,82 @@
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.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **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)
## Branching Strategy
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) 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 -84
View File
@@ -1,84 +1 @@
# CLAUDE.md @AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in 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.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **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/heartbeat/`): Periodic agent wake-up service for scheduled task checking.
- **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)
## Branching Strategy
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) 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.
+2
View File
@@ -12,6 +12,8 @@ software together: with care, clarity, and respect for the next person reading t
## Maintainers ## 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 | Focus | | Maintainer | Focus |
|------------|-------| |------------|-------|
| [@re-bin](https://github.com/re-bin) | Project lead, `main` branch | | [@re-bin](https://github.com/re-bin) | Project lead, `main` branch |
+7 -5
View File
@@ -14,8 +14,9 @@ RUN apt-get update && \
WORKDIR /app WORKDIR /app
# Install Python dependencies first (cached layer) # Install Python dependencies first (cached layer). Hatch reads the custom build
COPY pyproject.toml README.md LICENSE ./ # 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 bridge && touch nanobot/__init__.py && \ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \ uv pip install --system --no-cache . && \
rm -rf nanobot bridge rm -rf nanobot bridge
@@ -23,7 +24,8 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
# Copy the full source and install # Copy the full source and install
COPY nanobot/ nanobot/ COPY nanobot/ nanobot/
COPY bridge/ bridge/ COPY bridge/ bridge/
RUN uv pip install --system --no-cache . COPY webui/ webui/
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache .
# Build the WhatsApp bridge # Build the WhatsApp bridge
WORKDIR /app/bridge WORKDIR /app/bridge
@@ -43,8 +45,8 @@ RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/ent
USER nanobot USER nanobot
ENV HOME=/home/nanobot ENV HOME=/home/nanobot
# Gateway default port # Gateway health endpoint and optional WebUI/WebSocket channel ports
EXPOSE 18790 EXPOSE 18790 8765
ENTRYPOINT ["entrypoint.sh"] ENTRYPOINT ["entrypoint.sh"]
CMD ["status"] CMD ["status"]
+50 -22
View File
@@ -1,6 +1,18 @@
![cover-v5-optimized](./images/GitHub_README.png) ![nanobot README cover](./images/readme-cover.png)
<div align="center"> <div align="center">
<p>
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview">English</a> |
<a href="https://nanobot.wiki/cn/docs/latest/getting-started/nanobot-overview">简体中文</a> |
<a href="https://nanobot.wiki/zh-Hant/docs/latest/getting-started/nanobot-overview">繁體中文</a> |
<a href="https://nanobot.wiki/es/docs/latest/getting-started/nanobot-overview">Español</a> |
<a href="https://nanobot.wiki/fr/docs/latest/getting-started/nanobot-overview">Français</a> |
<a href="https://nanobot.wiki/id/docs/latest/getting-started/nanobot-overview">Bahasa Indonesia</a> |
<a href="https://nanobot.wiki/ja/docs/latest/getting-started/nanobot-overview">日本語</a> |
<a href="https://nanobot.wiki/ko/docs/latest/getting-started/nanobot-overview">한국어</a> |
<a href="https://nanobot.wiki/ru/docs/latest/getting-started/nanobot-overview">Русский</a> |
<a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a>
</p>
<p> <p>
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a> <a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a> <a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
@@ -19,10 +31,30 @@
</p> </p>
</div> </div>
🐈 **nanobot** is an open-source and ultra-lightweight AI agent in the spirit of [OpenClaw](https://github.com/openclaw/openclaw), [Claude Code](https://www.anthropic.com/claude-code), and [Codex](https://www.openai.com/codex/). It keeps the core agent loop small and readable while still supporting chat channels, memory, MCP and practical deployment paths, so you can go from local setup to a long-running personal agent with minimal overhead. 🐈 **nanobot** is an open-source, ultra-lightweight agent runtime for people who want to own their AI agent stack. It gives you a small, readable core plus the practical pieces for real long-running agents: WebUI, chat channels, tools, memory, MCP, model routing, and deployment.
## 📢 News ## 📢 News
- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
<details>
<summary>Earlier news</summary>
- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
- **2026-05-15** 🚀 Released **v0.2.0****`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat. - **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects. - **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads. - **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
@@ -32,10 +64,6 @@
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses. - **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick. - **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries. - **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
<details>
<summary>Earlier news</summary>
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish. - **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries. - **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance. - **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
@@ -60,7 +88,7 @@
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks. - **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened. - **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media. - **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji. - **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config. - **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback. - **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools. - **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
@@ -132,12 +160,13 @@
</details> </details>
## 💡 Key Features of nanobot ## 💡 Why nanobot
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core. - **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend. - **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email.
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in. - **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page. - **Small core**: readable internals with MCP, memory, deployment, and automation built in.
- **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
## 📦 Install ## 📦 Install
@@ -211,13 +240,13 @@ nanobot agent
- Want different LLM providers, web search, MCP, security settings, or more config options? See [Configuration](./docs/configuration.md) - Want different LLM providers, web search, MCP, security settings, or more config options? See [Configuration](./docs/configuration.md)
- Want to run locally? Use [Atomic Chat](./docs/configuration.md#atomic-chat-local), [vLLM](./docs/configuration.md#vllm-local-openai-compatible), [Ollama](./docs/configuration.md#ollama-local), and [others](./docs/configuration.md#local-providers).
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md) - Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md) - Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
## 🧪 WebUI (Development) ## 🌐 WebUI
> [!NOTE] The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser.
> The WebUI development workflow currently requires a source checkout and is not yet shipped together with the official packaged release. See [WebUI Document](./webui/README.md) for full WebUI development docs and build steps.
<p align="center"> <p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900"> <img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
@@ -235,13 +264,12 @@ nanobot agent
nanobot gateway nanobot gateway
``` ```
**3. Start the webui dev server** **3. Open the WebUI**
```bash Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
cd webui
bun install > [!TIP]
bun run dev > Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
```
## 🏗️ Architecture ## 🏗️ Architecture
@@ -330,4 +358,4 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
<p align="center"> <p align="center">
<em> Thanks for visiting ✨ nanobot!</em><br><br> <em> Thanks for visiting ✨ nanobot!</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views"> <img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views">
</p> </p>
+1 -3
View File
@@ -46,17 +46,15 @@ core_agent=$(count_top_level_py_lines "nanobot/agent")
core_bus=$(count_top_level_py_lines "nanobot/bus") core_bus=$(count_top_level_py_lines "nanobot/bus")
core_config=$(count_top_level_py_lines "nanobot/config") core_config=$(count_top_level_py_lines "nanobot/config")
core_cron=$(count_top_level_py_lines "nanobot/cron") core_cron=$(count_top_level_py_lines "nanobot/cron")
core_heartbeat=$(count_top_level_py_lines "nanobot/heartbeat")
core_session=$(count_top_level_py_lines "nanobot/session") core_session=$(count_top_level_py_lines "nanobot/session")
print_row "agent/" "$core_agent" print_row "agent/" "$core_agent"
print_row "bus/" "$core_bus" print_row "bus/" "$core_bus"
print_row "config/" "$core_config" print_row "config/" "$core_config"
print_row "cron/" "$core_cron" print_row "cron/" "$core_cron"
print_row "heartbeat/" "$core_heartbeat"
print_row "session/" "$core_session" print_row "session/" "$core_session"
core_total=$((core_agent + core_bus + core_config + core_cron + core_heartbeat + core_session)) core_total=$((core_agent + core_bus + core_config + core_cron + core_session))
echo "" echo ""
echo "Separate buckets" echo "Separate buckets"
+1
View File
@@ -20,6 +20,7 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- 18790:18790 - 18790:18790
- 8765:8765
deploy: deploy:
resources: resources:
limits: limits:
+1
View File
@@ -15,6 +15,7 @@ Start here for setup, everyday usage, and deployment.
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot | | Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings | | Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
| Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts | | Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts |
| WebUI | [`../webui/README.md`](../webui/README.md) | Open the bundled browser UI; LAN access; Vite dev server for contributors |
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces | | Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints | | CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior | | In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
+106
View File
@@ -17,6 +17,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
| **Wecom** | Bot ID + Bot Secret | | **Wecom** | Bot ID + Bot Secret |
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint | | **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
| **Mochat** | Claw token (auto-setup available) | | **Mochat** | Claw token (auto-setup available) |
| **Signal** | signal-cli daemon + phone number |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>
@@ -50,6 +51,43 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
nanobot gateway 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>
<details> <details>
@@ -206,6 +244,7 @@ for reliable encryption, password login is recommended instead. If the
"userId": "@nanobot:matrix.org", "userId": "@nanobot:matrix.org",
"password": "mypasswordhere", "password": "mypasswordhere",
"e2eeEnabled": true, "e2eeEnabled": true,
"sasVerification": true,
"allowFrom": ["@your_user:matrix.org"], "allowFrom": ["@your_user:matrix.org"],
"groupPolicy": "open", "groupPolicy": "open",
"groupAllowFrom": [], "groupAllowFrom": [],
@@ -225,6 +264,7 @@ for reliable encryption, password login is recommended instead. If the
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). | | `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. | | `allowRoomMentions` | Accept `@room` mentions in mention mode. |
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. | | `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. | | `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
@@ -669,3 +709,69 @@ nanobot gateway
``` ```
</details> </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>
+3 -3
View File
@@ -56,17 +56,17 @@ Preset names come from the top-level `modelPresets` config. Switching is runtime
## Periodic Tasks ## Periodic Tasks
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel. The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks under `## Active Tasks`, the agent executes them and delivers results to your most recently active chat channel. If there are no active tasks, the heartbeat is skipped silently.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`): **Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown ```markdown
## Periodic Tasks ## Active Tasks
- [ ] Check weather forecast and send a summary - [ ] Check weather forecast and send a summary
- [ ] Scan inbox for urgent emails - [ ] Scan inbox for urgent emails
``` ```
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
> **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. > **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.
+264 -13
View File
@@ -26,7 +26,52 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
} }
``` ```
For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read: Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`.
### More examples
**MCP servers** — both stdio `env` and HTTP `headers`:
```json
{
"tools": {
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
},
"remote": {
"url": "https://example.com/mcp/",
"headers": { "Authorization": "Bearer ${REMOTE_MCP_TOKEN}" }
}
}
}
}
```
**Web search providers:**
```json
{
"tools": {
"web": {
"search": {
"provider": "brave",
"apiKey": "${BRAVE_API_KEY}"
}
}
}
}
```
### Loading variables at startup
Pick whatever fits your deployment — nanobot only reads `os.environ` at startup, so any mechanism that populates the process environment works.
**systemd** — use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
```ini ```ini
# /etc/systemd/system/nanobot.service (excerpt) # /etc/systemd/system/nanobot.service (excerpt)
@@ -42,6 +87,35 @@ TELEGRAM_TOKEN=your-token-here
IMAP_PASSWORD=your-password-here IMAP_PASSWORD=your-password-here
``` ```
**Docker** — pass an env file to the locally built image (one `KEY=VALUE` per line), or use `-e KEY=value`:
```bash
docker run --rm --env-file=./nanobot.env \
-v ~/.nanobot:/home/nanobot/.nanobot \
nanobot agent -m "Hello"
```
**direnv** — drop a `.envrc` in your working directory and run `direnv allow`:
```bash
# .envrc (auto-loaded by direnv)
export TELEGRAM_TOKEN=your-token-here
export ANTHROPIC_API_KEY=...
```
**Secret managers (1Password, Bitwarden, pass)** — wrap the process so secrets only exist as env vars for the lifetime of the run, never on disk:
```bash
# 1Password — references in .env.tpl look like `op://Vault/Item/field`
op run --env-file=.env.tpl -- nanobot agent
# pass (passwordstore.org)
ANTHROPIC_API_KEY="$(pass show api/anthropic)" nanobot agent
# Bitwarden
ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
```
## Providers ## Providers
> [!TIP] > [!TIP]
@@ -52,14 +126,17 @@ IMAP_PASSWORD=your-password-here
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers. > - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config. > - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.com/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config. > - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default. > - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
|----------|---------|-------------| |----------|---------|-------------|
| `custom` | Any OpenAI-compatible endpoint | — | | `custom` | Any OpenAI-compatible endpoint | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) | | `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
| `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) |
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) | | `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) | | `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
@@ -73,11 +150,13 @@ IMAP_PASSWORD=your-password-here
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) | | `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
| `ant_ling` | LLM (Ant Ling / 蚂蚁百灵) | [developer.ant-ling.com](https://developer.ant-ling.com/en/docs/api-reference/openai/) |
| `ollama` | LLM (local, Ollama) | — | | `ollama` | LLM (local, Ollama) | — |
| `lm_studio` | LLM (local, LM Studio) | — | | `lm_studio` | LLM (local, LM Studio) | — |
| `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — | | `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — |
@@ -89,6 +168,73 @@ IMAP_PASSWORD=your-password-here
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` | | `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) | | `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
<details>
<summary><b>OpenAI</b></summary>
By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normally and routes GPT-5/o-series or explicit `reasoningEffort` requests through the Responses API when useful. You can force a specific API surface:
```json
{
"providers": {
"openai": {
"apiKey": "${OPENAI_API_KEY}",
"apiType": "chat_completions"
}
}
}
```
Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
```json
{
"providers": {
"openai": {
"apiKey": "${OPENAI_API_KEY}",
"apiType": "responses",
"extraBody": {
"tools": [{ "type": "web_search" }],
"include": ["web_search_call.action.sources"]
}
}
}
}
```
</details>
<details>
<summary><b>Skywork / APIFree</b></summary>
Skywork uses APIFree's OpenAI-compatible Agent API endpoint. Configure the provider
once, then use Skywork model IDs such as `skywork-ai/skyclaw-v1`.
```json
{
"providers": {
"skywork": {
"apiKey": "${SKYWORK_API_KEY}",
"apiBase": "https://api.apifree.ai/agent/v1"
}
},
"agents": {
"defaults": {
"provider": "skywork",
"model": "skywork-ai/skyclaw-v1",
"maxTokens": 32768,
"contextWindowTokens": 131072
}
}
}
```
You can also reference `${APIFREE_API_KEY}` in `apiKey` if that is how your
environment names the credential.
</details>
<details> <details>
<summary><b>AWS Bedrock (Converse API)</b></summary> <summary><b>AWS Bedrock (Converse API)</b></summary>
@@ -370,6 +516,96 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
</details> </details>
<details>
<summary><b>Xiaomi MiMo</b></summary>
Xiaomi MiMo models are automatically detected by the `xiaomi_mimo` provider when
the model name contains `mimo`. The default API base is
`https://api.xiaomimimo.com/v1`.
> **Token Plan**: If you're using MiMo's token plan, override `apiBase` with the
> dedicated endpoint:
>
> ```json
> {
> "providers": {
> "xiaomi_mimo": {
> "apiKey": "${XIAOMIMIMO_API_KEY}",
> "apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"
> }
> },
> "agents": {
> "defaults": {
> "model": "xiaomi/mimo-v2.5-pro"
> }
> }
> }
> ```
>
> No need to set `provider` explicitly — the model name contains `mimo`, which
> auto-matches to the `xiaomi_mimo` provider spec. Use an API key from the MiMo
> token plan console and check the MiMo platform for the latest supported model
> names.
</details>
<details>
<summary><b>StepFun Step Plan (subscription)</b></summary>
Step Plan is StepFun's subscription-based service for high-frequency AI developers.
If you're on a Step Plan subscription, override `apiBase` in the existing `stepfun`
provider config to point to the dedicated Step Plan endpoint.
```json
{
"providers": {
"stepfun": {
"apiKey": "${STEPFUN_API_KEY}",
"apiBase": "https://api.stepfun.com/step_plan/v1"
}
},
"agents": {
"defaults": {
"provider": "stepfun",
"model": "step-3.5-flash"
}
}
}
```
Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and
`step-router-v1`.
</details>
<details>
<summary><b>Ant Ling (OpenAI-compatible)</b></summary>
Ant Ling is available through nanobot's built-in OpenAI-compatible provider flow.
The default API base points to `https://api.ant-ling.com/v1`, so you usually
only need to set `apiKey`.
```json
{
"providers": {
"antLing": {
"apiKey": "${ANT_LING_API_KEY}"
}
},
"agents": {
"defaults": {
"provider": "ant_ling",
"model": "Ling-2.6-flash"
}
}
}
```
Official OpenAI-compatible model names include `Ling-2.6-1T`,
`Ling-2.6-flash`, `Ling-2.5-1T`, `Ling-1T`, `Ring-2.5-1T`, and `Ring-1T`.
</details>
<details> <details>
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary> <summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
@@ -438,6 +674,8 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
</details> </details>
<a id="local-providers"></a>
<a id="ollama-local"></a>
<details> <details>
<summary><b>Ollama (local)</b></summary> <summary><b>Ollama (local)</b></summary>
@@ -503,12 +741,19 @@ ollama run llama3.2
</details> </details>
<a id="atomic-chat-local"></a>
<details> <details>
<summary><b>Atomic Chat (local)</b></summary> <summary><b>Atomic Chat (local)</b></summary>
[Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Start Atomic Chat and enable the local API server, then point nanobot at it. [Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Use it when you want to run nanobot against a model on your own machine instead of a hosted API provider.
**1. Add to config** (partial — merge into `~/.nanobot/config.json`): **1. Start Atomic Chat**
- Install [Atomic Chat](https://atomic.chat/) on your machine.
- Open Atomic Chat, download a model, and keep the app running. The local API is enabled by default.
- Copy the model ID exposed by the local API. For example, the model ID for `Qwen 3 32B` might be `qwen3-32b`.
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
```json ```json
{ {
@@ -521,13 +766,13 @@ ollama run llama3.2
"agents": { "agents": {
"defaults": { "defaults": {
"provider": "atomic_chat", "provider": "atomic_chat",
"model": "your-model-id-from-atomic-chat" "model": "qwen3-32b"
} }
} }
} }
``` ```
> **Note:** Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects. The `model` string must match the model id Atomic Chat exposes on its OpenAI-compatible endpoint. > **Note:** Replace `qwen3-32b` with the model ID from Atomic Chat. Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects.
> `provider: "auto"` also works when `providers.atomic_chat.apiBase` is configured, but setting `"provider": "atomic_chat"` is the clearest option. > `provider: "auto"` also works when `providers.atomic_chat.apiBase` is configured, but setting `"provider": "atomic_chat"` is the clearest option.
@@ -608,6 +853,7 @@ docker run -d \
> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details. > See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
</details> </details>
<a id="vllm-local-openai-compatible"></a>
<details> <details>
<summary><b>vLLM (local / OpenAI-compatible)</b></summary> <summary><b>vLLM (local / OpenAI-compatible)</b></summary>
@@ -797,6 +1043,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
"channels": { "channels": {
"sendProgress": true, "sendProgress": true,
"sendToolHints": false, "sendToolHints": false,
"extractDocumentText": true,
"sendMaxRetries": 3, "sendMaxRetries": 3,
"transcriptionProvider": "groq", "transcriptionProvider": "groq",
"transcriptionLanguage": null, "transcriptionLanguage": null,
@@ -810,8 +1057,9 @@ Global settings that apply to all channels. Configure under the `channels` secti
| `sendProgress` | `true` | Stream agent's text progress to the channel | | `sendProgress` | `true` | Stream agent's text progress to the channel |
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) | | `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. | | `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. | | `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key and optional `apiBase` are auto-resolved from the matching provider config. Chat-style bases such as `https://api.groq.com/openai/v1` are normalized to the audio transcription endpoint. |
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. | | `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
`sendProgress` and `sendToolHints` can also be overridden per channel. The `sendProgress` and `sendToolHints` can also be overridden per channel. The
@@ -917,7 +1165,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": { "web": {
"search": { "search": {
"provider": "brave", "provider": "brave",
"apiKey": "BSA..." "apiKey": "${BRAVE_API_KEY}"
} }
} }
} }
@@ -931,7 +1179,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": { "web": {
"search": { "search": {
"provider": "tavily", "provider": "tavily",
"apiKey": "tvly-..." "apiKey": "${TAVILY_API_KEY}"
} }
} }
} }
@@ -945,7 +1193,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": { "web": {
"search": { "search": {
"provider": "jina", "provider": "jina",
"apiKey": "jina_..." "apiKey": "${JINA_API_KEY}"
} }
} }
} }
@@ -959,7 +1207,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": { "web": {
"search": { "search": {
"provider": "kagi", "provider": "kagi",
"apiKey": "your-kagi-api-key" "apiKey": "${KAGI_API_KEY}"
} }
} }
} }
@@ -973,7 +1221,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
"web": { "web": {
"search": { "search": {
"provider": "olostep", "provider": "olostep",
"apiKey": "YOUR_OLOSTEP_API_KEY" "apiKey": "${OLOSTEP_API_KEY}"
} }
} }
} }
@@ -1050,7 +1298,7 @@ If you want to always use the local conversion, you can force it using:
## Image Generation ## Image Generation
Image generation is configured under `tools.imageGeneration` and uses provider credentials from `providers.openrouter` or `providers.aihubmix`. Image generation is configured under `tools.imageGeneration` and uses credentials from the selected provider's `providers.<name>` block.
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting. See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
@@ -1136,11 +1384,14 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
> [!TIP] > [!TIP]
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent. > For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). | | `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. | | `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. | | `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
@@ -1283,7 +1534,7 @@ By default, nanobot uses `UTC` for runtime time context. If you want the agent t
} }
``` ```
This affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset. This affects runtime time strings shown to the model, such as runtime context. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.
Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`. Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.
+33 -2
View File
@@ -10,6 +10,25 @@
> [!IMPORTANT] > [!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. > 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/README.md`](../webui/README.md) for details.
### Docker Compose ### Docker Compose
```bash ```bash
@@ -36,8 +55,20 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
# Edit config on host to add API keys # Edit config on host to add API keys
vim ~/.nanobot/config.json vim ~/.nanobot/config.json
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat) # Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway # 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 # 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 agent -m "Hello!"
+158 -28
View File
@@ -6,8 +6,6 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
## Quick Setup ## Quick Setup
OpenRouter example:
```json ```json
{ {
"providers": { "providers": {
@@ -19,34 +17,13 @@ OpenRouter example:
"imageGeneration": { "imageGeneration": {
"enabled": true, "enabled": true,
"provider": "openrouter", "provider": "openrouter",
"model": "openai/gpt-5.4-image-2", "model": "openai/gpt-5.4-image-2"
"defaultAspectRatio": "1:1",
"defaultImageSize": "1K"
} }
} }
} }
``` ```
AIHubMix example: See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
```json
{
"providers": {
"aihubmix": {
"apiKey": "${AIHUBMIX_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "aihubmix",
"model": "gpt-image-2-free",
"defaultAspectRatio": "1:1",
"defaultImageSize": "1K"
}
}
}
```
> [!TIP] > [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup. > Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -69,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool | | `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Currently `openrouter` and `aihubmix` are supported | | `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name | | `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.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.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -139,6 +116,160 @@ Configure:
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness. `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.com/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.com/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 ## Artifacts
Generated images are stored under the active nanobot instance's media directory: Generated images are stored under the active nanobot instance's media directory:
@@ -193,8 +324,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|---------|-------| |---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway | | `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 | | 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` or `aihubmix` | | `unsupported image generation provider` | Use `openrouter`, `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 | | 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 | | 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 | | Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
+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.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+20 -4
View File
@@ -2,9 +2,10 @@
nanobot - A lightweight AI agent framework nanobot - A lightweight AI agent framework
""" """
from importlib.metadata import PackageNotFoundError, version as _pkg_version
from pathlib import Path
import tomllib 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: def _read_pyproject_version() -> str | None:
@@ -21,12 +22,27 @@ def _resolve_version() -> str:
return _pkg_version("nanobot-ai") return _pkg_version("nanobot-ai")
except PackageNotFoundError: except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info. # Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.1.5.post3" return _read_pyproject_version() or "0.2.1"
__version__ = _resolve_version() __version__ = _resolve_version()
__logo__ = "🐈" __logo__ = "🐈"
from nanobot.nanobot import Nanobot, RunResult _LAZY_EXPORTS = {
"Nanobot": ".nanobot",
"RunResult": ".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"] __all__ = ["Nanobot", "RunResult"]
+11 -48
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Collection from collections.abc import Collection
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Coroutine from typing import TYPE_CHECKING, Callable, Coroutine
from loguru import logger from loguru import logger
@@ -37,27 +37,6 @@ class AutoCompact:
def _format_summary(text: str, last_active: datetime) -> str: def _format_summary(text: str, last_active: datetime) -> str:
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}" return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
def _split_unconsolidated(
self, session: Session,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Split live session tail into archiveable prefix and retained recent suffix."""
tail = list(session.messages[session.last_consolidated:])
if not tail:
return [], []
probe = Session(
key=session.key,
messages=tail.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES)
kept = probe.messages
cut = len(tail) - len(kept)
return tail[:cut], kept
def check_expired(self, schedule_background: Callable[[Coroutine], None], def check_expired(self, schedule_background: Callable[[Coroutine], None],
active_session_keys: Collection[str] = ()) -> None: active_session_keys: Collection[str] = ()) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks.""" """Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
@@ -74,33 +53,17 @@ class AutoCompact:
async def _archive(self, key: str) -> None: async def _archive(self, key: str) -> None:
try: try:
self.sessions.invalidate(key) summary = await self.consolidator.compact_idle_session(
session = self.sessions.get_or_create(key) key, self._RECENT_SUFFIX_MESSAGES,
archive_msgs, kept_msgs = self._split_unconsolidated(session) )
if not archive_msgs and not kept_msgs:
session.updated_at = datetime.now()
self.sessions.save(session)
return
last_active = session.updated_at
summary = ""
if archive_msgs:
summary = await self.consolidator.archive(archive_msgs) or ""
if summary and summary != "(nothing)": if summary and summary != "(nothing)":
self._summaries[key] = (summary, last_active) session = self.sessions.get_or_create(key)
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()} meta = session.metadata.get("_last_summary")
session.messages = kept_msgs if isinstance(meta, dict):
session.last_consolidated = 0 self._summaries[key] = (
session.updated_at = datetime.now() meta["text"],
self.sessions.save(session) datetime.fromisoformat(meta["last_active"]),
if archive_msgs: )
logger.info(
"Auto-compact: archived {} (archived={}, kept={}, summary={})",
key,
len(archive_msgs),
len(kept_msgs),
bool(summary),
)
except Exception: except Exception:
logger.exception("Auto-compact: failed for {}", key) logger.exception("Auto-compact: failed for {}", key)
finally: finally:
+69 -39
View File
@@ -3,26 +3,55 @@
import base64 import base64
import mimetypes import mimetypes
import platform import platform
from contextlib import suppress
from importlib.resources import files as pkg_files
from pathlib import Path from pathlib import Path
from typing import Any, Mapping, Sequence from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader 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.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
current_time_str, current_time_str,
detect_image_mime, detect_image_mime,
load_bundled_template,
truncate_text, truncate_text,
) )
from nanobot.utils.prompt_templates import render_template 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: class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent.""" """Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_MAX_RECENT_HISTORY = 50 _MAX_RECENT_HISTORY = 50
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size _MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
@@ -39,15 +68,18 @@ class ContextBuilder:
skill_names: list[str] | None = None, skill_names: list[str] | None = None,
channel: str | None = None, channel: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
session_key: str | None = None, workspace: Path | None = None,
) -> str: ) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills.""" """Build the system prompt from identity, bootstrap files, memory, and skills."""
parts = [self._get_identity(channel=channel)] root = workspace or self.workspace
parts = [self._get_identity(channel=channel, workspace=root)]
bootstrap = self._load_bootstrap_files() bootstrap = self._load_bootstrap_files(root)
if bootstrap: if bootstrap:
parts.append(bootstrap) parts.append(bootstrap)
parts.append(render_template("agent/tool_contract.md"))
memory = self.memory.get_memory_context() memory = self.memory.get_memory_context()
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"): if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
parts.append(f"# Memory\n\n{memory}") parts.append(f"# Memory\n\n{memory}")
@@ -74,32 +106,12 @@ class ContextBuilder:
if session_summary: if session_summary:
parts.append(f"[Archived Context Summary]\n\n{session_summary}") parts.append(f"[Archived Context Summary]\n\n{session_summary}")
# Inject P2P collaboration hint for task-scoped sessions
if session_key and session_key.startswith("task:"):
parts.append(self._p2p_collaboration_hint())
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
@staticmethod def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
def _p2p_collaboration_hint() -> str:
return (
"# Multi-Agent Collaboration\n\n"
"You are part of a decentralized agent network. You can:\n"
"- Use `broadcast_task` to announce subtasks and collect BIDs\n"
"- Use `dispatch_task` to assign tasks to specific agents\n"
"- Use `poll_task_result` to check task status\n"
"- Use `report_user` to deliver final results to the user\n"
"- Use `finalize_task` to terminate tasks\n\n"
"Rules:\n"
"- Never block waiting for results. Dispatch and continue.\n"
"- If a task times out, decide whether to retry, failover, or report partial.\n"
"- Respect the user's INTERRUPT messages — they have highest priority.\n"
"- You are currently in a task-scoped session; focus on the delegated task."
)
def _get_identity(self, channel: str | None = None) -> str:
"""Get the core identity section.""" """Get the core identity section."""
workspace_path = str(self.workspace.expanduser().resolve()) root = workspace or self.workspace
workspace_path = str(root.expanduser().resolve())
system = platform.system() system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@@ -143,12 +155,13 @@ class ContextBuilder:
return _to_blocks(left) + _to_blocks(right) return _to_blocks(left) + _to_blocks(right)
def _load_bootstrap_files(self) -> str: def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load all bootstrap files from workspace.""" """Load all bootstrap files from workspace."""
parts = [] parts = []
root = workspace or self.workspace
for filename in self.BOOTSTRAP_FILES: for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename file_path = root / filename
if file_path.exists(): if file_path.exists():
content = file_path.read_text(encoding="utf-8") content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}") parts.append(f"## {filename}\n\n{content}")
@@ -158,10 +171,9 @@ class ContextBuilder:
@staticmethod @staticmethod
def _is_template_content(content: str, template_path: str) -> bool: def _is_template_content(content: str, template_path: str) -> bool:
"""Check if *content* is identical to the bundled template (user hasn't customized it).""" """Check if *content* is identical to the bundled template (user hasn't customized it)."""
with suppress(Exception): tpl = load_bundled_template(template_path)
tpl = pkg_files("nanobot") / "templates" / template_path if tpl is not None:
if tpl.is_file(): return content.strip() == tpl.strip()
return content.strip() == tpl.read_text(encoding="utf-8").strip()
return False return False
def build_messages( def build_messages(
@@ -176,10 +188,21 @@ class ContextBuilder:
sender_id: str | None = None, sender_id: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None, session_metadata: Mapping[str, Any] | None = None,
session_key: str | 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,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """Build the complete message list for an LLM call."""
extra = goal_state_runtime_lines(session_metadata) 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( runtime_ctx = self._build_runtime_context(
channel, channel,
chat_id, chat_id,
@@ -198,7 +221,15 @@ class ContextBuilder:
else: else:
merged = user_content + [{"type": "text", "text": runtime_ctx}] merged = user_content + [{"type": "text", "text": runtime_ctx}]
messages = [ messages = [
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary, session_key=session_key)}, {
"role": "system",
"content": self.build_system_prompt(
skill_names,
channel=channel,
session_summary=session_summary,
workspace=root,
),
},
*history, *history,
] ]
if messages[-1].get("role") == current_role: if messages[-1].get("role") == current_role:
@@ -233,4 +264,3 @@ class ContextBuilder:
if not images: if not images:
return text return text
return images + [{"type": "text", "text": text}] return images + [{"type": "text", "text": text}]
+211 -171
View File
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers from nanobot.agent import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
@@ -22,16 +23,9 @@ from nanobot.agent.memory import Consolidator, Dream
from nanobot.agent.progress_hook import AgentProgressHook from nanobot.agent.progress_hook import AgentProgressHook
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.p2p import (
BroadcastTaskTool,
CheckAggregationTool,
DispatchTaskTool,
FinalizeTaskTool,
PollTaskResultTool,
ReportUserTool,
)
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
@@ -40,20 +34,32 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm
from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import (
WorkspaceScopeResolver,
bind_workspace_scope,
reset_workspace_scope,
)
from nanobot.session.goal_state import ( from nanobot.session.goal_state import (
goal_state_ws_blob, goal_state_runtime_lines,
runner_wall_llm_timeout_s, runner_wall_llm_timeout_s,
sustained_goal_active,
) )
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.utils.artifacts import generated_image_paths_from_messages from nanobot.session import turn_continuation
from nanobot.utils.document import extract_documents from nanobot.session.webui_turns import (
WebuiTurnCoordinator,
build_bus_progress_callback,
mark_webui_session,
)
from nanobot.utils.document import extract_documents, reference_non_image_attachments
from nanobot.utils.helpers import image_placeholder_text from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant from nanobot.utils.runtime import (
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn EMPTY_FINAL_RESPONSE_MESSAGE,
from nanobot.utils.webui_turn_helpers import publish_turn_run_status SUSTAINED_GOAL_CONTINUE_PROMPT,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.config.schema import ( from nanobot.config.schema import (
@@ -66,7 +72,6 @@ if TYPE_CHECKING:
UNIFIED_SESSION_KEY = "unified:default" UNIFIED_SESSION_KEY = "unified:default"
class TurnState(Enum): class TurnState(Enum):
RESTORE = auto() RESTORE = auto()
COMPACT = auto() COMPACT = auto()
@@ -108,7 +113,7 @@ class TurnContext:
save_skip: int = 0 save_skip: int = 0
outbound: OutboundMessage | None = None outbound: OutboundMessage | None = None
generated_media: list[str] = field(default_factory=list) suppress_response: bool = False
on_progress: Callable[..., Awaitable[None]] | None = None on_progress: Callable[..., Awaitable[None]] | None = None
on_stream: Callable[[str], Awaitable[None]] | None = None on_stream: Callable[[str], Awaitable[None]] | None = None
@@ -117,8 +122,8 @@ class TurnContext:
pending_queue: asyncio.Queue | None = None pending_queue: asyncio.Queue | None = None
pending_summary: str | None = None pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time) turn_wall_started_at: float = field(default_factory=time.time)
visible_run_started_at: float | None = None
turn_latency_ms: int | None = None turn_latency_ms: int | None = None
trace: list[StateTraceEntry] = field(default_factory=list) trace: list[StateTraceEntry] = field(default_factory=list)
@@ -144,6 +149,11 @@ class AgentLoop:
def tool_names(self) -> list[str]: def tool_names(self) -> list[str]:
return self.tools.tool_names return self.tools.tool_names
def llm_runtime(self) -> LLMRuntime:
"""Return the current provider/model pair owned by this loop."""
self._refresh_provider_snapshot()
return LLMRuntime(self.provider, self.model)
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint" _RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn" _PENDING_USER_TURN_KEY = "pending_user_turn"
@@ -167,6 +177,7 @@ class AgentLoop:
workspace: Path, workspace: Path,
model: str | None = None, model: str | None = None,
max_iterations: int | None = None, max_iterations: int | None = None,
max_concurrent_subagents: int | None = None,
context_window_tokens: int | None = None, context_window_tokens: int | None = None,
context_block_limit: int | None = None, context_block_limit: int | None = None,
max_tool_result_chars: int | None = None, max_tool_result_chars: int | None = None,
@@ -193,7 +204,6 @@ class AgentLoop:
model_preset: str | None = None, model_preset: str | None = None,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None, preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None, runtime_model_publisher: Callable[[str, str | None], None] | None = None,
p2p_shell: Any | None = None,
): ):
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
@@ -201,7 +211,6 @@ class AgentLoop:
defaults = AgentDefaults() defaults = AgentDefaults()
self.bus = bus self.bus = bus
self.channels_config = channels_config self.channels_config = channels_config
self.p2p_shell = p2p_shell
self.provider = provider self.provider = provider
self._provider_snapshot_loader = provider_snapshot_loader self._provider_snapshot_loader = provider_snapshot_loader
self._preset_snapshot_loader = preset_snapshot_loader self._preset_snapshot_loader = preset_snapshot_loader
@@ -240,6 +249,10 @@ class AgentLoop:
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
self.cron_service = cron_service self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.workspace_scopes = WorkspaceScopeResolver(
default_workspace=workspace,
default_restrict_to_workspace=restrict_to_workspace,
)
self._start_time = time.time() self._start_time = time.time()
self._last_usage: dict[str, int] = {} self._last_usage: dict[str, int] = {}
self._pending_turn_latency_ms: dict[str, int] = {} self._pending_turn_latency_ms: dict[str, int] = {}
@@ -247,6 +260,11 @@ class AgentLoop:
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace) self.sessions = session_manager or SessionManager(workspace)
self._webui_turns = WebuiTurnCoordinator(
bus=self.bus,
sessions=self.sessions,
schedule_background=lambda coro: self._schedule_background(coro),
)
self.tools = ToolRegistry() self.tools = ToolRegistry()
# One file-read/write tracker per logical session. The tool registry is # One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars. # shared by this loop, so tools resolve the active state via contextvars.
@@ -262,6 +280,7 @@ class AgentLoop:
restrict_to_workspace=restrict_to_workspace, restrict_to_workspace=restrict_to_workspace,
disabled_skills=disabled_skills, disabled_skills=disabled_skills,
max_iterations=self.max_iterations, max_iterations=self.max_iterations,
max_concurrent_subagents=max_concurrent_subagents,
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk), llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
) )
self._unified_session = unified_session self._unified_session = unified_session
@@ -347,6 +366,7 @@ class AgentLoop:
workspace=config.workspace_path, workspace=config.workspace_path,
model=model, model=model,
max_iterations=defaults.max_tool_iterations, max_iterations=defaults.max_tool_iterations,
max_concurrent_subagents=defaults.max_concurrent_subagents,
context_window_tokens=context_window_tokens, context_window_tokens=context_window_tokens,
context_block_limit=defaults.context_block_limit, context_block_limit=defaults.context_block_limit,
max_tool_result_chars=defaults.max_tool_result_chars, max_tool_result_chars=defaults.max_tool_result_chars,
@@ -462,6 +482,7 @@ class AgentLoop:
provider_snapshot_loader=self._provider_snapshot_loader, provider_snapshot_loader=self._provider_snapshot_loader,
image_generation_provider_configs=self._image_generation_provider_configs, image_generation_provider_configs=self._image_generation_provider_configs,
timezone=self.context.timezone or "UTC", timezone=self.context.timezone or "UTC",
workspace_sandbox=self.workspace_scopes.sandbox_status,
) )
loader = ToolLoader() loader = ToolLoader()
registered = loader.load(ctx, self.tools) registered = loader.load(ctx, self.tools)
@@ -473,45 +494,11 @@ class AgentLoop:
) )
registered.append("my") registered.append("my")
# Register P2P tools if enabled
if self.p2p_shell:
self.tools.register(DispatchTaskTool(shell=self.p2p_shell))
self.tools.register(PollTaskResultTool(shell=self.p2p_shell))
self.tools.register(BroadcastTaskTool(shell=self.p2p_shell))
self.tools.register(CheckAggregationTool(shell=self.p2p_shell))
self.tools.register(
ReportUserTool(
send_callback=self.bus.publish_outbound,
default_channel=getattr(self.channels_config, "default_channel", ""),
default_chat_id=getattr(self.channels_config, "default_chat_id", ""),
)
)
self.tools.register(FinalizeTaskTool(shell=self.p2p_shell, session_manager=self.sessions))
registered.append("p2p")
logger.info("Registered {} tools: {}", len(registered), registered) logger.info("Registered {} tools: {}", len(registered), registered)
async def _connect_mcp(self) -> None: async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy).""" """Connect configured MCP servers."""
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers: await agent_context.connect_mcp(self, self.tools)
return
self._mcp_connecting = True
from nanobot.agent.tools.mcp import connect_mcp_servers
try:
self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools)
if self._mcp_stacks:
self._mcp_connected = True
else:
logger.warning("No MCP servers connected successfully (will retry next message)")
except asyncio.CancelledError:
logger.warning("MCP connection cancelled (will retry next message)")
self._mcp_stacks.clear()
except BaseException as e:
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
self._mcp_stacks.clear()
finally:
self._mcp_connecting = False
def _set_tool_context( def _set_tool_context(
self, channel: str, chat_id: str, self, channel: str, chat_id: str,
@@ -519,7 +506,7 @@ class AgentLoop:
session_key: str | None = None, session_key: str | None = None,
) -> None: ) -> None:
"""Update context for all tools that need routing info.""" """Update context for all tools that need routing info."""
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware
if session_key is not None: if session_key is not None:
effective_key = session_key effective_key = session_key
@@ -550,34 +537,7 @@ class AgentLoop:
self, msg: InboundMessage self, msg: InboundMessage
) -> Callable[..., Awaitable[None]]: ) -> Callable[..., Awaitable[None]]:
"""Build a progress callback that publishes to the message bus.""" """Build a progress callback that publishes to the message bus."""
return build_bus_progress_callback(self.bus, msg)
async def _bus_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
await self.bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
return _bus_progress
async def _build_retry_wait_callback( async def _build_retry_wait_callback(
self, msg: InboundMessage self, msg: InboundMessage
@@ -608,10 +568,12 @@ class AgentLoop:
Returns True if the message was persisted. Returns True if the message was persisted.
""" """
if not turn_continuation.should_persist_user_message(msg.metadata):
return False
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p] media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip() has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths: if has_text or media_paths:
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {} extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs) extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else "" text = msg.content if isinstance(msg.content, str) else ""
session.add_message("user", text, **extra) session.add_message("user", text, **extra)
@@ -628,6 +590,7 @@ class AgentLoop:
pending_summary: str | None, pending_summary: str | None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the initial message list for the LLM turn.""" """Build the initial message list for the LLM turn."""
scope = self.workspace_scopes.for_message(msg, session.metadata)
return self.context.build_messages( return self.context.build_messages(
history=history, history=history,
current_message=image_generation_prompt(msg.content, msg.metadata), current_message=image_generation_prompt(msg.content, msg.metadata),
@@ -637,6 +600,9 @@ class AgentLoop:
sender_id=msg.sender_id, sender_id=msg.sender_id,
session_summary=pending_summary, session_summary=pending_summary,
session_metadata=session.metadata, session_metadata=session.metadata,
workspace=scope.project_path,
runtime_state=self,
inbound_message=msg,
) )
async def _dispatch_command_inline( async def _dispatch_command_inline(
@@ -750,7 +716,7 @@ class AgentLoop:
content = pending_msg.content content = pending_msg.content
media = pending_msg.media if pending_msg.media else None media = pending_msg.media if pending_msg.media else None
if media: if media:
content, media = extract_documents(content, media) content, media = self._prepare_message_media(content, media)
media = media or None media = media or None
user_content = self.context._build_user_content(content, media) user_content = self.context._build_user_content(content, media)
return {"role": "user", "content": user_content} return {"role": "user", "content": user_content}
@@ -786,7 +752,31 @@ class AgentLoop:
return items return items
active_session_key = session.key if session else session_key active_session_key = session.key if session else session_key
effective_scope = self.workspace_scopes.for_turn(
channel=channel,
message_metadata=metadata,
session_metadata=session.metadata if session is not None else None,
)
request_ctx = RequestContext(
channel=channel,
chat_id=chat_id,
message_id=message_id,
session_key=active_session_key,
metadata=dict(metadata or {}),
)
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key)) file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
request_token = bind_request_context(request_ctx)
workspace_token = bind_workspace_scope(effective_scope)
# Build continuation message that embeds the active goal objective so
# the LLM can see it even if earlier Runtime Context was truncated.
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
_goal_continue = (
"You have an active sustained goal:\n\n"
+ "\n".join(_goal_lines)
+ "\n\nPlease continue working toward the objective using your tools, "
"or call complete_goal if the work is truly finished."
) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT
session_metadata = session.metadata if session is not None else None
try: try:
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages, initial_messages=initial_messages,
@@ -797,7 +787,7 @@ class AgentLoop:
hook=hook, hook=hook,
error_message="Sorry, I encountered an error calling the AI model.", error_message="Sorry, I encountered an error calling the AI model.",
concurrent_tools=True, concurrent_tools=True,
workspace=self.workspace, workspace=effective_scope.project_path,
session_key=session.key if session else None, session_key=session.key if session else None,
context_window_tokens=self.context_window_tokens, context_window_tokens=self.context_window_tokens,
context_block_limit=self.context_block_limit, context_block_limit=self.context_block_limit,
@@ -812,17 +802,28 @@ class AgentLoop:
llm_timeout_s=runner_wall_llm_timeout_s( llm_timeout_s=runner_wall_llm_timeout_s(
self.sessions, self.sessions,
session.key if session is not None else session_key, session.key if session is not None else session_key,
metadata=(session.metadata if session is not None else None), metadata=session_metadata,
message_metadata=metadata,
), ),
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
goal_continue_message=_goal_continue,
)) ))
finally: finally:
reset_workspace_scope(workspace_token)
reset_request_context(request_token)
reset_file_states(file_state_token) reset_file_states(file_state_token)
self._last_usage = result.usage self._last_usage = result.usage
if result.stop_reason == "max_iterations": if result.stop_reason == "max_iterations":
logger.warning("Max iterations ({}) reached", self.max_iterations) logger.warning("Max iterations ({}) reached", self.max_iterations)
should_stream = turn_continuation.should_stream_budget_response(
stop_reason=result.stop_reason,
pending_queue_available=pending_queue is not None and session is not None,
session_metadata=session_metadata,
message_metadata=metadata,
)
# Push final content through stream so streaming channels (e.g. Feishu) # Push final content through stream so streaming channels (e.g. Feishu)
# update the card instead of leaving it empty. # update the card instead of leaving it empty.
if on_stream and on_stream_end: if on_stream and on_stream_end and should_stream:
await on_stream(result.final_content or "") await on_stream(result.final_content or "")
await on_stream_end(resuming=False) await on_stream_end(resuming=False)
elif result.stop_reason == "error": elif result.stop_reason == "error":
@@ -855,13 +856,15 @@ class AgentLoop:
continue continue
raw = msg.content.strip() raw = msg.content.strip()
effective_key = self._effective_session_key(msg)
if await agent_context.handle_runtime_control(self, msg, self.tools):
continue
if self.commands.is_priority(raw): if self.commands.is_priority(raw):
await self._dispatch_command_inline( await self._dispatch_command_inline(
msg, msg.session_key, raw, msg, effective_key, raw,
self.commands.dispatch_priority, self.commands.dispatch_priority,
) )
continue continue
effective_key = self._effective_session_key(msg)
# If this session already has an active pending queue (i.e. a task # If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn # is processing this session), route the message there for mid-turn
# injection instead of creating a competing task. # injection instead of creating a competing task.
@@ -912,13 +915,13 @@ class AgentLoop:
lock = self._session_locks.setdefault(session_key, asyncio.Lock()) lock = self._session_locks.setdefault(session_key, asyncio.Lock())
gate = self._concurrency_gate or nullcontext() gate = self._concurrency_gate or nullcontext()
# Register a pending queue so follow-up messages for this session are pending: asyncio.Queue | None = None
# routed here (mid-turn injection) instead of spawning a new task.
pending = asyncio.Queue(maxsize=20)
self._pending_queues[session_key] = pending
try: try:
async with lock, gate: async with lock, gate:
# Only the task that owns the session lock may publish the
# active mid-turn injection queue for this session.
pending = asyncio.Queue(maxsize=20)
self._pending_queues[session_key] = pending
try: try:
on_stream = on_stream_end = None on_stream = on_stream_end = None
if msg.metadata.get("_wants_stream"): if msg.metadata.get("_wants_stream"):
@@ -963,39 +966,14 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {}, content="", metadata=msg.metadata or {},
)) ))
if msg.channel == "websocket": continuing = turn_continuation.internal_continuation_pending(msg.metadata)
# Signal that the turn is fully complete (all tools executed, if msg.channel == "websocket" and not continuing:
# final text streamed). This lets WS clients know when to
# definitively stop the loading indicator.
turn_lat = self._pending_turn_latency_ms.pop(session_key, None) turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True} await self._webui_turns.handle_turn_end(
if turn_lat is not None: msg,
turn_metadata["latency_ms"] = int(turn_lat) session_key=session_key,
sess_turn = self.sessions.get_or_create(session_key) latency_ms=turn_lat,
turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata) )
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=turn_metadata,
))
if msg.metadata.get("webui") is True:
async def _generate_title_and_notify() -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=self.provider,
model=self.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={**msg.metadata, "_session_updated": True},
))
self._schedule_background(_generate_title_and_notify())
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key) logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so # Preserve partial context from the interrupted turn so
@@ -1028,27 +1006,40 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
content="Sorry, I encountered an error.", content="Sorry, I encountered an error.",
)) ))
finally:
# Drain any messages still in the pending queue and re-publish
# them to the bus so they are processed as fresh inbound messages
# rather than silently lost. Only remove our own queue; a
# later task waiting on the lock must not be able to steal
# cleanup ownership.
queue = None
if self._pending_queues.get(session_key) is pending:
queue = self._pending_queues.pop(session_key, None)
else:
queue = pending
if queue is not None:
leftover = 0
while True:
try:
item = queue.get_nowait()
except asyncio.QueueEmpty:
break
await self.bus.publish_inbound(item)
leftover += 1
if leftover:
logger.info(
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
if not turn_continuation.internal_continuation_pending(msg.metadata):
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
finally: finally:
# Drain any messages still in the pending queue and re-publish if pending is None:
# them to the bus so they are processed as fresh inbound messages await self._webui_turns.publish_run_status(msg, "idle")
# rather than silently lost. self._pending_turn_latency_ms.pop(session_key, None)
queue = self._pending_queues.pop(session_key, None) self._webui_turns.discard(session_key)
if queue is not None:
leftover = 0
while True:
try:
item = queue.get_nowait()
except asyncio.QueueEmpty:
break
await self.bus.publish_inbound(item)
leftover += 1
if leftover:
logger.info(
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
await publish_turn_run_status(self.bus, msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections.""" """Drain pending background archives, then close MCP connections."""
@@ -1117,6 +1108,7 @@ class AgentLoop:
} }
history = session.get_history(**_hist_kwargs) history = session.get_history(**_hist_kwargs)
current_role = "assistant" if is_subagent else "user" current_role = "assistant" if is_subagent else "user"
workspace_scope = self.workspace_scopes.for_message(msg, session.metadata)
messages = self.context.build_messages( messages = self.context.build_messages(
history=history, history=history,
@@ -1127,6 +1119,10 @@ class AgentLoop:
sender_id=msg.sender_id, sender_id=msg.sender_id,
session_summary=pending, session_summary=pending,
session_metadata=session.metadata, session_metadata=session.metadata,
workspace=workspace_scope.project_path,
runtime_state=self,
inbound_message=msg,
skip_runtime_lines=is_subagent,
) )
t_wall = time.time() t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop( final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@@ -1186,12 +1182,17 @@ class AgentLoop:
) )
key = session_key or msg.session_key key = session_key or msg.session_key
t0 = time.time()
ctx = TurnContext( ctx = TurnContext(
msg=msg, msg=msg,
session=None, session=None,
session_key=key, session_key=key,
state=TurnState.RESTORE, state=TurnState.RESTORE,
turn_id=f"{key}:{time.time_ns()}", turn_id=f"{key}:{time.time_ns()}",
turn_wall_started_at=t0,
visible_run_started_at=turn_continuation.internal_continuation_run_started_at(
msg.metadata,
),
on_progress=on_progress, on_progress=on_progress,
on_stream=on_stream, on_stream=on_stream,
on_stream_end=on_stream_end, on_stream_end=on_stream_end,
@@ -1259,7 +1260,6 @@ class AgentLoop:
all_msgs: list[dict[str, Any]], all_msgs: list[dict[str, Any]],
stop_reason: str, stop_reason: str,
had_injections: bool, had_injections: bool,
generated_media: list[str],
on_stream: Callable[[str], Awaitable[None]] | None, on_stream: Callable[[str], Awaitable[None]] | None,
*, *,
turn_latency_ms: int | None = None, turn_latency_ms: int | None = None,
@@ -1283,7 +1283,6 @@ class AgentLoop:
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=final_content, content=final_content,
media=generated_media,
metadata=meta, metadata=meta,
) )
@@ -1292,7 +1291,7 @@ class AgentLoop:
msg = ctx.msg msg = ctx.msg
if msg.media: if msg.media:
new_content, image_only = extract_documents(msg.content, msg.media) new_content, image_only = self._prepare_message_media(msg.content, msg.media)
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only) ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only)
msg = ctx.msg msg = ctx.msg
@@ -1304,6 +1303,7 @@ class AgentLoop:
if ctx.session is None: if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key) ctx.session = self.sessions.get_or_create(ctx.session_key)
mark_webui_session(ctx.session, msg.metadata) mark_webui_session(ctx.session, msg.metadata)
self.workspace_scopes.persist_message_scope(ctx.session, msg)
if self._restore_runtime_checkpoint(ctx.session): if self._restore_runtime_checkpoint(ctx.session):
self.sessions.save(ctx.session) self.sessions.save(ctx.session)
@@ -1312,6 +1312,16 @@ class AgentLoop:
return "ok" return "ok"
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
if self._should_extract_document_text():
return extract_documents(content, media)
return reference_non_image_attachments(content, media)
def _should_extract_document_text(self) -> bool:
if self.channels_config is None:
return True
return self.channels_config.extract_document_text
async def _state_compact(self, ctx: TurnContext) -> str: async def _state_compact(self, ctx: TurnContext) -> str:
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key) ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
ctx.pending_summary = pending ctx.pending_summary = pending
@@ -1364,9 +1374,17 @@ class AgentLoop:
"include_timestamps": True, "include_timestamps": True,
} }
ctx.history = ctx.session.get_history(**_hist_kwargs) ctx.history = ctx.session.get_history(**_hist_kwargs)
self._webui_turns.capture_title_context(
ctx.session_key,
ctx.msg,
self.llm_runtime(),
)
ctx.initial_messages = self._build_initial_messages( ctx.initial_messages = self._build_initial_messages(
ctx.msg, ctx.session, ctx.history, ctx.pending_summary ctx.msg,
ctx.session,
ctx.history,
ctx.pending_summary,
) )
ctx.user_persisted_early = self._persist_user_message_early( ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session ctx.msg, ctx.session
@@ -1380,7 +1398,13 @@ class AgentLoop:
return "ok" return "ok"
async def _state_run(self, ctx: TurnContext) -> str: async def _state_run(self, ctx: TurnContext) -> str:
await publish_turn_run_status(self.bus, ctx.msg, "running") if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time()
await self._webui_turns.publish_run_status(
ctx.msg,
"running",
started_at=ctx.visible_run_started_at,
)
result = await self._run_agent_loop( result = await self._run_agent_loop(
ctx.initial_messages, ctx.initial_messages,
on_progress=ctx.on_progress, on_progress=ctx.on_progress,
@@ -1401,20 +1425,25 @@ class AgentLoop:
ctx.all_messages = all_msgs ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason ctx.stop_reason = stop_reason
ctx.had_injections = had_injections ctx.had_injections = had_injections
await turn_continuation.maybe_continue_turn(ctx)
return "ok" return "ok"
async def _state_save(self, ctx: TurnContext) -> str: async def _state_save(self, ctx: TurnContext) -> str:
if ctx.final_content is None or not ctx.final_content.strip(): turn_continuation.prepare_save_boundary(ctx)
if (
(ctx.final_content is None or not ctx.final_content.strip())
and not ctx.suppress_response
):
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0) latency_started_at = (
skip_msgs = ctx.all_messages[ctx.save_skip:] ctx.visible_run_started_at
ctx.generated_media = generated_image_paths_from_messages(skip_msgs) if turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
mt = self.tools.get("message") and ctx.visible_run_started_at is not None
extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else [] else ctx.turn_wall_started_at
merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra) )
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
self._save_turn( self._save_turn(
ctx.session, ctx.all_messages, ctx.save_skip, ctx.session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms, turn_latency_ms=ctx.turn_latency_ms,
@@ -1434,13 +1463,15 @@ class AgentLoop:
return "ok" return "ok"
async def _state_respond(self, ctx: TurnContext) -> str: async def _state_respond(self, ctx: TurnContext) -> str:
if ctx.suppress_response:
ctx.outbound = None
return "ok"
ctx.outbound = self._assemble_outbound( ctx.outbound = self._assemble_outbound(
ctx.msg, ctx.msg,
ctx.final_content, ctx.final_content,
ctx.all_messages, ctx.all_messages,
ctx.stop_reason, ctx.stop_reason,
ctx.had_injections, ctx.had_injections,
ctx.generated_media,
ctx.on_stream, ctx.on_stream,
turn_latency_ms=ctx.turn_latency_ms, turn_latency_ms=ctx.turn_latency_ms,
) )
@@ -1675,10 +1706,19 @@ class AgentLoop:
channel=channel, sender_id="user", chat_id=chat_id, channel=channel, sender_id="user", chat_id=chat_id,
content=content, media=media or [], content=content, media=media or [],
) )
return await self._process_message( # Share the dispatch lock so direct calls serialize with bus turns.
msg, lock = self._session_locks.setdefault(session_key, asyncio.Lock())
session_key=session_key, try:
on_progress=on_progress, async with lock:
on_stream=on_stream, return await self._process_message(
on_stream_end=on_stream_end, msg,
) session_key=session_key,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
)
finally:
if channel == "websocket":
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
+75 -1
View File
@@ -678,11 +678,18 @@ class Consolidator:
The budget reserves space for completion tokens and a safety buffer The budget reserves space for completion tokens and a safety buffer
so the LLM request never exceeds the context window. so the LLM request never exceeds the context window.
""" """
if not session.messages or self.context_window_tokens <= 0: if self.context_window_tokens <= 0:
return return
lock = self.get_lock(session.key) lock = self.get_lock(session.key)
async with lock: async with lock:
# Refresh session reference: AutoCompact may have replaced it.
fresh = self.sessions.get_or_create(session.key)
if fresh is not session:
session = fresh
if not session.messages:
return
budget = self._input_token_budget budget = self._input_token_budget
target = int(budget * self.consolidation_ratio) target = int(budget * self.consolidation_ratio)
last_summary = await self._consolidate_replay_overflow( last_summary = await self._consolidate_replay_overflow(
@@ -769,6 +776,73 @@ class Consolidator:
# the summary injection strategy with AutoCompact._archive(). # the summary injection strategy with AutoCompact._archive().
self._persist_last_summary(session, last_summary) self._persist_last_summary(session, last_summary)
async def compact_idle_session(
self,
session_key: str,
max_suffix: int = 8,
) -> str | None:
"""Hard-truncate an idle session under the consolidation lock.
Used by AutoCompact so all session mutation goes through a single
lock-protected path. Returns the summary text on success, ``None``
if the LLM failed (raw_archive fallback), or ``""`` if there was
nothing to archive.
"""
lock = self.get_lock(session_key)
async with lock:
self.sessions.invalidate(session_key)
session = self.sessions.get_or_create(session_key)
tail = list(session.messages[session.last_consolidated:])
if not tail:
session.updated_at = datetime.now()
self.sessions.save(session)
return ""
probe = Session(
key=session.key,
messages=tail.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
kept = probe.messages
archive_msgs = dropped[already_consolidated:]
if not archive_msgs and not kept:
session.updated_at = datetime.now()
self.sessions.save(session)
return ""
last_active = session.updated_at
summary: str | None = ""
if archive_msgs:
summary = await self.archive(archive_msgs)
if summary and summary != "(nothing)":
session.metadata["_last_summary"] = {
"text": summary,
"last_active": last_active.isoformat(),
}
session.messages = kept
session.last_consolidated = 0
session.updated_at = datetime.now()
self.sessions.save(session)
if archive_msgs:
logger.info(
"Idle-session compact for {}: archived={}, kept={}, summary={}",
session_key,
len(archive_msgs),
len(kept),
bool(summary),
)
return summary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dream — heavyweight cron-scheduled memory consolidation # Dream — heavyweight cron-scheduled memory consolidation
+135 -13
View File
@@ -8,13 +8,23 @@ import os
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from loguru import logger from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_trackers,
)
from nanobot.utils.file_edit_events import (
prepare_file_edit_tracker as _prepare_file_edit_tracker,
)
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
IncrementalThinkExtractor, IncrementalThinkExtractor,
build_assistant_message, build_assistant_message,
@@ -26,10 +36,15 @@ from nanobot.utils.helpers import (
strip_think, strip_think,
truncate_text, truncate_text,
) )
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import ( from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE, EMPTY_FINAL_RESPONSE_MESSAGE,
build_finalization_retry_message, build_finalization_retry_message,
build_goal_continue_message,
build_length_recovery_message, build_length_recovery_message,
ensure_nonempty_tool_result, ensure_nonempty_tool_result,
is_blank_text, is_blank_text,
@@ -38,6 +53,10 @@ from nanobot.utils.runtime import (
) )
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
_ARREARAGE_ERROR_MESSAGE = (
"The AI provider rejected the request because the API key is out of quota or the "
"account is in arrears. Please top up / check the billing status of your API key and try again."
)
_PERSISTED_MODEL_ERROR_PLACEHOLDER = "[Assistant reply unavailable due to model error.]" _PERSISTED_MODEL_ERROR_PLACEHOLDER = "[Assistant reply unavailable due to model error.]"
_MAX_EMPTY_RETRIES = 2 _MAX_EMPTY_RETRIES = 2
_MAX_LENGTH_RECOVERIES = 3 _MAX_LENGTH_RECOVERIES = 3
@@ -47,11 +66,14 @@ _SNIP_SAFETY_BUFFER = 1024
_MICROCOMPACT_KEEP_RECENT = 10 _MICROCOMPACT_KEEP_RECENT = 10
_MICROCOMPACT_MIN_CHARS = 500 _MICROCOMPACT_MIN_CHARS = 500
_COMPACTABLE_TOOLS = frozenset({ _COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "web_search", "web_fetch", "list_dir", "list_exec_sessions",
}) })
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]" _BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
# Backward-compatible module attribute for tests/extensions that monkeypatch
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
prepare_file_edit_tracker = _prepare_file_edit_tracker
@dataclass(slots=True) @dataclass(slots=True)
@@ -82,6 +104,8 @@ class AgentRunSpec:
checkpoint_callback: Any | None = None checkpoint_callback: Any | None = None
injection_callback: Any | None = None injection_callback: Any | None = None
llm_timeout_s: float | None = None llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: str | None = None
@dataclass(slots=True) @dataclass(slots=True)
@@ -152,6 +176,7 @@ class AgentRunner:
*, *,
phase: str = "after error", phase: str = "after error",
iteration: int | None = None, iteration: int | None = None,
allow_goal_continue: bool = False,
) -> tuple[bool, int]: ) -> tuple[bool, int]:
"""Drain pending injections. Returns (should_continue, updated_cycles). """Drain pending injections. Returns (should_continue, updated_cycles).
@@ -160,12 +185,19 @@ class AgentRunner:
and *iteration* are both provided) and return (True, cycles+1) so the and *iteration* are both provided) and return (True, cycles+1) so the
caller continues the iteration loop. Otherwise return (False, cycles). caller continues the iteration loop. Otherwise return (False, cycles).
""" """
if injection_cycles >= _MAX_INJECTION_CYCLES: injections: list[dict[str, Any]] = []
return False, injection_cycles real_injection = False
injections = await self._drain_injections(spec) if injection_cycles < _MAX_INJECTION_CYCLES:
injections = await self._drain_injections(spec)
real_injection = bool(injections)
if not injections and allow_goal_continue and assistant_message is not None:
predicate = spec.goal_active_predicate
if predicate is not None and predicate():
injections = [build_goal_continue_message(spec.goal_continue_message)]
if not injections: if not injections:
return False, injection_cycles return False, injection_cycles
injection_cycles += 1 if real_injection:
injection_cycles += 1
if assistant_message is not None: if assistant_message is not None:
messages.append(assistant_message) messages.append(assistant_message)
if iteration is not None: if iteration is not None:
@@ -181,10 +213,13 @@ class AgentRunner:
}, },
) )
self._append_injected_messages(messages, injections) self._append_injected_messages(messages, injections)
logger.info( if real_injection:
"Injected {} follow-up message(s) {} ({}/{})", logger.info(
len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES, "Injected {} follow-up message(s) {} ({}/{})",
) len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
)
else:
logger.info("Injected sustained-goal continuation {}", phase)
return True, injection_cycles return True, injection_cycles
async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]: async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]:
@@ -460,6 +495,7 @@ class AgentRunner:
spec, messages, assistant_message, injection_cycles, spec, messages, assistant_message, injection_cycles,
phase="after final response", phase="after final response",
iteration=iteration, iteration=iteration,
allow_goal_continue=True,
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
@@ -472,7 +508,10 @@ class AgentRunner:
continue continue
if response.finish_reason == "error": if response.finish_reason == "error":
final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE if LLMProvider.is_arrearage_response(response):
final_content = _ARREARAGE_ERROR_MESSAGE
else:
final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE
stop_reason = "error" stop_reason = "error"
error = final_content error = final_content
self._append_model_error_placeholder(messages) self._append_model_error_placeholder(messages)
@@ -619,6 +658,24 @@ class AgentRunner:
) )
progress_state: dict[str, bool] | None = None progress_state: dict[str, bool] | None = None
live_file_edits: StreamingFileEditTracker | None = None
if (
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
):
async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None:
await invoke_file_edit_progress(spec.progress_callback, events)
live_file_edits = StreamingFileEditTracker(
workspace=spec.workspace,
tools=spec.tools,
emit=_emit_live_file_edits,
)
async def _tool_call_delta(delta: dict[str, Any]) -> None:
if live_file_edits is not None:
await live_file_edits.update(delta)
if wants_streaming: if wants_streaming:
async def _stream(delta: str) -> None: async def _stream(delta: str) -> None:
@@ -636,6 +693,7 @@ class AgentRunner:
**kwargs, **kwargs,
on_content_delta=_stream, on_content_delta=_stream,
on_thinking_delta=_thinking, on_thinking_delta=_thinking,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
) )
elif wants_progress_streaming: elif wants_progress_streaming:
stream_buf = "" stream_buf = ""
@@ -665,6 +723,7 @@ class AgentRunner:
coro = self.provider.chat_stream_with_retry( coro = self.provider.chat_stream_with_retry(
**kwargs, **kwargs,
on_content_delta=_stream_progress, on_content_delta=_stream_progress,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
) )
else: else:
coro = self.provider.chat_with_retry(**kwargs) coro = self.provider.chat_with_retry(**kwargs)
@@ -679,6 +738,14 @@ class AgentRunner:
await coro if outer_timeout_s is None await coro if outer_timeout_s is None
else await asyncio.wait_for(coro, timeout=outer_timeout_s) else await asyncio.wait_for(coro, timeout=outer_timeout_s)
) )
if live_file_edits is not None:
await live_file_edits.flush()
if response.should_execute_tools:
live_file_edits.apply_final_call_ids(response.tool_calls)
await live_file_edits.error_unmatched(
response.tool_calls if response.should_execute_tools else [],
"Tool call did not complete.",
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
if outer_timeout_s is None: if outer_timeout_s is None:
return LLMResponse( return LLMResponse(
@@ -813,6 +880,30 @@ class AgentRunner:
return prep_error + hint, event, ( return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None RuntimeError(prep_error) if spec.fail_on_tool_error else None
) )
emit_file_edit_events = (
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
)
progress_callback = spec.progress_callback if emit_file_edit_events else None
file_edit_trackers = (
prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=spec.workspace,
params=params if isinstance(params, dict) else None,
)
if progress_callback is not None
else None
)
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_start_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)
try: try:
if tool is not None: if tool is not None:
result = await tool.execute(**params) result = await tool.execute(**params)
@@ -821,6 +912,14 @@ class AgentRunner:
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except BaseException as exc: except BaseException as exc:
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, str(exc))
for file_edit_tracker in file_edit_trackers
],
)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
"status": "error", "status": "error",
@@ -842,6 +941,14 @@ class AgentRunner:
return payload, event, None return payload, event, None
if isinstance(result, str) and result.startswith("Error"): if isinstance(result, str) and result.startswith("Error"):
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, result)
for file_edit_tracker in file_edit_trackers
],
)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
"status": "error", "status": "error",
@@ -860,6 +967,15 @@ class AgentRunner:
return result + hint, event, RuntimeError(result) return result + hint, event, RuntimeError(result)
return result + hint, event, None return result + hint, event, None
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_end_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)
detail = "" if result is None else str(result) detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip() detail = detail.replace("\n", " ").strip()
if not detail: if not detail:
@@ -1164,7 +1280,13 @@ class AgentRunner:
return messages return messages
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages) system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
remaining_budget = max(128, budget - system_tokens) fixed_tokens, _ = estimate_prompt_tokens_chain(
self.provider,
spec.model,
system_messages,
spec.tools.get_definitions(),
)
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
kept: list[dict[str, Any]] = [] kept: list[dict[str, Any]] = []
kept_tokens = 0 kept_tokens = 0
for message in reversed(non_system): for message in reversed(non_system):
+62 -21
View File
@@ -16,6 +16,12 @@ from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.security.workspace_access import (
WorkspaceScope,
bind_workspace_scope,
reset_workspace_scope,
workspace_sandbox_status,
)
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig from nanobot.config.schema import AgentDefaults, ToolsConfig
@@ -79,6 +85,7 @@ class SubagentManager:
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
disabled_skills: list[str] | None = None, disabled_skills: list[str] | None = None,
max_iterations: int | None = None, max_iterations: int | None = None,
max_concurrent_subagents: int | None = None,
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None, llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
): ):
defaults = AgentDefaults() defaults = AgentDefaults()
@@ -95,7 +102,11 @@ class SubagentManager:
if max_iterations is not None if max_iterations is not None
else defaults.max_tool_iterations else defaults.max_tool_iterations
) )
self.max_concurrent_subagents = defaults.max_concurrent_subagents self.max_concurrent_subagents = (
max_concurrent_subagents
if max_concurrent_subagents is not None
else defaults.max_concurrent_subagents
)
self.runner = AgentRunner(provider) self.runner = AgentRunner(provider)
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[None]] = {} self._running_tasks: dict[str, asyncio.Task[None]] = {}
@@ -123,6 +134,10 @@ class SubagentManager:
config=cfg, config=cfg,
workspace=str(root.resolve()), workspace=str(root.resolve()),
file_state_store=FileStates(), file_state_store=FileStates(),
workspace_sandbox=workspace_sandbox_status(
restrict_to_workspace=cfg.restrict_to_workspace,
workspace=root,
),
) )
ToolLoader().load(ctx, registry, scope="subagent") ToolLoader().load(ctx, registry, scope="subagent")
return registry return registry
@@ -140,6 +155,8 @@ class SubagentManager:
origin_chat_id: str = "direct", origin_chat_id: str = "direct",
session_key: str | None = None, session_key: str | None = None,
origin_message_id: str | None = None, origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> str: ) -> str:
"""Spawn a subagent to execute a task in the background.""" """Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
@@ -155,7 +172,16 @@ class SubagentManager:
self._task_statuses[task_id] = status self._task_statuses[task_id] = status
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin, status, origin_message_id) self._run_subagent(
task_id,
task,
display_label,
origin,
status,
origin_message_id,
temperature,
workspace_scope,
)
) )
self._running_tasks[task_id] = bg_task self._running_tasks[task_id] = bg_task
if session_key: if session_key:
@@ -182,6 +208,8 @@ class SubagentManager:
origin: dict[str, str], origin: dict[str, str],
status: SubagentStatus, status: SubagentStatus,
origin_message_id: str | None = None, origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> None: ) -> None:
"""Execute the subagent task and announce the result.""" """Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label) logger.info("Subagent [{}] starting task: {}", task_id, label)
@@ -191,8 +219,13 @@ class SubagentManager:
status.iteration = payload.get("iteration", status.iteration) status.iteration = payload.get("iteration", status.iteration)
try: try:
tools = self._build_tools() root = workspace_scope.project_path if workspace_scope is not None else self.workspace
system_prompt = self._build_subagent_prompt() 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]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": task}, {"role": "user", "content": task},
@@ -204,20 +237,27 @@ class SubagentManager:
if self._llm_wall_timeout_for_session if self._llm_wall_timeout_for_session
else None else None
) )
result = await self.runner.run(AgentRunSpec( token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
initial_messages=messages, try:
tools=tools, result = await self.runner.run(AgentRunSpec(
model=self.model, initial_messages=messages,
max_iterations=self.max_iterations, tools=tools,
max_tool_result_chars=self.max_tool_result_chars, model=self.model,
hook=_SubagentHook(task_id, status), temperature=temperature,
max_iterations_message="Task completed but no final response was generated.", max_iterations=self.max_iterations,
error_message=None, max_tool_result_chars=self.max_tool_result_chars,
fail_on_tool_error=True, hook=_SubagentHook(task_id, status),
checkpoint_callback=_on_checkpoint, max_iterations_message="Task completed but no final response was generated.",
session_key=sess_key, error_message=None,
llm_timeout_s=llm_timeout, fail_on_tool_error=True,
)) 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.phase = "done"
status.stop_reason = result.stop_reason status.stop_reason = result.stop_reason
@@ -311,20 +351,21 @@ class SubagentManager:
lines.append(f"- {result.error}") lines.append(f"- {result.error}")
return "\n".join(lines) or (result.error or "Error: subagent execution failed.") return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
def _build_subagent_prompt(self) -> str: def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
"""Build a focused system prompt for the subagent.""" """Build a focused system prompt for the subagent."""
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
time_ctx = ContextBuilder._build_runtime_context(None, None) time_ctx = ContextBuilder._build_runtime_context(None, None)
root = workspace or self.workspace
skills_summary = SkillsLoader( skills_summary = SkillsLoader(
self.workspace, root,
disabled_skills=self.disabled_skills, disabled_skills=self.disabled_skills,
).build_skills_summary() ).build_skills_summary()
return render_template( return render_template(
"agent/subagent_system.md", "agent/subagent_system.md",
time_ctx=time_ctx, time_ctx=time_ctx,
workspace=str(self.workspace), workspace=str(root),
skills_summary=skills_summary or "", skills_summary=skills_summary or "",
) )
+290
View File
@@ -0,0 +1,290 @@
"""Apply file edits by providing structured edit instructions."""
from __future__ import annotations
import difflib
import re
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
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
def _validate_relative_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}")
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
raise _PatchError(f"patch path must be relative: {path}")
if any(part == ".." for part in re.split(r"[\\/]+", normalized)):
raise _PatchError(f"patch path must not contain '..': {path}")
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 _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("Relative path to the file to edit."),
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 must be relative. 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_relative_path(raw_path)
action = edit.get("action")
if not isinstance(action, str):
raise _PatchError(f"action required for edit: {path}")
source = self._resolve(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 = content.replace("\r\n", "\n") + new_text.replace("\r\n", "\n")
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)
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}"
+133
View File
@@ -0,0 +1,133 @@
"""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.security.workspace_access import current_tool_workspace
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base
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}"
+24
View File
@@ -1,9 +1,15 @@
"""Runtime context for tool construction.""" """Runtime context for tool construction."""
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, runtime_checkable from typing import Any, Callable, Protocol, runtime_checkable
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
"nanobot_tool_request_context",
default=None,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class RequestContext: class RequestContext:
@@ -21,6 +27,23 @@ class ContextAware(Protocol):
... ...
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 @dataclass
class ToolContext: class ToolContext:
config: Any config: Any
@@ -33,3 +56,4 @@ class ToolContext:
provider_snapshot_loader: Callable[[], Any] | None = None provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None image_generation_provider_configs: dict[str, Any] | None = None
timezone: str = "UTC" timezone: str = "UTC"
workspace_sandbox: Any | None = None
+598
View File
@@ -0,0 +1,598 @@
"""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
@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,
)
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)
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}"
+129 -25
View File
@@ -10,6 +10,7 @@ from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
IntegerSchema, IntegerSchema,
@@ -28,10 +29,18 @@ class _FsTool(Tool):
allowed_dir: Path | None = None, allowed_dir: Path | None = None,
extra_allowed_dirs: list[Path] | None = None, extra_allowed_dirs: list[Path] | None = None,
file_states: FileStates | None = None, file_states: FileStates | None = None,
restrict_to_workspace: bool | None = None,
sandbox_restricts_workspace: bool = False,
): ):
self._workspace = workspace self._workspace = workspace
self._allowed_dir = allowed_dir self._allowed_dir = allowed_dir
self._extra_allowed_dirs = extra_allowed_dirs self._extra_allowed_dirs = extra_allowed_dirs
self._restrict_to_workspace = (
bool(restrict_to_workspace)
if restrict_to_workspace is not None
else allowed_dir is not None
)
self._sandbox_restricts_workspace = sandbox_restricts_workspace
# Explicit state is used by isolated runners like Dream/subagents. # Explicit state is used by isolated runners like Dream/subagents.
# Main AgentLoop tools leave this unset and resolve state from the # Main AgentLoop tools leave this unset and resolve state from the
# current async task, which keeps shared tool instances session-safe. # current async task, which keeps shared tool instances session-safe.
@@ -46,13 +55,16 @@ class _FsTool(Tool):
ctx.config.restrict_to_workspace ctx.config.restrict_to_workspace
or ctx.config.exec.sandbox or ctx.config.exec.sandbox
) )
sandbox_restricts = bool(ctx.config.exec.sandbox)
allowed_dir = Path(ctx.workspace) if restrict else None allowed_dir = Path(ctx.workspace) if restrict else None
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None extra_read = [BUILTIN_SKILLS_DIR]
return cls( return cls(
workspace=Path(ctx.workspace), workspace=Path(ctx.workspace),
allowed_dir=allowed_dir, allowed_dir=allowed_dir,
extra_allowed_dirs=extra_read, extra_allowed_dirs=extra_read,
file_states=ctx.file_state_store, file_states=ctx.file_state_store,
restrict_to_workspace=ctx.config.restrict_to_workspace,
sandbox_restricts_workspace=sandbox_restricts,
) )
@property @property
@@ -62,13 +74,21 @@ class _FsTool(Tool):
return current_file_states(self._fallback_file_states) return current_file_states(self._fallback_file_states)
def _resolve(self, path: str) -> Path: def _resolve(self, path: str) -> Path:
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
)
return resolve_workspace_path( return resolve_workspace_path(
path, path,
self._workspace, access.project_path,
self._allowed_dir, access.allowed_root,
self._extra_allowed_dirs, self._extra_allowed_dirs,
) )
def _display_workspace(self) -> Path | None:
return current_tool_workspace(self._workspace).project_path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# read_file # read_file
@@ -132,6 +152,10 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
minimum=1, minimum=1,
), ),
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"), pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
force=BooleanSchema(
description="Bypass same-file read deduplication and return content again.",
default=False,
),
required=["path"], required=["path"],
) )
) )
@@ -154,7 +178,11 @@ class ReadFileTool(_FsTool):
"Text output format: LINE_NUM|CONTENT. " "Text output format: LINE_NUM|CONTENT. "
"Images return visual content for analysis. " "Images return visual content for analysis. "
"Supports PDF, DOCX, XLSX, PPTX documents. " "Supports PDF, DOCX, XLSX, PPTX documents. "
"Use find_files/list_dir first when the path is uncertain. "
"Read the relevant range before editing so replacements or patches "
"are based on current content. "
"Use offset and limit for large text files. " "Use offset and limit for large text files. "
"Use force=true to re-read content even if unchanged. "
"Reads exceeding ~128K chars are truncated." "Reads exceeding ~128K chars are truncated."
) )
@@ -162,7 +190,15 @@ class ReadFileTool(_FsTool):
def read_only(self) -> bool: def read_only(self) -> bool:
return True return True
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any: async def execute(
self,
path: str | None = None,
offset: int = 1,
limit: int | None = None,
pages: str | None = None,
force: bool = False,
**kwargs: Any,
) -> Any:
try: try:
if not path: if not path:
return "Error reading file: Unknown path" return "Error reading file: Unknown path"
@@ -202,7 +238,13 @@ class ReadFileTool(_FsTool):
current_mtime = os.path.getmtime(fp) current_mtime = os.path.getmtime(fp)
except OSError: except OSError:
current_mtime = 0.0 current_mtime = 0.0
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit: if (
not force
and entry
and entry.can_dedup
and entry.offset == offset
and entry.limit == limit
):
if current_mtime != entry.mtime: if current_mtime != entry.mtime:
# File was modified externally - force full read and mark as not dedupable # File was modified externally - force full read and mark as not dedupable
entry.can_dedup = False entry.can_dedup = False
@@ -365,9 +407,10 @@ class WriteFileTool(_FsTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
"Write content to a file. Overwrites if the file already exists; " "Create a new file or intentionally replace an entire file with "
"creates parent directories as needed. " "the provided content. Overwrites existing files and creates parent "
"For partial edits, prefer edit_file instead." "directories as needed. For code changes or partial edits, prefer "
"apply_patch; use edit_file only for small exact replacements."
) )
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str: async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
@@ -657,6 +700,24 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
old_text=StringSchema("The text to find and replace"), old_text=StringSchema("The text to find and replace"),
new_text=StringSchema("The text to replace with"), new_text=StringSchema("The text to replace with"),
replace_all=BooleanSchema(description="Replace all occurrences (default false)"), replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
occurrence=IntegerSchema(
1,
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
minimum=1,
nullable=True,
),
line_hint=IntegerSchema(
1,
description="Optional 1-based line hint used to choose the nearest match.",
minimum=1,
nullable=True,
),
expected_replacements=IntegerSchema(
1,
description="Optional guard for the number of replacements that must be made.",
minimum=1,
nullable=True,
),
required=["path", "old_text", "new_text"], required=["path", "old_text", "new_text"],
) )
) )
@@ -674,10 +735,13 @@ class EditFileTool(_FsTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
"Edit a file by replacing old_text with new_text. " "Perform a small, exact replacement in one file by replacing "
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. " "old_text with new_text. Use this for narrow text substitutions "
"If old_text matches multiple times, you must provide more context " "with old_text copied from read_file. For multi-file, structural, "
"or set replace_all=true. Shows a diff of the closest match on failure." "or generated code edits, prefer apply_patch. If old_text matches "
"multiple times, provide more context or set occurrence, line_hint, "
"replace_all, and expected_replacements. Shows closest-match "
"diagnostics on failure."
) )
@staticmethod @staticmethod
@@ -688,7 +752,8 @@ class EditFileTool(_FsTool):
async def execute( async def execute(
self, path: str | None = None, old_text: str | None = None, self, path: str | None = None, old_text: str | None = None,
new_text: str | None = None, new_text: str | None = None,
replace_all: bool = False, **kwargs: Any, replace_all: bool = False, occurrence: int | None = None,
line_hint: int | None = None, expected_replacements: int | None = None, **kwargs: Any,
) -> str: ) -> str:
try: try:
if not path: if not path:
@@ -697,10 +762,12 @@ class EditFileTool(_FsTool):
raise ValueError("Unknown old_text") raise ValueError("Unknown old_text")
if new_text is None: if new_text is None:
raise ValueError("Unknown new_text") raise ValueError("Unknown new_text")
if occurrence is not None and occurrence < 1:
# .ipynb detection return "Error: occurrence must be >= 1."
if path.endswith(".ipynb"): if line_hint is not None and line_hint < 1:
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file." return "Error: line_hint must be >= 1."
if expected_replacements is not None and expected_replacements < 1:
return "Error: expected_replacements must be >= 1."
fp = self._resolve(path) fp = self._resolve(path)
@@ -743,15 +810,42 @@ class EditFileTool(_FsTool):
if not matches: if not matches:
return self._not_found_msg(old_text, content, path) return self._not_found_msg(old_text, content, path)
count = len(matches) count = len(matches)
if replace_all and occurrence is not None:
return "Error: occurrence cannot be used with replace_all=true."
if replace_all and line_hint is not None:
return "Error: line_hint cannot be used with replace_all=true."
if occurrence is not None and line_hint is not None:
return "Error: line_hint cannot be used with occurrence."
if count > 1 and not replace_all: if count > 1 and not replace_all:
line_numbers = [match.line for match in matches] if occurrence is not None:
preview = ", ".join(f"line {n}" for n in line_numbers[:3]) if occurrence > count:
if len(line_numbers) > 3: return (
preview += ", ..." f"Error: occurrence {occurrence} is out of range; "
location_hint = f" at {preview}" if preview else "" f"old_text appears {count} times."
)
elif line_hint is not None:
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
distance = abs(nearest.line - line_hint)
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
return (
f"Error: line_hint {line_hint} is ambiguous; "
f"old_text appears {count} times."
)
else:
line_numbers = [match.line for match in matches]
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
if len(line_numbers) > 3:
preview += ", ..."
location_hint = f" at {preview}" if preview else ""
return (
f"Warning: old_text appears {count} times{location_hint}. "
"Provide more context, set occurrence to choose one match, "
"or set replace_all=true."
)
elif occurrence is not None and occurrence > count:
return ( return (
f"Warning: old_text appears {count} times{location_hint}. " f"Error: occurrence {occurrence} is out of range; "
"Provide more context to make it unique, or set replace_all=true." f"old_text appears {count} time."
) )
norm_new = new_text.replace("\r\n", "\n") norm_new = new_text.replace("\r\n", "\n")
@@ -760,7 +854,17 @@ class EditFileTool(_FsTool):
if fp.suffix.lower() not in self._MARKDOWN_EXTS: if fp.suffix.lower() not in self._MARKDOWN_EXTS:
norm_new = self._strip_trailing_ws(norm_new) norm_new = self._strip_trailing_ws(norm_new)
selected = matches if replace_all else matches[:1] if replace_all:
selected = matches
elif line_hint is not None:
selected = [min(matches, key=lambda match: abs(match.line - line_hint))]
else:
selected = [matches[occurrence - 1 if occurrence else 0]]
if expected_replacements is not None and len(selected) != expected_replacements:
return (
f"Error: expected {expected_replacements} replacements but "
f"would make {len(selected)}."
)
new_content = content new_content = content
for match in reversed(selected): for match in reversed(selected):
replacement = _preserve_quote_style(norm_old, match.text, norm_new) replacement = _preserve_quote_style(norm_old, match.text, norm_new)
+22 -36
View File
@@ -14,13 +14,15 @@ from nanobot.agent.tools.schema import (
StringSchema, StringSchema,
tool_parameters_schema, tool_parameters_schema,
) )
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.providers.image_generation import ( from nanobot.providers.image_generation import (
AIHubMixImageGenerationClient,
ImageGenerationError, ImageGenerationError,
OpenRouterImageGenerationClient, ImageGenerationProvider,
get_image_gen_provider,
) )
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
from nanobot.utils.artifacts import ( from nanobot.utils.artifacts import (
ArtifactError, ArtifactError,
generated_image_tool_result, generated_image_tool_result,
@@ -117,41 +119,36 @@ class ImageGenerationTool(Tool):
def _provider_config(self) -> ProviderConfig | None: def _provider_config(self) -> ProviderConfig | None:
return self.provider_configs.get(self.config.provider) return self.provider_configs.get(self.config.provider)
def _provider_client(self) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | None: def _provider_client(self) -> ImageGenerationProvider | None:
provider = self._provider_config() provider = self._provider_config()
cls = get_image_gen_provider(self.config.provider)
if cls is None:
return None
kwargs = { kwargs = {
"api_key": provider.api_key if provider else None, "api_key": provider.api_key if provider else None,
"api_base": provider.api_base if provider else None, "api_base": provider.api_base if provider else None,
"extra_headers": provider.extra_headers if provider else None, "extra_headers": provider.extra_headers if provider else None,
"extra_body": provider.extra_body if provider else None, "extra_body": provider.extra_body if provider else None,
} }
if self.config.provider == "openrouter": return cls(**kwargs)
return OpenRouterImageGenerationClient(**kwargs)
if self.config.provider == "aihubmix":
return AIHubMixImageGenerationClient(**kwargs)
return None
def _missing_api_key_error(self) -> str:
provider = self.config.provider
if provider == "openrouter":
return "Error: OpenRouter API key is not configured. Set providers.openrouter.apiKey."
if provider == "aihubmix":
return "Error: AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
return f"Error: {provider} API key is not configured."
def _resolve_reference_image(self, value: str) -> str: def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser() access = current_tool_workspace(self.workspace, restrict_to_workspace=True)
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path workspace = access.project_path or self.workspace
try: try:
resolved = path.resolve(strict=True) resolved = resolve_allowed_path(
except OSError as exc: value,
raise ImageGenerationError(f"reference image not found: {value}") from exc workspace=workspace,
allowed_root=access.allowed_root,
allowed_roots = [self.workspace.resolve(), get_media_dir().resolve()] extra_allowed_roots=[get_media_dir()] if access.allowed_root is not None else None,
if not any(_is_relative_to(resolved, root) for root in allowed_roots): strict=True,
)
except WorkspaceBoundaryError as exc:
raise ImageGenerationError( raise ImageGenerationError(
"reference_images must be inside the workspace or nanobot media directory" "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(): if not resolved.is_file():
raise ImageGenerationError(f"reference image is not a file: {value}") raise ImageGenerationError(f"reference image is not a file: {value}")
raw = resolved.read_bytes() raw = resolved.read_bytes()
@@ -176,9 +173,6 @@ class ImageGenerationTool(Tool):
client = self._provider_client() client = self._provider_client()
if client is None: if client is None:
return f"Error: unsupported image generation provider '{self.config.provider}'" return f"Error: unsupported image generation provider '{self.config.provider}'"
provider = self._provider_config()
if not provider or not provider.api_key:
return self._missing_api_key_error()
requested = count or 1 requested = count or 1
if requested > self.config.max_images_per_turn: if requested > self.config.max_images_per_turn:
@@ -213,11 +207,3 @@ class ImageGenerationTool(Tool):
return generated_image_tool_result(artifacts) return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc: except (ArtifactError, ImageGenerationError, OSError) as exc:
return f"Error: {exc}" return f"Error: {exc}"
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
+13 -6
View File
@@ -16,6 +16,7 @@ There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui``
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -45,15 +46,22 @@ class _GoalToolsMixin(ContextAware):
def __init__(self, sessions: SessionManager, bus: Any | None = None) -> None: def __init__(self, sessions: SessionManager, bus: Any | None = None) -> None:
self._sessions = sessions self._sessions = sessions
self._bus = bus self._bus = bus
self._request_ctx: RequestContext | None = None # 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: def set_context(self, ctx: RequestContext) -> None:
self._request_ctx = ctx self._request_ctx.set(ctx)
def _session(self): def _session(self):
if self._request_ctx is None: request_ctx = self._request_ctx.get()
if request_ctx is None:
return None return None
key = self._request_ctx.session_key key = request_ctx.session_key
if not key: if not key:
return None return None
return self._sessions.get_or_create(key) return self._sessions.get_or_create(key)
@@ -61,7 +69,7 @@ class _GoalToolsMixin(ContextAware):
async def _publish_goal_state_ws(self, metadata: dict[str, Any]) -> None: async def _publish_goal_state_ws(self, metadata: dict[str, Any]) -> None:
"""Fan-out authoritative goal snapshot for this WebSocket chat only.""" """Fan-out authoritative goal snapshot for this WebSocket chat only."""
bus = self._bus bus = self._bus
rc = self._request_ctx rc = self._request_ctx.get()
if bus is None or rc is None or rc.channel != "websocket": if bus is None or rc is None or rc.channel != "websocket":
return return
cid = (rc.chat_id or "").strip() cid = (rc.chat_id or "").strip()
@@ -224,4 +232,3 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
if tail: if tail:
return f"Goal marked complete ({ended}). Recap:\n{tail}" return f"Goal marked complete ({ended}). Recap:\n{tail}"
return f"Goal marked complete ({ended})." return f"Goal marked complete ({ended})."
+279 -1
View File
@@ -6,13 +6,20 @@ import re
import shutil import shutil
import urllib.parse import urllib.parse
from contextlib import AsyncExitStack, suppress from contextlib import AsyncExitStack, suppress
from typing import Any from typing import Any, Mapping
from weakref import WeakKeyDictionary
import httpx import httpx
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_ACK,
RUNTIME_CONTROL_MCP_RELOAD,
InboundMessage,
)
# Transient connection errors that warrant a single retry. # Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network # These typically happen when an MCP server restarts or a network
@@ -33,6 +40,7 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.). # Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs. # Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
_SANITIZE_RE = re.compile(r"_+") _SANITIZE_RE = re.compile(r"_+")
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
def _sanitize_name(name: str) -> str: def _sanitize_name(name: str) -> str:
@@ -503,6 +511,7 @@ async def connect_mcp_servers(
command=command, command=command,
args=args, args=args,
env=env, env=env,
cwd=cfg.cwd or None,
) )
read, write = await server_stack.enter_async_context(stdio_client(params)) read, write = await server_stack.enter_async_context(stdio_client(params))
elif transport_type == "sse": elif transport_type == "sse":
@@ -662,3 +671,272 @@ async def connect_mcp_servers(
server_stacks[result[0]] = result[1] server_stacks[result[0]] = result[1]
return server_stacks return server_stacks
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted session kwargs for MCP preset attachments."""
mcp_presets = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
def runtime_lines(
message: Any,
*,
available_server_names: set[str] | None = None,
configured_server_names: set[str] | None = None,
connected_server_names: set[str] | None = None,
skip: bool = False,
) -> list[str]:
"""Return model-visible MCP preset annotations for the current turn."""
if skip:
return []
if configured_server_names is None:
configured_server_names = available_server_names
if connected_server_names is None:
connected_server_names = available_server_names
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
structured = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
if not isinstance(structured, list):
return []
lines: list[str] = []
for item in structured[:8]:
if not isinstance(item, Mapping):
continue
raw_name = str(item.get("name") or "").strip().lower()
if not raw_name:
continue
display = str(item.get("display_name") or raw_name).strip() or raw_name
transport = str(item.get("transport") or "mcp").strip() or "mcp"
prefix = f"mcp_{raw_name}_"
if configured_server_names is not None and raw_name not in configured_server_names:
lines.append(
"MCP Preset Attachment: "
f"@{raw_name} ({display}; transport={transport}) is configured in WebUI Settings, "
"but this gateway has not loaded the latest MCP settings yet. "
f"Tools with prefix `{prefix}` may not be available yet; if they are missing, "
"tell the user to restart nanobot."
)
continue
if connected_server_names is not None and raw_name not in connected_server_names:
lines.append(
"MCP Preset Attachment: "
f"@{raw_name} ({display}; transport={transport}) is configured, "
"but its MCP connection is not currently live. "
f"Tools with prefix `{prefix}` may be unavailable; tell the user to open Settings, "
"run the preset test, and restart nanobot only if hot reload is unavailable."
)
continue
lines.append(
"MCP Preset Attachment: "
f"@{raw_name} ({display}; transport={transport}; tool_prefix={prefix}). "
f"Prefer available tools whose names start with `{prefix}` for this request; "
"do not substitute shell commands for this MCP integration unless the user asks."
)
return lines
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
"""Connect configured MCP servers that are not currently live."""
missing_servers = {
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
}
if state._mcp_connecting or not missing_servers:
return
state._mcp_connecting = True
try:
connected = await connect_mcp_servers(missing_servers, registry)
state._mcp_stacks.update(connected)
state._mcp_connected = bool(state._mcp_stacks)
if connected:
logger.info("MCP connected servers: {}", sorted(connected))
else:
logger.warning("No MCP servers connected successfully (will retry next message)")
except asyncio.CancelledError:
logger.warning("MCP connection cancelled (will retry next message)")
state._mcp_connected = bool(state._mcp_stacks)
except BaseException as e:
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
state._mcp_connected = bool(state._mcp_stacks)
finally:
state._mcp_connecting = False
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"""Reconcile live MCP connections with the current config file."""
async with _reload_lock(state):
try:
from nanobot.config.loader import (load_config,
resolve_config_env_vars)
config = resolve_config_env_vars(load_config())
next_servers = dict(config.tools.mcp_servers)
except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc)
return {
"ok": False,
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
current_servers = dict(state._mcp_servers)
current_names = set(current_servers)
next_names = set(next_servers)
removed = sorted(current_names - next_names)
added = sorted(next_names - current_names)
changed = sorted(
name
for name in current_names & next_names
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
)
tools_removed = 0
for name in [*removed, *changed]:
tools_removed += _unregister_server_tools(state, registry, name)
await _close_server(state, name)
state._mcp_servers = next_servers
retry_missing = sorted(
name
for name in next_names
if name not in state._mcp_stacks and name not in set(added) | set(changed)
)
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, AsyncExitStack] = {}
if to_connect:
connected = await connect_mcp_servers(to_connect, registry)
state._mcp_stacks.update(connected)
state._mcp_connected = bool(state._mcp_stacks)
failed = sorted(set(to_connect) - set(connected))
unchanged = not removed and not added and not changed and not retry_missing
ok = not failed
if failed:
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
elif unchanged:
message = "MCP config is already live."
elif retry_missing and not added and not changed and not removed:
message = "MCP connections refreshed without restarting nanobot."
else:
message = "MCP config reloaded without restarting nanobot."
logger.info(
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
added,
changed,
removed,
retry_missing,
sorted(connected),
failed,
tools_removed,
)
return {
"ok": ok,
"message": message,
"added": added,
"changed": changed,
"removed": removed,
"retried": retry_missing,
"connected": sorted(state._mcp_stacks),
"configured": sorted(state._mcp_servers),
"failed": failed,
"tools_removed": tools_removed,
"requires_restart": False,
}
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
"""Ask the running agent loop to reconcile live MCP connections."""
loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
await bus.publish_inbound(
InboundMessage(
channel="system",
sender_id="webui-settings",
chat_id="runtime",
content=RUNTIME_CONTROL_MCP_RELOAD,
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
RUNTIME_CONTROL_ACK: ack,
},
)
)
try:
result = await asyncio.wait_for(ack, timeout=timeout)
except asyncio.TimeoutError:
return {
"ok": False,
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
return result if isinstance(result, dict) else {
"ok": False,
"message": "MCP hot reload returned an unexpected response.",
"requires_restart": True,
}
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
if control != RUNTIME_CONTROL_MCP_RELOAD:
return False
ack = metadata.get(RUNTIME_CONTROL_ACK)
try:
result = await reload_servers(state, registry)
except Exception as exc:
logger.exception("MCP hot reload failed")
result = {
"ok": False,
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
ack.set_result(result)
return True
def _reload_lock(state: Any) -> asyncio.Lock:
try:
return _RELOAD_LOCKS[state]
except KeyError:
lock = asyncio.Lock()
_RELOAD_LOCKS[state] = lock
return lock
def _server_signature(cfg: Any) -> Any:
if hasattr(cfg, "model_dump"):
return cfg.model_dump(mode="json")
return cfg
def _tool_prefix(server_name: str) -> str:
safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in server_name)
while "__" in safe_name:
safe_name = safe_name.replace("__", "_")
return f"mcp_{safe_name}_"
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
prefix = _tool_prefix(server_name)
removed = 0
for tool_name in list(registry.tool_names):
if tool_name.startswith(prefix):
registry.unregister(tool_name)
removed += 1
return removed
async def _close_server(state: Any, server_name: str) -> None:
stack = state._mcp_stacks.pop(server_name, None)
if stack is None:
return
try:
await stack.aclose()
except (RuntimeError, BaseExceptionGroup):
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
+31 -8
View File
@@ -4,10 +4,13 @@ from contextvars import ContextVar
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path from nanobot.config.paths import get_workspace_path
@@ -31,8 +34,8 @@ from nanobot.config.paths import get_workspace_path
media=ArraySchema( media=ArraySchema(
StringSchema(""), StringSchema(""),
description=( description=(
"Optional list of existing file paths to attach for proactive or cross-channel delivery. " "Optional list of existing file paths to attach. "
"Do not use this to resend generate_image outputs in the current chat." "Use artifact paths returned by generate_image here when delivering generated images."
), ),
), ),
buttons=ArraySchema( buttons=ArraySchema(
@@ -82,6 +85,10 @@ class MessageTool(Tool, ContextAware):
"message_record_channel_delivery", "message_record_channel_delivery",
default=False, default=False,
) )
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
"message_suppress_delivery",
default=False,
)
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: Any) -> Tool:
@@ -120,6 +127,14 @@ class MessageTool(Tool, ContextAware):
"""Restore previous proactive delivery recording state.""" """Restore previous proactive delivery recording state."""
self._record_channel_delivery_var.reset(token) 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 @property
def _sent_in_turn(self) -> bool: def _sent_in_turn(self) -> bool:
return self._sent_in_turn_var.get() return self._sent_in_turn_var.get()
@@ -140,8 +155,8 @@ class MessageTool(Tool, ContextAware):
"Do not use this for the normal reply in the current chat: answer naturally instead. " "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 " "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. " "unless the user explicitly asked you to proactively send an existing file attachment. "
"When generate_image creates images in the current chat, the final assistant reply " "When generate_image creates images in the current chat, use the message tool "
"automatically attaches them; do not call message just to announce or resend them. " "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. " "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." "Do NOT use read_file to send files — that only reads content for your own analysis."
) )
@@ -149,15 +164,19 @@ class MessageTool(Tool, ContextAware):
def _resolve_media(self, media: list[str]) -> list[str]: def _resolve_media(self, media: list[str]) -> list[str]:
"""Resolve local media attachments and enforce workspace restriction when enabled.""" """Resolve local media attachments and enforce workspace restriction when enabled."""
resolved: list[str] = [] resolved: list[str] = []
allowed_dir = self._workspace if self._restrict_to_workspace else None access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
)
workspace = access.project_path or self._workspace
for p in media: for p in media:
if p.startswith(("http://", "https://")): if p.startswith(("http://", "https://")):
resolved.append(p) resolved.append(p)
elif not self._restrict_to_workspace: elif not access.restrict_to_workspace:
path = Path(p).expanduser() path = Path(p).expanduser()
resolved.append(p if path.is_absolute() else str(self._workspace / path)) resolved.append(p if path.is_absolute() else str(workspace / path))
else: else:
resolved.append(str(resolve_workspace_path(p, self._workspace, allowed_dir))) resolved.append(str(resolve_workspace_path(p, workspace, access.allowed_root)))
return resolved return resolved
async def execute( async def execute(
@@ -236,6 +255,10 @@ class MessageTool(Tool, ContextAware):
metadata=metadata, 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: try:
await self._send_callback(msg) await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id: if channel == default_channel and chat_id == default_chat_id:
-162
View File
@@ -1,162 +0,0 @@
"""NotebookEditTool — edit Jupyter .ipynb notebooks."""
from __future__ import annotations
import json
import uuid
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools.filesystem import _FsTool
def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
cell: dict[str, Any] = {
"cell_type": cell_type,
"source": source,
"metadata": {},
}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
if generate_id:
cell["id"] = uuid.uuid4().hex[:8]
return cell
def _make_empty_notebook() -> dict:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
@tool_parameters(
tool_parameters_schema(
path=StringSchema("Path to the .ipynb notebook file"),
cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
new_source=StringSchema("New source content for the cell"),
cell_type=StringSchema(
"Cell type: 'code' or 'markdown' (default: code)",
enum=["code", "markdown"],
),
edit_mode=StringSchema(
"Mode: 'replace' (default), 'insert' (after target), or 'delete'",
enum=["replace", "insert", "delete"],
),
required=["path", "cell_index"],
)
)
class NotebookEditTool(_FsTool):
"""Edit Jupyter notebook cells: replace, insert, or delete."""
_scopes = {"core"}
_VALID_CELL_TYPES = frozenset({"code", "markdown"})
_VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
@property
def name(self) -> str:
return "notebook_edit"
@property
def description(self) -> str:
return (
"Edit a Jupyter notebook (.ipynb) cell. "
"Modes: replace (default) replaces cell content, "
"insert adds a new cell after the target index, "
"delete removes the cell at the index. "
"cell_index is 0-based."
)
async def execute(
self,
path: str | None = None,
cell_index: int = 0,
new_source: str = "",
cell_type: str = "code",
edit_mode: str = "replace",
**kwargs: Any,
) -> str:
try:
if not path:
return "Error: path is required"
if not path.endswith(".ipynb"):
return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
if edit_mode not in self._VALID_EDIT_MODES:
return (
f"Error: Invalid edit_mode '{edit_mode}'. "
"Use one of: replace, insert, delete."
)
if cell_type not in self._VALID_CELL_TYPES:
return (
f"Error: Invalid cell_type '{cell_type}'. "
"Use one of: code, markdown."
)
fp = self._resolve(path)
# Create new notebook if file doesn't exist and mode is insert
if not fp.exists():
if edit_mode != "insert":
return f"Error: File not found: {path}"
nb = _make_empty_notebook()
cell = _new_cell(new_source, cell_type, generate_id=True)
nb["cells"].append(cell)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully created {fp} with 1 cell"
try:
nb = json.loads(fp.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
return f"Error: Failed to parse notebook: {e}"
cells = nb.get("cells", [])
nbformat_minor = nb.get("nbformat_minor", 0)
generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
if edit_mode == "delete":
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells.pop(cell_index)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully deleted cell {cell_index} from {fp}"
if edit_mode == "insert":
insert_at = min(cell_index + 1, len(cells))
cell = _new_cell(new_source, cell_type, generate_id=generate_id)
cells.insert(insert_at, cell)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully inserted cell at index {insert_at} in {fp}"
# Default: replace
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells[cell_index]["source"] = new_source
if cell_type and cells[cell_index].get("cell_type") != cell_type:
cells[cell_index]["cell_type"] = cell_type
if cell_type == "code":
cells[cell_index].setdefault("outputs", [])
cells[cell_index].setdefault("execution_count", None)
elif "outputs" in cells[cell_index]:
del cells[cell_index]["outputs"]
cells[cell_index].pop("execution_count", None)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully edited cell {cell_index} in {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error editing notebook: {e}"
-328
View File
@@ -1,328 +0,0 @@
"""P2P tools for inter-agent task dispatch and coordination."""
from __future__ import annotations
from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool
from nanobot.bus.events import OutboundMessage
class DispatchTaskTool(Tool):
"""Asynchronously dispatch a task to another agent. Non-blocking."""
def __init__(self, shell: "P2PShell"):
self._shell = shell
@property
def name(self) -> str:
return "dispatch_task"
@property
def description(self) -> str:
return (
"Dispatch a task to a specific target agent. Returns immediately with a receipt. "
"The target agent will process the task independently. Use poll_task_result later to check completion. "
"Do NOT block waiting for results."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Target agent ID"},
"task_description": {"type": "string", "description": "Clear description of the task"},
"parent_task_id": {"type": "string", "description": "Parent task ID for ancestry tracking"},
"deadline_seconds": {"type": "integer", "default": 300, "description": "Task deadline in seconds"},
"allow_redelegation": {"type": "boolean", "default": True, "description": "Whether the target may re-delegate"},
},
"required": ["to", "task_description"],
}
async def execute(
self,
to: str,
task_description: str,
parent_task_id: str | None = None,
deadline_seconds: int = 300,
allow_redelegation: bool = True,
**kwargs: Any,
) -> str:
result = self._shell.dispatch(
to=to,
parent_task_id=parent_task_id,
description=task_description,
deadline_seconds=deadline_seconds,
allow_redelegation=allow_redelegation,
)
if result.get("status") == "rejected":
return f"Error: dispatch rejected — {result.get('reason', 'unknown')}"
if result.get("status") == "circuit_open":
failover = result.get("failover_to")
return f"Error: circuit open for {to}. Failover candidate: {failover or 'none'}"
return (
f"Dispatched to {to}. Task ID: {result.get('task_id')}. "
f"Depth: {result.get('depth', 0)}."
)
class PollTaskResultTool(Tool):
"""Poll the status of a previously dispatched task."""
def __init__(self, shell: "P2PShell"):
self._shell = shell
@property
def name(self) -> str:
return "poll_task_result"
@property
def description(self) -> str:
return (
"Check the current status of a task you previously dispatched. "
"Returns completed, pending, timeout, failed, or not_found. "
"Call this proactively — do not wait for automatic notifications."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Task ID returned by dispatch_task"},
},
"required": ["task_id"],
}
async def execute(self, task_id: str, **kwargs: Any) -> str:
result = self._shell.poll(task_id)
status = result.get("status")
if status == "not_found":
return f"Task {task_id} not found."
if status == "pending":
return f"Task {task_id} is pending (elapsed {result.get('elapsed', '?')}s)."
if status == "timeout":
return f"Task {task_id} timed out after {result.get('elapsed', '?')}s."
if status in ("completed", "failed", "aborted"):
from_agent = result.get("from", "unknown")
content = result.get("result", "")
preview = content[:500] + "..." if len(content) > 500 else content
return f"Task {task_id} is {status} (from {from_agent}).\n\n{preview}"
return f"Task {task_id} status: {status}"
class BroadcastTaskTool(Tool):
"""Broadcast subtasks to discover capable agents."""
def __init__(self, shell: "P2PShell"):
self._shell = shell
@property
def name(self) -> str:
return "broadcast_task"
@property
def description(self) -> str:
return (
"Announce subtasks to the agent network to collect BIDs. "
"Returns immediately. Use check_aggregation later to see which agents responded. "
"Each subtask should include a capability hint for matching."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Your task identifier"},
"subtasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subtask_id": {"type": "string"},
"description": {"type": "string"},
"capability": {"type": "string", "description": "Required capability, e.g. 'web_search'"},
"budget_seconds": {"type": "integer", "default": 300},
},
"required": ["subtask_id", "description", "capability"],
},
},
"aggregation_timeout": {"type": "integer", "default": 30, "description": "Seconds to wait for BIDs"},
},
"required": ["task_id", "subtasks"],
}
async def execute(
self,
task_id: str,
subtasks: list[dict[str, Any]],
aggregation_timeout: int = 30,
**kwargs: Any,
) -> str:
result = self._shell.broadcast(task_id, subtasks, aggregation_timeout)
invited = result.get("invited", 0)
return f"Broadcast opened for {task_id}. Invited {invited} agent(s). Use check_aggregation to collect BIDs."
class CheckAggregationTool(Tool):
"""Check the status of a broadcast aggregation window."""
def __init__(self, shell: "P2PShell"):
self._shell = shell
@property
def name(self) -> str:
return "check_aggregation"
@property
def description(self) -> str:
return (
"Check whether a previously broadcast task has collected enough BIDs or timed out. "
"Returns the list of responding agents and their bids, or a pending status with counts."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Task ID used in broadcast_task"},
},
"required": ["task_id"],
}
async def execute(self, task_id: str, **kwargs: Any) -> str:
result = self._shell.check_aggregation(task_id)
status = result.get("status")
if status == "no_window":
return f"No broadcast window found for {task_id}."
if status == "pending":
received = result.get("received", 0)
expected = result.get("expected", "?")
remaining = result.get("seconds_remaining", 0)
return (
f"Aggregation pending for {task_id}: "
f"{received}/{expected} received, {remaining}s remaining."
)
if status == "closed":
entries = result.get("entries", [])
lines = [f"Aggregation closed for {task_id} ({result.get('reason', '')}):", ""]
for e in entries:
agent = e.get("from", "unknown")
sub = e.get("subtask_id", "")
lines.append(f"- {agent} bid for {sub}")
return "\n".join(lines)
return f"Unknown aggregation status for {task_id}: {status}"
class ReportUserTool(Tool):
"""Deliver a final answer to the user."""
def __init__(
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
default_chat_id: str = "",
):
self._send_callback = send_callback
self._default_channel = default_channel
self._default_chat_id = default_chat_id
@property
def name(self) -> str:
return "report_user"
@property
def description(self) -> str:
return (
"Report the final answer to the user. Use this when you have gathered enough results. "
"Status 'partial' means some subtasks are incomplete — list them in pending_items."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"final_answer": {"type": "string", "description": "Complete answer for the user"},
"status": {"type": "string", "enum": ["success", "partial", "failed"]},
"pending_items": {
"type": "array",
"items": {"type": "string"},
"description": "Incomplete items when status is partial",
},
"task_summary": {"type": "string", "description": "Optional brief summary"},
},
"required": ["final_answer", "status"],
}
async def execute(
self,
final_answer: str,
status: str,
pending_items: list[str] | None = None,
task_summary: str = "",
**kwargs: Any,
) -> str:
if not self._send_callback:
return "Error: report_user not configured (no send callback)"
parts = [final_answer]
if pending_items:
parts.append(f"\n\nPending items:\n" + "\n".join(f"- {i}" for i in pending_items))
if task_summary:
parts.append(f"\n\nSummary: {task_summary}")
content = "\n".join(parts)
msg = OutboundMessage(
channel=self._default_channel,
chat_id=self._default_chat_id,
content=content,
)
await self._send_callback(msg)
return f"Reported to user (status={status})."
class FinalizeTaskTool(Tool):
"""Force-finalize a task and close its sessions."""
def __init__(self, shell: "P2PShell", session_manager: "SessionManager | None" = None):
self._shell = shell
self._session_manager = session_manager
@property
def name(self) -> str:
return "finalize_task"
@property
def description(self) -> str:
return (
"Terminate a task and all its subtasks. Use when the user says 'stop', "
"or when a task is fundamentally blocked. outcome can be completed, failed, or aborted."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task_id": {"type": "string"},
"outcome": {"type": "string", "enum": ["completed", "failed", "aborted"]},
"reason": {"type": "string", "description": "Why the task was finalized"},
},
"required": ["task_id", "outcome"],
}
async def execute(
self,
task_id: str,
outcome: str,
reason: str = "",
**kwargs: Any,
) -> str:
self._shell.finalize(task_id, outcome, reason)
if self._session_manager:
self._session_manager.finalize_task_session(task_id)
return f"Task {task_id} finalized with outcome={outcome}."
+11 -23
View File
@@ -3,21 +3,15 @@
from pathlib import Path from pathlib import Path
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.security.workspace_policy import (
WORKSPACE_BOUNDARY_NOTE = ( is_path_within,
" (this is a hard policy boundary, not a transient failure; " resolve_allowed_path,
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
) )
def is_under(path: Path, directory: Path) -> bool: def is_under(path: Path, directory: Path) -> bool:
"""Return True when path resolves under directory.""" """Return True when path resolves under directory."""
try: return is_path_within(path, directory)
path.relative_to(directory.resolve())
return True
except ValueError:
return False
def resolve_workspace_path( def resolve_workspace_path(
@@ -27,16 +21,10 @@ def resolve_workspace_path(
extra_allowed_dirs: list[Path] | None = None, extra_allowed_dirs: list[Path] | None = None,
) -> Path: ) -> Path:
"""Resolve path against workspace and enforce allowed directory containment.""" """Resolve path against workspace and enforce allowed directory containment."""
p = Path(path).expanduser() extra_roots = [get_media_dir(), *(extra_allowed_dirs or [])] if allowed_dir else None
if not p.is_absolute() and workspace: return resolve_allowed_path(
p = workspace / p path,
resolved = p.resolve() workspace=workspace,
if allowed_dir: allowed_root=allowed_dir,
media_path = get_media_dir().resolve() extra_allowed_roots=extra_roots,
all_dirs = [allowed_dir, media_path, *(extra_allowed_dirs or [])] )
if not any(is_under(resolved, d) for d in all_dirs):
raise PermissionError(
f"Path {path} is outside allowed directory {allowed_dir}"
+ WORKSPACE_BOUNDARY_NOTE
)
return resolved
+3
View File
@@ -42,6 +42,9 @@ class RuntimeState(Protocol):
@property @property
def exec_config(self) -> Any: ... def exec_config(self) -> Any: ...
@property
def workspace_sandbox(self) -> Any: ...
@property @property
def subagents(self) -> Any: ... def subagents(self) -> Any: ...
+172 -4
View File
@@ -1,4 +1,4 @@
"""Search tools: grep.""" """Search tools: file discovery and grep."""
from __future__ import annotations from __future__ import annotations
@@ -12,6 +12,7 @@ from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250 _DEFAULT_HEAD_LIMIT = 250
_DEFAULT_FILE_HEAD_LIMIT = 200
T = TypeVar("T") T = TypeVar("T")
_TYPE_GLOB_MAP = { _TYPE_GLOB_MAP = {
"py": ("*.py", "*.pyi"), "py": ("*.py", "*.pyi"),
@@ -88,13 +89,22 @@ def _matches_type(name: str, file_type: str | None) -> bool:
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns) 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): class _SearchTool(_FsTool):
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS) _IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
def _display_path(self, target: Path, root: Path) -> str: def _display_path(self, target: Path, root: Path) -> str:
if self._workspace: workspace = self._display_workspace()
if workspace:
with suppress(ValueError): with suppress(ValueError):
return target.relative_to(self._workspace).as_posix() return target.relative_to(workspace).as_posix()
return target.relative_to(root).as_posix() return target.relative_to(root).as_posix()
def _iter_files(self, root: Path) -> Iterable[Path]: def _iter_files(self, root: Path) -> Iterable[Path]:
@@ -109,6 +119,163 @@ class _SearchTool(_FsTool):
yield current / filename 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): class GrepTool(_SearchTool):
"""Search file contents using a regex-like pattern.""" """Search file contents using a regex-like pattern."""
_scopes = {"core", "subagent"} _scopes = {"core", "subagent"}
@@ -125,7 +292,8 @@ class GrepTool(_SearchTool):
return ( return (
"Search file contents with a regex pattern. " "Search file contents with a regex pattern. "
"Default output_mode is files_with_matches (file paths only); " "Default output_mode is files_with_matches (file paths only); "
"use content mode for matching lines with context. " "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." "Skips binary and files >2 MB. Supports glob/type filtering."
) )
+15 -6
View File
@@ -3,16 +3,18 @@
from __future__ import annotations from __future__ import annotations
import time import time
from typing import Any from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config.schema import Base from nanobot.config.schema import Base
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus
class MyToolConfig(Base): class MyToolConfig(Base):
"""Self-inspection tool configuration.""" """Self-inspection tool configuration."""
@@ -33,6 +35,12 @@ def _has_real_attr(obj: Any, key: str) -> bool:
return False return False
def _is_subagent_status(value: Any) -> bool:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
class MyTool(Tool, ContextAware): class MyTool(Tool, ContextAware):
"""Check and set the agent loop's runtime configuration.""" """Check and set the agent loop's runtime configuration."""
@@ -68,6 +76,7 @@ class MyTool(Tool, ContextAware):
"_current_iteration", # updated by runner only "_current_iteration", # updated by runner only
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked "exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked "web_config", # inspect allowed (e.g. check enable), modify blocked
"workspace_sandbox", # read-only view of workspace enforcement level
}) })
_DENIED_ATTRS = frozenset({ _DENIED_ATTRS = frozenset({
@@ -214,7 +223,7 @@ class MyTool(Tool, ContextAware):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def _format_status(st: SubagentStatus, indent: str = " ") -> str: def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
elapsed = time.monotonic() - st.started_at elapsed = time.monotonic() - st.started_at
tool_summary = ", ".join( tool_summary = ", ".join(
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:] f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
@@ -232,14 +241,14 @@ class MyTool(Tool, ContextAware):
@staticmethod @staticmethod
def _format_value(val: Any, key: str = "") -> str: def _format_value(val: Any, key: str = "") -> str:
if isinstance(val, SubagentStatus): if _is_subagent_status(val):
header = f"Subagent [{val.task_id}] '{val.label}'" header = f"Subagent [{val.task_id}] '{val.label}'"
detail = MyTool._format_status(val, " ") detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}" return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict # SubagentManager: delegate to its _task_statuses dict
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict): if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
return MyTool._format_value(val._task_statuses, key) return MyTool._format_value(val._task_statuses, key)
if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus): if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
prefix = f"{key}: " if key else "" prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"] lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items(): for tid, st in val.items():
@@ -349,7 +358,7 @@ class MyTool(Tool, ContextAware):
parts.append(self._format_value(getattr(state, k, None), k)) parts.append(self._format_value(getattr(state, k, None), k))
parts.append(self._format_value(state.model_preset, "model_preset")) parts.append(self._format_value(state.model_preset, "model_preset"))
# Other useful top-level keys shown in description # Other useful top-level keys shown in description
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"): 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): if _has_real_attr(state, k):
parts.append(self._format_value(getattr(state, k, None), k)) parts.append(self._format_value(getattr(state, k, None), k))
# Token usage # Token usage
+299 -71
View File
@@ -8,6 +8,7 @@ import re
import shutil import shutil
import sys import sys
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,10 +16,27 @@ from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, 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.sandbox import wrap_command
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema 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" _IS_WINDOWS = sys.platform == "win32"
@@ -36,7 +54,7 @@ _WORKSPACE_BOUNDARY_NOTE = (
class ExecToolConfig(Base): class ExecToolConfig(Base):
"""Shell exec tool configuration.""" """Shell exec tool configuration."""
enable: bool = True enable: bool = True
timeout: int = 60 timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
path_append: str = "" path_append: str = ""
sandbox: str = "" sandbox: str = ""
allowed_env_keys: list[str] = Field(default_factory=list) allowed_env_keys: list[str] = Field(default_factory=list)
@@ -44,10 +62,22 @@ class ExecToolConfig(Base):
deny_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(
tool_parameters_schema( tool_parameters_schema(
command=StringSchema("The shell command to execute"), command=StringSchema("The shell command to execute"),
cmd=StringSchema("Compatibility alias for command"),
working_dir=StringSchema("Optional working directory for the command"), working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema( timeout=IntegerSchema(
60, 60,
description=( description=(
@@ -57,7 +87,44 @@ class ExecToolConfig(Base):
minimum=1, minimum=1,
maximum=600, maximum=600,
), ),
required=["command"], 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 true).",
default=True,
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): class ExecTool(Tool):
@@ -81,6 +148,7 @@ class ExecTool(Tool):
working_dir=ctx.workspace, working_dir=ctx.workspace,
timeout=cfg.timeout, timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace, restrict_to_workspace=ctx.config.restrict_to_workspace,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox, sandbox=cfg.sandbox,
path_append=cfg.path_append, path_append=cfg.path_append,
allowed_env_keys=cfg.allowed_env_keys, allowed_env_keys=cfg.allowed_env_keys,
@@ -95,9 +163,12 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None, deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None, allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "", sandbox: str = "",
path_append: str = "", path_append: str = "",
allowed_env_keys: list[str] | None = None, allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None,
): ):
self.timeout = timeout self.timeout = timeout
self.working_dir = working_dir self.working_dir = working_dir
@@ -123,8 +194,12 @@ class ExecTool(Tool):
] ]
self.allow_patterns = allow_patterns or [] self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace 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_append = path_append self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or [] self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@property @property
def name(self) -> str: def name(self) -> str:
@@ -150,10 +225,15 @@ class ExecTool(Tool):
def description(self) -> str: def description(self) -> str:
return ( return (
"Execute a shell command and return its output. " "Execute a shell command and return its output. "
"Prefer read_file/write_file/edit_file over cat/echo/sed, " "Use this for tests, builds, package commands, git commands, and "
"and grep/glob over shell find/grep. " "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. " "Use -y or --yes flags to avoid interactive prompts. "
"Output is truncated at 10 000 chars; timeout defaults to 60s." "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 @property
@@ -161,67 +241,45 @@ class ExecTool(Tool):
return True return True
async def execute( async def execute(
self, command: str, working_dir: str | None = None, self, command: str | None = None, cmd: str | None = None,
timeout: int | None = None, **kwargs: Any, 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: ) -> str:
cwd = working_dir or self.working_dir or os.getcwd() 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
# Prevent an LLM-supplied working_dir from escaping the configured prepared = self._prepare_command(command, working_dir, timeout, shell, login)
# workspace when restrict_to_workspace is enabled (#2826). Without if isinstance(prepared, str):
# this, a caller can pass working_dir="/etc" and then all absolute return prepared
# paths under /etc would pass the _guard_command check that anchors
# on cwd.
if self.restrict_to_workspace and self.working_dir:
try:
requested = Path(cwd).expanduser().resolve()
workspace_root = Path(self.working_dir).expanduser().resolve()
except Exception:
return (
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if requested != workspace_root and workspace_root not in requested.parents:
return (
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
guard_error = self._guard_command(command, cwd) if yield_time_ms is not None:
if guard_error: return await self._execute_session(prepared, yield_time_ms, max_output_chars)
return guard_error
if self.sandbox:
if _IS_WINDOWS:
logger.warning(
"Sandbox '{}' is not supported on Windows; running unsandboxed",
self.sandbox,
)
else:
workspace = self.working_dir or cwd
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
env = self._build_env()
if self.path_append:
if _IS_WINDOWS:
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
else:
env["NANOBOT_PATH_APPEND"] = self.path_append
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
try: try:
process = await self._spawn(command, cwd, env) process = await self._spawn(
prepared.command,
prepared.cwd,
prepared.env,
prepared.shell_program,
prepared.login,
)
try: try:
stdout, stderr = await asyncio.wait_for( stdout, stderr = await asyncio.wait_for(
process.communicate(), process.communicate(),
timeout=effective_timeout, timeout=prepared.timeout,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self._kill_process(process) await self._kill_process(process)
return f"Error: Command timed out after {effective_timeout} seconds" return f"Error: Command timed out after {prepared.timeout} seconds"
except asyncio.CancelledError: except asyncio.CancelledError:
await self._kill_process(process) await self._kill_process(process)
raise raise
@@ -240,7 +298,7 @@ class ExecTool(Tool):
result = "\n".join(output_parts) if output_parts else "(no output)" result = "\n".join(output_parts) if output_parts else "(no output)"
max_len = self._MAX_OUTPUT max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
if len(result) > max_len: if len(result) > max_len:
half = max_len // 2 half = max_len // 2
result = ( result = (
@@ -254,32 +312,192 @@ class ExecTool(Tool):
except Exception as e: except Exception as e:
return f"Error executing command: {str(e)}" return f"Error executing command: {str(e)}"
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,
)
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_append:
if _IS_WINDOWS:
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
else:
env["NANOBOT_PATH_APPEND"] = self.path_append
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
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=True if login is None else login,
)
@staticmethod @staticmethod
async def _spawn( async def _spawn(
command: str, cwd: str, env: dict[str, str], command: str, cwd: str, env: dict[str, str],
shell_program: str | None = None,
login: bool = True,
*,
stdin: int = asyncio.subprocess.DEVNULL,
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell.""" """Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS: if _IS_WINDOWS:
# create_subprocess_exec re-quotes args via list2cmdline, which if "\n" in command:
# breaks commands containing paths with spaces (e.g. "D:\Program return await asyncio.create_subprocess_exec(
# Files\python.exe" "script.py"). create_subprocess_shell passes "powershell", "-NoProfile", "-Command", command,
# the raw command string to COMSPEC without re-quoting. stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
return await asyncio.create_subprocess_shell( return await asyncio.create_subprocess_shell(
command, command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=env, env=env,
) )
bash = shutil.which("bash") or "/bin/bash" 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( return await asyncio.create_subprocess_exec(
bash, "-l", "-c", command, *args,
stdin=stdin,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=env, 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 @staticmethod
async def _kill_process(process: asyncio.subprocess.Process) -> None: async def _kill_process(process: asyncio.subprocess.Process) -> None:
"""Kill a subprocess and reap it to prevent zombies.""" """Kill a subprocess and reap it to prevent zombies."""
@@ -342,7 +560,13 @@ class ExecTool(Tool):
env[key] = val env[key] = val
return env return env
def _guard_command(self, command: str, cwd: str) -> str | None: def _guard_command(
self,
command: str,
cwd: str,
*,
restrict_to_workspace: bool | None = None,
) -> str | None:
"""Best-effort safety guard for potentially destructive commands.""" """Best-effort safety guard for potentially destructive commands."""
cmd = command.strip() cmd = command.strip()
lower = cmd.lower() lower = cmd.lower()
@@ -362,11 +586,17 @@ class ExecTool(Tool):
return "Error: Command blocked by allowlist filter (not in allowlist)" return "Error: Command blocked by allowlist filter (not in allowlist)"
from nanobot.security.network import contains_internal_url from nanobot.security.network import contains_internal_url
if contains_internal_url(cmd): 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. # The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)" return "Error: Command blocked by safety guard (internal/private URL detected)"
if self.restrict_to_workspace: 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: if "..\\" in cmd or "../" in cmd:
return ( return (
"Error: Command blocked by safety guard (path traversal detected)" "Error: Command blocked by safety guard (path traversal detected)"
@@ -391,11 +621,9 @@ class ExecTool(Tool):
continue continue
media_path = get_media_dir().resolve() media_path = get_media_dir().resolve()
if (p.is_absolute() if p.is_absolute() and not (
and cwd_path not in p.parents is_path_within(p, cwd_path)
and p != cwd_path or is_path_within(p, media_path)
and media_path not in p.parents
and p != media_path
): ):
return ( return (
"Error: Command blocked by safety guard (path outside working dir)" "Error: Command blocked by safety guard (path outside working dir)"
@@ -416,7 +644,7 @@ class ExecTool(Tool):
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share` # 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. # NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall( win_paths = re.findall(
r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)", r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command command
) )
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
+20 -2
View File
@@ -7,7 +7,8 @@ from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
@@ -17,6 +18,15 @@ if TYPE_CHECKING:
tool_parameters_schema( tool_parameters_schema(
task=StringSchema("The task for the subagent to complete"), task=StringSchema("The task for the subagent to complete"),
label=StringSchema("Optional short label for the task (for display)"), 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"], required=["task"],
) )
) )
@@ -58,7 +68,13 @@ class SpawnTool(Tool, ContextAware):
"and use a dedicated subdirectory when helpful." "and use a dedicated subdirectory when helpful."
) )
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.""" """Spawn a subagent to execute the given task."""
running = self._manager.get_running_count() running = self._manager.get_running_count()
limit = self._manager.max_concurrent_subagents limit = self._manager.max_concurrent_subagents
@@ -75,4 +91,6 @@ class SpawnTool(Tool, ContextAware):
origin_chat_id=self._origin_chat_id.get(), origin_chat_id=self._origin_chat_id.get(),
session_key=self._session_key.get(), session_key=self._session_key.get(),
origin_message_id=self._origin_message_id.get(), origin_message_id=self._origin_message_id.get(),
temperature=temperature,
workspace_scope=current_workspace_scope(),
) )
+104 -24
View File
@@ -8,7 +8,7 @@ import json
import os import os
import re import re
from typing import Any, Callable from typing import Any, Callable
from urllib.parse import quote, urlparse from urllib.parse import quote, urljoin, urlparse
import httpx import httpx
from loguru import logger from loguru import logger
@@ -78,9 +78,82 @@ def _validate_url(url: str) -> tuple[bool, str]:
def _validate_url_safe(url: str) -> tuple[bool, str]: def _validate_url_safe(url: str) -> tuple[bool, str]:
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check.""" """Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
from nanobot.security.network import validate_url_target from nanobot.security.network import validate_url_target
return validate_url_target(url) return validate_url_target(url)
async def _get_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, str | None]:
"""GET a URL while validating every redirect target before requesting it."""
current_url = url
for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg = _validate_url_safe(current_url)
if not is_valid:
return None, f"Redirect blocked: {error_msg}"
response = await client.get(current_url, headers=headers, follow_redirects=False)
is_redirect = 300 <= response.status_code < 400
if not is_redirect:
return response, None
location = response.headers.get("location")
if not location:
return response, None
next_url = urljoin(str(response.url), location)
is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid:
await response.aclose()
return None, f"Redirect blocked: {error_msg}"
await response.aclose()
current_url = next_url
return None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
async def _stream_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, Any | None, str | None]:
"""Open a streamed response while validating every redirect target first."""
current_url = url
for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg = _validate_url_safe(current_url)
if not is_valid:
return None, None, f"Redirect blocked: {error_msg}"
stream = client.stream(
"GET",
current_url,
headers=headers,
follow_redirects=False,
)
response = await stream.__aenter__()
is_redirect = 300 <= response.status_code < 400
if not is_redirect:
return response, stream, None
location = response.headers.get("location")
if not location:
return response, stream, None
next_url = urljoin(str(response.url), location)
is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid:
await stream.__aexit__(None, None, None)
return None, None, f"Redirect blocked: {error_msg}"
await stream.__aexit__(None, None, None)
current_url = next_url
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str: def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
"""Format provider results into shared plaintext output.""" """Format provider results into shared plaintext output."""
if not items: if not items:
@@ -382,17 +455,16 @@ class WebSearchTool(Tool):
return await self._search_duckduckgo(query, n) return await self._search_duckduckgo(query, n)
try: try:
async with httpx.AsyncClient(proxy=self.proxy) as client: async with httpx.AsyncClient(proxy=self.proxy) as client:
r = await client.get( r = await client.post(
"https://kagi.com/api/v0/search", "https://kagi.com/api/v1/search",
params={"q": query, "limit": n}, json={"query": query, "limit": n},
headers={"Authorization": f"Bot {api_key}", "User-Agent": self.user_agent}, headers={"Authorization": f"Bearer {api_key}", "User-Agent": self.user_agent},
timeout=10.0, timeout=10.0,
) )
r.raise_for_status() r.raise_for_status()
# t=0 items are search results; other values are related searches, etc.
items = [ items = [
{"title": d.get("title", ""), "url": d.get("url", ""), "content": d.get("snippet", "")} {"title": d.get("title", ""), "url": d.get("url", ""), "content": d.get("snippet", "")}
for d in r.json().get("data", []) if d.get("t") == 0 for d in r.json().get("data", {}).get("search", [])
] ]
return _format_results(query, items, n) return _format_results(query, items, n)
except Exception as e: except Exception as e:
@@ -488,19 +560,26 @@ class WebFetchTool(Tool):
# Detect and fetch images directly to avoid Jina's textual image captioning # Detect and fetch images directly to avoid Jina's textual image captioning
try: try:
async with httpx.AsyncClient(proxy=self.proxy, follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=15.0) as client: async with httpx.AsyncClient(proxy=self.proxy, timeout=15.0) as client:
async with client.stream("GET", url, headers={"User-Agent": self.user_agent}) as r: r, stream, redirect_error = await _stream_with_safe_redirects(
from nanobot.security.network import validate_resolved_url client,
url,
redir_ok, redir_err = validate_resolved_url(str(r.url)) headers={"User-Agent": self.user_agent},
if not redir_ok: )
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False) if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
try:
ctype = r.headers.get("content-type", "") ctype = r.headers.get("content-type", "")
if ctype.startswith("image/"): if ctype.startswith("image/"):
r.raise_for_status() r.raise_for_status()
raw = await r.aread() raw = await r.aread()
return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})") return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})")
finally:
if stream is not None:
await stream.__aexit__(None, None, None)
except Exception as e: except Exception as e:
logger.debug("Pre-fetch image detection failed for {}: {}", url, e) logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
@@ -549,23 +628,22 @@ class WebFetchTool(Tool):
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any: async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
"""Local fallback using readability-lxml.""" """Local fallback using readability-lxml."""
from readability import Document
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
follow_redirects=True,
max_redirects=MAX_REDIRECTS,
timeout=30.0, timeout=30.0,
proxy=self.proxy, proxy=self.proxy,
) as client: ) as client:
r = await client.get(url, headers={"User-Agent": self.user_agent}) r, redirect_error = await _get_with_safe_redirects(
client,
url,
headers={"User-Agent": self.user_agent},
)
if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
r.raise_for_status() r.raise_for_status()
from nanobot.security.network import validate_resolved_url
redir_ok, redir_err = validate_resolved_url(str(r.url))
if not redir_ok:
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
ctype = r.headers.get("content-type", "") ctype = r.headers.get("content-type", "")
if ctype.startswith("image/"): if ctype.startswith("image/"):
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})") return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
@@ -573,6 +651,8 @@ class WebFetchTool(Tool):
if "application/json" in ctype: if "application/json" in ctype:
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")): elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
from readability import Document
doc = Document(r.text) doc = Document(r.text)
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary()) content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
text = f"# {doc.title()}\n\n{content}" if doc.title() else content text = f"# {doc.title()}\n\n{content}" if doc.title() else content
+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",
]
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
"""CLI Apps helpers shared by the agent loop and settings surfaces."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted session kwargs for CLI app attachments."""
cli_apps = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
"""Return model-visible CLI app annotations for the current turn."""
if skip:
return []
text = message.content if isinstance(getattr(message, "content", None), str) else ""
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
return _cli_app_runtime_lines(text, metadata, workspace)
def _cli_app_runtime_lines(
text: str,
metadata: Mapping[str, Any] | None,
workspace: Path,
) -> list[str]:
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
mentions = [
item for item in structured
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
]
if mentions:
return [
"CLI App Attachment: "
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
if str(item.get("name") or "").strip()
]
if "@" not in text:
return []
try:
from nanobot.apps.cli import CliAppManager
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
except Exception:
return []
return [
"CLI App Mention: "
f"@{item['name']} "
f"(installed; tool={item['tool']}; "
f"entry_point={item['entry_point'] or 'unknown'}; "
f"skill={item['skill']}). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
]
+56
View File
@@ -0,0 +1,56 @@
"""Neutral manifest shape for settings-managed agent apps.
The manifest is intentionally descriptive. Installers still live in their
own adapters, while this protocol gives the WebUI and future registries one
small vocabulary for capabilities, trust, and verified install/remove plans.
"""
from __future__ import annotations
from typing import Any
APP_PROTOCOL_SCHEMA = "agent-app.v1"
def compact_dict(values: dict[str, Any]) -> dict[str, Any]:
"""Drop empty optional values while preserving explicit booleans and zeros."""
return {
key: value
for key, value in values.items()
if value is not None and value != "" and value != [] and value != {}
}
def app_manifest(
*,
app_id: str,
display_name: str,
description: str,
category: str,
source: str,
capabilities: list[dict[str, Any]],
install: dict[str, Any],
remove: dict[str, Any],
trust: dict[str, Any],
version: str | None = None,
logo_url: str | None = None,
brand_color: str | None = None,
docs_url: str | None = None,
) -> dict[str, Any]:
"""Build a stable app manifest dictionary."""
return compact_dict({
"schema": APP_PROTOCOL_SCHEMA,
"id": app_id,
"display_name": display_name,
"version": version,
"description": description,
"category": category,
"source": source,
"logo_url": logo_url,
"brand_color": brand_color,
"docs_url": docs_url,
"capabilities": capabilities,
"install": install,
"remove": remove,
"trust": trust,
})
+6 -1
View File
@@ -9,6 +9,12 @@ from typing import Any
# render it and other channels may ignore unknown keys. # render it and other channels may ignore unknown keys.
OUTBOUND_META_AGENT_UI = "_agent_ui" OUTBOUND_META_AGENT_UI = "_agent_ui"
# Internal-only inbound metadata used by in-process channels to ask the agent
# loop to update runtime state without going through a user session.
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
@dataclass @dataclass
class InboundMessage: class InboundMessage:
@@ -45,4 +51,3 @@ class OutboundMessage:
media: list[str] = field(default_factory=list) media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list) buttons: list[list[str]] = field(default_factory=list)
+10
View File
@@ -207,6 +207,16 @@ if DISCORD_AVAILABLE:
) -> None: ) -> None:
await self._forward_slash_command(interaction, _command_text) await self._forward_slash_command(interaction, _command_text)
@self.tree.command(name="model", description="Show or switch runtime model preset")
@app_commands.describe(preset="Optional model preset name, such as default")
async def model_command(
interaction: discord.Interaction,
preset: str | None = None,
) -> None:
preset = (preset or "").strip()
command_text = f"/model {preset}" if preset else "/model"
await self._forward_slash_command(interaction, command_text)
@self.tree.command(name="help", description="Show available commands") @self.tree.command(name="help", description="Show available commands")
async def help_command(interaction: discord.Interaction) -> None: async def help_command(interaction: discord.Interaction) -> None:
sender_id = str(interaction.user.id) sender_id = str(interaction.user.id)
+30 -8
View File
@@ -57,11 +57,17 @@ class ChannelManager:
*, *,
session_manager: "SessionManager | None" = None, session_manager: "SessionManager | None" = None,
webui_runtime_model_name: Callable[[], str | None] | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
): ):
self.config = config self.config = config
self.bus = bus self.bus = bus
self._session_manager = session_manager self._session_manager = session_manager
self._webui_runtime_model_name = webui_runtime_model_name self._webui_runtime_model_name = webui_runtime_model_name
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None self._dispatch_task: asyncio.Task | None = None
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {} self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
@@ -70,36 +76,52 @@ class ChannelManager:
def _init_channels(self) -> None: def _init_channels(self) -> None:
"""Initialize channels discovered via pkgutil scan + entry_points plugins.""" """Initialize channels discovered via pkgutil scan + entry_points plugins."""
from nanobot.channels.registry import discover_all from nanobot.channels.registry import discover_channel_names, discover_enabled
transcription_provider = self.config.channels.transcription_provider transcription_provider = self.config.channels.transcription_provider
transcription_key = self._resolve_transcription_key(transcription_provider) transcription_key = self._resolve_transcription_key(transcription_provider)
transcription_base = self._resolve_transcription_base(transcription_provider) transcription_base = self._resolve_transcription_base(transcription_provider)
transcription_language = self.config.channels.transcription_language transcription_language = self.config.channels.transcription_language
for name, cls in discover_all().items(): # Collect enabled module names first, then only import those.
# Channel configs live in ChannelsConfig's extra fields (via
# extra="allow"), so we enumerate candidates from pkgutil scan
# (cheap, no imports) and any plugin keys in __pydantic_extra__.
names = discover_channel_names()
candidate_names = set(names)
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
candidate_names.update(extra.keys())
enabled_names: set[str] = set()
for name in candidate_names:
section = getattr(self.config.channels, name, None) section = getattr(self.config.channels, name, None)
if section is None: if section is None:
continue continue
enabled = ( if (
section.get("enabled", False) section.get("enabled", False)
if isinstance(section, dict) if isinstance(section, dict)
else getattr(section, "enabled", False) else getattr(section, "enabled", False)
) ):
if not enabled: enabled_names.add(name)
for name, cls in discover_enabled(enabled_names, _names=names).items():
section = getattr(self.config.channels, name, None)
if section is None:
continue continue
try: try:
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {}
# Only the WebSocket channel currently hosts the embedded webui
# surface; other channels stay oblivious to these knobs.
if cls.name == "websocket": if cls.name == "websocket":
if self._session_manager is not None: if self._session_manager is not None:
kwargs["session_manager"] = self._session_manager kwargs["session_manager"] = self._session_manager
static_path = _default_webui_dist() static_path = _default_webui_dist() if self._webui_static_dist else None
if static_path is not None: if static_path is not None:
kwargs["static_dist_path"] = static_path kwargs["static_dist_path"] = static_path
kwargs["workspace_path"] = self.config.workspace_path
kwargs["restrict_to_workspace"] = self.config.tools.restrict_to_workspace
if self._webui_runtime_model_name is not None: if self._webui_runtime_model_name is not None:
kwargs["runtime_model_name"] = self._webui_runtime_model_name kwargs["runtime_model_name"] = self._webui_runtime_model_name
kwargs["runtime_surface"] = self._webui_runtime_surface
kwargs["runtime_capabilities_overrides"] = self._webui_runtime_capabilities
channel = cls(section, self.bus, **kwargs) channel = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key channel.transcription_api_key = transcription_key
+134 -28
View File
@@ -8,21 +8,28 @@ from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal, TypeAlias from typing import Any, Literal, TypeAlias
from urllib.parse import quote, urlparse
from pydantic import Field from pydantic import Field
from nanobot.security.workspace_policy import is_path_within
try: try:
import aiohttp
import nh3 import nh3
from mistune import create_markdown from mistune import create_markdown
from nio import ( from nio import (
AsyncClient, AsyncClient,
AsyncClientConfig, AsyncClientConfig,
DownloadError,
InviteEvent, InviteEvent,
JoinError, JoinError,
KeyVerificationCancel,
KeyVerificationEvent,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationStart,
LoginResponse, LoginResponse,
MatrixRoom, MatrixRoom,
MemoryDownloadResponse,
RoomEncryptedMedia, RoomEncryptedMedia,
RoomMessage, RoomMessage,
RoomMessageMedia, RoomMessageMedia,
@@ -31,6 +38,7 @@ try:
RoomSendResponse, RoomSendResponse,
RoomTypingError, RoomTypingError,
SyncError, SyncError,
ToDeviceError,
UploadError, UploadError,
) )
from nio.crypto.attachments import decrypt_attachment from nio.crypto.attachments import decrypt_attachment
@@ -62,6 +70,10 @@ _MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.f
MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia) MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
class _MediaTooLargeError(Exception):
"""Raised when an inbound Matrix media download exceeds the configured cap."""
MATRIX_MARKDOWN = create_markdown( MATRIX_MARKDOWN = create_markdown(
escape=True, escape=True,
plugins=["table", "strikethrough", "url", "superscript", "subscript"], plugins=["table", "strikethrough", "url", "superscript", "subscript"],
@@ -188,8 +200,10 @@ class MatrixConfig(Base):
access_token: str = "" access_token: str = ""
device_id: str = "" device_id: str = ""
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled") e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled")
sas_verification: bool = Field(default=False, alias="sasVerification")
sync_stop_grace_seconds: int = 2 sync_stop_grace_seconds: int = 2
max_media_bytes: int = 20 * 1024 * 1024 max_media_bytes: int = 20 * 1024 * 1024
max_concurrent_media_downloads: int = 2
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "mention", "allowlist"] = "open" group_policy: Literal["open", "mention", "allowlist"] = "open"
group_allow_from: list[str] = Field(default_factory=list) group_allow_from: list[str] = Field(default_factory=list)
@@ -231,6 +245,9 @@ class MatrixChannel(BaseChannel):
self._server_upload_limit_checked = False self._server_upload_limit_checked = False
self._stream_bufs: dict[str, _StreamBuf] = {} self._stream_bufs: dict[str, _StreamBuf] = {}
self._started_at_ms: int = 0 self._started_at_ms: int = 0
self._media_download_semaphore = asyncio.Semaphore(
max(1, int(self.config.max_concurrent_media_downloads))
)
async def start(self) -> None: async def start(self) -> None:
@@ -258,6 +275,7 @@ class MatrixChannel(BaseChannel):
) )
self._register_event_callbacks() self._register_event_callbacks()
self._register_to_device_callbacks()
self._register_response_callbacks() self._register_response_callbacks()
if not self.config.e2ee_enabled: if not self.config.e2ee_enabled:
@@ -344,11 +362,7 @@ class MatrixChannel(BaseChannel):
"""Check path is inside workspace (when restriction enabled).""" """Check path is inside workspace (when restriction enabled)."""
if not self._restrict_to_workspace or not self._workspace: if not self._restrict_to_workspace or not self._workspace:
return True return True
try: return is_path_within(path, self._workspace)
path.resolve(strict=False).relative_to(self._workspace)
return True
except ValueError:
return False
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]: def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
"""Deduplicate and resolve outbound attachment paths.""" """Deduplicate and resolve outbound attachment paths."""
@@ -566,11 +580,77 @@ class MatrixChannel(BaseChannel):
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
self.client.add_event_callback(self._on_room_invite, InviteEvent) self.client.add_event_callback(self._on_room_invite, InviteEvent)
def _register_to_device_callbacks(self) -> None:
if self.config.e2ee_enabled and self.config.sas_verification:
self.client.add_to_device_callback(
self._on_key_verification_event,
(KeyVerificationEvent,),
)
def _register_response_callbacks(self) -> None: def _register_response_callbacks(self) -> None:
self.client.add_response_callback(self._on_sync_error, SyncError) self.client.add_response_callback(self._on_sync_error, SyncError)
self.client.add_response_callback(self._on_join_error, JoinError) self.client.add_response_callback(self._on_join_error, JoinError)
self.client.add_response_callback(self._on_send_error, RoomSendError) self.client.add_response_callback(self._on_send_error, RoomSendError)
def _is_sas_sender_allowed(self, sender: str) -> bool:
return bool(sender and self.is_allowed(sender))
async def _on_key_verification_event(self, event: KeyVerificationEvent) -> None:
try:
await self._handle_key_verification_event(event)
except asyncio.CancelledError:
raise
except Exception:
self.logger.exception("Matrix SAS verification handling failed")
async def _handle_key_verification_event(self, event: KeyVerificationEvent) -> None:
if not (self.config.e2ee_enabled and self.config.sas_verification):
return
if not self.client:
return
sender = str(getattr(event, "sender", "") or "")
transaction_id = str(getattr(event, "transaction_id", "") or "")
if not transaction_id or not self._is_sas_sender_allowed(sender):
return
if isinstance(event, KeyVerificationStart):
if "emoji" not in (getattr(event, "short_authentication_string", None) or []):
self.logger.info(
"Ignoring Matrix SAS verification from {} without emoji support",
sender,
)
return
response = await self.client.accept_key_verification(transaction_id)
if isinstance(response, ToDeviceError):
self.logger.warning("Matrix SAS accept failed for {}: {}", sender, response)
return
if isinstance(event, KeyVerificationKey):
responses = await self.client.send_to_device_messages()
if any(isinstance(response, ToDeviceError) for response in responses):
self.logger.warning("Matrix SAS key share failed for {}", sender)
return
response = await self.client.confirm_short_auth_string(transaction_id)
if isinstance(response, ToDeviceError):
self.logger.warning("Matrix SAS confirm failed for {}: {}", sender, response)
return
if isinstance(event, KeyVerificationMac):
sas = getattr(self.client, "key_verifications", {}).get(transaction_id)
if sas is not None and getattr(sas, "verified", False):
self.logger.info("Matrix SAS verification completed for {}", sender)
return
if isinstance(event, KeyVerificationCancel):
self.logger.info(
"Matrix SAS verification cancelled by {}: {}",
sender,
getattr(event, "reason", ""),
)
def _is_fatal_auth_response(self, response: Any) -> bool: def _is_fatal_auth_response(self, response: Any) -> bool:
code = getattr(response, "status_code", None) code = getattr(response, "status_code", None)
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"} is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
@@ -743,7 +823,7 @@ class MatrixChannel(BaseChannel):
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None: def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
info = self._event_source_content(event).get("info") info = self._event_source_content(event).get("info")
size = info.get("size") if isinstance(info, dict) else None size = info.get("size") if isinstance(info, dict) else None
return size if isinstance(size, int) and size >= 0 else None return size if type(size) is int and size >= 0 else None
def _event_mime(self, event: MatrixMediaEvent) -> str | None: def _event_mime(self, event: MatrixMediaEvent) -> str | None:
info = self._event_source_content(event).get("info") info = self._event_source_content(event).get("info")
@@ -772,26 +852,48 @@ class MatrixChannel(BaseChannel):
event_prefix = (event_id[:24] or "evt").strip("_") event_prefix = (event_id[:24] or "evt").strip("_")
return self._media_dir() / f"{event_prefix}_{stem}{suffix}" return self._media_dir() / f"{event_prefix}_{stem}{suffix}"
async def _download_media_bytes(self, mxc_url: str) -> bytes | None: async def _download_media_bytes(self, mxc_url: str, limit_bytes: int) -> bytes | None:
if not self.client: if not self.client or limit_bytes <= 0:
raise _MediaTooLargeError
parsed = urlparse(mxc_url)
if parsed.scheme != "mxc" or not parsed.netloc or not parsed.path.strip("/"):
return None return None
response = await self.client.download(mxc=mxc_url)
if isinstance(response, DownloadError): homeserver = str(getattr(self.client, "homeserver", "") or self.config.homeserver).rstrip("/")
self.logger.warning("download failed for {}: {}", mxc_url, response) media_url = (
f"{homeserver}/_matrix/client/v1/media/download/"
f"{quote(parsed.netloc, safe='')}/{quote(parsed.path.strip('/'), safe='')}"
)
token = getattr(self.client, "access_token", None) or self.config.access_token
headers = {"Authorization": f"Bearer {token}"} if token else None
timeout = aiohttp.ClientTimeout(total=None)
try:
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
async with session.get(media_url, params={"allow_remote": "true"}) as response:
if response.status >= 400:
self.logger.warning("download failed for {}: HTTP {}", mxc_url, response.status)
return None
content_length = response.headers.get("Content-Length")
if content_length is not None:
try:
if int(content_length) > limit_bytes:
raise _MediaTooLargeError
except ValueError:
pass
chunks = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
chunks.extend(chunk)
if len(chunks) > limit_bytes:
raise _MediaTooLargeError
return bytes(chunks)
except _MediaTooLargeError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
self.logger.warning("download failed for {}", mxc_url, exc_info=True)
return None return None
body = getattr(response, "body", None)
if isinstance(body, (bytes, bytearray)):
return bytes(body)
if isinstance(response, MemoryDownloadResponse):
return bytes(response.body)
if isinstance(body, (str, Path)):
path = Path(body)
if path.is_file():
try:
return path.read_bytes()
except OSError:
return None
return None
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None: def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None) key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
@@ -820,10 +922,14 @@ class MatrixChannel(BaseChannel):
limit_bytes = await self._effective_media_limit_bytes() limit_bytes = await self._effective_media_limit_bytes()
declared = self._event_declared_size_bytes(event) declared = self._event_declared_size_bytes(event)
if declared is not None and declared > limit_bytes: if declared is None or declared > limit_bytes:
return None, _ATTACH_TOO_LARGE.format(filename) return None, _ATTACH_TOO_LARGE.format(filename)
downloaded = await self._download_media_bytes(mxc_url) try:
async with self._media_download_semaphore:
downloaded = await self._download_media_bytes(mxc_url, limit_bytes)
except _MediaTooLargeError:
return None, _ATTACH_TOO_LARGE.format(filename)
if downloaded is None: if downloaded is None:
return None, fail return None, fail
+49
View File
@@ -53,6 +53,13 @@ if MSTEAMS_AVAILABLE:
MSTEAMS_REF_TTL_DAYS = 30 MSTEAMS_REF_TTL_DAYS = 30
MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com" MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com"
MSTEAMS_DEFAULT_TRUSTED_SERVICE_URL_HOSTS = [
"smba.trafficmanager.net",
"smba.infra.gcc.teams.microsoft.com",
"smba.infra.gov.teams.microsoft.us",
"smba.infra.dod.teams.microsoft.us",
"*.botframework.com",
]
MSTEAMS_REF_META_FILENAME = "msteams_conversations_meta.json" MSTEAMS_REF_META_FILENAME = "msteams_conversations_meta.json"
MSTEAMS_REF_LOCK_FILENAME = "msteams_conversations.lock" MSTEAMS_REF_LOCK_FILENAME = "msteams_conversations.lock"
MSTEAMS_REF_TOUCH_INTERVAL_S = 300 MSTEAMS_REF_TOUCH_INTERVAL_S = 300
@@ -76,6 +83,9 @@ class MSTeamsConfig(Base):
prune_web_chat_refs: bool = True prune_web_chat_refs: bool = True
prune_non_personal_refs: bool = True prune_non_personal_refs: bool = True
ref_touch_interval_s: int = Field(default=MSTEAMS_REF_TOUCH_INTERVAL_S, ge=0) ref_touch_interval_s: int = Field(default=MSTEAMS_REF_TOUCH_INTERVAL_S, ge=0)
trusted_service_url_hosts: list[str] = Field(
default_factory=lambda: MSTEAMS_DEFAULT_TRUSTED_SERVICE_URL_HOSTS.copy()
)
@dataclass @dataclass
@@ -242,6 +252,11 @@ class MSTeamsChannel(BaseChannel):
if not ref: if not ref:
raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}") raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}")
if not self._is_trusted_service_url(ref.service_url):
raise RuntimeError(
f"MSTeams conversation ref has untrusted service_url for chat_id={msg.chat_id}"
)
token = await self._get_access_token() token = await self._get_access_token()
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities" base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id) use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
@@ -284,6 +299,13 @@ class MSTeamsChannel(BaseChannel):
if not sender_id or not conversation_id or not service_url: if not sender_id or not conversation_id or not service_url:
return return
if not self._is_trusted_service_url(service_url):
self.logger.warning(
"Ignoring MSTeams activity with untrusted serviceUrl host: {}",
service_url,
)
return
if recipient.get("id") and from_user.get("id") == recipient.get("id"): if recipient.get("id") and from_user.get("id") == recipient.get("id"):
return return
@@ -626,6 +648,29 @@ class MSTeamsChannel(BaseChannel):
return host == MSTEAMS_WEBCHAT_HOST or host.endswith(f".{MSTEAMS_WEBCHAT_HOST}") return host == MSTEAMS_WEBCHAT_HOST or host.endswith(f".{MSTEAMS_WEBCHAT_HOST}")
return MSTEAMS_WEBCHAT_HOST in normalized.lower() return MSTEAMS_WEBCHAT_HOST in normalized.lower()
def _is_trusted_service_url(self, service_url: str) -> bool:
"""Return True for HTTPS Bot Framework service URLs trusted for bearer replies."""
parsed = urlparse(service_url.strip())
if parsed.scheme.lower() != "https":
return False
host = (parsed.hostname or "").strip().lower().rstrip(".")
if not host:
return False
for pattern in self.config.trusted_service_url_hosts:
trusted_host = str(pattern or "").strip().lower().rstrip(".")
if not trusted_host:
continue
if trusted_host.startswith("*."):
suffix = trusted_host[1:]
if host.endswith(suffix) and host != suffix.lstrip("."):
return True
continue
if host == trusted_host:
return True
return False
def _prune_conversation_refs(self, *, now: float | None = None) -> bool: def _prune_conversation_refs(self, *, now: float | None = None) -> bool:
"""Remove stale and unsupported conversation refs from memory.""" """Remove stale and unsupported conversation refs from memory."""
if not self._conversation_refs: if not self._conversation_refs:
@@ -637,6 +682,10 @@ class MSTeamsChannel(BaseChannel):
keys_to_drop: list[str] = [] keys_to_drop: list[str] = []
for key, ref in self._conversation_refs.items(): for key, ref in self._conversation_refs.items():
if not self._is_trusted_service_url(ref.service_url):
keys_to_drop.append(key)
continue
if self.config.prune_web_chat_refs and self._is_webchat_service_url(ref.service_url): if self.config.prune_web_chat_refs and self._is_webchat_service_url(ref.service_url):
keys_to_drop.append(key) keys_to_drop.append(key)
continue continue
+39 -15
View File
@@ -1,5 +1,4 @@
"""Auto-discovery for built-in channel modules and external plugins.""" """Auto-discovery for built-in channel modules and external plugins."""
from __future__ import annotations from __future__ import annotations
import importlib import importlib
@@ -37,12 +36,14 @@ def load_channel_class(module_name: str) -> type[BaseChannel]:
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}") raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
def discover_plugins() -> dict[str, type[BaseChannel]]: def discover_plugins(enabled_names: set[str] | None = None) -> dict[str, type[BaseChannel]]:
"""Discover external channel plugins registered via entry_points.""" """Discover external channel plugins registered via entry_points."""
from importlib.metadata import entry_points from importlib.metadata import entry_points
plugins: dict[str, type[BaseChannel]] = {} plugins: dict[str, type[BaseChannel]] = {}
for ep in entry_points(group="nanobot.channels"): for ep in entry_points(group="nanobot.channels"):
if enabled_names is not None and ep.name not in enabled_names:
continue
try: try:
cls = ep.load() cls = ep.load()
plugins[ep.name] = cls plugins[ep.name] = cls
@@ -51,21 +52,44 @@ def discover_plugins() -> dict[str, type[BaseChannel]]:
return plugins return plugins
def discover_enabled(
enabled_names: set[str],
*,
_names: list[str] | None = None,
_include_all_external: bool = False,
) -> dict[str, type[BaseChannel]]:
"""Return channels whose module names are in *enabled_names*.
Uses cheap ``pkgutil.iter_modules`` to list names, then imports only
those that match skipping the heavy third-party SDK imports of
unneeded channels.
"""
names = _names if _names is not None else discover_channel_names()
result: dict[str, type[BaseChannel]] = {}
for modname in names:
if modname not in enabled_names:
continue
try:
result[modname] = load_channel_class(modname)
except ImportError as e:
logger.debug("Skipping built-in channel '{}': {}", modname, e)
external = discover_plugins(None if _include_all_external else enabled_names)
shadowed = set(external) & set(result)
if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
if _include_all_external:
result.update({k: v for k, v in external.items() if k not in shadowed})
else:
result.update({k: v for k, v in external.items() if k not in shadowed and k in enabled_names})
return result
def discover_all() -> dict[str, type[BaseChannel]]: def discover_all() -> dict[str, type[BaseChannel]]:
"""Return all channels: built-in (pkgutil) merged with external (entry_points). """Return all channels: built-in (pkgutil) merged with external (entry_points).
Built-in channels take priority an external plugin cannot shadow a built-in name. Built-in channels take priority an external plugin cannot shadow a built-in name.
""" """
builtin: dict[str, type[BaseChannel]] = {} names = discover_channel_names()
for modname in discover_channel_names(): return discover_enabled(set(names), _names=names, _include_all_external=True)
try:
builtin[modname] = load_channel_class(modname)
except ImportError as e:
logger.debug("Skipping built-in channel '{}': {}", modname, e)
external = discover_plugins()
shadowed = set(external) & set(builtin)
if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
return {**external, **builtin}
File diff suppressed because it is too large Load Diff
+165 -12
View File
@@ -10,8 +10,9 @@ from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import urlparse
from pydantic import Field from pydantic import Field, field_validator, model_validator
from telegram import ( from telegram import (
BotCommand, BotCommand,
InlineKeyboardButton, InlineKeyboardButton,
@@ -225,11 +226,22 @@ class _StreamBuf:
stream_id: str | None = None stream_id: str | None = None
@dataclass
class _QueuedTelegramUpdate:
"""Telegram update staged for per-session ordered processing."""
kind: Literal["command", "message"]
update: Update
context: Any
sort_key: tuple[int, int]
class TelegramConfig(Base): class TelegramConfig(Base):
"""Telegram channel configuration.""" """Telegram channel configuration."""
enabled: bool = False enabled: bool = False
token: str = "" token: str = ""
mode: Literal["polling", "webhook"] = "polling"
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
proxy: str | None = None proxy: str | None = None
reply_to_message: bool = False reply_to_message: bool = False
@@ -241,13 +253,48 @@ class TelegramConfig(Base):
# Enable inline keyboard buttons in Telegram messages. # Enable inline keyboard buttons in Telegram messages.
inline_keyboards: bool = False inline_keyboards: bool = False
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1) stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
webhook_url: str = ""
webhook_listen_host: str = "127.0.0.1"
webhook_listen_port: int = Field(default=8081, ge=1, le=65535)
webhook_path: str = "/telegram"
webhook_secret_token: str = ""
webhook_max_connections: int = Field(default=4, ge=1, le=100)
@field_validator("webhook_path")
@classmethod
def webhook_path_must_start_with_slash(cls, value: str) -> str:
value = value.strip() or "/telegram"
if not value.startswith("/"):
raise ValueError('webhook_path must start with "/"')
return value
@model_validator(mode="after")
def validate_webhook_config(self) -> "TelegramConfig":
if self.mode != "webhook":
return self
url = self.webhook_url.strip()
if not url:
raise ValueError("webhook_url is required when Telegram mode is webhook")
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.netloc:
raise ValueError("webhook_url must be a public HTTPS URL")
secret = self.webhook_secret_token.strip()
if not secret:
raise ValueError("webhook_secret_token is required when Telegram mode is webhook")
if len(secret) > 256 or re.match(r"^[A-Za-z0-9_-]+$", secret) is None:
raise ValueError(
"webhook_secret_token must be 1-256 characters using only A-Z, a-z, 0-9, _ and -"
)
return self
class TelegramChannel(BaseChannel): class TelegramChannel(BaseChannel):
""" """
Telegram channel using long polling. Telegram channel using long polling or webhook mode.
Simple and reliable - no webhook/public IP needed. Long polling is the default. Webhook mode requires a public HTTPS URL and a
Telegram secret token.
""" """
name = "telegram" name = "telegram"
@@ -294,6 +341,8 @@ class TelegramChannel(BaseChannel):
self._bot_user_id: int | None = None self._bot_user_id: int | None = None
self._bot_username: str | None = None self._bot_username: str | None = None
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
self._inbound_workers: dict[str, asyncio.Task] = {}
def is_allowed(self, sender_id: str) -> bool: def is_allowed(self, sender_id: str) -> bool:
"""Preserve Telegram's legacy id|username allowlist matching.""" """Preserve Telegram's legacy id|username allowlist matching."""
@@ -326,7 +375,7 @@ class TelegramChannel(BaseChannel):
return content return content
async def start(self) -> None: async def start(self) -> None:
"""Start the Telegram bot with long polling.""" """Start the Telegram bot."""
if not self.config.token: if not self.config.token:
self.logger.error("bot token not configured") self.logger.error("bot token not configured")
return return
@@ -394,9 +443,12 @@ class TelegramChannel(BaseChannel):
else: else:
allowed_updates = ["message"] allowed_updates = ["message"]
self.logger.info("Starting bot (polling mode)...") if self.config.mode == "webhook":
self.logger.info("Starting bot (webhook mode)...")
else:
self.logger.info("Starting bot (polling mode)...")
# Initialize and start polling # Initialize and start receiving updates
await self._app.initialize() await self._app.initialize()
await self._app.start() await self._app.start()
@@ -412,12 +464,26 @@ class TelegramChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.warning("Failed to register bot commands: {}", e) self.logger.warning("Failed to register bot commands: {}", e)
# Start polling (this runs until stopped) if self.config.mode == "webhook":
await self._app.updater.start_polling( # ``url_path`` is the local HTTP route. ``webhook_url`` is the
allowed_updates=allowed_updates, # public HTTPS URL Telegram calls; reverse proxies may rewrite it.
drop_pending_updates=False, # Process pending messages on startup await self._app.updater.start_webhook(
error_callback=self._on_polling_error, listen=self.config.webhook_listen_host,
) port=self.config.webhook_listen_port,
url_path=self.config.webhook_path.lstrip("/"),
webhook_url=self.config.webhook_url.strip(),
allowed_updates=allowed_updates,
drop_pending_updates=False,
secret_token=self.config.webhook_secret_token.strip(),
max_connections=self.config.webhook_max_connections,
)
else:
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
allowed_updates=allowed_updates,
drop_pending_updates=False, # Process pending messages on startup
error_callback=self._on_polling_error,
)
# Keep running until stopped # Keep running until stopped
while self._running: while self._running:
@@ -436,6 +502,11 @@ class TelegramChannel(BaseChannel):
self._media_group_tasks.clear() self._media_group_tasks.clear()
self._media_group_buffers.clear() self._media_group_buffers.clear()
for task in self._inbound_workers.values():
task.cancel()
self._inbound_workers.clear()
self._inbound_buffers.clear()
if self._app: if self._app:
self.logger.info("Stopping bot...") self.logger.info("Stopping bot...")
await self._app.updater.stop() await self._app.updater.stop()
@@ -995,10 +1066,85 @@ class TelegramChannel(BaseChannel):
if len(self._message_threads) > 1000: if len(self._message_threads) > 1000:
self._message_threads.pop(next(iter(self._message_threads))) self._message_threads.pop(next(iter(self._message_threads)))
@staticmethod
def _queue_key_for_message(message) -> str:
"""Return the final nanobot session key used for ordered Telegram ingress."""
return TelegramChannel._derive_topic_session_key(message) or f"telegram:{message.chat_id}"
@staticmethod
def _sort_key_for_update(update: Update) -> tuple[int, int]:
"""Sort by chat message id first, then Telegram update id."""
message = getattr(update, "message", None)
message_id = int(getattr(message, "message_id", 0) or 0)
update_id = int(getattr(update, "update_id", 0) or 0)
return (message_id, update_id)
def _enqueue_ordered_update(
self,
*,
kind: Literal["command", "message"],
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
"""Stage a Telegram update behind a short per-session reorder window."""
message = update.message
key = self._queue_key_for_message(message)
self._inbound_buffers.setdefault(key, []).append(
_QueuedTelegramUpdate(
kind=kind,
update=update,
context=context,
sort_key=self._sort_key_for_update(update),
)
)
if key not in self._inbound_workers:
self._inbound_workers[key] = asyncio.create_task(
self._drain_ordered_updates(key)
)
async def _drain_ordered_updates(self, key: str) -> None:
"""Drain one Telegram session buffer in stable message order."""
try:
while self._running:
await asyncio.sleep(0.2)
batch = self._inbound_buffers.get(key, [])
if not batch:
break
self._inbound_buffers[key] = []
batch.sort(key=lambda item: item.sort_key)
for item in batch:
try:
if item.kind == "command":
await self._process_forward_command(item.update, item.context)
else:
await self._process_message_update(item.update, item.context)
except Exception as e:
self.logger.warning(
"Telegram queued update handling failed for {}: {}",
key,
e,
)
if not self._inbound_buffers.get(key):
self._inbound_buffers.pop(key, None)
except asyncio.CancelledError:
raise
except Exception as e:
self.logger.warning("Telegram ordered update worker failed for {}: {}", key, e)
finally:
if not self._inbound_buffers.get(key):
self._inbound_workers.pop(key, None)
async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Forward slash commands to the bus for unified handling in AgentLoop.""" """Forward slash commands to the bus for unified handling in AgentLoop."""
if not update.message or not update.effective_user: if not update.message or not update.effective_user:
return return
if not self._running:
await self._process_forward_command(update, context)
return
self._enqueue_ordered_update(kind="command", update=update, context=context)
async def _process_forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Process a queued slash command."""
message = update.message message = update.message
user = update.effective_user user = update.effective_user
sender_id = self._sender_id(user) sender_id = self._sender_id(user)
@@ -1027,6 +1173,13 @@ class TelegramChannel(BaseChannel):
"""Handle incoming messages (text, photos, voice, documents).""" """Handle incoming messages (text, photos, voice, documents)."""
if not update.message or not update.effective_user: if not update.message or not update.effective_user:
return return
if not self._running:
await self._process_message_update(update, context)
return
self._enqueue_ordered_update(kind="message", update=update, context=context)
async def _process_message_update(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Process a queued Telegram message update."""
message = update.message message = update.message
user = update.effective_user user = update.effective_user
File diff suppressed because it is too large Load Diff
+163 -6
View File
@@ -79,6 +79,12 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
ERRCODE_SESSION_EXPIRED = -14 ERRCODE_SESSION_EXPIRED = -14
SESSION_PAUSE_DURATION_S = 60 * 60 SESSION_PAUSE_DURATION_S = 60 * 60
# iLink context_token is observed to expire server-side after ~90-160s of
# agent inactivity (openclaw/openclaw#61174). Proactively refresh before
# sending if the cached token is older than this threshold.
CONTEXT_TOKEN_MAX_AGE_S = 60
# Retry constants (matching the reference plugin's monitor.ts) # Retry constants (matching the reference plugin's monitor.ts)
MAX_CONSECUTIVE_FAILURES = 3 MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY_S = 30 BACKOFF_DELAY_S = 30
@@ -159,6 +165,8 @@ class WeixinChannel(BaseChannel):
self._session_pause_until: float = 0.0 self._session_pause_until: float = 0.0
self._typing_tasks: dict[str, asyncio.Task] = {} self._typing_tasks: dict[str, asyncio.Task] = {}
self._typing_tickets: dict[str, dict[str, Any]] = {} self._typing_tickets: dict[str, dict[str, Any]] = {}
self._context_token_at: dict[str, float] = {}
self._pending_tool_hints: dict[str, list[str]] = {}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# State persistence # State persistence
@@ -486,6 +494,7 @@ class WeixinChannel(BaseChannel):
except Exception: except Exception:
if not self._running: if not self._running:
break break
self.logger.exception("WeChat poll loop error")
consecutive_failures += 1 consecutive_failures += 1
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
consecutive_failures = 0 consecutive_failures = 0
@@ -495,6 +504,7 @@ class WeixinChannel(BaseChannel):
async def stop(self) -> None: async def stop(self) -> None:
self._running = False self._running = False
self._pending_tool_hints.clear()
if self._poll_task and not self._poll_task.done(): if self._poll_task and not self._poll_task.done():
self._poll_task.cancel() self._poll_task.cancel()
for chat_id in list(self._typing_tasks): for chat_id in list(self._typing_tasks):
@@ -545,6 +555,7 @@ class WeixinChannel(BaseChannel):
# Check for API-level errors (monitor.ts checks both ret and errcode) # Check for API-level errors (monitor.ts checks both ret and errcode)
ret = data.get("ret", 0) ret = data.get("ret", 0)
errcode = data.get("errcode", 0) errcode = data.get("errcode", 0)
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0) is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
if is_error: if is_error:
@@ -575,8 +586,10 @@ class WeixinChannel(BaseChannel):
# Process messages (WeixinMessage[] from types.ts) # Process messages (WeixinMessage[] from types.ts)
msgs: list[dict] = data.get("msgs", []) or [] msgs: list[dict] = data.get("msgs", []) or []
for msg in msgs: for msg in msgs:
with suppress(Exception): try:
await self._process_message(msg) await self._process_message(msg)
except Exception:
self.logger.exception("Failed to process WeChat message")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Inbound message processing (matches inbound.ts + process-message.ts) # Inbound message processing (matches inbound.ts + process-message.ts)
@@ -610,6 +623,7 @@ class WeixinChannel(BaseChannel):
ctx_token = msg.get("context_token", "") ctx_token = msg.get("context_token", "")
if ctx_token: if ctx_token:
self._context_tokens[from_user_id] = ctx_token self._context_tokens[from_user_id] = ctx_token
self._context_token_at[from_user_id] = time.time()
self._save_state() self._save_state()
# Parse item_list (WeixinMessage.item_list — types.ts:161) # Parse item_list (WeixinMessage.item_list — types.ts:161)
@@ -915,6 +929,99 @@ class WeixinChannel(BaseChannel):
} }
return "" return ""
async def _refresh_context_token_if_stale(
self, chat_id: str, context_token: str
) -> str:
"""Return a fresh context_token if the cached one is too old.
iLink context_token expires server-side after a short idle period
(empirically ~90s). Proactively refreshing before sending prevents
silent message loss on long agent turns or cron pushes.
"""
if not context_token:
return context_token
now = time.time()
cached_at = self._context_token_at.get(chat_id, 0)
age = now - cached_at
if age < CONTEXT_TOKEN_MAX_AGE_S:
return context_token
self.logger.debug(
"WeChat context_token for {} is {:.0f}s old; refreshing via getconfig",
chat_id,
age,
)
body: dict[str, Any] = {
"ilink_user_id": chat_id,
"context_token": context_token,
"base_info": BASE_INFO,
}
try:
data = await self._api_post("ilink/bot/getconfig", body)
except Exception as e:
self.logger.warning("WeChat getconfig failed for {}: {}", chat_id, e)
return context_token
if data.get("ret", 0) != 0:
self.logger.warning(
"WeChat getconfig returned ret={} for {}: {}",
data.get("ret"),
chat_id,
data.get("errmsg", ""),
)
return context_token
new_token = str(data.get("context_token", "") or "")
if new_token and new_token != context_token:
self.logger.info(
"WeChat context_token refreshed for {} (age {:.0f}s -> fresh)",
chat_id,
age,
)
self._context_tokens[chat_id] = new_token
self._context_token_at[chat_id] = now
self._save_state()
return new_token
return context_token
async def _flush_tool_hints(self, chat_id: str) -> None:
"""Send any buffered tool hints for *chat_id* as a single message.
Tool hints are coalesced to reduce message count and avoid hitting the
WeChat iLink rate limit (~7 msgs / 5 min). Failures are logged but
not raised so that the main message send is never blocked.
"""
hints = self._pending_tool_hints.pop(chat_id, None)
if not hints:
return
self.logger.info(
"Flushing {} buffered tool hint(s) for {}",
len(hints),
chat_id,
)
ctx_token = self._context_tokens.get(chat_id, "")
ctx_token = await self._refresh_context_token_if_stale(chat_id, ctx_token)
if not ctx_token:
self.logger.warning(
"Dropped {} buffered tool hint(s) for {}: no context_token",
len(hints),
chat_id,
)
return
try:
await self._send_text(chat_id, "\n\n".join(hints), ctx_token)
except Exception:
self.logger.exception(
"Failed to flush buffered tool hints for {}", chat_id
)
async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None: async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None:
"""Best-effort sendtyping wrapper.""" """Best-effort sendtyping wrapper."""
if not typing_ticket: if not typing_ticket:
@@ -944,11 +1051,47 @@ class WeixinChannel(BaseChannel):
self._assert_session_active() self._assert_session_active()
is_progress = bool((msg.metadata or {}).get("_progress", False)) is_progress = bool((msg.metadata or {}).get("_progress", False))
# Buffer tool hints to coalesce consecutive ones and avoid burning
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
if is_progress and (msg.metadata or {}).get("_tool_hint"):
if not self.send_tool_hints:
return
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
self.logger.debug(
"Buffered tool hint for {} (count={})",
msg.chat_id,
len(self._pending_tool_hints[msg.chat_id]),
)
return
# Reasoning deltas are invisible in WeChat (there is no reasoning
# UI). Skip them entirely — do not send and do not flush buffer.
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
self.logger.debug(
"Dropped invisible reasoning delta for {}", msg.chat_id
)
return
content = msg.content.strip()
# Empty progress messages (e.g. after_iteration tool_events) must
# NOT act as separators — they have no visible content.
if is_progress and not content and not (msg.media or []):
self.logger.debug(
"Skipped empty progress message for {} (no visible content)",
msg.chat_id,
)
return
# Flush buffered hints before sending any visible message.
await self._flush_tool_hints(msg.chat_id)
if not is_progress: if not is_progress:
await self._stop_typing(msg.chat_id, clear_remote=True) await self._stop_typing(msg.chat_id, clear_remote=True)
content = msg.content.strip()
ctx_token = self._context_tokens.get(msg.chat_id, "") ctx_token = self._context_tokens.get(msg.chat_id, "")
ctx_token = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token)
if not ctx_token: if not ctx_token:
raise RuntimeError( raise RuntimeError(
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send" f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
@@ -1037,6 +1180,18 @@ class WeixinChannel(BaseChannel):
with suppress(Exception): with suppress(Exception):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL) await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Weixin iLink does not support native streaming deltas.
We only hook ``_stream_end`` so buffered tool hints are flushed even
when the final answer carries the ``_streamed`` flag and bypasses
:meth:`send`.
"""
if metadata and metadata.get("_stream_end"):
await self._flush_tool_hints(chat_id)
async def _start_typing(self, chat_id: str, context_token: str = "") -> None: async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
"""Start typing indicator immediately when a message is received.""" """Start typing indicator immediately when a message is received."""
if not self._client or not self._token or not chat_id: if not self._client or not self._token or not chat_id:
@@ -1120,10 +1275,11 @@ class WeixinChannel(BaseChannel):
} }
data = await self._api_post("ilink/bot/sendmessage", body) data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0) errcode = data.get("errcode", 0)
if errcode and errcode != 0: if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
raise RuntimeError( raise RuntimeError(
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}" f"WeChat send text error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
) )
async def _send_media_file( async def _send_media_file(
@@ -1270,10 +1426,11 @@ class WeixinChannel(BaseChannel):
} }
data = await self._api_post("ilink/bot/sendmessage", body) data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0) errcode = data.get("errcode", 0)
if errcode and errcode != 0: if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
raise RuntimeError( raise RuntimeError(
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}" f"WeChat send media error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
) )
+342 -114
View File
@@ -75,7 +75,7 @@ class SafeFileHistory(FileHistory):
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
from nanobot.config.paths import get_workspace_path, is_default_workspace from nanobot.config.paths import get_workspace_path, is_default_workspace
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.p2p.shell import P2PShell from nanobot.utils.evaluator import evaluate_response
from nanobot.utils.helpers import sync_workspace_templates from nanobot.utils.helpers import sync_workspace_templates
from nanobot.utils.restart import ( from nanobot.utils.restart import (
consume_restart_notice_from_env, consume_restart_notice_from_env,
@@ -92,17 +92,41 @@ app = typer.Typer(
console = Console() console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"} EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "", "", "")
_REASONING_FLUSH_CHARS = 60
_HEARTBEAT_PREAMBLE = (
"[Your response will be delivered directly to the user's messaging app. "
"Output ONLY the final user-facing message. Never reference internal "
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
"decision process. If nothing needs reporting, respond with just "
"'All clear.' and nothing else.]\n\n"
)
def _resolve_p2p(config: Config) -> P2PShell | None: def _heartbeat_has_active_tasks(content: str) -> bool:
"""Resolve P2P config and create the stateless P2P shell.""" """True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
mb_cfg = config.mailbox in_comment = False
if not mb_cfg.enabled: in_active_section: bool = False
return None for line in content.splitlines():
return P2PShell( stripped = line.strip()
agent_id=mb_cfg.agent_id, if in_comment:
mailboxes_root=mb_cfg.mailboxes_root, if "-->" in stripped:
) in_comment = False
continue
if not stripped or stripped.startswith("#"):
if stripped.startswith("##") and not stripped.startswith("###"):
heading = stripped.lstrip("#").strip().lower()
in_active_section = heading.startswith("active tasks")
continue
if stripped.startswith("<!--"):
if "-->" not in stripped[4:]:
in_comment = True
continue
if in_active_section is False:
continue
return True
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display # CLI input: prompt_toolkit for editing, paste, history, and display
@@ -254,6 +278,35 @@ def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, render
target.print(f" [dim]↳ {text}[/dim]") target.print(f" [dim]↳ {text}[/dim]")
class _ReasoningBuffer:
def __init__(self) -> None:
self._text = ""
def add(self, text: str) -> str | None:
if not text:
return None
self._text += text
if self._should_flush(text):
return self.flush()
return None
def flush(self) -> str | None:
text = self._text.strip()
self._text = ""
return text or None
def clear(self) -> None:
self._text = ""
def _should_flush(self, text: str) -> bool:
stripped = text.rstrip()
return (
"\n" in text
or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
or len(self._text) >= _REASONING_FLUSH_CHARS
)
def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None: def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
"""Print reasoning/thinking content in a distinct style.""" """Print reasoning/thinking content in a distinct style."""
if not text.strip(): if not text.strip():
@@ -266,6 +319,16 @@ def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer:
target.print(f"[dim italic]✻ {text}[/dim italic]") target.print(f"[dim italic]✻ {text}[/dim italic]")
def _flush_cli_reasoning(
reasoning_buffer: _ReasoningBuffer,
thinking: ThinkingSpinner | None,
renderer: StreamRenderer | None = None,
) -> None:
text = reasoning_buffer.flush()
if text:
_print_cli_reasoning(text, thinking, renderer)
async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None: async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
"""Print an interactive progress line, pausing the spinner if needed.""" """Print an interactive progress line, pausing the spinner if needed."""
if not text.strip(): if not text.strip():
@@ -284,6 +347,7 @@ async def _maybe_print_interactive_progress(
thinking: ThinkingSpinner | None, thinking: ThinkingSpinner | None,
channels_config: Any, channels_config: Any,
renderer: StreamRenderer | None = None, renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool: ) -> bool:
metadata = msg.metadata or {} metadata = msg.metadata or {}
if metadata.get("_retry_wait"): if metadata.get("_retry_wait"):
@@ -293,12 +357,24 @@ async def _maybe_print_interactive_progress(
if not metadata.get("_progress"): if not metadata.get("_progress"):
return False return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if metadata.get("_reasoning_end"):
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True
is_tool_hint = metadata.get("_tool_hint", False) is_tool_hint = metadata.get("_tool_hint", False)
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False) is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
if is_reasoning: if is_reasoning:
if channels_config and not channels_config.show_reasoning: if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
return True return True
_print_cli_reasoning(msg.content, thinking, renderer) text = reasoning_buffer.add(msg.content)
if text:
_print_cli_reasoning(text, thinking, renderer)
return True return True
if channels_config and is_tool_hint and not channels_config.send_tool_hints: if channels_config and is_tool_hint and not channels_config.send_tool_hints:
return True return True
@@ -578,6 +654,7 @@ def serve(
from nanobot.api.server import create_app from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
if verbose: if verbose:
@@ -593,17 +670,11 @@ def serve(
sync_workspace_templates(runtime_config.workspace_path) sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus() bus = MessageBus()
session_manager = SessionManager(runtime_config.workspace_path) session_manager = SessionManager(runtime_config.workspace_path)
p2p_shell = _resolve_p2p(runtime_config)
try: try:
agent_loop = AgentLoop.from_config( agent_loop = AgentLoop.from_config(
runtime_config, bus, runtime_config, bus,
session_manager=session_manager, session_manager=session_manager,
p2p_shell=p2p_shell, image_generation_provider_configs=image_gen_provider_configs(runtime_config),
image_generation_provider_configs={
"openrouter": runtime_config.providers.openrouter,
"aihubmix": runtime_config.providers.aihubmix,
},
) )
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
@@ -667,11 +738,144 @@ def gateway(
_run_gateway(cfg, port=port) _run_gateway(cfg, port=port)
def _load_or_create_desktop_config(config: str | None, workspace: str | None) -> Config:
"""Load the desktop-owned config, creating it on first launch."""
from nanobot.config.loader import (
get_config_path,
load_config,
resolve_config_env_vars,
save_config,
set_config_path,
)
from nanobot.config.schema import Config as NanobotConfig
config_path = Path(config).expanduser().resolve() if config else get_config_path()
set_config_path(config_path)
created = False
if config_path.exists():
try:
loaded = resolve_config_env_vars(load_config(config_path))
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
else:
loaded = NanobotConfig()
created = True
if workspace:
workspace_path = Path(workspace).expanduser()
loaded.agents.defaults.workspace = str(workspace_path)
created = True
if created:
save_config(loaded, config_path)
return loaded
def _configure_desktop_gateway(
config: Config,
*,
webui_port: int,
webui_socket: str | None,
token_issue_secret: str,
) -> None:
"""Force a local WebSocket-only gateway for the desktop app process."""
config.gateway.host = "127.0.0.1"
config.gateway.port = webui_port
config.gateway.heartbeat.enabled = False
extras = dict(getattr(config.channels, "__pydantic_extra__", None) or {})
for name, section in list(extras.items()):
if name == "websocket":
continue
if isinstance(section, dict):
extras[name] = {**section, "enabled": False}
else:
with suppress(Exception):
setattr(section, "enabled", False)
extras[name] = section
websocket_cfg = extras.get("websocket")
if not isinstance(websocket_cfg, dict):
websocket_cfg = {}
websocket_cfg.update(
{
"enabled": True,
"host": "127.0.0.1",
"port": webui_port,
"unix_socket_path": webui_socket or "",
"path": "/",
"token_issue_secret": token_issue_secret,
"websocket_requires_token": True,
"allow_from": ["*"],
"streaming": True,
}
)
extras["websocket"] = websocket_cfg
config.channels.__pydantic_extra__ = extras
@app.command("desktop-gateway", hidden=True)
def desktop_gateway(
webui_port: int = typer.Option(0, "--webui-port", min=0, max=65535),
webui_socket: str | None = typer.Option(None, "--webui-socket", help="Unix socket path for desktop IPC"),
token_issue_secret: str = typer.Option(..., "--token-issue-secret"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Desktop workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Desktop config file"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Start the private local gateway used by nanobot Desktop."""
if not token_issue_secret.strip():
console.print("[red]Error: --token-issue-secret is required[/red]")
raise typer.Exit(1)
if webui_port <= 0 and not (webui_socket or "").strip():
console.print("[red]Error: --webui-port or --webui-socket is required[/red]")
raise typer.Exit(1)
if verbose:
logger.remove(_log_handler_id)
logger.add(
sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<cyan>{extra[channel]}</cyan> | "
"<level>{message}</level>"
),
level="DEBUG",
colorize=None,
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
cfg = _load_or_create_desktop_config(config, workspace)
_configure_desktop_gateway(
cfg,
webui_port=webui_port,
webui_socket=webui_socket,
token_issue_secret=token_issue_secret,
)
_run_gateway(
cfg,
port=webui_port,
webui_static_dist=False,
webui_runtime_surface="native",
webui_runtime_capabilities={
"can_restart_engine": True,
"can_pick_folder": True,
"can_open_logs": True,
"can_export_diagnostics": True,
},
health_server_enabled=False,
)
def _run_gateway( def _run_gateway(
config: Config, config: Config,
*, *,
port: int | None = None, port: int | None = None,
open_browser_url: str | None = None, open_browser_url: str | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
) -> None: ) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
@@ -681,8 +885,8 @@ def _run_gateway(
from nanobot.channels.websocket import publish_runtime_model_update from nanobot.channels.websocket import publish_runtime_model_update
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
port = port if port is not None else config.gateway.port port = port if port is not None else config.gateway.port
@@ -705,8 +909,6 @@ def _run_gateway(
cron_store_path = config.workspace_path / "cron" / "jobs.json" cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path) cron = CronService(cron_store_path)
p2p_shell = _resolve_p2p(config)
# Create agent with cron service # Create agent with cron service
agent = AgentLoop.from_config( agent = AgentLoop.from_config(
config, bus, config, bus,
@@ -715,10 +917,7 @@ def _run_gateway(
context_window_tokens=provider_snapshot.context_window_tokens, context_window_tokens=provider_snapshot.context_window_tokens,
cron_service=cron, cron_service=cron,
session_manager=session_manager, session_manager=session_manager,
image_generation_provider_configs={ image_generation_provider_configs=image_gen_provider_configs(config),
"openrouter": config.providers.openrouter,
"aihubmix": config.providers.aihubmix,
},
provider_snapshot_loader=load_provider_snapshot, provider_snapshot_loader=load_provider_snapshot,
runtime_model_publisher=lambda model, preset: publish_runtime_model_update( runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
bus, bus,
@@ -726,7 +925,6 @@ def _run_gateway(
preset, preset,
), ),
provider_signature=provider_snapshot.signature, provider_signature=provider_snapshot.signature,
p2p_shell=p2p_shell,
) )
from nanobot.agent.loop import UNIFIED_SESSION_KEY from nanobot.agent.loop import UNIFIED_SESSION_KEY
@@ -778,6 +976,9 @@ def _run_gateway(
# Set cron callback (needs agent) # Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None: async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent.""" """Execute a cron job through the agent."""
async def _silent(*_args, **_kwargs):
pass
# Dream is an internal job — run directly, not through the agent loop. # Dream is an internal job — run directly, not through the agent loop.
if job.name == "dream": if job.name == "dream":
try: try:
@@ -787,7 +988,67 @@ def _run_gateway(
logger.exception("Dream cron job failed") logger.exception("Dream cron job failed")
return None return None
from nanobot.utils.evaluator import evaluate_response # Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
if job.name == "heartbeat":
heartbeat_file = config.workspace_path / "HEARTBEAT.md"
try:
content = heartbeat_file.read_text(encoding="utf-8")
except OSError:
logger.debug("Heartbeat: HEARTBEAT.md missing")
return None
if not _heartbeat_has_active_tasks(content):
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
return None
channel, chat_id = _pick_heartbeat_target()
if channel == "cli":
return None
prompt = (
_HEARTBEAT_PREAMBLE
+ f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}"
)
# Internal check: funnel all output through the post-run gate so the
# turn can't deliver directly via the message tool and skip it.
suppress_token = None
if isinstance(message_tool, MessageTool):
suppress_token = message_tool.set_suppress_delivery(True)
try:
resp = await agent.process_direct(
prompt,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent,
)
finally:
if isinstance(message_tool, MessageTool) and suppress_token is not None:
message_tool.reset_suppress_delivery(suppress_token)
response = resp.content if resp else ""
# Keep a small tail of heartbeat history so the loop stays bounded.
session = agent.sessions.get_or_create("heartbeat")
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
agent.sessions.save(session)
if not response:
return None
# Fail closed: stay silent on evaluator failure instead of notifying.
should_notify = await evaluate_response(
response, prompt, agent.provider, agent.model,
default_notify=False,
)
if should_notify:
logger.info("Heartbeat: completed, delivering response")
await _deliver_to_channel(
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
record=True,
)
else:
logger.info("Heartbeat: silenced by post-run evaluation")
return response
reminder_note = ( reminder_note = (
"The scheduled time has arrived. Deliver this reminder to the user now, " "The scheduled time has arrived. Deliver this reminder to the user now, "
@@ -802,9 +1063,6 @@ def _run_gateway(
if isinstance(cron_tool, CronTool): if isinstance(cron_tool, CronTool):
cron_token = cron_tool.set_cron_context(True) cron_token = cron_tool.set_cron_context(True)
async def _silent(*_args, **_kwargs):
pass
message_record_token = None message_record_token = None
if isinstance(message_tool, MessageTool): if isinstance(message_tool, MessageTool):
message_record_token = message_tool.set_record_channel_delivery(True) message_record_token = message_tool.set_record_channel_delivery(True)
@@ -861,12 +1119,14 @@ def _run_gateway(
bus, bus,
session_manager=session_manager, session_manager=session_manager,
webui_runtime_model_name=_webui_runtime_model_name, webui_runtime_model_name=_webui_runtime_model_name,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
) )
def _pick_heartbeat_target() -> tuple[str, str]: def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages.""" """Pick a routable channel/chat target for heartbeat-triggered messages."""
enabled = set(channels.enabled_channels) enabled = set(channels.enabled_channels)
# Prefer the most recently updated non-internal session on an enabled channel.
for item in session_manager.list_sessions(): for item in session_manager.list_sessions():
key = item.get("key") or "" key = item.get("key") or ""
if ":" not in key: if ":" not in key:
@@ -876,73 +1136,8 @@ def _run_gateway(
continue continue
if channel in enabled and chat_id: if channel in enabled and chat_id:
return channel, chat_id return channel, chat_id
# Fallback keeps prior behavior but remains explicit.
return "cli", "direct" return "cli", "direct"
# Create heartbeat service
heartbeat_preamble = (
"[Your response will be delivered directly to the user's messaging app. "
"Output ONLY the final user-facing message. Never reference internal "
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
"decision process. If nothing needs reporting, respond with just "
"'All clear.' and nothing else.]\n\n"
)
async def on_heartbeat_execute(tasks: str) -> str:
"""Phase 2: execute heartbeat tasks through the full agent loop."""
channel, chat_id = _pick_heartbeat_target()
async def _silent(*_args, **_kwargs):
pass
resp = await agent.process_direct(
heartbeat_preamble + tasks,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent,
)
# Keep a small tail of heartbeat history so the loop stays bounded
# without losing all short-term context between runs.
session = agent.sessions.get_or_create("heartbeat")
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
agent.sessions.save(session)
return resp.content if resp else ""
async def on_heartbeat_notify(response: str) -> None:
"""Deliver a heartbeat response to the user's channel.
In addition to publishing the outbound message, this injects the
delivered text as an assistant turn into the *target channel's*
session. Without this, a user reply on the channel (e.g. "Sure")
lands in a session that has no context about the heartbeat message
and the agent cannot follow through.
"""
channel, chat_id = _pick_heartbeat_target()
if channel == "cli":
return # No external channel available to deliver to
await _deliver_to_channel(
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
record=True,
)
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService(
workspace=config.workspace_path,
provider=agent.provider,
model=agent.model,
on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify,
interval_s=hb_cfg.interval_s,
enabled=hb_cfg.enabled,
timezone=config.agents.defaults.timezone,
p2p_shell=p2p_shell,
bus=bus,
)
if channels.enabled_channels: if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else: else:
@@ -952,7 +1147,11 @@ def _run_gateway(
if cron_status["jobs"] > 0: if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s") hb_cfg = config.gateway.heartbeat
if hb_cfg.enabled:
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
else:
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
async def _health_server(host: str, health_port: int): async def _health_server(host: str, health_port: int):
"""Lightweight HTTP health endpoint on the gateway port.""" """Lightweight HTTP health endpoint on the gateway port."""
@@ -996,21 +1195,37 @@ def _run_gateway(
console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health") console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health")
async with server: async with server:
await server.serve_forever() await server.serve_forever()
# Register Dream system job (always-on, idempotent on restart) # Register Dream system job (idempotent on restart)
dream_cfg = config.agents.defaults.dream dream_cfg = config.agents.defaults.dream
if dream_cfg.model_override: if dream_cfg.model_override:
agent.dream.model = dream_cfg.model_override agent.dream.model = dream_cfg.model_override
agent.dream.max_batch_size = dream_cfg.max_batch_size agent.dream.max_batch_size = dream_cfg.max_batch_size
agent.dream.max_iterations = dream_cfg.max_iterations agent.dream.max_iterations = dream_cfg.max_iterations
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
from nanobot.cron.types import CronJob, CronPayload from nanobot.cron.types import CronJob, CronPayload, CronSchedule
cron.register_system_job(CronJob( if dream_cfg.enabled:
id="dream", cron.register_system_job(CronJob(
name="dream", id="dream",
schedule=dream_cfg.build_schedule(config.agents.defaults.timezone), name="dream",
payload=CronPayload(kind="system_event"), schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
)) payload=CronPayload(kind="system_event"),
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}") ))
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
else:
console.print("[yellow]○[/yellow] Dream: disabled")
# Register Heartbeat system job (idempotent on restart)
if hb_cfg.enabled:
cron.register_system_job(CronJob(
id="heartbeat",
name="heartbeat",
schedule=CronSchedule(
kind="every",
every_ms=hb_cfg.interval_s * 1000,
tz=config.agents.defaults.timezone,
),
payload=CronPayload(kind="system_event"),
))
async def _open_browser_when_ready() -> None: async def _open_browser_when_ready() -> None:
"""Wait for the gateway to bind, then point the user's browser at the webui.""" """Wait for the gateway to bind, then point the user's browser at the webui."""
@@ -1038,12 +1253,12 @@ def _run_gateway(
async def run(): async def run():
try: try:
await cron.start() await cron.start()
await heartbeat.start()
tasks = [ tasks = [
agent.run(), agent.run(),
channels.start_all(), channels.start_all(),
_health_server(config.gateway.host, port),
] ]
if health_server_enabled:
tasks.append(_health_server(config.gateway.host, port))
if open_browser_url: if open_browser_url:
tasks.append(_open_browser_when_ready()) tasks.append(_open_browser_when_ready())
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
@@ -1056,7 +1271,6 @@ def _run_gateway(
console.print(traceback.format_exc()) console.print(traceback.format_exc())
finally: finally:
await agent.close_mcp() await agent.close_mcp()
heartbeat.stop()
cron.stop() cron.stop()
agent.stop() agent.stop()
await channels.stop_all() await channels.stop_all()
@@ -1089,6 +1303,7 @@ def agent(
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.providers.image_generation import image_gen_provider_configs
config = _load_runtime_config(config, workspace) config = _load_runtime_config(config, workspace)
sync_workspace_templates(config.workspace_path) sync_workspace_templates(config.workspace_path)
@@ -1103,8 +1318,6 @@ def agent(
cron_store_path = config.workspace_path / "cron" / "jobs.json" cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path) cron = CronService(cron_store_path)
p2p_shell = _resolve_p2p(config)
if logs: if logs:
logger.enable("nanobot") logger.enable("nanobot")
else: else:
@@ -1114,7 +1327,7 @@ def agent(
agent_loop = AgentLoop.from_config( agent_loop = AgentLoop.from_config(
config, bus, config, bus,
cron_service=cron, cron_service=cron,
p2p_shell=p2p_shell, image_generation_provider_configs=image_gen_provider_configs(config),
) )
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
@@ -1130,12 +1343,25 @@ def agent(
_thinking: ThinkingSpinner | None = None _thinking: ThinkingSpinner | None = None
def _make_progress(renderer: StreamRenderer | None = None): def _make_progress(renderer: StreamRenderer | None = None):
reasoning_buffer = _ReasoningBuffer()
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None: async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
ch = agent_loop.channels_config ch = agent_loop.channels_config
if _kwargs.get("reasoning_end"):
if ch and not ch.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
return
if reasoning: if reasoning:
if ch and not ch.show_reasoning: if ch and not ch.show_reasoning:
reasoning_buffer.clear()
return return
_print_cli_reasoning(content, _thinking, renderer) text = reasoning_buffer.add(content)
if text:
_print_cli_reasoning(text, _thinking, renderer)
return return
if ch and tool_hint and not ch.send_tool_hints: if ch and tool_hint and not ch.send_tool_hints:
return return
@@ -1206,6 +1432,7 @@ def agent(
turn_done.set() turn_done.set()
turn_response: list[tuple[str, dict]] = [] turn_response: list[tuple[str, dict]] = []
renderer: StreamRenderer | None = None renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer()
async def _consume_outbound(): async def _consume_outbound():
while True: while True:
@@ -1231,6 +1458,7 @@ def agent(
renderer, renderer,
agent_loop.channels_config, agent_loop.channels_config,
renderer, renderer,
reasoning_buffer,
): ):
continue continue
@@ -1271,6 +1499,7 @@ def agent(
turn_done.clear() turn_done.clear()
turn_response.clear() turn_response.clear()
reasoning_buffer.clear()
renderer = StreamRenderer( renderer = StreamRenderer(
render_markdown=markdown, render_markdown=markdown,
bot_name=config.agents.defaults.bot_name, bot_name=config.agents.defaults.bot_name,
@@ -1312,7 +1541,6 @@ def agent(
console.print("\nGoodbye!") console.print("\nGoodbye!")
break break
finally: finally:
pass
agent_loop.stop() agent_loop.stop()
outbound_task.cancel() outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True) await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
+218 -2
View File
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
get_model_suggestions, get_model_suggestions,
) )
from nanobot.config.loader import get_config_path, load_config from nanobot.config.loader import get_config_path, load_config
from nanobot.config.schema import Config from nanobot.config.schema import Config, ModelPresetConfig
console = Console() console = Console()
@@ -49,6 +49,10 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
_BACK_PRESSED = object() # Sentinel value for back navigation _BACK_PRESSED = object() # Sentinel value for back navigation
# Cache of model-preset names populated at runtime so that field handlers can
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
_MODEL_PRESET_CACHE: set[str] = set()
def _get_questionary(): def _get_questionary():
"""Return questionary or raise a clear error when wizard deps are unavailable.""" """Return questionary or raise a clear error when wizard deps are unavailable."""
@@ -588,9 +592,102 @@ def _handle_context_window_field(
setattr(working_model, field_name, new_value) setattr(working_model, field_name, new_value)
def _handle_model_preset_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'model_preset' field with a list of existing presets."""
preset_names = sorted(_MODEL_PRESET_CACHE)
choices = ["(clear/unset)"] + preset_names
default_choice = str(current_value) if current_value else "(clear/unset)"
new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED:
return
if new_value == "(clear/unset)":
setattr(working_model, field_name, None)
elif new_value is not None:
setattr(working_model, field_name, new_value)
def _handle_provider_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'provider' field with a list of registered providers."""
provider_names = sorted(_get_provider_names().keys())
choices = ["auto"] + provider_names
default_choice = str(current_value) if current_value else "auto"
new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED:
return
if new_value is not None:
setattr(working_model, field_name, new_value)
def _handle_fallback_models_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'fallback_models' field with preset-aware list management."""
from nanobot.config.schema import InlineFallbackConfig
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
preset_names = sorted(_MODEL_PRESET_CACHE)
while True:
console.clear()
console.print(f"[bold]{field_display}[/bold]")
if items:
for idx, item in enumerate(items, 1):
if isinstance(item, InlineFallbackConfig):
console.print(f" {idx}. {item.model} ({item.provider}) [inline]")
else:
console.print(f" {idx}. {item}")
else:
console.print(" [dim](empty)[/dim]")
console.print()
choices = ["[+] Add preset"]
if items:
choices.append("[-] Remove last")
choices.append("[X] Clear all")
choices.append("[Done]")
choices.append("<- Back")
answer = _get_questionary().select(
"Manage fallback models:",
choices=choices,
qmark=">",
).ask()
if answer is None or answer == "<- Back":
return
if answer == "[Done]":
setattr(working_model, field_name, items)
return
if answer == "[+] Add preset":
if not preset_names:
console.print("[yellow]! No presets defined yet.[/yellow]")
_get_questionary().press_any_key_to_continue().ask()
continue
add_choices = [p for p in preset_names if p not in items]
if not add_choices:
console.print("[yellow]! All presets already added.[/yellow]")
_get_questionary().press_any_key_to_continue().ask()
continue
picked = _select_with_back("Select preset:", add_choices)
if picked is _BACK_PRESSED or picked is None:
continue
items.append(picked)
elif answer == "[-] Remove last" and items:
items.pop()
elif answer == "[X] Clear all" and items:
items.clear()
_FIELD_HANDLERS: dict[str, Any] = { _FIELD_HANDLERS: dict[str, Any] = {
"model": _handle_model_field, "model": _handle_model_field,
"context_window_tokens": _handle_context_window_field, "context_window_tokens": _handle_context_window_field,
"model_preset": _handle_model_preset_field,
"provider": _handle_provider_field,
"fallback_models": _handle_fallback_models_field,
} }
@@ -757,6 +854,116 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]") console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
# --- Model Preset Configuration ---
def _sync_preset_cache(config: Config) -> None:
"""Synchronise the module-level preset name cache from config."""
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
def _configure_model_presets(config: Config) -> None:
"""Configure model presets (CRUD)."""
_sync_preset_cache(config)
def get_preset_choices() -> list[str]:
choices: list[str] = []
for name, preset in config.model_presets.items():
choices.append(f"{name} ({preset.model})")
choices.append("[+] Add new preset")
choices.append("<- Back")
return choices
last_preset_name: str | None = None
while True:
try:
console.clear()
_show_section_header(
"Model Presets",
"Create, edit or delete named model presets for quick switching",
)
choices = get_preset_choices()
default_choice = None
if last_preset_name:
for c in choices:
if c.startswith(last_preset_name + " ("):
default_choice = c
break
answer = _select_with_back(
"Select preset:", choices, default=default_choice
)
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break
assert isinstance(answer, str)
if answer == "[+] Add new preset":
name_input = _get_questionary().text(
"Preset name:",
validate=lambda t: True if t and t.strip() else "Name cannot be empty",
).ask()
if not name_input:
continue
name = name_input.strip()
if name in config.model_presets:
console.print(f"[yellow]! Preset '{name}' already exists[/yellow]")
_pause()
continue
if name == "default":
console.print("[yellow]! 'default' is reserved (auto-generated from Agent Settings)[/yellow]")
_pause()
continue
new_preset = ModelPresetConfig(model="")
updated = _configure_pydantic_model(new_preset, f"New Preset: {name}")
if updated is not None:
config.model_presets[name] = updated
_sync_preset_cache(config)
last_preset_name = name
continue
# Editing / deleting an existing preset
preset_name = answer.split(" (", 1)[0]
preset = config.model_presets.get(preset_name)
if preset is None:
continue
last_preset_name = preset_name
choices = ["Edit", "Cancel"]
if preset_name != "default":
choices.insert(1, "Delete")
action = _select_with_back(
f"Preset: {preset_name}",
choices,
default="Edit",
)
if action is _BACK_PRESSED or action == "Cancel" or action is None:
continue
if action == "Delete":
confirm = _get_questionary().confirm(
f"Delete preset '{preset_name}'?",
default=False,
).ask()
if confirm:
del config.model_presets[preset_name]
_sync_preset_cache(config)
last_preset_name = None
continue
if action == "Edit":
updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}")
if updated is not None:
config.model_presets[preset_name] = updated
_sync_preset_cache(config)
except KeyboardInterrupt:
console.print("\n[dim]Returning to main menu...[/dim]")
break
# --- Provider Configuration --- # --- Provider Configuration ---
@@ -948,7 +1155,7 @@ _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None), "Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None), "Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None), "API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
"Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None), "Gateway": ("Gateway Settings", "Configure server host, port", None),
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}), "Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
} }
@@ -1043,6 +1250,12 @@ def _show_summary(config: Config) -> None:
channel_rows.append((display, status)) channel_rows.append((display, status))
_print_summary_panel(channel_rows, "Chat Channels") _print_summary_panel(channel_rows, "Chat Channels")
# Model Presets
preset_rows = []
for name, preset in config.model_presets.items():
preset_rows.append((name, f"{preset.model} (ctx={preset.context_window_tokens})"))
_print_summary_panel(preset_rows, "Model Presets")
# Settings sections # Settings sections
for title, model in [ for title, model in [
("Agent Settings", config.agents.defaults), ("Agent Settings", config.agents.defaults),
@@ -1112,6 +1325,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
original_config = base_config.model_copy(deep=True) original_config = base_config.model_copy(deep=True)
config = base_config.model_copy(deep=True) config = base_config.model_copy(deep=True)
_sync_preset_cache(config)
last_main_choice: str | None = None last_main_choice: str | None = None
while True: while True:
@@ -1123,6 +1337,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
"What would you like to configure?", "What would you like to configure?",
choices=[ choices=[
"[P] LLM Provider", "[P] LLM Provider",
"[M] Model Presets",
"[C] Chat Channel", "[C] Chat Channel",
"[H] Channel Common", "[H] Channel Common",
"[A] Agent Settings", "[A] Agent Settings",
@@ -1149,6 +1364,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
_menu_dispatch = { _menu_dispatch = {
"[P] LLM Provider": lambda: _configure_providers(config), "[P] LLM Provider": lambda: _configure_providers(config),
"[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config), "[C] Chat Channel": lambda: _configure_channels(config),
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"), "[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"), "[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
+1 -1
View File
@@ -123,7 +123,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session.""" """Cancel all active tasks and subagents for the session."""
loop = ctx.loop loop = ctx.loop
msg = ctx.msg msg = ctx.msg
total = await loop._cancel_active_tasks(msg.session_key) total = await loop._cancel_active_tasks(ctx.key)
content = f"Stopped {total} task(s)." if total else "No active task to stop." content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage( return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, channel=msg.channel, chat_id=msg.chat_id, content=content,
+7 -1
View File
@@ -10,10 +10,11 @@ import pydantic
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from nanobot.config.schema import Config from nanobot.config.schema import Config, _resolve_tool_config_refs
# Global variable to store current config path (for multi-instance support) # Global variable to store current config path (for multi-instance support)
_current_config_path: Path | None = None _current_config_path: Path | None = None
_schema_refs_ready = False
def set_config_path(path: Path) -> None: def set_config_path(path: Path) -> None:
@@ -39,6 +40,11 @@ def load_config(config_path: Path | None = None) -> Config:
Returns: Returns:
Loaded configuration object. Loaded configuration object.
""" """
global _schema_refs_ready
if not _schema_refs_ready:
_resolve_tool_config_refs()
_schema_refs_ready = True
path = config_path or get_config_path() path = config_path or get_config_path()
config = Config() config = Config()
+39 -17
View File
@@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
from nanobot.cron.types import CronSchedule from nanobot.cron.types import CronSchedule
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig from nanobot.agent.tools.shell import ExecToolConfig
@@ -36,6 +37,7 @@ class ChannelsConfig(Base):
send_progress: bool = True # stream agent's text progress to the channel send_progress: bool = True # stream agent's text progress to the channel
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
show_reasoning: bool = True # surface model reasoning when channel implements it show_reasoning: bool = True # surface model reasoning when channel implements it
extract_document_text: bool = True # extract text from document attachments before sending to the model
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included) send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai" transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
@@ -46,6 +48,7 @@ class DreamConfig(Base):
_HOUR_MS = 3_600_000 _HOUR_MS = 3_600_000
enabled: bool = True # Register the periodic Dream consolidation job on startup
interval_h: int = Field(default=2, ge=1) # Every 2 hours by default interval_h: int = Field(default=2, ge=1) # Every 2 hours by default
cron: str | None = Field(default=None, exclude=True) # Legacy compatibility override cron: str | None = Field(default=None, exclude=True) # Legacy compatibility override
model_override: str | None = Field( model_override: str | None = Field(
@@ -91,6 +94,7 @@ FallbackCandidate = str | InlineFallbackConfig
class ModelPresetConfig(Base): class ModelPresetConfig(Base):
"""A named set of model + generation parameters for quick switching.""" """A named set of model + generation parameters for quick switching."""
label: str | None = None
model: str model: str
provider: str = "auto" provider: str = "auto"
max_tokens: int = 8192 max_tokens: int = 8192
@@ -169,8 +173,9 @@ class ProviderConfig(Base):
api_key: str | None = None api_key: str | None = None
api_base: str | None = None api_base: str | None = None
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
extra_body: dict[str, Any] | None = None # Extra fields merged into every request body extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
class BedrockProviderConfig(ProviderConfig): class BedrockProviderConfig(ProviderConfig):
@@ -190,6 +195,7 @@ class ProvidersConfig(Base):
openai: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig)
openrouter: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
huggingface: ProviderConfig = Field(default_factory=ProviderConfig) huggingface: ProviderConfig = Field(default_factory=ProviderConfig)
skywork: ProviderConfig = Field(default_factory=ProviderConfig) # Skywork / APIFree API gateway
deepseek: ProviderConfig = Field(default_factory=ProviderConfig) deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
groq: ProviderConfig = Field(default_factory=ProviderConfig) groq: ProviderConfig = Field(default_factory=ProviderConfig)
zhipu: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
@@ -207,8 +213,10 @@ class ProvidersConfig(Base):
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰) stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米) xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international) byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
@@ -218,9 +226,19 @@ class ProvidersConfig(Base):
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆) qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys) nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
@model_validator(mode="after")
def _validate_api_type_scope(self) -> "ProvidersConfig":
for name in self.__class__.model_fields:
if name == "openai":
continue
provider = getattr(self, name, None)
if isinstance(provider, ProviderConfig) and provider.api_type != "auto":
raise ValueError("providers.<name>.api_type is only supported for providers.openai")
return self
class HeartbeatConfig(Base): class HeartbeatConfig(Base):
"""Heartbeat service configuration.""" """Heartbeat service configuration (now backed by cron)."""
enabled: bool = True enabled: bool = True
interval_s: int = 30 * 60 # 30 minutes interval_s: int = 30 * 60 # 30 minutes
@@ -250,6 +268,7 @@ class MCPServerConfig(Base):
command: str = "" # Stdio: command to run (e.g. "npx") command: str = "" # Stdio: command to run (e.g. "npx")
args: list[str] = Field(default_factory=list) # Stdio: command arguments args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
cwd: str = "" # Stdio: working directory for MCP server runtime artifacts
url: str = "" # HTTP/SSE: endpoint URL url: str = "" # HTTP/SSE: endpoint URL
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
tool_timeout: int = 30 # seconds before a tool call is cancelled tool_timeout: int = 30 # seconds before a tool call is cancelled
@@ -273,28 +292,25 @@ class ToolsConfig(Base):
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig")) web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig")) exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig")) my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
image_generation: ImageGenerationToolConfig = Field( image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"), default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
) )
restrict_to_workspace: bool = False # restrict all tool access to workspace directory restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible
webui_allow_local_service_access: bool = Field(
default=True,
validation_alias=AliasChoices(
"webuiAllowLocalServiceAccess",
"webui_allow_local_service_access",
"allowLocalPreviewAccess",
"allow_local_preview_access",
),
) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
class P2PConfig(Base):
"""P2P collaboration network configuration."""
enabled: bool = False
agent_id: str = ""
description: str = ""
capabilities: list[str] = Field(default_factory=list)
allow_from: list[str] = Field(default_factory=lambda: ["*"])
max_concurrent_tasks: int = 3
poll_interval: float = 5.0
mailboxes_root: str = "~/.nanobot/mailboxes"
class Config(BaseSettings): class Config(BaseSettings):
"""Root configuration for nanobot.""" """Root configuration for nanobot."""
@@ -308,7 +324,11 @@ class Config(BaseSettings):
default_factory=dict, default_factory=dict,
validation_alias=AliasChoices("modelPresets", "model_presets"), validation_alias=AliasChoices("modelPresets", "model_presets"),
) )
mailbox: P2PConfig = Field(default_factory=P2PConfig)
def __init__(self, **values: Any) -> None:
if not type(self).__pydantic_complete__:
_resolve_tool_config_refs()
super().__init__(**values)
@model_validator(mode="after") @model_validator(mode="after")
def _validate_model_preset(self) -> "Config": def _validate_model_preset(self) -> "Config":
@@ -473,6 +493,7 @@ def _resolve_tool_config_refs() -> None:
""" """
import sys import sys
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig from nanobot.agent.tools.shell import ExecToolConfig
@@ -481,6 +502,7 @@ def _resolve_tool_config_refs() -> None:
# Re-export into this module's namespace # Re-export into this module's namespace
mod = sys.modules[__name__] mod = sys.modules[__name__]
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined] mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined] mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined] mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined] mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
+13 -1
View File
@@ -1,6 +1,18 @@
"""Cron service for scheduled agent tasks.""" """Cron service for scheduled agent tasks."""
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronSchedule from nanobot.cron.types import CronJob, CronSchedule
__all__ = ["CronService", "CronJob", "CronSchedule"] __all__ = ["CronService", "CronJob", "CronSchedule"]
_LAZY = {"CronService": ".service"}
def __getattr__(name: str):
module_path = _LAZY.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
-5
View File
@@ -1,5 +0,0 @@
"""Heartbeat service for periodic agent wake-ups."""
from nanobot.heartbeat.service import HeartbeatService
__all__ = ["HeartbeatService"]
-267
View File
@@ -1,267 +0,0 @@
"""Heartbeat service - periodic agent wake-up to check for tasks."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Coroutine
from loguru import logger
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
_HEARTBEAT_TOOL = [
{
"type": "function",
"function": {
"name": "heartbeat",
"description": "Report heartbeat decision after reviewing tasks.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["skip", "run"],
"description": "skip = nothing to do, run = has active tasks",
},
"tasks": {
"type": "string",
"description": "Natural-language summary of active tasks (required for run)",
},
},
"required": ["action"],
},
},
}
]
class HeartbeatService:
"""
Periodic heartbeat service that wakes the agent to check for tasks.
Phase 1 (decision): reads HEARTBEAT.md and asks the LLM via a virtual
tool call whether there are active tasks. This avoids free-text parsing
and the unreliable HEARTBEAT_OK token.
Phase 2 (execution): only triggered when Phase 1 returns ``run``. The
``on_execute`` callback runs the task through the full agent loop and
returns the result to deliver.
"""
def __init__(
self,
workspace: Path,
provider: LLMProvider,
model: str,
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
interval_s: int = 30 * 60,
enabled: bool = True,
timezone: str | None = None,
p2p_shell: Any | None = None,
bus: Any | None = None,
):
self.workspace = workspace
self.provider = provider
self.model = model
self.on_execute = on_execute
self.on_notify = on_notify
self.interval_s = interval_s
self.enabled = enabled
self.timezone = timezone
self.p2p_shell = p2p_shell
self.bus = bus
self._running = False
self._task: asyncio.Task | None = None
self._last_inbox_scan: float = 0.0
@property
def heartbeat_file(self) -> Path:
return self.workspace / "HEARTBEAT.md"
def _read_heartbeat_file(self) -> str | None:
if self.heartbeat_file.exists():
try:
return self.heartbeat_file.read_text(encoding="utf-8")
except Exception:
return None
return None
async def _decide(self, content: str) -> tuple[str, str]:
"""Phase 1: ask LLM to decide skip/run via virtual tool call.
Returns (action, tasks) where action is 'skip' or 'run'.
"""
from nanobot.utils.helpers import current_time_str
response = await self.provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
{"role": "user", "content": (
f"Current Time: {current_time_str(self.timezone)}\n\n"
"Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
f"{content}"
)},
],
tools=_HEARTBEAT_TOOL,
model=self.model,
)
if not response.should_execute_tools:
if response.has_tool_calls:
logger.warning(
"Ignoring heartbeat tool calls under finish_reason='{}'",
response.finish_reason,
)
return "skip", ""
args = response.tool_calls[0].arguments
return args.get("action", "skip"), args.get("tasks", "")
async def start(self) -> None:
"""Start the heartbeat service."""
if not self.enabled:
logger.info("Heartbeat disabled")
return
if self._running:
logger.warning("Heartbeat already running")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("Heartbeat started (every {}s)", self.interval_s)
def stop(self) -> None:
"""Stop the heartbeat service."""
self._running = False
if self._task:
self._task.cancel()
self._task = None
async def _run_loop(self) -> None:
"""Main heartbeat loop."""
while self._running:
try:
await asyncio.sleep(self.interval_s)
if self._running:
await self._tick()
except asyncio.CancelledError:
break
except Exception:
logger.exception("Heartbeat error")
@staticmethod
def _is_deliverable(response: str) -> bool:
"""Check if a heartbeat response is suitable for user delivery.
Filters out two classes of bad output before the evaluator runs:
1. **Finalization fallback** the runner hit empty-response retries
and produced a canned error message. For heartbeat, empty output
is a valid "nothing to report" outcome, not a failure.
2. **Leaked reasoning** the model reflected internal file names,
decision logic, or meta-commentary instead of a user-facing report.
"""
text = response.lower()
# Runner finalization fallback
if "couldn't produce a final answer" in text:
return False
# Leaked internal reasoning patterns
leaked_patterns = [
"heartbeat.md",
"awareness.md",
"judgment call:",
"decision logic",
"valid options are",
"my instructions",
"i am supposed to",
"strict heartbeat interpretation",
]
if any(pattern in text for pattern in leaked_patterns):
return False
return True
async def _tick(self) -> None:
"""Execute a single heartbeat tick."""
from nanobot.utils.evaluator import evaluate_response
# --- P2P inbox scan ---
if self.p2p_shell and self.bus:
try:
new_msgs = self.p2p_shell.scan_new_inbox(since=self._last_inbox_scan)
if new_msgs:
self._last_inbox_scan = time.time()
from nanobot.bus.events import InboundMessage
for msg in new_msgs:
await self.bus.publish_inbound(
InboundMessage(
channel="p2p",
sender_id=msg.get("from", "unknown"),
chat_id=msg.get("task_id", ""),
content=msg.get("payload", {}).get("description", ""),
metadata={"p2p_msg": msg},
)
)
logger.info(
"Heartbeat: injected P2P task {} from {}",
msg.get("task_id", ""),
msg.get("from", "unknown"),
)
except Exception:
logger.exception("Heartbeat P2P scan failed")
# --- Legacy heartbeat file check ---
content = self._read_heartbeat_file()
if not content:
logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
return
logger.info("Heartbeat: checking for tasks...")
try:
action, tasks = await self._decide(content)
if action != "run":
logger.info("Heartbeat: OK (nothing to report)")
return
logger.info("Heartbeat: tasks found, executing...")
if self.on_execute:
response = await self.on_execute(tasks)
if not response:
logger.info("Heartbeat: no response from execution")
return
if not self._is_deliverable(response):
logger.info(
"Heartbeat: suppressed non-deliverable response ({})",
response[:80],
)
return
should_notify = await evaluate_response(
response, tasks, self.provider, self.model,
)
if should_notify and self.on_notify:
logger.info("Heartbeat: completed, delivering response")
await self.on_notify(response)
else:
logger.info("Heartbeat: silenced by post-run evaluation")
except Exception:
logger.exception("Heartbeat execution failed")
async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat."""
content = self._read_heartbeat_file()
if not content:
return None
action, tasks = await self._decide(content)
if action != "run" or not self.on_execute:
return None
return await self.on_execute(tasks)
+2 -4
View File
@@ -8,6 +8,7 @@ from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.providers.image_generation import image_gen_provider_configs
@dataclass(slots=True) @dataclass(slots=True)
@@ -63,10 +64,7 @@ class Nanobot:
loop = AgentLoop.from_config( loop = AgentLoop.from_config(
config, config,
image_generation_provider_configs={ image_generation_provider_configs=image_gen_provider_configs(config),
"openrouter": config.providers.openrouter,
"aihubmix": config.providers.aihubmix,
},
) )
return cls(loop) return cls(loop)
-5
View File
@@ -1,5 +0,0 @@
"""P2P inter-agent coordination layer."""
from nanobot.p2p.shell import P2PShell
__all__ = ["P2PShell"]
-426
View File
@@ -1,426 +0,0 @@
"""P2P shell: filesystem-backed inter-agent coordination.
All state is stored in the mailbox filesystem; this class is stateless.
Restarting the gateway restores all task state by scanning files.
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any, Literal
from loguru import logger
class P2PShell:
"""Stateless P2P coordination shell backed by the mailbox filesystem."""
def __init__(self, agent_id: str, mailboxes_root: str):
self.agent_id = agent_id
self.root = Path(mailboxes_root).expanduser()
self.inbox = self.root / agent_id / "inbox"
self.processed = self.root / agent_id / "processed"
self.links_dir = self.root / "_links"
self.windows_dir = self.root / "_windows"
for d in (self.inbox, self.processed, self.links_dir, self.windows_dir):
d.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# Discovery
# ------------------------------------------------------------------
def discover(self, capability: str, top_k: int = 3) -> list[dict[str, Any]]:
"""Read _registry.json and return candidates matching capability."""
registry = self._load_json(self.root / "_registry.json", default={})
candidates: list[dict[str, Any]] = []
for aid, info in registry.items():
if aid == self.agent_id:
continue
caps = info.get("capabilities", [])
if capability.lower() in " ".join(caps).lower():
candidates.append({"agent_id": aid, **info})
# Sort: idle first, then by current task load
candidates.sort(key=lambda x: (x.get("status") != "idle", x.get("current_tasks", 0)))
return candidates[:top_k]
def heartbeat(self, description: str, capabilities: list[str]) -> None:
"""Write self state into the shared _registry.json."""
registry = self._load_json(self.root / "_registry.json", default={})
registry[self.agent_id] = {
"description": description,
"capabilities": capabilities,
"status": "idle",
"last_heartbeat": int(time.time()),
"endpoint": "",
}
self._atomic_write(self.root / "_registry.json", registry)
# ------------------------------------------------------------------
# Task dispatch
# ------------------------------------------------------------------
def dispatch(
self,
to: str,
parent_task_id: str | None,
description: str,
deadline_seconds: int = 300,
allow_redelegation: bool = True,
) -> dict[str, Any]:
"""Write a task into the target agent's inbox and return a receipt."""
task_id = (
f"{parent_task_id}.{int(time.time())}"
if parent_task_id
else f"root_{int(time.time())}"
)
depth = self._get_depth(parent_task_id) if parent_task_id else 0
if depth >= 3:
return {"status": "rejected", "reason": "max_depth_exceeded"}
if parent_task_id and self._is_ancestor(to, parent_task_id):
return {"status": "rejected", "reason": "ancestry_loop"}
if not self._circuit_allow(to):
failover = self._find_failover(to)
return {"status": "circuit_open", "failover_to": failover}
target_inbox = self.root / to / "inbox"
target_inbox.mkdir(parents=True, exist_ok=True)
if list(target_inbox.glob(f"task_{task_id}_from_{self.agent_id}_*.json")):
return {"status": "dispatched", "task_id": task_id, "note": "cached"}
ancestry = (
(self._get_ancestry(parent_task_id) + [self.agent_id])
if parent_task_id
else [self.agent_id]
)
msg: dict[str, Any] = {
"version": "p2p/v1",
"type": "task_dispatch",
"from": self.agent_id,
"to": to,
"task_id": task_id,
"ancestry": ancestry,
"depth": depth + 1,
"payload": {
"description": description,
"allow_redelegation": allow_redelegation,
},
"deadline": int(time.time()) + deadline_seconds,
"timestamp": int(time.time()),
}
path = target_inbox / f"task_{task_id}_from_{self.agent_id}_{os.urandom(4).hex()}.json"
self._atomic_write(path, msg)
logger.info("P2P dispatch: {} -> {} (task_id={})", self.agent_id, to, task_id)
return {"status": "dispatched", "task_id": task_id, "depth": depth + 1}
def poll(self, task_id: str) -> dict[str, Any]:
"""Scan inbox/processed and return task status."""
# Check processed results first
results = list(self.processed.glob(f"result_{task_id}_from_*.json"))
if results:
data = self._load_json(results[0])
payload = data.get("payload", {})
return {
"status": payload.get("outcome", "completed"),
"result": payload.get("content", ""),
"from": data["from"],
}
# Check inbox for results (not yet moved to processed)
inbox_results = list(self.inbox.glob(f"result_{task_id}_from_*.json"))
if inbox_results:
data = self._load_json(inbox_results[0])
payload = data.get("payload", {})
return {
"status": payload.get("outcome", "completed"),
"result": payload.get("content", ""),
"from": data["from"],
}
# Check inbox for pending task dispatches
pending = list(self.inbox.glob(f"task_{task_id}_from_*.json"))
if pending:
data = self._load_json(pending[0])
deadline = data.get("deadline", 0)
elapsed = int(time.time() - data["timestamp"])
if time.time() > deadline:
return {"status": "timeout", "elapsed": elapsed}
return {"status": "pending", "elapsed": elapsed}
return {"status": "not_found"}
# ------------------------------------------------------------------
# Aggregation (broadcast + check)
# ------------------------------------------------------------------
def broadcast(
self,
task_id: str,
subtasks: list[dict[str, Any]],
aggregation_timeout: int = 30,
) -> dict[str, Any]:
"""Write bid requests to candidate agents and create a window descriptor."""
targets: list[tuple[str, str]] = [] # (subtask_id, agent_id)
for sub in subtasks:
caps = sub.get("capability", "")
found = self.discover(caps, top_k=3)
targets.extend([(sub["subtask_id"], a["agent_id"]) for a in found])
for subtask_id, target in targets:
msg: dict[str, Any] = {
"version": "p2p/v1",
"type": "bid_request",
"from": self.agent_id,
"to": target,
"task_id": task_id,
"subtask_id": subtask_id,
"payload": sub,
"deadline": int(time.time()) + aggregation_timeout,
"timestamp": int(time.time()),
}
target_inbox = self.root / target / "inbox"
target_inbox.mkdir(parents=True, exist_ok=True)
path = target_inbox / f"bid_{task_id}_{subtask_id}_from_{self.agent_id}.json"
self._atomic_write(path, msg)
window: dict[str, Any] = {
"task_id": task_id,
"mode": "bid",
"expected": len(targets),
"deadline": int(time.time()) + aggregation_timeout,
"created_at": int(time.time()),
}
self._atomic_write(self.windows_dir / f"{task_id}.json", window)
logger.info(
"P2P broadcast: {} invited {} agents for task_id={}",
self.agent_id,
len(targets),
task_id,
)
return {"status": "bidding_opened", "task_id": task_id, "invited": len(targets)}
def check_aggregation(self, task_id: str) -> dict[str, Any]:
"""Lazily check aggregation status by scanning files."""
window_path = self.windows_dir / f"{task_id}.json"
if not window_path.exists():
return {"status": "no_window"}
window = self._load_json(window_path)
mode = window.get("mode", "bid")
deadline = window.get("deadline", 0)
pattern = f"{mode}_{task_id}_*_from_*.json"
entries: list[dict[str, Any]] = []
for f in self.inbox.glob(pattern):
data = self._load_json(f)
entries.append(
{
"from": data.get("from", ""),
"subtask_id": data.get("subtask_id", ""),
"payload": data.get("payload", {}),
}
)
is_timeout = time.time() > deadline
is_full = window.get("expected") and len(entries) >= window["expected"]
if is_timeout or is_full:
self._atomic_write(
self.processed / f"window_{task_id}.json",
{**window, "closed_at": int(time.time()), "received": len(entries)},
)
window_path.unlink(missing_ok=True)
return {
"status": "closed",
"mode": mode,
"entries": entries,
"reason": "timeout" if is_timeout else "full",
}
return {
"status": "pending",
"received": len(entries),
"expected": window.get("expected"),
"seconds_remaining": max(0, deadline - int(time.time())),
}
# ------------------------------------------------------------------
# Result reporting
# ------------------------------------------------------------------
def report_result(
self,
to: str,
task_id: str,
outcome: Literal["completed", "failed", "aborted"],
content: str,
callback: dict[str, Any] | None = None,
) -> None:
"""Worker calls this to write a result into the manager's inbox."""
msg: dict[str, Any] = {
"version": "p2p/v1",
"type": "result",
"from": self.agent_id,
"to": to,
"task_id": task_id,
"payload": {"outcome": outcome, "content": content},
"timestamp": int(time.time()),
}
if callback:
msg["callback"] = callback
target_inbox = self.root / to / "inbox"
target_inbox.mkdir(parents=True, exist_ok=True)
path = target_inbox / f"result_{task_id}_from_{self.agent_id}_{os.urandom(4).hex()}.json"
self._atomic_write(path, msg)
logger.info("P2P result: {} -> {} (task_id={}, outcome={})", self.agent_id, to, task_id, outcome)
# ------------------------------------------------------------------
# Finalization
# ------------------------------------------------------------------
def finalize(self, task_id: str, outcome: str, reason: str = "") -> None:
"""Move all task files from inbox to processed and mark outcome."""
for src in list(self.inbox.glob(f"*{task_id}*")):
data = self._load_json(src)
data.setdefault("payload", {})
data["payload"]["outcome"] = outcome
data["payload"]["reason"] = reason
dst = self.processed / src.name
self._atomic_write(dst, data)
src.unlink(missing_ok=True)
logger.info("P2P finalize: task_id={} outcome={}", task_id, outcome)
# ------------------------------------------------------------------
# Circuit breaker
# ------------------------------------------------------------------
def _circuit_allow(self, to: str) -> bool:
link = self._load_json(
self.links_dir / f"{to}.json",
default={"failures": 0, "last_failure": 0, "open": False},
)
if not link.get("open"):
return True
backoff = 300 * (2 ** max(0, link.get("failures", 0) - 3))
if time.time() - link.get("last_failure", 0) > backoff:
link["open"] = False
self._atomic_write(self.links_dir / f"{to}.json", link)
return True
return False
def record_failure(self, to: str) -> None:
link = self._load_json(
self.links_dir / f"{to}.json",
default={"failures": 0, "last_failure": 0, "open": False},
)
link["failures"] = link.get("failures", 0) + 1
link["last_failure"] = int(time.time())
if link["failures"] >= 3:
link["open"] = True
self._atomic_write(self.links_dir / f"{to}.json", link)
def record_success(self, to: str) -> None:
link = self._load_json(
self.links_dir / f"{to}.json",
default={"failures": 0, "last_failure": 0, "open": False},
)
link["failures"] = 0
link["open"] = False
self._atomic_write(self.links_dir / f"{to}.json", link)
# ------------------------------------------------------------------
# Inbox scanning (for HeartbeatService)
# ------------------------------------------------------------------
def scan_inbox(self) -> list[dict[str, Any]]:
"""Return all task_dispatch messages currently in inbox."""
messages: list[dict[str, Any]] = []
for f in sorted(self.inbox.glob("task_*_from_*.json"), key=lambda p: p.stat().st_mtime):
data = self._load_json(f)
# Skip expired tasks
if time.time() > data.get("deadline", 0):
continue
data["_filename"] = f.name
messages.append(data)
return messages
def scan_new_inbox(self, since: float | None = None) -> list[dict[str, Any]]:
"""Return inbox messages newer than the given timestamp."""
messages: list[dict[str, Any]] = []
for f in self.inbox.glob("task_*_from_*.json"):
mtime = f.stat().st_mtime
if since is not None and mtime <= since:
continue
data = self._load_json(f)
if time.time() > data.get("deadline", 0):
continue
data["_filename"] = f.name
data["_mtime"] = mtime
messages.append(data)
return sorted(messages, key=lambda x: x.get("_mtime", 0))
def mark_processed(self, filename: str) -> None:
"""Move a single inbox file to processed."""
src = self.inbox / filename
if not src.exists():
return
dst = self.processed / filename
try:
import shutil
shutil.move(str(src), str(dst))
except Exception:
logger.warning("Failed to mark processed: {}", filename)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _load_json(self, path: Path, default: Any | None = None) -> Any:
if not path.exists():
return default if default is not None else {}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _atomic_write(self, path: Path, data: dict[str, Any]) -> None:
tmp = path.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
tmp.rename(path)
def _get_depth(self, task_id: str) -> int:
return task_id.count(".")
def _is_ancestor(self, agent_id: str, parent_task_id: str) -> bool:
for f in list(self.processed.glob(f"*{parent_task_id}*")) + list(
self.inbox.glob(f"*{parent_task_id}*")
):
data = self._load_json(f)
if agent_id in data.get("ancestry", []):
return True
return False
def _get_ancestry(self, task_id: str) -> list[str]:
for f in list(self.processed.glob(f"*{task_id}*")) + list(
self.inbox.glob(f"*{task_id}*")
):
data = self._load_json(f)
return data.get("ancestry", [])
return []
def _find_failover(self, to: str) -> str | None:
registry = self._load_json(self.root / "_registry.json", default={})
target_caps = registry.get(to, {}).get("capabilities", [])
for aid, info in registry.items():
if aid == to:
continue
if any(c in info.get("capabilities", []) for c in target_caps):
return aid
return None
+49 -3
View File
@@ -45,13 +45,21 @@ class AnthropicProvider(LLMProvider):
if api_key: if api_key:
client_kw["api_key"] = api_key client_kw["api_key"] = api_key
if api_base: if api_base:
client_kw["base_url"] = api_base client_kw["base_url"] = self._normalize_base_url(api_base)
if extra_headers: if extra_headers:
client_kw["default_headers"] = extra_headers client_kw["default_headers"] = extra_headers
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification. # Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
client_kw["max_retries"] = 0 client_kw["max_retries"] = 0
self._client = AsyncAnthropic(**client_kw) self._client = AsyncAnthropic(**client_kw)
@staticmethod
def _normalize_base_url(api_base: str) -> str:
"""Anthropic SDK appends /v1 to request paths internally."""
normalized = api_base.rstrip("/")
if normalized.endswith("/v1"):
return normalized[: -len("/v1")]
return normalized
@classmethod @classmethod
def _handle_error(cls, e: Exception) -> LLMResponse: def _handle_error(cls, e: Exception) -> LLMResponse:
response = getattr(e, "response", None) response = getattr(e, "response", None)
@@ -228,6 +236,13 @@ class AnthropicProvider(LLMProvider):
if converted: if converted:
result.append(converted) result.append(converted)
continue continue
if not item.get("type"):
# Anthropic requires every content block to declare a "type".
# A tool that returned a bare dict (or a list of dicts) lands
# here; coerce it to a text block instead of emitting a block
# the API rejects with "content.0.type: Field required".
result.append({"type": "text", "text": str(item)})
continue
result.append(item) result.append(item)
return result or "(empty)" return result or "(empty)"
@@ -590,6 +605,7 @@ class AnthropicProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
kwargs = self._build_kwargs( kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature, messages, tools, model, max_tokens, temperature,
@@ -598,11 +614,12 @@ class AnthropicProvider(LLMProvider):
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try: try:
async with self._client.messages.stream(**kwargs) as stream: async with self._client.messages.stream(**kwargs) as stream:
if on_content_delta or on_thinking_delta: if on_content_delta or on_thinking_delta or on_tool_call_delta:
# Idle timeout must track *any* SSE chunk (thinking_delta, # Idle timeout must track *any* SSE chunk (thinking_delta,
# tool JSON deltas, etc.), not only text_stream tokens. # tool JSON deltas, etc.), not only text_stream tokens.
# Otherwise extended thinking can stall text_stream for minutes # Otherwise extended thinking can stall text_stream for minutes
# while the connection is healthy (e.g. MiniMax Anthropic). # while the connection is healthy (e.g. MiniMax Anthropic).
tool_blocks: dict[int, dict[str, str]] = {}
while True: while True:
try: try:
chunk = await asyncio.wait_for( chunk = await asyncio.wait_for(
@@ -611,7 +628,22 @@ class AnthropicProvider(LLMProvider):
) )
except StopAsyncIteration: except StopAsyncIteration:
break break
if ( if chunk.type == "content_block_start":
block = getattr(chunk, "content_block", None)
if getattr(block, "type", None) == "tool_use":
index = int(getattr(chunk, "index", 0) or 0)
state = {
"call_id": str(getattr(block, "id", "") or ""),
"name": str(getattr(block, "name", "") or ""),
}
tool_blocks[index] = state
if on_tool_call_delta:
await on_tool_call_delta({
"index": index,
**state,
"arguments_delta": "",
})
elif (
chunk.type == "content_block_delta" chunk.type == "content_block_delta"
and getattr(chunk.delta, "type", None) == "thinking_delta" and getattr(chunk.delta, "type", None) == "thinking_delta"
): ):
@@ -625,6 +657,20 @@ class AnthropicProvider(LLMProvider):
text = getattr(chunk.delta, "text", None) or "" text = getattr(chunk.delta, "text", None) or ""
if text and on_content_delta: if text and on_content_delta:
await on_content_delta(text) await on_content_delta(text)
elif (
chunk.type == "content_block_delta"
and getattr(chunk.delta, "type", None) == "input_json_delta"
):
partial = getattr(chunk.delta, "partial_json", None) or ""
if partial and on_tool_call_delta:
index = int(getattr(chunk, "index", 0) or 0)
state = tool_blocks.get(index, {})
await on_tool_call_delta({
"index": index,
"call_id": state.get("call_id", ""),
"name": state.get("name", ""),
"arguments_delta": partial,
})
response = await asyncio.wait_for( response = await asyncio.wait_for(
stream.get_final_message(), stream.get_final_message(),
timeout=idle_timeout_s, timeout=idle_timeout_s,
+2 -1
View File
@@ -158,6 +158,7 @@ class AzureOpenAIProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
_ = on_thinking_delta _ = on_thinking_delta
body = self._build_body( body = self._build_body(
@@ -169,7 +170,7 @@ class AzureOpenAIProvider(LLMProvider):
try: try:
stream = await self._client.responses.create(**body) stream = await self._client.responses.create(**body)
content, tool_calls, finish_reason, usage, reasoning_content = ( content, tool_calls, finish_reason, usage, reasoning_content = (
await consume_sdk_stream(stream, on_content_delta) await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
) )
return LLMResponse( return LLMResponse(
content=content or None, content=content or None,
+47 -4
View File
@@ -70,11 +70,11 @@ class LLMResponse:
@property @property
def should_execute_tools(self) -> bool: def should_execute_tools(self) -> bool:
"""Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``. """Tools execute only when has_tool_calls AND finish_reason is a tool-capable stop.
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220).""" Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
if not self.has_tool_calls: if not self.has_tool_calls:
return False return False
return self.finish_reason in ("tool_calls", "stop") return self.finish_reason in ("tool_calls", "function_call", "stop")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -112,6 +112,7 @@ class LLMProvider(ABC):
"server error", "server error",
"temporarily unavailable", "temporarily unavailable",
"速率限制", "速率限制",
"访问量过大",
) )
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429}) _RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"}) _TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
@@ -314,6 +315,29 @@ class LLMProvider(ABC):
return cls._is_transient_error(response.content) return cls._is_transient_error(response.content)
@classmethod
def is_arrearage_response(cls, response: LLMResponse) -> bool:
"""Detect API-key arrearage / quota / billing errors that won't clear on retry.
These surface as HTTP 402 or as billing semantic tokens (e.g.
``insufficient_quota``, ``payment_required``); reuses the same token and
text markers the 429 retry policy treats as non-retryable.
"""
if response.error_status_code is not None and int(response.error_status_code) == 402:
return True
type_token = cls._normalize_error_token(response.error_type)
code_token = cls._normalize_error_token(response.error_code)
if any(
token in cls._NON_RETRYABLE_429_ERROR_TOKENS
for token in (type_token, code_token)
if token is not None
):
return True
content = (response.content or "").lower()
return any(marker in content for marker in cls._NON_RETRYABLE_429_TEXT_MARKERS)
@staticmethod @staticmethod
def _normalize_error_token(value: Any) -> str | None: def _normalize_error_token(value: Any) -> str | None:
if value is None: if value is None:
@@ -500,6 +524,7 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
"""Stream a chat completion, calling *on_content_delta* for each text chunk. """Stream a chat completion, calling *on_content_delta* for each text chunk.
@@ -513,7 +538,7 @@ class LLMProvider(ABC):
full content as a single delta. Providers that support native full content as a single delta. Providers that support native
streaming should override this method. streaming should override this method.
""" """
_ = on_thinking_delta _ = on_thinking_delta, on_tool_call_delta
response = await self.chat( response = await self.chat(
messages=messages, tools=tools, model=model, messages=messages, tools=tools, model=model,
max_tokens=max_tokens, temperature=temperature, max_tokens=max_tokens, temperature=temperature,
@@ -543,6 +568,7 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
retry_mode: str = "standard", retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None, on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
@@ -554,12 +580,22 @@ class LLMProvider(ABC):
if reasoning_effort is self._SENTINEL: if reasoning_effort is self._SENTINEL:
reasoning_effort = self.generation.reasoning_effort reasoning_effort = self.generation.reasoning_effort
has_streamed_content = False
async def _tracking_delta(text: str) -> None:
nonlocal has_streamed_content
if text:
has_streamed_content = True
if on_content_delta:
await on_content_delta(text)
kw: dict[str, Any] = dict( kw: dict[str, Any] = dict(
messages=messages, tools=tools, model=model, messages=messages, tools=tools, model=model,
max_tokens=max_tokens, temperature=temperature, max_tokens=max_tokens, temperature=temperature,
reasoning_effort=reasoning_effort, tool_choice=tool_choice, reasoning_effort=reasoning_effort, tool_choice=tool_choice,
on_content_delta=on_content_delta, on_content_delta=_tracking_delta if on_content_delta is not None else None,
on_thinking_delta=on_thinking_delta, on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
) )
return await self._run_with_retry( return await self._run_with_retry(
self._safe_chat_stream, self._safe_chat_stream,
@@ -567,6 +603,7 @@ class LLMProvider(ABC):
messages, messages,
retry_mode=retry_mode, retry_mode=retry_mode,
on_retry_wait=on_retry_wait, on_retry_wait=on_retry_wait,
should_retry_guard=lambda: not has_streamed_content,
) )
async def chat_with_retry( async def chat_with_retry(
@@ -713,6 +750,7 @@ class LLMProvider(ABC):
*, *,
retry_mode: str, retry_mode: str,
on_retry_wait: Callable[[str], Awaitable[None]] | None, on_retry_wait: Callable[[str], Awaitable[None]] | None,
should_retry_guard: Callable[[], bool] | None = None,
) -> LLMResponse: ) -> LLMResponse:
attempt = 0 attempt = 0
delays = list(self._CHAT_RETRY_DELAYS) delays = list(self._CHAT_RETRY_DELAYS)
@@ -726,6 +764,11 @@ class LLMProvider(ABC):
if response.finish_reason != "error": if response.finish_reason != "error":
return response return response
last_response = response last_response = response
if should_retry_guard is not None and not should_retry_guard():
logger.warning(
"LLM stream failed after content was emitted; skipping retry"
)
return response
error_key = ((response.content or "").strip().lower() or None) error_key = ((response.content or "").strip().lower() or None)
if error_key and error_key == last_error_key: if error_key and error_key == last_error_key:
identical_error_count += 1 identical_error_count += 1
+2 -1
View File
@@ -704,8 +704,9 @@ class BedrockProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
_ = on_thinking_delta _ = on_thinking_delta, on_tool_call_delta
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
content_parts: list[str] = [] content_parts: list[str] = []
reasoning_parts: list[str] = [] reasoning_parts: list[str] = []
+3
View File
@@ -98,6 +98,7 @@ def _make_provider_core(
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
spec=spec, spec=spec,
extra_body=p.extra_body if p else None, extra_body=p.extra_body if p else None,
api_type=p.api_type if p and provider_name == "openai" else "auto",
) )
provider.generation = resolved.to_generation_settings() provider.generation = resolved.to_generation_settings()
@@ -183,6 +184,7 @@ def provider_signature(
config.get_api_base(fallback.model, preset=fallback), config.get_api_base(fallback.model, preset=fallback),
fp.extra_headers if fp else None, fp.extra_headers if fp else None,
fp.extra_body if fp else None, fp.extra_body if fp else None,
fp.api_type if fp else "auto",
getattr(fp, "region", None) if fp else None, getattr(fp, "region", None) if fp else None,
getattr(fp, "profile", None) if fp else None, getattr(fp, "profile", None) if fp else None,
fallback.max_tokens, fallback.max_tokens,
@@ -199,6 +201,7 @@ def provider_signature(
config.get_api_base(resolved.model, preset=resolved), config.get_api_base(resolved.model, preset=resolved),
p.extra_headers if p else None, p.extra_headers if p else None,
p.extra_body if p else None, p.extra_body if p else None,
p.api_type if p else "auto",
getattr(p, "region", None) if p else None, getattr(p, "region", None) if p else None,
getattr(p, "profile", None) if p else None, getattr(p, "profile", None) if p else None,
resolved.max_tokens, resolved.max_tokens,
+4 -1
View File
@@ -207,8 +207,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
async def _refresh_client_api_key(self) -> str: async def _refresh_client_api_key(self) -> str:
token = await self._get_copilot_access_token() token = await self._get_copilot_access_token()
client = await self._ensure_client()
self.api_key = token self.api_key = token
self._client.api_key = token client.api_key = token
return token return token
async def chat( async def chat(
@@ -243,6 +244,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
tool_choice: str | dict[str, object] | None = None, tool_choice: str | dict[str, object] | None = None,
on_content_delta: Callable[[str], None] | None = None, on_content_delta: Callable[[str], None] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, object]], Awaitable[None]] | None = None,
): ):
await self._refresh_client_api_key() await self._refresh_client_api_key()
return await super().chat_stream( return await super().chat_stream(
@@ -255,4 +257,5 @@ class GitHubCopilotProvider(OpenAICompatProvider):
tool_choice=tool_choice, tool_choice=tool_choice,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta, on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
) )
File diff suppressed because it is too large Load Diff
+176 -17
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json import json
import os
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
@@ -14,7 +15,7 @@ from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
consume_sse, consume_sse_with_reasoning,
convert_messages, convert_messages,
convert_tools, convert_tools,
) )
@@ -40,6 +41,8 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort: str | None, reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None, tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
"""Shared request logic for both chat() and chat_stream().""" """Shared request logic for both chat() and chat_stream()."""
model = model or self.default_model model = model or self.default_model
@@ -60,30 +63,52 @@ class OpenAICodexProvider(LLMProvider):
"tool_choice": tool_choice or "auto", "tool_choice": tool_choice or "auto",
"parallel_tool_calls": True, "parallel_tool_calls": True,
} }
if reasoning_effort and reasoning_effort.lower() != "none": reasoning_options = _build_reasoning_options(reasoning_effort)
body["reasoning"] = {"effort": reasoning_effort} if reasoning_options:
body["reasoning"] = reasoning_options
if tools: if tools:
body["tools"] = convert_tools(tools) body["tools"] = convert_tools(tools)
try: try:
try: try:
content, tool_calls, finish_reason = await _request_codex( content, tool_calls, finish_reason, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True, DEFAULT_CODEX_URL, headers, body, verify=True,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
) )
except Exception as e: except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e): if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise raise
logger.warning("SSL verification failed for Codex API; retrying with verify=False") logger.warning("SSL verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex( content, tool_calls, finish_reason, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False, DEFAULT_CODEX_URL, headers, body, verify=False,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
) )
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason) return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
reasoning_content=reasoning_content,
)
except Exception as e: except Exception as e:
msg = f"Error calling Codex: {e}" response = _codex_error_response(e)
retry_after = getattr(e, "retry_after", None) or self._extract_retry_after(msg) exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after) logger.warning(
"Codex API request failed: type={} kind={} retryable={} status={} "
"error_type={} error_code={} retry_after={} summary={}",
exc_type,
response.error_kind,
response.error_should_retry,
response.error_status_code,
response.error_type,
response.error_code,
response.retry_after,
_codex_log_summary(exc_type, response),
)
return response
async def chat( async def chat(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
@@ -100,9 +125,18 @@ class OpenAICodexProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
_ = on_thinking_delta return await self._call_codex(
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta) messages,
tools,
model,
reasoning_effort,
tool_choice,
on_content_delta,
on_thinking_delta,
on_tool_call_delta,
)
def get_default_model(self) -> str: def get_default_model(self) -> str:
return self.default_model return self.default_model
@@ -114,6 +148,16 @@ def _strip_model_prefix(model: str) -> str:
return model return model
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str] | None:
"""Opt in to visible summaries without changing provider-default effort."""
if reasoning_effort and reasoning_effort.lower() == "none":
return {"effort": "none"}
options = {"summary": "auto"}
if reasoning_effort:
options["effort"] = reasoning_effort
return options
def _build_headers(account_id: str, token: str) -> dict[str, str]: def _build_headers(account_id: str, token: str) -> dict[str, str]:
return { return {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
@@ -127,9 +171,22 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]:
class _CodexHTTPError(RuntimeError): class _CodexHTTPError(RuntimeError):
def __init__(self, message: str, retry_after: float | None = None): def __init__(
self,
message: str,
*,
status_code: int | None = None,
retry_after: float | None = None,
error_type: str | None = None,
error_code: str | None = None,
should_retry: bool | None = None,
):
super().__init__(message) super().__init__(message)
self.status_code = status_code
self.retry_after = retry_after self.retry_after = retry_after
self.error_type = error_type
self.error_code = error_code
self.should_retry = should_retry
async def _request_codex( async def _request_codex(
@@ -138,17 +195,31 @@ async def _request_codex(
body: dict[str, Any], body: dict[str, Any],
verify: bool, verify: bool,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]: on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client: on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, str | None]:
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response: async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code != 200: if response.status_code != 200:
text = await response.aread() text = await response.aread()
raw = text.decode("utf-8", "ignore")
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers) retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
error_type, error_code = LLMProvider._extract_error_type_code(raw)
raise _CodexHTTPError( raise _CodexHTTPError(
_friendly_error(response.status_code, text.decode("utf-8", "ignore")), _friendly_error(response.status_code, raw),
status_code=response.status_code,
retry_after=retry_after, retry_after=retry_after,
error_type=error_type,
error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
) )
return await consume_sse(response, on_content_delta) return await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
)
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
@@ -157,6 +228,94 @@ def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
def _friendly_error(status_code: int, raw: str) -> str: def _friendly_error(status_code: int, raw: str) -> str:
_ = raw
if status_code == 429: if status_code == 429:
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later." return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
return f"HTTP {status_code}: {raw}" return f"HTTP {status_code}: Codex API request failed"
def _codex_error_response(exc: Exception) -> LLMResponse:
"""Convert Codex transport/API failures into actionable, retryable metadata."""
exc_type = "CodexHTTPError" if isinstance(exc, _CodexHTTPError) else type(exc).__name__
detail = str(exc).strip()
status_code = getattr(exc, "status_code", None)
error_kind: str | None = None
default_detail: str | None = None
should_retry: bool | None = getattr(exc, "should_retry", None)
if isinstance(exc, (httpx.TimeoutException, asyncio.TimeoutError)):
error_kind = "timeout"
default_detail = "timed out waiting for response"
should_retry = True if should_retry is None else should_retry
elif isinstance(exc, httpx.RemoteProtocolError):
error_kind = "connection"
default_detail = "network protocol error while reading response"
should_retry = True if should_retry is None else should_retry
elif isinstance(exc, (httpx.NetworkError, httpx.TransportError)):
error_kind = "connection"
default_detail = "network connection failed"
should_retry = True if should_retry is None else should_retry
elif isinstance(exc, _CodexHTTPError):
error_kind = "http"
default_detail = "HTTP request failed"
if status_code is not None and should_retry is None:
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
should_retry = _should_retry_status(
int(status_code),
getattr(exc, "error_type", None),
getattr(exc, "error_code", None),
retry_content,
)
detail = detail or default_detail or "unexpected error"
message = f"Error calling Codex ({exc_type}): {detail}"
retry_after = getattr(exc, "retry_after", None) or LLMProvider._extract_retry_after(message)
return LLMResponse(
content=message,
finish_reason="error",
retry_after=retry_after,
error_status_code=int(status_code) if status_code is not None else None,
error_kind=error_kind,
error_type=getattr(exc, "error_type", None),
error_code=getattr(exc, "error_code", None),
error_retry_after_s=retry_after,
error_should_retry=should_retry,
)
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
if response.error_status_code is not None:
parts = [f"HTTP {response.error_status_code}"]
if response.error_type:
parts.append(f"type={response.error_type}")
if response.error_code:
parts.append(f"code={response.error_code}")
return " ".join(parts)
kind = (response.error_kind or "").strip()
if kind:
return f"{exc_type} {kind}"
return exc_type
def _should_retry_status(
status_code: int,
error_type: str | None,
error_code: str | None,
content: str | None,
) -> bool:
if status_code == 429:
return LLMProvider._is_retryable_429_response(
LLMResponse(
content=content or "",
finish_reason="error",
error_status_code=status_code,
error_type=error_type,
error_code=error_code,
)
)
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
+300 -110
View File
@@ -11,25 +11,15 @@ import secrets
import string import string
import time import time
import uuid import uuid
from collections import deque
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from ipaddress import ip_address from ipaddress import ip_address
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx
import json_repair import json_repair
from loguru import logger from loguru import logger
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI
else:
if os.environ.get("LANGFUSE_SECRET_KEY"):
logger.warning(
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
"install with `pip install langfuse` to enable tracing"
)
from openai import AsyncOpenAI
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
consume_sdk_stream, consume_sdk_stream,
@@ -39,8 +29,15 @@ from nanobot.providers.openai_responses import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from openai import AsyncOpenAI as AsyncOpenAIType
from nanobot.providers.registry import ProviderSpec from nanobot.providers.registry import ProviderSpec
# Module-level placeholder — set lazily by _ensure_client on first real
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
# that ``unittest.mock.patch`` can find and replace it.
AsyncOpenAI: Any = None
_ALLOWED_MSG_KEYS = frozenset({ _ALLOWED_MSG_KEYS = frozenset({
"role", "content", "tool_calls", "tool_call_id", "name", "role", "content", "tool_calls", "tool_call_id", "name",
"reasoning_content", "extra_content", "reasoning_content", "extra_content",
@@ -78,41 +75,43 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
"enable_thinking": lambda on: {"enable_thinking": on}, "enable_thinking": lambda on: {"enable_thinking": on},
"reasoning_split": lambda on: {"reasoning_split": on}, "reasoning_split": lambda on: {"reasoning_split": on},
} }
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
}
_MODEL_THINKING_STYLES: dict[str, str] = {
**dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"),
**dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"),
}
def _is_kimi_thinking_model(model_name: str) -> bool: def _model_slug(model_name: str) -> str:
"""Return True if model_name refers to a Kimi thinking-capable model. return model_name.lower().rsplit("/", 1)[-1]
Supports two forms:
- Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
is checked against _KIMI_THINKING_MODELS
This covers both the native Moonshot provider (bare slug) and
OpenRouter-style names (``"publisher/slug"``).
"""
name = model_name.lower()
if name in _KIMI_THINKING_MODELS:
return True
if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS:
return True
return False
def _is_mimo_thinking_model(model_name: str) -> bool: def _model_thinking_style(model_name: str) -> str:
"""Return True if model_name refers to a MiMo thinking-capable model. return _MODEL_THINKING_STYLES.get(_model_slug(model_name), "")
Mirrors _is_kimi_thinking_model: gateway providers (e.g. OpenRouter
routing ``xiaomi/mimo-v2.5-pro``) have no ``thinking_style`` on their def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]:
spec, so the spec-driven branch in _build_kwargs misses them. The styles: list[str] = []
model-name path catches those cases. if spec and spec.thinking_style:
""" styles.append(spec.thinking_style)
name = model_name.lower() model_style = _model_thinking_style(model_name)
if name in _MIMO_THINKING_MODELS: if model_style and model_style not in styles:
return True styles.append(model_style)
if "/" in name and name.rsplit("/", 1)[1] in _MIMO_THINKING_MODELS: return styles
return True
return False
def _thinking_extra_body(style: str, thinking_enabled: bool) -> dict[str, Any] | None:
builder = _THINKING_STYLE_MAP.get(style)
return builder(thinking_enabled) if builder else None
def _gateway_reasoning_extra_body(style: str, effort: str | None) -> dict[str, Any] | None:
if not effort:
return None
builder = _GATEWAY_REASONING_STYLE_MAP.get(style)
return builder(effort) if builder else None
def _openai_compat_timeout_s() -> float: def _openai_compat_timeout_s() -> float:
@@ -275,6 +274,47 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
return merged return merged
def _merge_unique_list(base: Any, override: Any) -> Any:
"""Append list values while preserving order and removing duplicates."""
if not isinstance(base, list) or not isinstance(override, list):
return override
result: list[Any] = []
seen: set[str] = set()
for value in [*base, *override]:
try:
key = json.dumps(value, sort_keys=True, ensure_ascii=False)
except Exception:
key = repr(value)
if key in seen:
continue
seen.add(key)
result.append(value)
return result
def _merge_responses_extra_body(
body: dict[str, Any],
extra_body: dict[str, Any],
) -> dict[str, Any]:
"""Merge configured Responses API body fields without clobbering tools."""
reserved = {"include", "tools"}
regular_extra = {key: value for key, value in extra_body.items() if key not in reserved}
merged = _deep_merge(body, regular_extra)
if "include" in extra_body:
merged["include"] = _merge_unique_list(body.get("include"), extra_body["include"])
if "tools" in extra_body:
current_tools = body.get("tools")
configured_tools = extra_body["tools"]
if isinstance(current_tools, list) and isinstance(configured_tools, list):
merged["tools"] = [*current_tools, *configured_tools]
else:
merged["tools"] = configured_tools
return merged
class OpenAICompatProvider(LLMProvider): class OpenAICompatProvider(LLMProvider):
"""Unified provider for all OpenAI-compatible APIs. """Unified provider for all OpenAI-compatible APIs.
@@ -290,55 +330,90 @@ class OpenAICompatProvider(LLMProvider):
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
spec: ProviderSpec | None = None, spec: ProviderSpec | None = None,
extra_body: dict[str, Any] | None = None, extra_body: dict[str, Any] | None = None,
api_type: str = "auto",
): ):
super().__init__(api_key, api_base) super().__init__(api_key, api_base)
self.default_model = default_model self.default_model = default_model
self.extra_headers = extra_headers or {} self.extra_headers = extra_headers or {}
self._spec = spec self._spec = spec
self._extra_body = extra_body or {} self._extra_body = extra_body or {}
self._api_type = api_type if spec and spec.name == "openai" else "auto"
if api_key and spec and spec.env_key: if api_key and spec and spec.env_key:
self._setup_env(api_key, api_base) self._setup_env(api_key, api_base)
effective_base = api_base or (spec.default_api_base if spec else None) or None effective_base = api_base or (spec.default_api_base if spec else None) or None
self._effective_base = effective_base self._effective_base = effective_base
default_headers = {"x-session-affinity": uuid.uuid4().hex} self._default_headers = {"x-session-affinity": uuid.uuid4().hex}
if _uses_openrouter_attribution(spec, effective_base): if _uses_openrouter_attribution(spec, effective_base):
default_headers.update(_DEFAULT_OPENROUTER_HEADERS) self._default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
if extra_headers: if extra_headers:
default_headers.update(extra_headers) self._default_headers.update(extra_headers)
self._api_key_for_client = api_key or "no-key"
self._is_local = _is_local_endpoint(spec, effective_base)
# Local model servers (Ollama, llama.cpp, vLLM) often close idle # Lazy-init: the OpenAI client and its httpx transport are expensive
# HTTP connections before the client-side keepalive expires. When # to create (~700 ms on Windows). Defer until first use.
# two LLM calls happen seconds apart (e.g. heartbeat _decide then self._client: AsyncOpenAIType | None = None
# process_direct), the second call may grab a now-dead pooled self._client_lock = asyncio.Lock()
# connection, causing a transient APIConnectionError on every first
# attempt. Disabling keepalive for local endpoints avoids this by
# opening a fresh connection for each request, which is cheap on a
# LAN. Cloud providers benefit from keepalive, so we leave the
# default pool settings for them.
timeout_s = _openai_compat_timeout_s()
http_client: httpx.AsyncClient | None = None
if _is_local_endpoint(spec, effective_base):
http_client = httpx.AsyncClient(
limits=httpx.Limits(keepalive_expiry=0),
timeout=timeout_s,
)
self._client = AsyncOpenAI(
api_key=api_key or "no-key",
base_url=effective_base,
default_headers=default_headers,
max_retries=0,
timeout=timeout_s,
http_client=http_client,
)
# Responses API circuit breaker: skip after repeated failures, # Responses API circuit breaker: skip after repeated failures,
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds. # probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
self._responses_failures: dict[str, int] = {} self._responses_failures: dict[str, int] = {}
self._responses_tripped_at: dict[str, float] = {} self._responses_tripped_at: dict[str, float] = {}
def _build_client(self) -> None:
"""Create the OpenAI client using the current module-level AsyncOpenAI."""
import httpx
timeout_s = _openai_compat_timeout_s()
http_client: httpx.AsyncClient | None = None
if self._is_local:
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
# HTTP connections before the client-side keepalive expires. When
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
# process_direct), the second call may grab a now-dead pooled
# connection, causing a transient APIConnectionError on every first
# attempt. Disabling keepalive for local endpoints avoids this by
# opening a fresh connection for each request, which is cheap on a
# LAN. Cloud providers benefit from keepalive, so we leave the
# default pool settings for them.
http_client = httpx.AsyncClient(
limits=httpx.Limits(keepalive_expiry=0),
timeout=timeout_s,
)
self._client = AsyncOpenAI(
api_key=self._api_key_for_client,
base_url=self._effective_base,
default_headers=self._default_headers,
max_retries=0,
timeout=timeout_s,
http_client=http_client,
)
async def _ensure_client(self):
"""Return the shared OpenAI client, creating it on first call."""
if self._client is not None:
return self._client
async with self._client_lock:
if self._client is not None:
return self._client
global AsyncOpenAI
if AsyncOpenAI is None:
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI as _AsyncOpenAI
else:
if os.environ.get("LANGFUSE_SECRET_KEY"):
logger.warning(
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
"install with `pip install langfuse` to enable tracing"
)
from openai import AsyncOpenAI as _AsyncOpenAI
AsyncOpenAI = _AsyncOpenAI
self._build_client()
return self._client
def _setup_env(self, api_key: str, api_base: str | None) -> None: def _setup_env(self, api_key: str, api_base: str | None) -> None:
"""Set environment variables based on provider spec.""" """Set environment variables based on provider spec."""
spec = self._spec spec = self._spec
@@ -396,6 +471,10 @@ class OpenAICompatProvider(LLMProvider):
return tool_call_id return tool_call_id
return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
def _should_normalize_tool_call_ids(self) -> bool:
"""Return True for providers that reject normal OpenAI tool call IDs."""
return bool(self._spec and self._spec.name == "mistral")
@staticmethod @staticmethod
def _normalize_tool_call_arguments(arguments: Any) -> str: def _normalize_tool_call_arguments(arguments: Any) -> str:
"""Force function.arguments into a valid JSON object string.""" """Force function.arguments into a valid JSON object string."""
@@ -432,22 +511,60 @@ class OpenAICompatProvider(LLMProvider):
"""Strip non-standard keys, normalize tool_call IDs.""" """Strip non-standard keys, normalize tool_call IDs."""
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS) sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
id_map: dict[str, str] = {} id_map: dict[str, str] = {}
pending_tool_ids: dict[str, deque[str]] = {}
force_string_content = bool(self._spec and self._spec.name == "deepseek") force_string_content = bool(self._spec and self._spec.name == "deepseek")
normalize_tool_ids = self._should_normalize_tool_call_ids()
def map_id(value: Any) -> Any: def map_id(value: Any) -> Any:
if not isinstance(value, str): if not isinstance(value, str):
return value return value
if not normalize_tool_ids:
return value
return id_map.setdefault(value, self._normalize_tool_call_id(value)) return id_map.setdefault(value, self._normalize_tool_call_id(value))
def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str:
if isinstance(value, str) and value:
base = map_id(value)
else:
base = _short_tool_id()
if not isinstance(base, str) or not base:
base = _short_tool_id()
if base not in used_ids:
return base
seed = value if isinstance(value, str) and value else base
salt = 1
while True:
candidate = self._normalize_tool_call_id(f"{seed}:{idx}:{salt}")
if isinstance(candidate, str) and candidate not in used_ids:
return candidate
salt += 1
def map_tool_result_id(value: Any) -> Any:
if not isinstance(value, str):
return value
queue = pending_tool_ids.get(value)
if queue:
mapped = queue.popleft()
if not queue:
pending_tool_ids.pop(value, None)
return mapped
return map_id(value)
for clean in sanitized: for clean in sanitized:
if isinstance(clean.get("tool_calls"), list): if isinstance(clean.get("tool_calls"), list):
normalized = [] normalized = []
for tc in clean["tool_calls"]: used_ids: set[str] = set()
for idx, tc in enumerate(clean["tool_calls"]):
if not isinstance(tc, dict): if not isinstance(tc, dict):
normalized.append(tc) normalized.append(tc)
continue continue
tc_clean = dict(tc) tc_clean = dict(tc)
tc_clean["id"] = map_id(tc_clean.get("id")) raw_id = tc_clean.get("id")
mapped_id = unique_tool_id(raw_id, used_ids, idx)
tc_clean["id"] = mapped_id
used_ids.add(mapped_id)
if isinstance(raw_id, str) and raw_id:
pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id)
function = tc_clean.get("function") function = tc_clean.get("function")
if isinstance(function, dict): if isinstance(function, dict):
function_clean = dict(function) function_clean = dict(function)
@@ -465,7 +582,7 @@ class OpenAICompatProvider(LLMProvider):
# that mix non-empty content with tool_calls. # that mix non-empty content with tool_calls.
clean["content"] = None clean["content"] = None
if "tool_call_id" in clean and clean["tool_call_id"]: if "tool_call_id" in clean and clean["tool_call_id"]:
clean["tool_call_id"] = map_id(clean["tool_call_id"]) clean["tool_call_id"] = map_tool_result_id(clean["tool_call_id"])
if ( if (
force_string_content force_string_content
and not (clean.get("role") == "assistant" and clean.get("tool_calls")) and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
@@ -552,39 +669,27 @@ class OpenAICompatProvider(LLMProvider):
if wire_effort and semantic_effort != "none": if wire_effort and semantic_effort != "none":
kwargs["reasoning_effort"] = wire_effort kwargs["reasoning_effort"] = wire_effort
# Provider-specific thinking parameters. # Only send thinking controls when reasoning_effort is explicit so
# Only sent when reasoning_effort is explicitly configured so that # omitting the config preserves each provider's default.
# the provider default is preserved otherwise. if reasoning_effort is not None:
# The mapping is driven by ProviderSpec.thinking_style so that adding
# a new provider never requires touching this function.
if spec and spec.thinking_style and reasoning_effort is not None:
thinking_enabled = semantic_effort not in ("none", "minimal") thinking_enabled = semantic_effort not in ("none", "minimal")
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled) for thinking_style in _thinking_styles_for(spec, model_name):
if extra: extra = _thinking_extra_body(thinking_style, thinking_enabled)
kwargs.setdefault("extra_body", {}).update(extra) if extra:
kwargs.setdefault("extra_body", {}).update(extra)
gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else ""
if gateway_style and _model_thinking_style(model_name):
extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort)
if extra:
kwargs.setdefault("extra_body", {}).update(extra)
# Model-level thinking injection for Kimi thinking-capable models. # Moonshot rejects requests that carry both 'reasoning_effort'
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup # and the native 'thinking' param. We already expressed the
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled # user's intent via the provider-native shape, so drop the
# identically to bare names like "kimi-k2.5". # redundant wire-level kwarg. Only kimi models need this —
if reasoning_effort is not None and _is_kimi_thinking_model(model_name): # Xiaomi's API accepts both params.
thinking_enabled = semantic_effort not in ("none", "minimal") if _model_slug(model_name) in _KIMI_THINKING_MODELS:
kwargs.setdefault("extra_body", {}).update( kwargs.pop("reasoning_effort", None)
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
# Model-level thinking injection for MiMo thinking-capable models.
# Same shape as Kimi: gateway providers (OpenRouter, etc.) lack the
# xiaomi_mimo spec's thinking_style, so the spec-driven branch above
# misses them — match by model name to catch "xiaomi/mimo-v2.5-pro"
# and friends. (Direct xiaomi_mimo requests are also covered here;
# both branches write the same payload, so the dict update is a
# safe no-op for already-handled cases.)
if reasoning_effort is not None and _is_mimo_thinking_model(model_name):
thinking_enabled = semantic_effort not in ("none", "minimal")
kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
if tools: if tools:
kwargs["tools"] = tools kwargs["tools"] = tools
@@ -599,8 +704,7 @@ class OpenAICompatProvider(LLMProvider):
and semantic_effort not in ("none", "minimal") and semantic_effort not in ("none", "minimal")
and ( and (
(spec and spec.thinking_style) (spec and spec.thinking_style)
or _is_kimi_thinking_model(model_name) or _model_thinking_style(model_name)
or _is_mimo_thinking_model(model_name)
) )
) )
implicit_deepseek_thinking = ( implicit_deepseek_thinking = (
@@ -631,8 +735,14 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None, reasoning_effort: str | None,
) -> bool: ) -> bool:
"""Use Responses API only for direct OpenAI requests that benefit from it.""" """Use Responses API only for direct OpenAI requests that benefit from it."""
if self._api_type == "chat_completions":
return False
if self._spec and self._spec.name not in ("openai", "github_copilot"): if self._spec and self._spec.name not in ("openai", "github_copilot"):
return False return False
if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions.
return True
if self._spec is None or self._spec.name != "github_copilot": if self._spec is None or self._spec.name != "github_copilot":
if not _is_direct_openai_base(self._effective_base): if not _is_direct_openai_base(self._effective_base):
return False return False
@@ -646,7 +756,14 @@ class OpenAICompatProvider(LLMProvider):
if not wants: if not wants:
return False return False
# Circuit breaker: skip after repeated failures, probe periodically. return self._responses_circuit_allows_probe(model, reasoning_effort)
def _responses_circuit_allows_probe(
self,
model: str | None,
reasoning_effort: str | None,
) -> bool:
"""Return False when the Responses API circuit breaker is open."""
key = _responses_circuit_key(model, self.default_model, reasoning_effort) key = _responses_circuit_key(model, self.default_model, reasoning_effort)
failures = self._responses_failures.get(key, 0) failures = self._responses_failures.get(key, 0)
if failures >= _RESPONSES_FAILURE_THRESHOLD: if failures >= _RESPONSES_FAILURE_THRESHOLD:
@@ -738,6 +855,10 @@ class OpenAICompatProvider(LLMProvider):
body["tools"] = convert_tools(tools) body["tools"] = convert_tools(tools)
body["tool_choice"] = tool_choice or "auto" body["tool_choice"] = tool_choice or "auto"
extra_body = getattr(self, "_extra_body", {})
if extra_body:
body = _merge_responses_extra_body(body, extra_body)
return body return body
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -902,7 +1023,7 @@ class OpenAICompatProvider(LLMProvider):
args = json_repair.loads(args) args = json_repair.loads(args)
ec, prov, fn_prov = _extract_tc_extras(tc) ec, prov, fn_prov = _extract_tc_extras(tc)
parsed_tool_calls.append(ToolCallRequest( parsed_tool_calls.append(ToolCallRequest(
id=_short_tool_id(), id=str(tc_map.get("id") or _short_tool_id()),
name=str(fn.get("name") or ""), name=str(fn.get("name") or ""),
arguments=args if isinstance(args, dict) else {}, arguments=args if isinstance(args, dict) else {},
extra_content=ec, extra_content=ec,
@@ -945,7 +1066,7 @@ class OpenAICompatProvider(LLMProvider):
args = json_repair.loads(args) args = json_repair.loads(args)
ec, prov, fn_prov = _extract_tc_extras(tc) ec, prov, fn_prov = _extract_tc_extras(tc)
tool_calls.append(ToolCallRequest( tool_calls.append(ToolCallRequest(
id=_short_tool_id(), id=str(getattr(tc, "id", None) or _short_tool_id()),
name=tc.function.name, name=tc.function.name,
arguments=args, arguments=args,
extra_content=ec, extra_content=ec,
@@ -999,6 +1120,21 @@ class OpenAICompatProvider(LLMProvider):
if fn_prov: if fn_prov:
buf["fn_prov"] = fn_prov buf["fn_prov"] = fn_prov
def _accum_legacy_function_call(function_call: Any) -> None:
"""Accumulate legacy ``delta.function_call`` streaming chunks."""
if not function_call:
return
buf = tc_bufs.setdefault(0, {
"id": "", "name": "", "arguments": "",
"extra_content": None, "prov": None, "fn_prov": None,
})
fn_name = _get(function_call, "name")
if fn_name:
buf["name"] = str(fn_name)
fn_args = _get(function_call, "arguments")
if fn_args:
buf["arguments"] += str(fn_args)
for chunk in chunks: for chunk in chunks:
if isinstance(chunk, str): if isinstance(chunk, str):
content_parts.append(chunk) content_parts.append(chunk)
@@ -1029,6 +1165,7 @@ class OpenAICompatProvider(LLMProvider):
reasoning_parts.append(text) reasoning_parts.append(text)
for idx, tc in enumerate(delta.get("tool_calls") or []): for idx, tc in enumerate(delta.get("tool_calls") or []):
_accum_tc(tc, idx) _accum_tc(tc, idx)
_accum_legacy_function_call(delta.get("function_call"))
usage = cls._extract_usage(chunk_map) or usage usage = cls._extract_usage(chunk_map) or usage
continue continue
@@ -1047,8 +1184,19 @@ class OpenAICompatProvider(LLMProvider):
reasoning = getattr(delta, "reasoning", None) reasoning = getattr(delta, "reasoning", None)
if reasoning: if reasoning:
reasoning_parts.append(reasoning) reasoning_parts.append(reasoning)
for tc in (delta.tool_calls or []) if delta else []: for tc in (getattr(delta, "tool_calls", None) or []) if delta else []:
_accum_tc(tc, getattr(tc, "index", 0)) _accum_tc(tc, getattr(tc, "index", 0))
if delta:
_accum_legacy_function_call(getattr(delta, "function_call", None))
# Some providers (e.g. Zhipu/GLM) reuse the same tool_call id for
# parallel tool calls in streaming mode. Deduplicate before building
# the response so downstream tool messages don't collide.
_seen_tc_ids: set[str] = set()
for b in tc_bufs.values():
if not b["id"] or b["id"] in _seen_tc_ids:
b["id"] = _short_tool_id()
_seen_tc_ids.add(b["id"])
return LLMResponse( return LLMResponse(
content="".join(content_parts) or None, content="".join(content_parts) or None,
@@ -1164,6 +1312,7 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse: ) -> LLMResponse:
await self._ensure_client()
try: try:
if self._should_use_responses_api(model, reasoning_effort): if self._should_use_responses_api(model, reasoning_effort):
try: try:
@@ -1180,6 +1329,8 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would # falling back to /chat/completions cannot succeed and would
# hide the real error. # hide the real error.
raise raise
if self._api_type == "responses":
raise
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise
self._record_responses_failure(model, reasoning_effort) self._record_responses_failure(model, reasoning_effort)
@@ -1203,7 +1354,9 @@ class OpenAICompatProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
await self._ensure_client()
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try: try:
if self._should_use_responses_api(model, reasoning_effort): if self._should_use_responses_api(model, reasoning_effort):
@@ -1226,9 +1379,16 @@ class OpenAICompatProvider(LLMProvider):
except StopAsyncIteration: except StopAsyncIteration:
break break
content, tool_calls, finish_reason, usage, reasoning_content = await consume_sdk_stream( (
content,
tool_calls,
finish_reason,
usage,
reasoning_content,
) = await consume_sdk_stream(
_timed_stream(), _timed_stream(),
on_content_delta, on_content_delta,
on_tool_call_delta=on_tool_call_delta,
) )
self._record_responses_success(model, reasoning_effort) self._record_responses_success(model, reasoning_effort)
return LLMResponse( return LLMResponse(
@@ -1244,6 +1404,8 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would # falling back to /chat/completions cannot succeed and would
# hide the real error. # hide the real error.
raise raise
if self._api_type == "responses":
raise
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise
self._record_responses_failure(model, reasoning_effort) self._record_responses_failure(model, reasoning_effort)
@@ -1252,6 +1414,12 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature, messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice, reasoning_effort, tool_choice,
) )
if self._spec and self._spec.name == "zhipu" and tools and on_tool_call_delta:
# Z.AI/GLM keeps streaming tool-call arguments behind an
# explicit provider flag. Pass it through the OpenAI SDK's
# extra_body escape hatch so the usual delta.tool_calls path
# can surface live file-edit progress.
kwargs.setdefault("extra_body", {})["tool_stream"] = True
kwargs["stream"] = True kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True} kwargs["stream_options"] = {"include_usage": True}
stream = await self._client.chat.completions.create(**kwargs) stream = await self._client.chat.completions.create(**kwargs)
@@ -1279,6 +1447,28 @@ class OpenAICompatProvider(LLMProvider):
r_text = self._extract_text_content(reasoning) r_text = self._extract_text_content(reasoning)
if r_text: if r_text:
await on_thinking_delta(r_text) await on_thinking_delta(r_text)
if on_tool_call_delta:
for idx, tool_delta in enumerate(
getattr(delta_obj, "tool_calls", None) or []
):
fn = _get(tool_delta, "function")
tool_index = _get(tool_delta, "index")
await on_tool_call_delta({
"index": tool_index if tool_index is not None else idx,
"call_id": str(_get(tool_delta, "id") or ""),
"name": str(_get(fn, "name") or "") if fn is not None else "",
"arguments_delta": (
str(_get(fn, "arguments") or "") if fn is not None else ""
),
})
function_call = getattr(delta_obj, "function_call", None)
if function_call:
await on_tool_call_delta({
"index": 0,
"call_id": "",
"name": str(_get(function_call, "name") or ""),
"arguments_delta": str(_get(function_call, "arguments") or ""),
})
return self._parse_chunks(chunks) return self._parse_chunks(chunks)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return LLMResponse( return LLMResponse(
@@ -10,6 +10,7 @@ from nanobot.providers.openai_responses.parsing import (
FINISH_REASON_MAP, FINISH_REASON_MAP,
consume_sdk_stream, consume_sdk_stream,
consume_sse, consume_sse,
consume_sse_with_reasoning,
iter_sse, iter_sse,
map_finish_reason, map_finish_reason,
parse_response_output, parse_response_output,
@@ -22,6 +23,7 @@ __all__ = [
"split_tool_call_id", "split_tool_call_id",
"iter_sse", "iter_sse",
"consume_sse", "consume_sse",
"consume_sse_with_reasoning",
"consume_sdk_stream", "consume_sdk_stream",
"map_finish_reason", "map_finish_reason",
"parse_response_output", "parse_response_output",
@@ -15,6 +15,7 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
""" """
system_prompt = "" system_prompt = ""
input_items: list[dict[str, Any]] = [] input_items: list[dict[str, Any]] = []
used_item_ids: set[str] = set()
for idx, msg in enumerate(messages): for idx, msg in enumerate(messages):
role = msg.get("role") role = msg.get("role")
@@ -30,17 +31,19 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
if role == "assistant": if role == "assistant":
if isinstance(content, str) and content: if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({ input_items.append({
"type": "message", "role": "assistant", "type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": content}], "content": [{"type": "output_text", "text": content}],
"status": "completed", "id": f"msg_{idx}", "status": "completed", "id": message_id,
}) })
for tool_call in msg.get("tool_calls", []) or []: for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {} fn = tool_call.get("function") or {}
call_id, item_id = split_tool_call_id(tool_call.get("id")) call_id, item_id = split_tool_call_id(tool_call.get("id"))
response_item_id = _unique_item_id(item_id or f"fc_{idx}", used_item_ids)
input_items.append({ input_items.append({
"type": "function_call", "type": "function_call",
"id": item_id or f"fc_{idx}", "id": response_item_id,
"call_id": call_id or f"call_{idx}", "call_id": call_id or f"call_{idx}",
"name": fn.get("name"), "name": fn.get("name"),
"arguments": fn.get("arguments") or "{}", "arguments": fn.get("arguments") or "{}",
@@ -97,6 +100,20 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
return converted return converted
def _unique_item_id(item_id: str, used: set[str]) -> str:
"""Return a Responses input item id that is unique within one request."""
if item_id not in used:
used.add(item_id)
return item_id
suffix = 2
while f"{item_id}_{suffix}" in used:
suffix += 1
unique = f"{item_id}_{suffix}"
used.add(unique)
return unique
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
"""Split a compound ``call_id|item_id`` string. """Split a compound ``call_id|item_id`` string.
+133 -6
View File
@@ -62,12 +62,31 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N
async def consume_sse( async def consume_sse(
response: httpx.Response, response: httpx.Response,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]: ) -> tuple[str, list[ToolCallRequest], str]:
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``.""" """Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
content, tool_calls, finish_reason, _ = await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
return content, tool_calls, finish_reason
async def consume_sse_with_reasoning(
response: httpx.Response,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, str | None]:
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
content = "" content = ""
tool_calls: list[ToolCallRequest] = [] tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {} tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop" finish_reason = "stop"
reasoning_content: str | None = None
streamed_reasoning = False
async for event in iter_sse(response): async for event in iter_sse(response):
event_type = event.get("type") event_type = event.get("type")
@@ -82,19 +101,60 @@ async def consume_sse(
"name": item.get("name"), "name": item.get("name"),
"arguments": item.get("arguments") or "", "arguments": item.get("arguments") or "",
} }
if on_tool_call_delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(item.get("name") or ""),
"arguments_delta": "",
})
elif event_type == "response.output_text.delta": elif event_type == "response.output_text.delta":
delta_text = event.get("delta") or "" delta_text = event.get("delta") or ""
content += delta_text content += delta_text
if on_content_delta and delta_text: if on_content_delta and delta_text:
await on_content_delta(delta_text) await on_content_delta(delta_text)
elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or ""
if delta_text:
reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True
if on_reasoning_delta:
await on_reasoning_delta(delta_text)
elif event_type == "response.reasoning_summary_text.done":
text = event.get("text") or ""
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.reasoning_summary_part.done":
part = event.get("part") or {}
text = part.get("text") if part.get("type") == "summary_text" else None
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.function_call_arguments.delta": elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id") call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers: if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] += event.get("delta") or "" delta = event.get("delta") or ""
tool_call_buffers[call_id]["arguments"] += delta
if on_tool_call_delta and delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments_delta": str(delta),
})
elif event_type == "response.function_call_arguments.done": elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id") call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers: if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or "" arguments = event.get("arguments") or ""
tool_call_buffers[call_id]["arguments"] = arguments
if on_tool_call_delta:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments": str(arguments),
})
elif event_type == "response.output_item.done": elif event_type == "response.output_item.done":
item = event.get("item") or {} item = event.get("item") or {}
if item.get("type") == "function_call": if item.get("type") == "function_call":
@@ -103,6 +163,13 @@ async def consume_sse(
continue continue
buf = tool_call_buffers.get(call_id) or {} buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") or "{}" args_raw = buf.get("arguments") or item.get("arguments") or "{}"
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(buf.get("name") or item.get("name") or ""),
"arguments": str(args_raw),
})
try: try:
args = json.loads(args_raw) args = json.loads(args_raw)
except Exception: except Exception:
@@ -121,14 +188,44 @@ async def consume_sse(
arguments=args, arguments=args,
) )
) )
elif item.get("type") == "reasoning" and not reasoning_content:
summary = _extract_reasoning_summary_from_output([item])
if summary:
reasoning_content = summary
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type == "response.completed": elif event_type == "response.completed":
status = (event.get("response") or {}).get("status") response_obj = event.get("response") or {}
status = response_obj.get("status")
finish_reason = map_finish_reason(status) finish_reason = map_finish_reason(status)
if not reasoning_content:
summary = _extract_reasoning_summary_from_output(response_obj.get("output") or [])
if summary:
reasoning_content = summary
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type in {"error", "response.failed"}: elif event_type in {"error", "response.failed"}:
detail = event.get("error") or event.get("message") or event detail = event.get("error") or event.get("message") or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}") raise RuntimeError(f"Response failed: {str(detail)[:500]}")
return content, tool_calls, finish_reason return content, tool_calls, finish_reason, reasoning_content
def _extract_reasoning_summary_from_output(output: Any) -> str | None:
parts: list[str] = []
for item in output or []:
if not isinstance(item, dict):
dump = getattr(item, "model_dump", None)
item = dump() if callable(dump) else vars(item)
if item.get("type") != "reasoning":
continue
for summary in item.get("summary") or []:
if not isinstance(summary, dict):
dump = getattr(summary, "model_dump", None)
summary = dump() if callable(dump) else vars(summary)
if summary.get("type") == "summary_text" and summary.get("text"):
parts.append(summary["text"])
return "".join(parts) or None
def parse_response_output(response: Any) -> LLMResponse: def parse_response_output(response: Any) -> LLMResponse:
@@ -210,11 +307,13 @@ def parse_response_output(response: Any) -> LLMResponse:
async def consume_sdk_stream( async def consume_sdk_stream(
stream: Any, stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``.""" """Consume an SDK async stream from ``client.responses.create(stream=True)``."""
content = "" content = ""
tool_calls: list[ToolCallRequest] = [] tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {} tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop" finish_reason = "stop"
usage: dict[str, int] = {} usage: dict[str, int] = {}
reasoning_content: str | None = None reasoning_content: str | None = None
@@ -232,6 +331,12 @@ async def consume_sdk_stream(
"name": getattr(item, "name", None), "name": getattr(item, "name", None),
"arguments": getattr(item, "arguments", None) or "", "arguments": getattr(item, "arguments", None) or "",
} }
if on_tool_call_delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(getattr(item, "name", None) or ""),
"arguments_delta": "",
})
elif event_type == "response.output_text.delta": elif event_type == "response.output_text.delta":
delta_text = getattr(event, "delta", "") or "" delta_text = getattr(event, "delta", "") or ""
content += delta_text content += delta_text
@@ -240,11 +345,26 @@ async def consume_sdk_stream(
elif event_type == "response.function_call_arguments.delta": elif event_type == "response.function_call_arguments.delta":
call_id = getattr(event, "call_id", None) call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers: if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] += getattr(event, "delta", "") or "" delta = getattr(event, "delta", "") or ""
tool_call_buffers[call_id]["arguments"] += delta
if on_tool_call_delta and delta:
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments_delta": str(delta),
})
elif event_type == "response.function_call_arguments.done": elif event_type == "response.function_call_arguments.done":
call_id = getattr(event, "call_id", None) call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers: if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = getattr(event, "arguments", "") or "" arguments = getattr(event, "arguments", "") or ""
tool_call_buffers[call_id]["arguments"] = arguments
if on_tool_call_delta:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments": str(arguments),
})
elif event_type == "response.output_item.done": elif event_type == "response.output_item.done":
item = getattr(event, "item", None) item = getattr(event, "item", None)
if item and getattr(item, "type", None) == "function_call": if item and getattr(item, "type", None) == "function_call":
@@ -253,6 +373,13 @@ async def consume_sdk_stream(
continue continue
buf = tool_call_buffers.get(call_id) or {} buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}" args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}"
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(buf.get("name") or getattr(item, "name", None) or ""),
"arguments": str(args_raw),
})
try: try:
args = json.loads(args_raw) args = json.loads(args_raw)
except Exception: except Exception:
+41 -1
View File
@@ -71,6 +71,11 @@ class ProviderSpec:
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax) # "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
thinking_style: str = "" thinking_style: str = ""
# Gateway-native reasoning control to pair with model-level thinking styles.
# "reasoning_effort" — {"reasoning": {"effort": <none|minimal|...>}}
# (OpenRouter)
gateway_reasoning_style: str = ""
# When True, treat the "reasoning" response field as formal content # When True, treat the "reasoning" response field as formal content
# when "content" is empty. Only set this for providers (e.g. StepFun) # when "content" is empty. Only set this for providers (e.g. StepFun)
# whose API returns the actual answer in "reasoning" instead of "content". # whose API returns the actual answer in "reasoning" instead of "content".
@@ -142,6 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="openrouter", detect_by_base_keyword="openrouter",
default_api_base="https://openrouter.ai/api/v1", default_api_base="https://openrouter.ai/api/v1",
supports_prompt_caching=True, supports_prompt_caching=True,
gateway_reasoning_style="reasoning_effort",
), ),
# Hugging Face Inference Providers: OpenAI-compatible router for chat models. # Hugging Face Inference Providers: OpenAI-compatible router for chat models.
ProviderSpec( ProviderSpec(
@@ -155,6 +161,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="huggingface", detect_by_base_keyword="huggingface",
default_api_base="https://router.huggingface.co/v1", default_api_base="https://router.huggingface.co/v1",
), ),
# Skywork API platform (APIFree): OpenAI-compatible MaaS gateway.
ProviderSpec(
name="skywork",
keywords=("skywork", "skyclaw", "apifree"),
env_key="SKYWORK_API_KEY",
display_name="Skywork",
backend="openai_compat",
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
is_gateway=True,
detect_by_base_keyword="apifree.ai",
default_api_base="https://api.apifree.ai/agent/v1",
),
# AiHubMix: global gateway, OpenAI-compatible interface. # AiHubMix: global gateway, OpenAI-compatible interface.
# strip_model_prefix=True: doesn't understand "anthropic/claude-3", # strip_model_prefix=True: doesn't understand "anthropic/claude-3",
# strips to bare "claude-3". # strips to bare "claude-3".
@@ -181,6 +199,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://api.siliconflow.cn/v1", default_api_base="https://api.siliconflow.cn/v1",
), ),
# Novita AI: OpenAI-compatible gateway for hosted model APIs.
ProviderSpec(
name="novita",
keywords=("novita",),
env_key="NOVITA_API_KEY",
display_name="Novita AI",
backend="openai_compat",
is_gateway=True,
detect_by_base_keyword="novita",
default_api_base="https://api.novita.ai/openai",
),
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models # VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
ProviderSpec( ProviderSpec(
name="volcengine", name="volcengine",
@@ -390,13 +420,23 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
default_api_base="https://api.longcat.chat/openai/v1", default_api_base="https://api.longcat.chat/openai/v1",
), ),
# Ant Ling: OpenAI-compatible API for Ling/Ring model families.
ProviderSpec(
name="ant_ling",
keywords=("ant_ling", "ant-ling", "ling-", "ring-"),
env_key="ANT_LING_API_KEY",
display_name="Ant Ling",
backend="openai_compat",
detect_by_base_keyword="ant-ling.com",
default_api_base="https://api.ant-ling.com/v1",
),
# === Local deployment (matched by config key, NOT by api_base) ========= # === Local deployment (matched by config key, NOT by api_base) =========
# vLLM / any OpenAI-compatible local server # vLLM / any OpenAI-compatible local server
ProviderSpec( ProviderSpec(
name="vllm", name="vllm",
keywords=("vllm",), keywords=("vllm",),
env_key="HOSTED_VLLM_API_KEY", env_key="HOSTED_VLLM_API_KEY",
display_name="vLLM/Local", display_name="vLLM",
backend="openai_compat", backend="openai_compat",
is_local=True, is_local=True,
), ),
+27 -8
View File
@@ -7,6 +7,25 @@ from pathlib import Path
import httpx import httpx
from loguru import logger from loguru import logger
_TRANSCRIPTIONS_PATH = "audio/transcriptions"
def _resolve_transcription_url(api_base: str | None, default_url: str) -> str:
"""Resolve the full transcription endpoint URL.
Accepts either a chat-style base (e.g. ``https://api.groq.com/openai/v1``)
or a complete URL already ending in ``/audio/transcriptions``. A chat-style
base the form users naturally copy from their LLM provider config gets
the path appended instead of being POSTed verbatim and 404ing (#3637).
"""
if not api_base:
return default_url
base = api_base.rstrip("/")
if base.endswith(_TRANSCRIPTIONS_PATH):
return base
return f"{base}/{_TRANSCRIPTIONS_PATH}"
# Up to 3 retries (4 attempts total) with exponential backoff on transient # Up to 3 retries (4 attempts total) with exponential backoff on transient
# failures. Whisper endpoints occasionally return 502/503 under load, and # failures. Whisper endpoints occasionally return 502/503 under load, and
# mobile-network transcription callers hit sporadic connect/read errors. # mobile-network transcription callers hit sporadic connect/read errors.
@@ -127,12 +146,12 @@ class OpenAITranscriptionProvider:
language: str | None = None, language: str | None = None,
): ):
self.api_key = api_key or os.environ.get("OPENAI_API_KEY") self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
self.api_url = ( self.api_url = _resolve_transcription_url(
api_base api_base or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL"),
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL") "https://api.openai.com/v1/audio/transcriptions",
or "https://api.openai.com/v1/audio/transcriptions"
) )
self.language = language or None self.language = language or None
logger.debug("OpenAI transcription endpoint: {}", self.api_url)
async def transcribe(self, file_path: str | Path) -> str: async def transcribe(self, file_path: str | Path) -> str:
if not self.api_key: if not self.api_key:
@@ -166,12 +185,12 @@ class GroqTranscriptionProvider:
language: str | None = None, language: str | None = None,
): ):
self.api_key = api_key or os.environ.get("GROQ_API_KEY") self.api_key = api_key or os.environ.get("GROQ_API_KEY")
self.api_url = ( self.api_url = _resolve_transcription_url(
api_base api_base or os.environ.get("GROQ_BASE_URL"),
or os.environ.get("GROQ_BASE_URL") "https://api.groq.com/openai/v1/audio/transcriptions",
or "https://api.groq.com/openai/v1/audio/transcriptions"
) )
self.language = language or None self.language = language or None
logger.debug("Groq transcription endpoint: {}", self.api_url)
async def transcribe(self, file_path: str | Path) -> str: async def transcribe(self, file_path: str | Path) -> str:
""" """
+45 -5
View File
@@ -36,15 +36,36 @@ def configure_ssrf_whitelist(cidrs: list[str]) -> None:
_allowed_networks = nets _allowed_networks = nets
def _normalize_addr(
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
"""Normalize IPv6-mapped IPv4 addresses to their IPv4 form.
``::ffff:127.0.0.1`` is semantically identical to ``127.0.0.1`` but
Python's ipaddress treats it as an IPv6Address that matches neither
``127.0.0.0/8`` nor ``::1/128``. Converting it to IPv4 ensures
blocklist/allowlist checks work correctly.
"""
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
return addr.ipv4_mapped
return addr
def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
if _allowed_networks and any(addr in net for net in _allowed_networks): normalized = _normalize_addr(addr)
if _allowed_networks and any(normalized in net for net in _allowed_networks):
return False return False
return any(addr in net for net in _BLOCKED_NETWORKS) return any(normalized in net for net in _BLOCKED_NETWORKS)
def validate_url_target(url: str) -> tuple[bool, str]: def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]:
"""Validate a URL is safe to fetch: scheme, hostname, and resolved IPs. """Validate a URL is safe to fetch: scheme, hostname, and resolved IPs.
``allow_loopback`` is intentionally narrow: it only permits literal
loopback hosts (localhost, 127.0.0.0/8, ::1) when every resolved address is
loopback. It does not allow RFC1918, link-local, metadata, or public DNS
names that happen to resolve to loopback.
Returns (ok, error_message). When ok is True, error_message is empty. Returns (ok, error_message). When ok is True, error_message is empty.
""" """
try: try:
@@ -66,11 +87,16 @@ def validate_url_target(url: str) -> tuple[bool, str]:
except socket.gaierror: except socket.gaierror:
return False, f"Cannot resolve hostname: {hostname}" return False, f"Cannot resolve hostname: {hostname}"
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
for info in infos: for info in infos:
try: try:
addr = ipaddress.ip_address(info[4][0]) addr = ipaddress.ip_address(info[4][0])
except ValueError: except ValueError:
continue continue
addrs.append(addr)
if allow_loopback and _is_allowed_loopback_target(hostname, addrs):
return True, ""
for addr in addrs:
if _is_private(addr): if _is_private(addr):
return False, f"Blocked: {hostname} resolves to private/internal address {addr}" return False, f"Blocked: {hostname} resolves to private/internal address {addr}"
@@ -109,11 +135,25 @@ def validate_resolved_url(url: str) -> tuple[bool, str]:
return True, "" return True, ""
def contains_internal_url(command: str) -> bool: def contains_internal_url(command: str, *, allow_loopback: bool = False) -> bool:
"""Return True if the command string contains a URL targeting an internal/private address.""" """Return True if the command string contains a URL targeting an internal/private address."""
for m in _URL_RE.finditer(command): for m in _URL_RE.finditer(command):
url = m.group(0) url = m.group(0)
ok, _ = validate_url_target(url) ok, _ = validate_url_target(url, allow_loopback=allow_loopback)
if not ok: if not ok:
return True return True
return False return False
def _is_allowed_loopback_target(
hostname: str,
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address],
) -> bool:
if not addrs or not all(_normalize_addr(addr).is_loopback for addr in addrs):
return False
normalized = hostname.rstrip(".").lower()
if normalized == "localhost":
return True
with suppress(ValueError):
return ipaddress.ip_address(hostname).is_loopback
return False
+430
View File
@@ -0,0 +1,430 @@
"""Workspace access scope and sandbox capability helpers."""
from __future__ import annotations
import os
from contextvars import ContextVar, Token
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
WorkspaceAccessMode = Literal["restricted", "full"]
WORKSPACE_SCOPE_METADATA_KEY = "workspace_scope"
_ACCESS_MODES = {"restricted", "full"}
_TRUE_VALUES = {"1", "true", "yes", "on", "enabled"}
_FALSE_VALUES = {"0", "false", "no", "off", "disabled", ""}
_PROVIDER_LABELS = {
"none": "None",
"unknown": "Unknown system sandbox",
"macos_app_sandbox": "macOS App Sandbox",
"bwrap": "Bubblewrap",
}
_CURRENT_WORKSPACE_SCOPE: ContextVar["WorkspaceScope | None"] = ContextVar(
"nanobot_workspace_scope",
default=None,
)
class WorkspaceScopeError(ValueError):
"""Raised when a requested WebUI workspace scope is invalid."""
status = 400
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(frozen=True)
class WorkspaceSandboxStatus:
"""Resolved workspace sandbox state for runtime display and tooling."""
restrict_to_workspace: bool
workspace_root: str
level: str
enforced: bool
provider: str
provider_label: str
summary: str
def as_dict(self) -> dict[str, object]:
return {
"restrict_to_workspace": self.restrict_to_workspace,
"workspace_root": self.workspace_root,
"level": self.level,
"enforced": self.enforced,
"provider": self.provider,
"provider_label": self.provider_label,
"summary": self.summary,
}
@dataclass(frozen=True)
class WorkspaceScope:
"""Effective project root and access mode for one agent turn."""
project_path: Path
access_mode: WorkspaceAccessMode
restrict_to_workspace: bool
sandbox_status: WorkspaceSandboxStatus
source_channel: str | None = None
@property
def project_name(self) -> str:
return self.project_path.name or str(self.project_path)
def metadata(self) -> dict[str, str]:
return {
"project_path": str(self.project_path),
"access_mode": self.access_mode,
}
def payload(self) -> dict[str, Any]:
return {
**self.metadata(),
"project_name": self.project_name,
"restrict_to_workspace": self.restrict_to_workspace,
"sandbox_status": self.sandbox_status.as_dict(),
}
@dataclass(frozen=True)
class ToolWorkspace:
"""Workspace policy resolved for a tool call."""
project_path: Path | None
restrict_to_workspace: bool
scope: WorkspaceScope | None = None
@property
def allowed_root(self) -> Path | None:
if self.restrict_to_workspace and self.project_path is not None:
return self.project_path
return None
@dataclass(frozen=True)
class WorkspaceScopeResolver:
"""Resolve the effective workspace scope at an agent turn boundary."""
default_workspace: str | Path
default_restrict_to_workspace: bool
scoped_channel: str = "websocket"
@property
def sandbox_status(self) -> WorkspaceSandboxStatus:
return self.default().sandbox_status
def default(self) -> WorkspaceScope:
return default_workspace_scope(
self.default_workspace,
self.default_restrict_to_workspace,
)
def for_message(
self,
msg: Any,
session_metadata: Any,
) -> WorkspaceScope:
return self.for_turn(
channel=getattr(msg, "channel", None),
message_metadata=getattr(msg, "metadata", None),
session_metadata=session_metadata,
)
def for_turn(
self,
*,
channel: str | None,
message_metadata: Any,
session_metadata: Any,
) -> WorkspaceScope:
if channel != self.scoped_channel:
return self.default()
return resolve_effective_workspace_scope(
message_metadata=message_metadata,
session_metadata=session_metadata,
default_workspace=self.default_workspace,
default_restrict_to_workspace=self.default_restrict_to_workspace,
source_channel=channel,
)
def persist_message_scope(self, session: Any, msg: Any) -> None:
if getattr(msg, "channel", None) != self.scoped_channel:
return
metadata = getattr(msg, "metadata", None)
if not isinstance(metadata, dict):
return
raw = metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
if isinstance(raw, dict):
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(raw)
def workspace_sandbox_status(
*,
restrict_to_workspace: bool,
workspace: str | Path,
environ: dict[str, str] | None = None,
) -> WorkspaceSandboxStatus:
"""Return how workspace restriction is enforced in the current host."""
workspace_root = str(Path(workspace).expanduser().resolve(strict=False))
provider = _env_system_provider(environ)
if not restrict_to_workspace:
return WorkspaceSandboxStatus(
restrict_to_workspace=False,
workspace_root=workspace_root,
level="off",
enforced=False,
provider="none",
provider_label=_provider_label("none"),
summary="Workspace restriction is disabled.",
)
if provider:
label = _provider_label(provider)
return WorkspaceSandboxStatus(
restrict_to_workspace=True,
workspace_root=workspace_root,
level="system",
enforced=True,
provider=provider,
provider_label=label,
summary=f"Workspace restriction is system-enforced by {label}.",
)
return WorkspaceSandboxStatus(
restrict_to_workspace=True,
workspace_root=workspace_root,
level="application",
enforced=False,
provider="none",
provider_label=_provider_label("none"),
summary="Workspace restriction uses nanobot application-level guards.",
)
def default_access_mode(restrict_to_workspace: bool) -> WorkspaceAccessMode:
return "restricted" if restrict_to_workspace else "full"
def build_workspace_scope(
project_path: str | Path,
access_mode: str,
*,
source_channel: str | None = None,
) -> WorkspaceScope:
mode = _normalize_access_mode(access_mode)
root = Path(project_path).expanduser().resolve(strict=False)
restrict = mode == "restricted"
return WorkspaceScope(
project_path=root,
access_mode=mode,
restrict_to_workspace=restrict,
sandbox_status=workspace_sandbox_status(
restrict_to_workspace=restrict,
workspace=root,
),
source_channel=source_channel,
)
def default_workspace_scope(
workspace: str | Path,
restrict_to_workspace: bool,
*,
source_channel: str | None = None,
) -> WorkspaceScope:
return build_workspace_scope(
workspace,
default_access_mode(restrict_to_workspace),
source_channel=source_channel,
)
def validate_workspace_scope_payload(
raw: Any,
*,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
"""Validate a client-requested workspace scope."""
if raw is None:
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
if not isinstance(raw, dict):
raise WorkspaceScopeError("workspace_scope must be an object")
raw_path = raw.get("project_path") or raw.get("path")
if raw_path is None or raw_path == "":
raw_path = str(Path(default_workspace).expanduser().resolve(strict=False))
if not isinstance(raw_path, str):
raise WorkspaceScopeError("project_path must be a string")
if "\0" in raw_path:
raise WorkspaceScopeError("project_path contains invalid characters")
project = Path(raw_path).expanduser()
if not project.is_absolute():
raise WorkspaceScopeError("project_path must be absolute")
project = project.resolve(strict=False)
if not project.is_dir():
raise WorkspaceScopeError("project_path must be an existing directory")
raw_mode = raw.get("access_mode")
if raw_mode is None:
raw_mode = default_access_mode(default_restrict_to_workspace)
if not isinstance(raw_mode, str):
raise WorkspaceScopeError("access_mode must be a string")
return build_workspace_scope(project, raw_mode, source_channel=source_channel)
def workspace_scope_from_metadata(
metadata: Any,
*,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
"""Resolve persisted metadata, falling back safely for old or stale sessions."""
if not isinstance(metadata, dict):
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
try:
return validate_workspace_scope_payload(
metadata.get(WORKSPACE_SCOPE_METADATA_KEY),
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
except WorkspaceScopeError:
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
def resolve_effective_workspace_scope(
*,
message_metadata: Any,
session_metadata: Any,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
if isinstance(message_metadata, dict) and WORKSPACE_SCOPE_METADATA_KEY in message_metadata:
return workspace_scope_from_metadata(
message_metadata,
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
return workspace_scope_from_metadata(
session_metadata,
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
def bind_workspace_scope(scope: WorkspaceScope) -> Token[WorkspaceScope | None]:
return _CURRENT_WORKSPACE_SCOPE.set(scope)
def reset_workspace_scope(token: Token[WorkspaceScope | None]) -> None:
_CURRENT_WORKSPACE_SCOPE.reset(token)
def current_workspace_scope() -> WorkspaceScope | None:
return _CURRENT_WORKSPACE_SCOPE.get()
def current_tool_workspace(
default_workspace: str | Path | None,
*,
restrict_to_workspace: bool = False,
sandbox_restricts_workspace: bool = False,
) -> ToolWorkspace:
"""Return the workspace/access policy for the current tool call."""
scope = current_workspace_scope()
project_path = (
scope.project_path
if scope is not None
else Path(default_workspace).expanduser() if default_workspace is not None else None
)
restrict = (
scope.restrict_to_workspace
if scope is not None
else bool(restrict_to_workspace)
) or sandbox_restricts_workspace
return ToolWorkspace(
project_path=project_path,
restrict_to_workspace=restrict,
scope=scope,
)
def current_scope_allows_loopback(*, enabled: bool) -> bool:
"""Return True when the current WebUI Full Access turn may touch loopback URLs."""
scope = current_workspace_scope()
return bool(
enabled
and scope is not None
and scope.source_channel == "websocket"
and scope.access_mode == "full"
and not scope.restrict_to_workspace
)
def _env_system_provider(environ: dict[str, str] | None = None) -> str | None:
env = environ if environ is not None else os.environ
explicit_provider = env.get("NANOBOT_WORKSPACE_SANDBOX_PROVIDER")
enforced = env.get("NANOBOT_WORKSPACE_SANDBOX_ENFORCED")
compatibility = env.get("NANOBOT_SANDBOX_ENFORCED")
marker = enforced if enforced is not None else compatibility
if marker is None:
return None
normalized_marker = marker.strip().lower()
if normalized_marker in _FALSE_VALUES:
return None
if normalized_marker in _TRUE_VALUES:
return _normalize_provider(explicit_provider)
return _normalize_provider(marker)
def _normalize_provider(value: str | None) -> str:
if not value:
return "unknown"
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
return normalized or "unknown"
def _provider_label(provider: str) -> str:
if provider in _PROVIDER_LABELS:
return _PROVIDER_LABELS[provider]
return provider.replace("_", " ").title()
def _normalize_access_mode(value: str) -> WorkspaceAccessMode:
mode = value.strip().lower().replace("_", "-")
if mode == "restrict":
mode = "restricted"
if mode == "full-access":
mode = "full"
if mode not in _ACCESS_MODES:
raise WorkspaceScopeError("access_mode must be restricted or full")
return mode # type: ignore[return-value]
+85
View File
@@ -0,0 +1,85 @@
"""Workspace path boundary helpers.
These helpers are application-level guards. They make path decisions
consistent across tools, but they are not a replacement for an OS sandbox.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
WORKSPACE_BOUNDARY_NOTE = (
" (this is a hard policy boundary, not a transient failure; "
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
)
class WorkspaceBoundaryError(PermissionError):
"""Raised when a requested path escapes an allowed workspace boundary."""
def resolve_path(path: str | Path, workspace: str | Path | None = None, *, strict: bool = False) -> Path:
"""Resolve *path*, interpreting relative paths against *workspace* when set."""
candidate = Path(path).expanduser()
if not candidate.is_absolute() and workspace is not None:
candidate = Path(workspace).expanduser() / candidate
return candidate.resolve(strict=strict)
def is_path_within(path: str | Path, root: str | Path) -> bool:
"""Return True when *path* resolves to *root* or a descendant of *root*."""
try:
resolved_path = Path(path).expanduser().resolve(strict=False)
resolved_root = Path(root).expanduser().resolve(strict=False)
resolved_path.relative_to(resolved_root)
return True
except (OSError, RuntimeError, TypeError, ValueError):
return False
def is_path_allowed(path: str | Path, roots: Iterable[str | Path]) -> bool:
"""Return True when *path* is inside any allowed root."""
return any(is_path_within(path, root) for root in roots)
def require_path_within(
path: str | Path,
root: str | Path,
*,
message: str | None = None,
) -> Path:
"""Resolve *path* and require it to be inside *root*."""
resolved = Path(path).expanduser().resolve(strict=False)
if not is_path_within(resolved, root):
raise WorkspaceBoundaryError(
message
or f"Path {path} is outside allowed directory {Path(root).expanduser()}"
+ WORKSPACE_BOUNDARY_NOTE
)
return resolved
def resolve_allowed_path(
path: str | Path,
*,
workspace: str | Path | None = None,
allowed_root: str | Path | None = None,
extra_allowed_roots: Iterable[str | Path] | None = None,
strict: bool = False,
) -> Path:
"""Resolve a path and enforce containment in allowed roots when configured."""
resolved = resolve_path(path, workspace, strict=False)
if allowed_root is None:
return resolve_path(path, workspace, strict=strict) if strict else resolved
roots = [allowed_root, *(extra_allowed_roots or [])]
if not is_path_allowed(resolved, roots):
raise WorkspaceBoundaryError(
f"Path {path} is outside allowed directory {Path(allowed_root).expanduser()}"
+ WORKSPACE_BOUNDARY_NOTE
)
if strict:
return resolve_path(path, workspace, strict=True)
return resolved
+19 -4
View File
@@ -43,6 +43,19 @@ def sustained_goal_active(metadata: Mapping[str, Any] | None) -> bool:
return isinstance(goal, dict) and goal.get("status") == "active" return isinstance(goal, dict) and goal.get("status") == "active"
def sustained_goal_turn(
metadata: Mapping[str, Any] | None,
*,
message_metadata: Mapping[str, Any] | None = None,
) -> bool:
"""True when this turn should use sustained-goal runtime limits."""
if sustained_goal_active(metadata):
return True
if not message_metadata:
return False
return str(message_metadata.get("original_command") or "").strip() == "/goal"
def parse_goal_state(blob: Any) -> dict[str, Any] | None: def parse_goal_state(blob: Any) -> dict[str, Any] | None:
if blob is None: if blob is None:
return None return None
@@ -98,14 +111,16 @@ def runner_wall_llm_timeout_s(
session_key: str | None, session_key: str | None,
*, *,
metadata: Mapping[str, Any] | None = None, metadata: Mapping[str, Any] | None = None,
message_metadata: Mapping[str, Any] | None = None,
) -> float | None: ) -> float | None:
"""Wall-clock cap for :class:`~nanobot.agent.runner.AgentRunner` when streaming an LLM. """Wall-clock cap for :class:`~nanobot.agent.runner.AgentRunner` when streaming an LLM.
Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when a sustained goal is Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when this is a
active; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory ``metadata`` when the sustained-goal turn; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory
caller already holds :attr:`~nanobot.session.manager.Session.metadata` for this turn. ``metadata`` when the caller already holds :attr:`~nanobot.session.manager.Session.metadata`
for this turn.
""" """
meta: Mapping[str, Any] | None = metadata meta: Mapping[str, Any] | None = metadata
if meta is None and session_key: if meta is None and session_key:
meta = sessions.get_or_create(session_key).metadata meta = sessions.get_or_create(session_key).metadata
return 0.0 if sustained_goal_active(meta) else None return 0.0 if sustained_goal_turn(meta, message_metadata=message_metadata) else None
+109 -53
View File
@@ -8,7 +8,7 @@ from contextlib import suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any
from loguru import logger from loguru import logger
@@ -19,6 +19,7 @@ from nanobot.utils.helpers import (
find_legal_message_start, find_legal_message_start,
image_placeholder_text, image_placeholder_text,
safe_filename, safe_filename,
strip_think,
) )
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
@@ -27,6 +28,8 @@ _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$") _LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$') _TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
_SESSION_PREVIEW_MAX_CHARS = 120 _SESSION_PREVIEW_MAX_CHARS = 120
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
def _sanitize_assistant_replay_text(content: str) -> str: def _sanitize_assistant_replay_text(content: str) -> str:
@@ -74,6 +77,17 @@ def _message_preview_text(message: dict[str, Any]) -> str:
return _text_preview(content) return _text_preview(content)
def _metadata_title(metadata: Any) -> str:
if not isinstance(metadata, dict):
return ""
title = metadata.get("title")
if not isinstance(title, str):
return ""
if metadata.get("title_user_edited") is True:
return title
return strip_think(title)
@dataclass @dataclass
class Session: class Session:
"""A conversation session.""" """A conversation session."""
@@ -165,6 +179,45 @@ class Session:
image_placeholder_text(p) for p in media if isinstance(p, str) and p image_placeholder_text(p) for p in media if isinstance(p, str) and p
) )
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
cli_apps = message.get("cli_apps")
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
cli_lines: list[str] = []
for item in cli_apps[:8]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip().lower()
if not name:
continue
entry = str(item.get("entry_point") or "unknown").strip() or "unknown"
cli_lines.append(
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry}; "
f"skill=skills/cli-app-{name}/SKILL.md]"
)
if cli_lines:
breadcrumbs = "\n".join(cli_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
mcp_presets = message.get("mcp_presets")
if (
role == "user"
and isinstance(mcp_presets, list)
and mcp_presets
and isinstance(content, str)
):
mcp_lines: list[str] = []
for item in mcp_presets[:8]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip().lower()
if not name:
continue
transport = str(item.get("transport") or "mcp").strip() or "mcp"
mcp_lines.append(
f"[MCP Preset Attachment: @{name}; tool_prefix=mcp_{name}_; "
f"transport={transport}]"
)
if mcp_lines:
breadcrumbs = "\n".join(mcp_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
if include_timestamps: if include_timestamps:
content = self._annotate_message_time(message, content) content = self._annotate_message_time(message, content)
if role == "assistant" and isinstance(content, str) and not content.strip(): if role == "assistant" and isinstance(content, str) and not content.strip():
@@ -216,13 +269,25 @@ class Session:
self.updated_at = datetime.now() self.updated_at = datetime.now()
self.metadata.pop("_last_summary", None) self.metadata.pop("_last_summary", None)
def retain_recent_legal_suffix(self, max_messages: int) -> None: def retain_recent_legal_suffix(self, max_messages: int) -> tuple[list[dict], int]:
"""Keep a legal recent suffix constrained by a hard message cap.""" """Keep a legal recent suffix constrained by a hard message cap.
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
the list of removed messages (in original order) and
*already_consolidated_count* is how many of those were inside the
pre-existing ``last_consolidated`` prefix and therefore do not need
raw archiving.
"""
if max_messages <= 0: if max_messages <= 0:
dropped = list(self.messages)
lc = self.last_consolidated
self.clear() self.clear()
return return dropped, min(lc, len(dropped))
if len(self.messages) <= max_messages: if len(self.messages) <= max_messages:
return return [], 0
original = list(self.messages)
before_lc = self.last_consolidated
retained = list(self.messages[-max_messages:]) retained = list(self.messages[-max_messages:])
@@ -253,10 +318,32 @@ class Session:
if start: if start:
retained = retained[start:] retained = retained[start:]
dropped = len(self.messages) - len(retained) # Compute actually-dropped messages using identity comparison so that
# even when retained is a non-contiguous slice of original (the else
# branch above), we never duplicate or lose messages.
retained_ids = set(id(m) for m in retained)
dropped = [m for m in original if id(m) not in retained_ids]
# Count how many dropped messages were in the already-consolidated
# prefix of the original list. This cannot be a simple min() because
# dropped may include messages from *after* the consolidated prefix
# (e.g. in the else branch).
already_consolidated = sum(
1 for i, m in enumerate(original)
if i < before_lc and id(m) not in retained_ids
)
# New last_consolidated = count of retained messages that were inside
# the old consolidated prefix.
new_lc = sum(
1 for i, m in enumerate(original)
if i < before_lc and id(m) in retained_ids
)
self.messages = retained self.messages = retained
self.last_consolidated = max(0, self.last_consolidated - dropped) self.last_consolidated = new_lc
self.updated_at = datetime.now() self.updated_at = datetime.now()
return dropped, already_consolidated
def enforce_file_cap( def enforce_file_cap(
self, self,
@@ -267,23 +354,17 @@ class Session:
if limit <= 0 or len(self.messages) <= limit: if limit <= 0 or len(self.messages) <= limit:
return return
before = list(self.messages) dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
before_last_consolidated = self.last_consolidated if not dropped:
before_count = len(before)
self.retain_recent_legal_suffix(limit)
dropped_count = before_count - len(self.messages)
if dropped_count <= 0:
return return
dropped = before[:dropped_count]
already_consolidated = min(before_last_consolidated, dropped_count)
archive_chunk = dropped[already_consolidated:] archive_chunk = dropped[already_consolidated:]
if archive_chunk and on_archive: if archive_chunk and on_archive:
on_archive(archive_chunk) on_archive(archive_chunk)
logger.info( logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}", "Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key, self.key,
dropped_count, len(dropped),
len(archive_chunk), len(archive_chunk),
len(self.messages), len(self.messages),
) )
@@ -581,36 +662,6 @@ class SessionManager:
return self._session_payload(repaired) return self._session_payload(repaired)
return None return None
def get_or_create_task_session(
self,
base_key: str,
task_id: str,
role: Literal["manager", "worker"] = "worker",
) -> Session:
"""Get or create an isolated session for a specific task.
Key format: task:{base_key}:{task_id}:{role}
Example: task:slack:C123:root_qml:manager
"""
task_key = f"task:{base_key}:{task_id}:{role}"
return self.get_or_create(task_key)
def list_task_sessions(self, base_key: str) -> list[Session]:
"""List all task-scoped sessions for a given base key."""
prefix = f"task:{base_key}:"
return [
session for key, session in self._cache.items()
if key.startswith(prefix)
]
def finalize_task_session(self, task_id: str) -> None:
"""Mark a task session as finalized (read-only) by setting metadata."""
prefix = f"task:"
for key, session in list(self._cache.items()):
if f":{task_id}:" in key and key.startswith(prefix):
session.metadata["finalized"] = True
self.save(session)
def list_sessions(self) -> list[dict[str, Any]]: def list_sessions(self) -> list[dict[str, Any]]:
""" """
List all sessions. List all sessions.
@@ -631,12 +682,21 @@ class SessionManager:
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1) key = data.get("key") or path.stem.replace("_", ":", 1)
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
title = metadata.get("title") if isinstance(metadata, dict) else None title = _metadata_title(metadata)
preview = "" preview = ""
fallback_preview = "" fallback_preview = ""
scanned_records = 0
scanned_chars = 0
for line in f: for line in f:
if not line.strip(): if not line.strip():
continue continue
scanned_records += 1
scanned_chars += len(line)
if (
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
item = json.loads(line) item = json.loads(line)
if item.get("_type") == "metadata": if item.get("_type") == "metadata":
continue continue
@@ -653,7 +713,7 @@ class SessionManager:
"key": key, "key": key,
"created_at": data.get("created_at"), "created_at": data.get("created_at"),
"updated_at": data.get("updated_at"), "updated_at": data.get("updated_at"),
"title": title if isinstance(title, str) else "", "title": title,
"preview": preview, "preview": preview,
"path": str(path) "path": str(path)
}) })
@@ -664,11 +724,7 @@ class SessionManager:
"key": repaired.key, "key": repaired.key,
"created_at": repaired.created_at.isoformat(), "created_at": repaired.created_at.isoformat(),
"updated_at": repaired.updated_at.isoformat(), "updated_at": repaired.updated_at.isoformat(),
"title": ( "title": _metadata_title(repaired.metadata),
repaired.metadata.get("title")
if isinstance(repaired.metadata.get("title"), str)
else ""
),
"preview": next( "preview": next(
( (
text text
+240
View File
@@ -0,0 +1,240 @@
"""Internal turn continuation helpers.
This module keeps budget-boundary continuation policy out of ``AgentLoop``.
The loop calls a small set of helpers; those helpers decide whether an internal
continuation is allowed and, when it is, queue the next turn directly.
"""
from __future__ import annotations
import dataclasses
from typing import Any, Mapping, MutableMapping
from loguru import logger
from nanobot.session.goal_state import (
goal_state_runtime_lines,
sustained_goal_active,
sustained_goal_turn,
)
INTERNAL_CONTINUATION_META = "_internal_continuation"
INTERNAL_CONTINUATION_KIND_META = "_internal_continuation_kind"
INTERNAL_CONTINUATION_PENDING_META = "_internal_continuation_pending"
INTERNAL_CONTINUATION_RUN_STARTED_AT_META = "_internal_continuation_run_started_at"
_GOAL_CONTINUATION_KIND = "sustained_goal"
_GOAL_CONTINUATION_SENDER = "system:continuation"
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = {
"_stream_id",
"_stream_delta",
"_stream_end",
"_resuming",
INTERNAL_CONTINUATION_PENDING_META,
}
def internal_continuation_inbound(metadata: Mapping[str, Any] | None) -> bool:
"""True for an inbound message created by an internal continuation policy."""
return bool(metadata and metadata.get(INTERNAL_CONTINUATION_META) is True)
def internal_continuation_pending(metadata: Mapping[str, Any] | None) -> bool:
"""True when the current turn scheduled an invisible continuation slice."""
return bool(metadata and metadata.get(INTERNAL_CONTINUATION_PENDING_META) is True)
def internal_continuation_run_started_at(metadata: Mapping[str, Any] | None) -> float | None:
"""Return the user-visible run start propagated across continuation slices."""
if not metadata:
return None
value = metadata.get(INTERNAL_CONTINUATION_RUN_STARTED_AT_META)
if not isinstance(value, int | float):
return None
started_at = float(value)
return started_at if started_at > 0 else None
def should_persist_user_message(metadata: Mapping[str, Any] | None) -> bool:
"""Return whether this inbound message should be persisted as user input."""
return not internal_continuation_inbound(metadata)
def should_stream_budget_response(
*,
stop_reason: str,
pending_queue_available: bool,
session_metadata: Mapping[str, Any] | None,
message_metadata: Mapping[str, Any] | None = None,
) -> bool:
"""Return whether the budget-boundary response should be sent to the user."""
return not _continuation_available(
stop_reason=stop_reason,
pending_queue_available=pending_queue_available,
session_metadata=session_metadata,
message_metadata=message_metadata,
)
async def maybe_continue_turn(ctx: Any) -> bool:
"""Queue an internal continuation for *ctx* when policy allows it."""
if ctx.session is None or ctx.pending_queue is None:
return False
if not _continuation_available(
stop_reason=ctx.stop_reason,
pending_queue_available=True,
session_metadata=ctx.session.metadata,
message_metadata=ctx.msg.metadata,
):
return False
metadata = _internal_continuation_metadata(
ctx.msg.metadata,
run_started_at=getattr(ctx, "visible_run_started_at", None),
)
content = _goal_continuation_prompt(ctx.session.metadata)
messages = _strip_terminal_assistant(ctx.all_messages, ctx.final_content)
_increment_goal_continuation_round(ctx.session.metadata)
logger.info("Turn budget reached; scheduling internal continuation")
ctx.msg.metadata[INTERNAL_CONTINUATION_PENDING_META] = True
ctx.final_content = ""
ctx.all_messages = messages
ctx.suppress_response = True
await ctx.pending_queue.put(
dataclasses.replace(
ctx.msg,
sender_id=_GOAL_CONTINUATION_SENDER,
content=content,
media=[],
metadata=metadata,
session_key_override=ctx.session_key,
)
)
return True
def prepare_save_boundary(ctx: Any) -> None:
"""Prepare continuation bookkeeping and the history append boundary."""
if ctx.session is not None:
clear_internal_continuation_state(ctx.session.metadata)
ctx.save_skip = _save_skip_for_turn(
message_metadata=ctx.msg.metadata,
initial_message_count=len(ctx.initial_messages),
history_count=len(ctx.history),
user_persisted_early=ctx.user_persisted_early,
)
def _continuation_available(
*,
stop_reason: str,
pending_queue_available: bool,
session_metadata: Mapping[str, Any] | None,
message_metadata: Mapping[str, Any] | None = None,
) -> bool:
if stop_reason != "max_iterations" or not pending_queue_available:
return False
return _goal_continuation_available(
session_metadata,
message_metadata=message_metadata,
)
def clear_internal_continuation_state(metadata: MutableMapping[str, Any]) -> None:
"""Reset policy bookkeeping once its owning runtime mode is inactive."""
if not sustained_goal_active(metadata):
metadata.pop(_GOAL_CONTINUATION_ROUNDS_KEY, None)
def _save_skip_for_turn(
*,
message_metadata: Mapping[str, Any] | None,
initial_message_count: int,
history_count: int,
user_persisted_early: bool,
) -> int:
"""Return the persisted-message append boundary for this turn."""
if internal_continuation_inbound(message_metadata):
return initial_message_count
return 1 + history_count + (1 if user_persisted_early else 0)
def _goal_continuation_available(
session_metadata: Mapping[str, Any] | None,
*,
message_metadata: Mapping[str, Any] | None = None,
max_rounds: int = _MAX_GOAL_CONTINUATION_ROUNDS,
) -> bool:
if not sustained_goal_turn(session_metadata, message_metadata=message_metadata):
return False
if not sustained_goal_active(session_metadata):
return False
try:
rounds = int((session_metadata or {}).get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
except (TypeError, ValueError):
rounds = 0
return rounds < max(0, max_rounds)
def _increment_goal_continuation_round(session_metadata: MutableMapping[str, Any]) -> None:
try:
rounds = int(session_metadata.get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
except (TypeError, ValueError):
rounds = 0
session_metadata[_GOAL_CONTINUATION_ROUNDS_KEY] = rounds + 1
def _internal_continuation_metadata(
message_metadata: Mapping[str, Any] | None,
*,
run_started_at: float | None = None,
) -> dict[str, Any]:
metadata = dict(message_metadata or {})
metadata[INTERNAL_CONTINUATION_META] = True
metadata[INTERNAL_CONTINUATION_KIND_META] = _GOAL_CONTINUATION_KIND
if run_started_at is not None:
metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] = float(run_started_at)
for key in _STRIPPED_INBOUND_META_KEYS:
metadata.pop(key, None)
return metadata
def _goal_continuation_prompt(metadata: Mapping[str, Any] | None) -> str:
lines = goal_state_runtime_lines(metadata)
if lines:
goal = "\n".join(lines)
return (
"Continue the active sustained goal after the previous turn reached "
"its tool-call budget.\n\n"
f"{goal}\n\n"
"Continue from the saved context. Do not mention the continuation "
"boundary to the user. Use tools as needed, and call complete_goal "
"when the objective is truly finished."
)
return (
"Continue the active sustained goal after the previous turn reached "
"its tool-call budget. Continue from the saved context. Do not mention "
"the continuation boundary to the user. Use tools as needed, and call "
"complete_goal when the objective is truly finished."
)
def _strip_terminal_assistant(
messages: list[dict[str, Any]],
final_content: str | None,
) -> list[dict[str, Any]]:
"""Drop the synthetic max-iteration assistant message before saving history."""
if not messages:
return messages
last = messages[-1]
if last.get("role") != "assistant":
return messages
if final_content is None or last.get("content") != final_content:
return messages
if last.get("tool_calls"):
return messages
return messages[:-1]
+372
View File
@@ -0,0 +1,372 @@
"""Session turn helpers for WebUI-capable WebSocket sessions.
AgentLoop uses these without importing a concrete channel plugin; only
``channel == "websocket"`` messages are affected.
"""
from __future__ import annotations
import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import strip_think, truncate_text
from nanobot.utils.llm_runtime import LLMRuntime
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
TITLE_MAX_CHARS = 60
TITLE_GENERATION_MAX_TOKENS = 96
TITLE_GENERATION_REASONING_EFFORT = "none"
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
return True
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = strip_think(text)
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
text = text[: TITLE_MAX_CHARS - 1].rstrip() + ""
return text
def _title_inputs(session: Session) -> tuple[str, str]:
user_text = ""
assistant_text = ""
for message in session.messages:
if message.get("_command") is True:
continue
role = message.get("role")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
content = strip_think(content)
if not content:
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
assistant_text = content.strip()
if user_text and assistant_text:
break
return user_text, assistant_text
async def maybe_generate_webui_title(
*,
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
cleaned_current_title = clean_generated_title(current_title)
if cleaned_current_title:
if cleaned_current_title != current_title:
session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title
sessions.save(session)
return False
session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None)
user_text, assistant_text = _title_inputs(session)
if not user_text:
return False
prompt = (
"Generate a concise title for this chat.\n"
"Rules:\n"
"- Use the same language as the user when practical.\n"
"- 3 to 8 words.\n"
"- No quotes.\n"
"- No punctuation at the end.\n"
"- Return only the title.\n\n"
f"User: {truncate_text(user_text, 1_000)}"
)
if assistant_text:
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=TITLE_GENERATION_MAX_TOKENS,
temperature=0.2,
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
title = clean_generated_title(response.content)
if not title or title.lower().startswith("error"):
logger.debug(
"WebUI title generation returned no usable title for {} (finish_reason={})",
session_key,
response.finish_reason,
)
return False
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
sessions.save(session)
return True
async def maybe_generate_webui_title_after_turn(
*,
channel: str,
metadata: dict[str, Any],
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
return await maybe_generate_webui_title(
sessions=sessions,
session_key=session_key,
provider=provider,
model=model,
)
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
"""Return ``time.time()`` when the active user turn began, if still running."""
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
async def publish_turn_run_status(
bus: MessageBus,
msg: InboundMessage,
status: str,
*,
started_at: float | None = None,
) -> None:
"""Notify WebSocket clients while a user turn is executing (timing strip)."""
if msg.channel != "websocket":
return
cid = str(msg.chat_id)
meta: dict[str, Any] = {
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
if status == "running":
if isinstance(started_at, int | float) and started_at > 0:
t0 = float(started_at)
else:
t0 = time.time()
meta["started_at"] = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=cid,
content="",
metadata=meta,
),
)
def build_bus_progress_callback(
bus: MessageBus,
msg: InboundMessage,
) -> Callable[..., Awaitable[None]]:
"""Return the bus progress callback for agent runtime events."""
async def _publish_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
file_edit_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
if file_edit_events:
meta["_file_edit_events"] = file_edit_events
await bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
if msg.channel == "websocket":
async def _websocket_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
file_edit_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
await _publish_progress(
content,
tool_hint=tool_hint,
tool_events=tool_events,
file_edit_events=file_edit_events,
reasoning=reasoning,
reasoning_end=reasoning_end,
)
return _websocket_progress
async def _bus_progress(
content: str,
*,
tool_hint: bool = False,
tool_events: list[dict[str, Any]] | None = None,
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
await _publish_progress(
content,
tool_hint=tool_hint,
tool_events=tool_events,
reasoning=reasoning,
reasoning_end=reasoning_end,
)
return _bus_progress
@dataclass
class WebuiTurnCoordinator:
"""Own the WebUI/WebSocket wire details that hang off AgentLoop turns."""
bus: MessageBus
sessions: SessionManager
schedule_background: Callable[[Awaitable[None]], None]
_title_contexts: dict[str, LLMRuntime] = field(default_factory=dict)
def capture_title_context(
self,
session_key: str,
msg: InboundMessage,
llm: LLMRuntime,
) -> None:
if msg.channel == "websocket" and msg.metadata.get("webui") is True:
self._title_contexts[session_key] = llm
def discard(self, session_key: str) -> None:
self._title_contexts.pop(session_key, None)
async def publish_run_status(
self,
msg: InboundMessage,
status: str,
*,
started_at: float | None = None,
) -> None:
await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
async def handle_turn_end(
self,
msg: InboundMessage,
*,
session_key: str,
latency_ms: int | None,
) -> None:
if msg.channel != "websocket":
return
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if latency_ms is not None:
turn_metadata["latency_ms"] = int(latency_ms)
session = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata=turn_metadata,
))
self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
title_context = self._title_contexts.pop(session_key, None)
if msg.metadata.get("webui") is not True or title_context is None:
return
async def _generate_title_and_notify(
title_llm: LLMRuntime = title_context,
) -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=title_llm.provider,
model=title_llm.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
self.schedule_background(_generate_title_and_notify())
-64
View File
@@ -1,64 +0,0 @@
---
name: create-instance
description: "Create a new nanobot instance with separate config and workspace. Use when the user wants to set up a new bot, create a new instance for a different channel, persona, or purpose. Triggers on: create instance, new bot, set up bot, add bot, create telegram/discord/feishu/slack/wechat/wecom/dingtalk/qq/email/matrix/msteams/whatsapp bot, multi-instance setup, inter-agent communication."
---
# Create Instance
Set up a new nanobot instance with its own config and workspace.
## Steps
1. **Collect information** (ask one at a time if not already provided):
- **Instance name** (required): short identifier, e.g. `telegram-bot`, `work-slack`
- **Channel type** (required): see table below
- **Model** (optional): LLM model, defaults to current instance
2. **Do NOT collect secrets** in the chat (API keys, bot tokens). API keys are automatically inherited from the current instance via `--inherit-config`. Channel-specific tokens must be filled in manually after creation.
3. **Run the creation script**:
```bash
python <skill-dir>/scripts/create_instance.py --name <name> --channel <channel> --inherit-config <current-config>
```
- `<skill-dir>` — the directory containing this SKILL.md
- `<current-config>` — current instance's config path, typically `~/.nanobot/config.json`
- Optional: `--model <model>`, `--config-dir <path>`
**Exec tool constraints:**
- Use forward-slash paths (works on all platforms)
- Do not wrap paths in quotes
- Do not use `cd`; pass the full script path directly
4. **Report results** to the user:
- Config and workspace paths (script outputs them)
- Required fields to fill in (script lists them)
- Start command: `nanobot gateway --config <config-path>`
## Available Channels
| Channel | Key | Required Fields |
|---------|-----|-----------------|
| Telegram | `telegram` | token |
| Discord | `discord` | token |
| Feishu / Lark | `feishu` | app_id, app_secret |
| DingTalk | `dingtalk` | client_id, client_secret |
| Slack | `slack` | bot_token, app_token |
| WeCom | `wecom` | bot_id, secret |
| WeChat OA | `weixin` | token |
| WhatsApp | `whatsapp` | bridge_token |
| QQ | `qq` | app_id, secret |
| Email | `email` | imap_host, imap_username, imap_password, smtp_host, smtp_username, smtp_password, from_address |
| Matrix | `matrix` | user_id, password or access_token |
| MS Teams | `msteams` | app_id, app_password, tenant_id |
| MoChat | `mochat` | claw_token |
| WebSocket | `websocket` | token |
For detailed channel configuration including optional fields, see `references/channels.md`.
## Troubleshooting
- **"Unknown channel"**: Channel name must match the Key column exactly. Run the script without arguments to see usage.
- **"Config already exists"**: Use a different `--name` or `--config-dir` to create in a new location.
- **Port conflicts**: The script auto-assigns free ports for gateway and API if defaults are in use.
@@ -1,195 +0,0 @@
# Channel Configuration Reference
Detailed configuration for each supported channel.
## Field Types
- **Required**: defaults to empty string `""`, must be filled in before the instance can start
- **Optional**: has a sensible default, can be customized
---
## telegram
**Required:**
- `token` — Bot token from @BotFather
**Notable optional:**
- `proxy` — HTTP proxy URL
- `group_policy``"open"` (all messages) or `"mention"` (default, only when @mentioned)
- `streaming` — Enable streaming responses (default: true)
- `reply_to_message` — Reply to the triggering message (default: false)
- `react_emoji` — Emoji for "thinking" reaction (default: `"eyes"`)
- `inline_keyboards` — Enable inline keyboard buttons (default: false)
## discord
**Required:**
- `token` — Bot token from Discord Developer Portal
**Notable optional:**
- `allow_channels` — Restrict to specific channel IDs
- `group_policy``"mention"` (default) or `"open"`
- `streaming` — Enable streaming (default: true)
- `proxy` — HTTP proxy URL
- `intents` — Discord gateway intents (default: 37377)
- `read_receipt_emoji` — Emoji for read receipt
- `working_emoji` — Emoji for "working" indicator
## feishu
**Required:**
- `app_id` — Feishu app ID
- `app_secret` — Feishu app secret
**Notable optional:**
- `encrypt_key` — Event encryption key
- `verification_token` — Event verification token
- `domain``"feishu"` (default) or `"lark"`
- `group_policy``"mention"` (default) or `"open"`
- `streaming` — Enable streaming (default: true)
## dingtalk
**Required:**
- `client_id` — DingTalk app client ID
- `client_secret` — DingTalk app client secret
**Notable optional:**
- `allow_from` — Allowed user IDs
## slack
**Required:**
- `bot_token` — Bot OAuth token (`xoxb-...`)
- `app_token` — App-level token (`xapp-...`)
**Notable optional:**
- `mode``"socket"` (default, Socket Mode) or `"webhook"`
- `reply_in_thread` — Reply in thread (default: true)
- `react_emoji` — "thinking" emoji (default: `"eyes"`)
- `done_emoji` — "done" emoji (default: `"white_check_mark"`)
- `group_policy``"mention"` (default) or `"open"`
- `dm.enabled` — Enable DM support
- `dm.policy` — DM policy
- `dm.allow_from` — Allowed DM users
## wecom
**Required:**
- `bot_id` — WeCom bot ID
- `secret` — WeCom bot secret
**Notable optional:**
- `allow_from` — Allowed users
- `welcome_message` — Welcome message for new chats
## weixin
**Required:**
- `token` — WeChat Official Account token
**Notable optional:**
- `base_url` — API base URL
- `cdn_base_url` — CDN base URL
- `state_dir` — State persistence directory
- `poll_timeout` — Long polling timeout
## whatsapp
**Required:**
- `bridge_token` — WhatsApp bridge token (auto-generated if absent)
**Notable optional:**
- `bridge_url` — Bridge WebSocket URL (default: `"ws://localhost:3001"`)
- `group_policy``"open"` (default) or `"mention"`
## qq
**Required:**
- `app_id` — QQ bot app ID
- `secret` — QQ bot secret
**Notable optional:**
- `msg_format``"plain"` or `"markdown"`
- `ack_message` — Acknowledgment message text
- `media_dir` — Media file directory
## email
**Required:**
- `imap_host` — IMAP server hostname
- `imap_username` — IMAP login username
- `imap_password` — IMAP login password
- `smtp_host` — SMTP server hostname
- `smtp_username` — SMTP login username
- `smtp_password` — SMTP login password
- `from_address` — Sender email address
**Notable optional:**
- `imap_port` — IMAP port (default: 993)
- `smtp_port` — SMTP port (default: 587)
- `imap_use_ssl` — Use SSL for IMAP (default: true)
- `smtp_use_tls` — Use TLS for SMTP (default: true)
- `poll_interval_seconds` — Polling interval (default: 30)
- `mark_seen` — Mark emails as read (default: true)
- `max_body_chars` — Max email body length (default: 12000)
- `subject_prefix` — Reply subject prefix (default: `"Re: "`)
- `verify_dkim` — Verify DKIM signatures (default: true)
- `verify_spf` — Verify SPF records (default: true)
- `allowed_attachment_types` — Allowed file extensions
- `max_attachment_size` — Max attachment size in bytes
- `consent_granted` — Must be set to `true` for the channel to start (default: false)
- `auto_reply_enabled` — Enable auto-reply (default: true)
## matrix
**Required:**
- `user_id` — Matrix user ID (e.g. `@bot:matrix.org`)
- `password` or `access_token` — Login password OR access token
**Notable optional:**
- `homeserver` — Homeserver URL (default: `"https://matrix.org"`)
- `device_id` — Device ID
- `e2eeEnabled` — Enable end-to-end encryption (default: true)
- `group_policy``"open"`, `"mention"`, or `"allowlist"`
- `streaming` — Enable streaming (default: false)
- `max_media_bytes` — Max media file size (default: 20MB)
## msteams
**Required:**
- `app_id` — Azure AD app ID
- `app_password` — Azure AD app password/secret
- `tenant_id` — Azure AD tenant ID
**Notable optional:**
- `host` — Listen host (default: `"0.0.0.0"`)
- `port` — Listen port (default: 3978)
- `reply_in_thread` — Reply in thread (default: true)
- `validate_inbound_auth` — Validate incoming auth (default: true)
## mochat
**Required:**
- `claw_token` — MoChat Claw token
**Notable optional:**
- `base_url` — API base URL
- `socket_url` — WebSocket URL
- `refresh_interval_ms` — Refresh interval in ms
- `watch_timeout_ms` — Watch timeout in ms
## websocket
Built-in WebSocket channel for programmatic access.
**Required:**
- `token` — Authentication token (enabled by default; set `websocket_requires_token: false` to disable)
**Notable optional:**
- `host` — Listen host (default: `"127.0.0.1"`)
- `port` — Listen port (default: 8765)
- `allow_from` — Allowed origins (default: `["*"]`)
- `streaming` — Enable streaming (default: true)
@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""Create a new nanobot instance with a dedicated config and workspace.
Usage:
create_instance.py --name <name> --channel <channel> [--model <model>] [--config-dir <dir>]
Examples:
create_instance.py --name telegram-bot --channel telegram
create_instance.py --name discord-bot --channel discord --model deepseek/deepseek-chat
create_instance.py --name my-bot --channel telegram --config-dir ~/.nanobot-custom
"""
from __future__ import annotations
import argparse
import json
import re
import socket
import sys
from pathlib import Path
def _validate_name(name: str) -> str:
"""Normalize and validate instance name."""
name = name.strip().lower()
name = re.sub(r"[^a-z0-9-]", "-", name)
name = re.sub(r"-{2,}", "-", name)
name = name.strip("-")
if not name:
print("[ERROR] Instance name must contain at least one letter or digit.", file=sys.stderr)
sys.exit(1)
if len(name) > 64:
print(f"[ERROR] Instance name too long ({len(name)} chars, max 64).", file=sys.stderr)
sys.exit(1)
return name
def _get_available_channels() -> list[str]:
"""Get list of available channel names without importing channel classes."""
from nanobot.channels.registry import discover_channel_names
return discover_channel_names()
def _run_onboard(config_path: Path, workspace: Path) -> None:
"""Create skeleton config + workspace using nanobot's programmatic API."""
from nanobot.cli.commands import _onboard_plugins
from nanobot.config.loader import save_config, set_config_path
from nanobot.config.paths import get_workspace_path
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
config = Config()
config.agents.defaults.workspace = str(workspace)
set_config_path(config_path)
save_config(config, config_path)
_onboard_plugins(config_path)
workspace_path = get_workspace_path(config.workspace_path)
if not workspace_path.exists():
workspace_path.mkdir(parents=True, exist_ok=True)
sync_workspace_templates(workspace_path)
def _patch_config(
config_path: Path,
*,
channel: str,
workspace: Path,
model: str | None,
name: str | None = None,
inherit_config_path: Path | None = None,
) -> dict:
"""Patch the generated config: enable channel, set workspace, optionally set model."""
data = json.loads(config_path.read_text(encoding="utf-8"))
# Inherit providers and model from current instance
if inherit_config_path and inherit_config_path.exists():
try:
src = json.loads(inherit_config_path.read_text(encoding="utf-8"))
# Inherit providers (API keys, api_base, etc.)
src_providers = src.get("providers", {})
if src_providers:
data.setdefault("providers", {})
for key, val in src_providers.items():
if isinstance(val, dict) and val.get("apiKey"):
data["providers"][key] = val
# Inherit model if not explicitly overridden
if not model:
parent_model = src.get("agents", {}).get("defaults", {}).get("model")
if parent_model:
model = parent_model
except Exception as exc:
print(f"[WARN] Could not inherit from {inherit_config_path}: {exc}", file=sys.stderr)
# Set workspace and model
data.setdefault("agents", {}).setdefault("defaults", {})
data["agents"]["defaults"]["workspace"] = str(workspace)
if model:
data["agents"]["defaults"]["model"] = model
# Enable the target channel
channels = data.setdefault("channels", {})
if channel in channels and isinstance(channels[channel], dict):
channels[channel]["enabled"] = True
else:
channels[channel] = {"enabled": True}
# Auto-assign ports if defaults are already in use
_assign_free_ports(data)
# Validate with Pydantic, then save
from nanobot.config.schema import Config
Config.model_validate(data)
config_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return data
def _is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
"""Check if a port is already in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind((host, port))
return False
except OSError:
return True
def _find_free_port(start: int, host: str = "127.0.0.1", max_tries: int = 100) -> int:
"""Find the first free port starting from `start`."""
for port in range(start, start + max_tries):
if not _is_port_in_use(port, host):
return port
# OS-level fallback: ask the kernel for an ephemeral port
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((host, 0))
return s.getsockname()[1]
def _assign_free_ports(data: dict) -> None:
"""If default gateway or API ports are in use, assign free ones."""
from nanobot.config.schema import ApiConfig, GatewayConfig
defaults = [
("gateway", GatewayConfig()),
("api", ApiConfig()),
]
for key, default_cfg in defaults:
section = data.setdefault(key, {})
port = section.get("port", default_cfg.port)
host = section.get("host", default_cfg.host)
if _is_port_in_use(port, host):
section["port"] = _find_free_port(port + 1, host)
def _get_channel_required_fields(channel: str) -> list[str]:
"""Inspect a channel's default config and list fields that are empty strings."""
try:
from nanobot.channels.registry import load_channel_class
cls = load_channel_class(channel)
default = cls.default_config()
return sorted(k for k, v in default.items() if isinstance(v, str) and v == "" and k != "enabled")
except Exception as exc:
print(f"[WARN] Could not inspect channel '{channel}' defaults: {exc}", file=sys.stderr)
return []
def main() -> None:
parser = argparse.ArgumentParser(
description="Create a new nanobot instance.",
)
parser.add_argument("--name", required=True, help="Instance name (e.g. telegram-bot)")
parser.add_argument("--channel", required=True, help="Channel type (e.g. telegram, discord)")
parser.add_argument("--model", default=None, help="LLM model (default: same as current instance)")
parser.add_argument(
"--config-dir",
default=None,
help="Config directory (default: ~/.nanobot-{name})",
)
parser.add_argument(
"--inherit-config",
default=None,
help="Path to current instance's config.json to copy API keys from",
)
args = parser.parse_args()
# Validate name
name = _validate_name(args.name)
# Validate channel
available = _get_available_channels()
if args.channel not in available:
print(f"[ERROR] Unknown channel: {args.channel}", file=sys.stderr)
print(f"Available channels: {', '.join(sorted(available))}", file=sys.stderr)
sys.exit(1)
# Resolve paths
home = Path.home()
config_dir = Path(args.config_dir).expanduser().resolve() if args.config_dir else home / f".nanobot-{name}"
config_path = config_dir / "config.json"
workspace = config_dir / "workspace"
# Check for duplicate
if config_path.exists():
print(f"[ERROR] Config already exists at {config_path}", file=sys.stderr)
print("Delete it first or use a different --config-dir.", file=sys.stderr)
sys.exit(1)
print(f"Creating instance '{name}'...")
print(f" Config dir: {config_dir}")
print(f" Workspace: {workspace}")
print(f" Channel: {args.channel}")
if args.model:
print(f" Model: {args.model}")
# Run onboard
_run_onboard(config_path, workspace)
# Patch config
inherit_path = Path(args.inherit_config).expanduser().resolve() if args.inherit_config else None
_patch_config(
config_path,
channel=args.channel,
workspace=workspace,
model=args.model,
name=name,
inherit_config_path=inherit_path,
)
# Report
print(f"\n[OK] Instance '{name}' created successfully.")
print(f" Config: {config_path}")
print(f" Workspace: {workspace}")
# List fields the user needs to fill in
required_fields = _get_channel_required_fields(args.channel)
if required_fields:
print(f"\n[IMPORTANT] Edit {config_path} and fill in these fields:")
for field in required_fields:
print(f" - channels.{args.channel}.{field}")
print(f"\nTo start the instance:")
print(f" nanobot gateway --config {config_path}")
if __name__ == "__main__":
main()
+1 -47
View File
@@ -15,7 +15,7 @@ If the `generate_image` tool is not available in the current tool list, tell the
- Image editing: pass the saved artifact path or user image path in `reference_images`. - Image editing: pass the saved artifact path or user image path in `reference_images`.
- Iterative edits in the same conversation: prefer the most recent generated image artifact if the user says things like "make it brighter", "change the background", or "try another version". - Iterative edits in the same conversation: prefer the most recent generated image artifact if the user says things like "make it brighter", "change the background", or "try another version".
- Ambiguous edits: ask a short clarifying question if multiple recent images could be the target. - Ambiguous edits: ask a short clarifying question if multiple recent images could be the target.
- In the current chat, do not call `message` just to announce or resend generated images. The runtime attaches images from `generate_image` to the final assistant reply automatically. - After generating images, call the `message` tool with the artifact paths in the `media` parameter to deliver them to the user.
## Prompt Rules ## Prompt Rules
@@ -42,52 +42,6 @@ For follow-up edits, pass the prior artifact `path` to `reference_images`. If th
Do not include internal replay markers such as `[Message Time: ...]`, `[image: /local/path]`, `generate_image(...)`, or `message(...)` in user-facing replies. Do not include internal replay markers such as `[Message Time: ...]`, `[image: /local/path]`, `generate_image(...)`, or `message(...)` in user-facing replies.
## Provider Notes
Do not ask users to paste API keys into chat. If configuration is needed, describe the fields; LLM provider and BYOK changes are hot-reloaded for new turns.
For OpenRouter, the image tool expects:
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-..."
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "openrouter",
"model": "openai/gpt-5.4-image-2"
}
}
}
```
For AIHubMix, the image tool expects:
```json
{
"providers": {
"aihubmix": {
"apiKey": "sk-..."
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "aihubmix",
"model": "gpt-image-2-free"
}
}
}
```
AIHubMix `gpt-image-2-free` uses AIHubMix's unified predictions endpoint internally (`/v1/models/openai/gpt-image-2-free/predictions`), not the OpenAI Images `/v1/images/generations` endpoint. If it fails with "Incorrect model ID", do not assume the key lacks permission until the provider config, model name, and gateway restart have been checked.
`providers.aihubmix.extraBody` can be used for provider-specific options. For example, `"extraBody": {"quality": "low"}` is optional but can make `gpt-image-2-free` faster and less likely to time out.
## Examples ## Examples
Generate a new image: Generate a new image:
+9 -5
View File
@@ -1,5 +1,9 @@
# Agent Instructions # Agent Instructions
## Workspace Guidance
Use this file for project-specific preferences, recurring workflow conventions, and instructions you want the agent to remember for this workspace. Keep durable facts about the user in `USER.md`, personality/style guidance in `SOUL.md`, and long-term memory in `memory/MEMORY.md`.
## Scheduled Reminders ## Scheduled Reminders
Before scheduling reminders, check available skills and follow skill guidance first. Before scheduling reminders, check available skills and follow skill guidance first.
@@ -10,10 +14,10 @@ Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegr
## Heartbeat Tasks ## Heartbeat Tasks
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks: `HEARTBEAT.md` is checked periodically when registered as a cron job. Use the built-in `cron` tool to schedule it (e.g. `cron add --name heartbeat --schedule "every 30m" --message "Check HEARTBEAT.md"`).
- **Add**: `edit_file` to append new tasks - Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
- **Remove**: `edit_file` to delete completed tasks - Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
- **Rewrite**: `write_file` to replace all tasks - Use `write_file` for first creation or intentional full-file rewrites.
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder. When the user asks for a recurring/periodic task, update `HEARTBEAT.md` and register it via `cron` instead of creating a one-time reminder.

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