mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e065cfffe | ||
|
|
27f7549c84 | ||
|
|
f5371a6c5a | ||
|
|
f19efcd990 | ||
|
|
6e462e62a6 | ||
|
|
ed47cf1562 | ||
|
|
6b5820ea89 | ||
|
|
1e7518a207 | ||
|
|
4a3818e03c | ||
|
|
276cbd947f | ||
|
|
234e895e5a | ||
|
|
8c9110fee3 | ||
|
|
e864fba522 | ||
|
|
a65a6f334c | ||
|
|
3824206ab2 | ||
|
|
2c78976728 | ||
|
|
fe0717b385 | ||
|
|
791c7fd505 | ||
|
|
6d406d93c9 | ||
|
|
053722a2a2 | ||
|
|
45d1caba1d | ||
|
|
89acea6fe1 | ||
|
|
01e4f3762a | ||
|
|
c339ce8bba | ||
|
|
12b88d3872 | ||
|
|
8ccb2a451b | ||
|
|
20748bc4e3 | ||
|
|
93de413432 | ||
|
|
d349b65713 | ||
|
|
54b250430d | ||
|
|
5794d481c3 | ||
|
|
e206ee4e70 | ||
|
|
2eb7398f34 | ||
|
|
f75d3519db | ||
|
|
7f8c3453e1 | ||
|
|
edf78e7054 | ||
|
|
bd0dd85f44 | ||
|
|
469d004773 | ||
|
|
f3d1b9ca2d | ||
|
|
c111aaa7ee | ||
|
|
9adcd3d923 | ||
|
|
0a1c98c142 | ||
|
|
7675364eae | ||
|
|
052fdc132d | ||
|
|
47d498bab0 | ||
|
|
04328f8129 | ||
|
|
137fbf875e | ||
|
|
1ee4349a08 | ||
|
|
9a1d1e64c7 | ||
|
|
a1fbfd9f7b | ||
|
|
bda0c099ab | ||
|
|
ef14d1ea92 | ||
|
|
c9e014fdea | ||
|
|
d32f8961b1 | ||
|
|
45a6466c2c | ||
|
|
99e04c2da2 | ||
|
|
87602a74ea | ||
|
|
ad55f3ce37 | ||
|
|
e284592649 | ||
|
|
2154dc51d0 | ||
|
|
fe7d94359b | ||
|
|
10930f3902 | ||
|
|
6b7470646f | ||
|
|
21f58cbabf | ||
|
|
c9d3e74342 | ||
|
|
5bd3d1e0af | ||
|
|
af85c356b8 | ||
|
|
198fd9f869 | ||
|
|
45ace23580 | ||
|
|
b4f069800e | ||
|
|
3f8170e835 | ||
|
|
cb03d2c748 | ||
|
|
bd94fefd1a | ||
|
|
42d7ad34a4 | ||
|
|
bb3b449e09 | ||
|
|
55317094b6 | ||
|
|
6313ae9f4f | ||
|
|
4137be62a1 | ||
|
|
0b9ca744c2 | ||
|
|
7685a89dcc | ||
|
|
bbd4f4054b | ||
|
|
0ae28571e7 | ||
|
|
6851a6ebd4 | ||
|
|
7768672c5b | ||
|
|
207813d3b5 | ||
|
|
e0c2d28f90 | ||
|
|
8559458258 | ||
|
|
04dbf17426 | ||
|
|
a52a88455b | ||
|
|
3ab09075c5 | ||
|
|
4ddd639e67 | ||
|
|
444f488563 | ||
|
|
88143a8bf0 | ||
|
|
7204d88a4c | ||
|
|
1a21542d11 | ||
|
|
f531f1ce38 | ||
|
|
941a2541eb | ||
|
|
be8ac1e484 | ||
|
|
f074aa7d80 | ||
|
|
ea7f4679f1 | ||
|
|
c188e96f4e | ||
|
|
815f15993c | ||
|
|
4333d6f103 | ||
|
|
65d32ffd6c | ||
|
|
379785d365 | ||
|
|
883776358e | ||
|
|
28141ce20b | ||
|
|
460c62c0b0 | ||
|
|
e86133c434 | ||
|
|
6c59332a8a | ||
|
|
a7b8a9ed46 | ||
|
|
7e135b45f3 | ||
|
|
fa73448f6f | ||
|
|
8a231b6e4d | ||
|
|
ef5318ebdc | ||
|
|
8f68040f05 | ||
|
|
0f88927364 | ||
|
|
3f33ff3143 | ||
|
|
29e99d3742 | ||
|
|
01a0f5aaf3 | ||
|
|
77a6003255 | ||
|
|
25a477050d | ||
|
|
ea0516e655 | ||
|
|
18a230de75 | ||
|
|
c5e053f83b | ||
|
|
b68ae4f9bc | ||
|
|
97e3b360c2 | ||
|
|
4353f4680b | ||
|
|
73bf299a59 | ||
|
|
d04ad1a5b4 | ||
|
|
67b56cba74 | ||
|
|
105230cc34 | ||
|
|
dd014b50a7 | ||
|
|
595d789c6f | ||
|
|
cf35238834 | ||
|
|
ef9780719d | ||
|
|
cc70a2a79f | ||
|
|
f9806cc60f | ||
|
|
76877036f7 | ||
|
|
a710a7d6f7 | ||
|
|
fff38f11a7 | ||
|
|
5e51c5014f | ||
|
|
937f04ac86 | ||
|
|
9eade9be5f | ||
|
|
400369f0b0 | ||
|
|
f0c989ba2d | ||
|
|
70505bd1fc | ||
|
|
162b6ee5bd | ||
|
|
b1d29ede7d | ||
|
|
800d51ecac | ||
|
|
3f9fb63d4c | ||
|
|
1cfa48ad4a | ||
|
|
8a42a9c73a | ||
|
|
72c8a47ac9 | ||
|
|
122cf4213b | ||
|
|
8c4a74ee3c | ||
|
|
4ec111c1a9 | ||
|
|
8222f3c85f | ||
|
|
d76493a63a | ||
|
|
220de320fb | ||
|
|
33b1c6f601 | ||
|
|
10b52cfb3a | ||
|
|
5d034dc79c | ||
|
|
b8d7708171 | ||
|
|
e725649146 | ||
|
|
6a95877196 | ||
|
|
83e4bae7db | ||
|
|
58b5bb9204 | ||
|
|
c579551bb1 | ||
|
|
8b645135bc | ||
|
|
28011413bc | ||
|
|
614ea86a81 | ||
|
|
6d28db3248 | ||
|
|
0d1221bece | ||
|
|
8b9f93d7d1 | ||
|
|
a119c35b1e | ||
|
|
067e0c4a40 | ||
|
|
5283ceae85 | ||
|
|
00cc0da530 | ||
|
|
b19a744110 | ||
|
|
c9c69e4316 | ||
|
|
8a79eb1aaa | ||
|
|
979f038ded | ||
|
|
f38fd7d5d3 | ||
|
|
5af22042ec | ||
|
|
aecb5fbc33 |
@@ -27,3 +27,9 @@ A bugfix should make the protected invariant clear, change the smallest surface
|
|||||||
## Explicit over magical
|
## Explicit over magical
|
||||||
|
|
||||||
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
||||||
|
|
||||||
|
## Configuration has an explicit owner
|
||||||
|
|
||||||
|
`FileConfigRepository` owns config-file reads, validation, revisions, and atomic writes. Process entry points may use the explicit functions in `config/loader.py`; components with an explicit config path should keep their own repository instance instead of importing a mutable global config object.
|
||||||
|
|
||||||
|
Persisted and runtime views are separate: `load_raw_config()` / `load_raw()` preserve `${VAR}` references for editing, while `load_effective_config()` / `load_effective()` return an isolated snapshot with references resolved. Read-modify-write flows must use `update()` / `update_config()` so a stale object cannot silently overwrite a newer change. Loading config is side-effect free; process-wide policies are applied explicitly during runtime startup.
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Config `${VAR}` References
|
## Config `${VAR}` References
|
||||||
|
|
||||||
`config/loader.py` resolves `${VAR}` patterns in `config.json` at load time. This is **not** a shell-like default-value syntax. If the environment variable is missing, `load_config` raises `ValueError` and the agent falls back to default configuration.
|
`load_raw_config()` and `FileConfigRepository.load_raw()` preserve `${VAR}` patterns so Settings can safely edit and save the persisted representation. Runtime entry points use `load_effective_config()` / `load_effective()` to resolve them in an isolated snapshot. This is **not** a shell-like default-value syntax. If a referenced variable is missing, effective loading raises `ValueError`.
|
||||||
|
|
||||||
Example valid usage:
|
Example valid usage:
|
||||||
```json
|
```json
|
||||||
@@ -16,7 +16,7 @@ Example valid usage:
|
|||||||
## Windows Compatibility
|
## Windows Compatibility
|
||||||
|
|
||||||
nanobot explicitly supports Windows. Key differences to keep in mind:
|
nanobot explicitly supports Windows. Key differences to keep in mind:
|
||||||
- `ExecTool` uses `cmd /c` on Windows instead of `sh -c` (`shell.py`).
|
- `ExecTool` defaults to PowerShell on Windows (`pwsh` when available, otherwise Windows PowerShell); pass `shell="cmd"` for cmd.exe syntax or cmd built-ins (`shell.py`).
|
||||||
- `cli/commands.py` forces `sys.stdout`/`stderr` to UTF-8 on startup to handle emoji and multilingual input.
|
- `cli/commands.py` forces `sys.stdout`/`stderr` to UTF-8 on startup to handle emoji and multilingual input.
|
||||||
- MCP stdio server commands are normalized for Windows path separators (`mcp.py`).
|
- MCP stdio server commands are normalized for Windows path separators (`mcp.py`).
|
||||||
- Always use `pathlib.Path` for path manipulation; do not assume `/` separators.
|
- Always use `pathlib.Path` for path manipulation; do not assume `/` separators.
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
|
|||||||
|
|
||||||
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
|
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
|
||||||
|
|
||||||
The only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time.
|
The only escape hatch is `configure_ssrf_whitelist(cidrs)`. Runtime entry points explicitly apply `config.tools.ssrf_whitelist` after loading the effective config; ordinary config reads must not mutate this process-wide policy.
|
||||||
|
|
||||||
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
|
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
|
|||||||
|
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `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.
|
- **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.
|
- **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`).
|
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
||||||
@@ -46,7 +46,7 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
|
|||||||
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
|
- **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).
|
- **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.
|
- **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.
|
- **Skills** (`nanobot/skills/`): Built-in skill definitions (cron, github, image-generation, etc.) loaded into agent context.
|
||||||
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
|
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
|
||||||
|
|
||||||
### Entry Points
|
### Entry Points
|
||||||
|
|||||||
+3
-2
@@ -17,15 +17,16 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
||||||
# hook from hatch_build.py even for this metadata-only install.
|
# hook from hatch_build.py even for this metadata-only install.
|
||||||
|
ARG NANOBOT_EXTRAS=whatsapp
|
||||||
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
||||||
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
||||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]" && \
|
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]" && \
|
||||||
rm -rf nanobot
|
rm -rf nanobot
|
||||||
|
|
||||||
# Copy the full source and install
|
# Copy the full source and install
|
||||||
COPY nanobot/ nanobot/
|
COPY nanobot/ nanobot/
|
||||||
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
||||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]"
|
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
|
||||||
|
|
||||||
# Create non-root user and config directory
|
# Create non-root user and config directory
|
||||||
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
||||||
|
|||||||
@@ -42,11 +42,37 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Install nanobot with no terminal/config background | [Start Without Technical Background](./docs/start-without-technical-background.md) |
|
| Install nanobot with no terminal/config background | [Start Without Technical Background](./docs/start-without-technical-background.md) |
|
||||||
| Install quickly and get one CLI reply | [Install](#-install) and [Quick Start](#-quick-start) |
|
| Install quickly and get one CLI reply | [Install](#-install) and [Quick Start](#-quick-start) |
|
||||||
| Open the bundled browser UI after the CLI works | [WebUI](#-webui) |
|
| Open the bundled browser UI | [WebUI](#-webui) |
|
||||||
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
||||||
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
||||||
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
||||||
|
|
||||||
|
## What can nanobot do?
|
||||||
|
|
||||||
|
nanobot is a self-hosted personal AI agent runtime. It can:
|
||||||
|
|
||||||
|
- run in a browser WebUI or terminal
|
||||||
|
- connect to Telegram, Discord, Slack, WeChat, Email, Mattermost, and other chat apps
|
||||||
|
- use tools such as files, shell, web search, web fetch, MCP, cron, image generation, and subagents
|
||||||
|
- keep session history and long-term memory through Dream
|
||||||
|
- run long-horizon goals and scheduled automations
|
||||||
|
- expose a Python SDK and OpenAI-compatible API for integrations
|
||||||
|
- deploy as a long-running local or server-side agent gateway
|
||||||
|
|
||||||
|
## Latest Release
|
||||||
|
|
||||||
|
**v0.2.2 - Durability Release**
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
|
||||||
|
- Segmented WebUI transcripts
|
||||||
|
- Python SDK runtime controls
|
||||||
|
- Automation management
|
||||||
|
- Search/STT provider improvements
|
||||||
|
- Gateway/session/provider reliability
|
||||||
|
|
||||||
|
[See full changelog](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
|
||||||
|
|
||||||
## Open Source Partners
|
## Open Source Partners
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -54,158 +80,20 @@
|
|||||||
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
|
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 📢 News
|
## Recent Updates
|
||||||
|
|
||||||
- **2026-06-22** 🚀 Released **v0.2.2** — **The Durability Release** makes nanobot sturdier for daily agent work: segmented WebUI transcripts, first-class Python SDK runtime controls, automation management, richer search/STT providers, and stronger gateway/session/provider reliability. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2) for details.
|
- **2026-07-12** Explicit `/goal` activation, safer runtime and workspace access.
|
||||||
- **2026-06-21** 🧰 Python SDK runtime controls, optional Keenable key, cleaner run hooks.
|
- **2026-07-11** Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
||||||
- **2026-06-20** 💬 Telegram rich messages, safer SDK concurrency, smoother Quick Start.
|
- **2026-07-10** Stable model routing, multiline CLI input, new automation guide.
|
||||||
- **2026-06-19** 🔎 Firecrawl app, OpenAI image edits, safer session deletion.
|
- **2026-07-09** Live file-edit diffs, safer localhost setup, Matrix image fixes.
|
||||||
- **2026-06-18** 💬 Feishu recovery, Keenable search, Mistral polish, workspace-aware git.
|
- **2026-07-08** Safer WebUI/API setup, onboard refresh, responsive prompt rail.
|
||||||
- **2026-06-17** 🧠 Default idle auto-compact, clearer `/dream`, macOS installer fixes.
|
|
||||||
- **2026-06-16** 🎯 Fresher goal context, Kimi K2.7 thinking, cleaner API retries.
|
|
||||||
- **2026-06-15** 📱 Mobile WebUI polish, optional file tools, real API usage.
|
|
||||||
- **2026-06-14** 🖼️ Themed cover, partner links, stronger Codex image streaming.
|
|
||||||
- **2026-06-13** 🗓️ Session-bound automations, sturdier WhatsApp, faster WebUI startup.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Earlier news</summary>
|
|
||||||
|
|
||||||
- **2026-06-12** 💬 Slack allowlisted channels can require mentions.
|
|
||||||
- **2026-06-11** ✂️ Fenced-code message splitting.
|
|
||||||
- **2026-06-10** 📜 Segmented transcripts, Exa/Bocha search, StepFun/SiliconFlow ASR.
|
|
||||||
- **2026-06-09** 🎙️ Shared voice input, more STT providers, TeX and email polish.
|
|
||||||
- **2026-06-08** 🧮 Token heatmap fix, safer MCP HTTP probing, docs cleanup.
|
|
||||||
- **2026-06-06** 🧰 SDK MCP cleanup, removable OpenAI image defaults.
|
|
||||||
- **2026-06-05** 🖼️ Azure AAD, custom image providers, `/skill`, steadier pairing.
|
|
||||||
- **2026-06-04** 🔌 MCP reconnects, `uv pip` install fallback, QQ pairing.
|
|
||||||
- **2026-06-03** 🧠 Hidden-history recovery, quieter email progress handling.
|
|
||||||
- **2026-06-02** 📬 Email attachments, Napcat QQ, Volcengine search, simpler Dream.
|
|
||||||
- **2026-06-01** 🚀 Released **v0.2.1** — **The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details.
|
|
||||||
- **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.
|
|
||||||
- **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-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-11** 🖥️ NVIDIA NIM support, terminal bot name and icon, streamed reasoning and MiMo toggle clarity.
|
|
||||||
- **2026-05-09** 🖼️ Sharper image replay, BYO web-search keys in Settings, Feishu threads routed cleanly.
|
|
||||||
- **2026-05-08** ✨ Inline chat image, redesigned Settings and keys, Dream memory aligned with visible history.
|
|
||||||
- **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-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
|
|
||||||
- **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-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
|
|
||||||
- **2026-05-01** ☁️ Native AWS Bedrock provider, tighter helper handoffs and scoped session files.
|
|
||||||
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp bridge refresh on source edits.
|
|
||||||
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
|
|
||||||
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
|
|
||||||
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
|
|
||||||
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
|
|
||||||
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
|
|
||||||
- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup.
|
|
||||||
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
|
|
||||||
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
|
|
||||||
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
|
||||||
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
|
||||||
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
|
||||||
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
|
|
||||||
- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
|
|
||||||
- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
|
|
||||||
- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
|
|
||||||
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
|
|
||||||
- **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-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
|
||||||
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
|
|
||||||
- **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-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
|
||||||
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
|
|
||||||
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
|
|
||||||
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
|
|
||||||
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
|
|
||||||
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
|
|
||||||
- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix.
|
|
||||||
- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes.
|
|
||||||
- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks.
|
|
||||||
- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API.
|
|
||||||
- **2026-03-28** 📚 Provider docs refresh; skill template wording fix.
|
|
||||||
- **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details.
|
|
||||||
- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries.
|
|
||||||
- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures.
|
|
||||||
- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured.
|
|
||||||
- **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI.
|
|
||||||
- **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command.
|
|
||||||
- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
|
|
||||||
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
|
|
||||||
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
|
|
||||||
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
|
|
||||||
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
|
|
||||||
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
|
|
||||||
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
|
|
||||||
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
|
|
||||||
- **2026-03-13** 🌐 Multi-provider web search, LangSmith, and broader reliability improvements.
|
|
||||||
- **2026-03-12** 🚀 VolcEngine support, Telegram reply context, `/restart`, and sturdier memory.
|
|
||||||
- **2026-03-11** 🔌 WeCom, Ollama, cleaner discovery, and safer tool behavior.
|
|
||||||
- **2026-03-10** 🧠 Token-based memory, shared retries, and cleaner gateway and Telegram behavior.
|
|
||||||
- **2026-03-09** 💬 Slack thread polish and better Feishu audio compatibility.
|
|
||||||
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
|
|
||||||
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
|
|
||||||
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
|
|
||||||
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
|
|
||||||
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
|
|
||||||
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
|
|
||||||
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
|
|
||||||
- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
|
|
||||||
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
|
|
||||||
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
|
|
||||||
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
|
|
||||||
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
|
|
||||||
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
|
|
||||||
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
|
|
||||||
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
|
|
||||||
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
|
|
||||||
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
|
|
||||||
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
|
|
||||||
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
|
|
||||||
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
|
|
||||||
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
|
|
||||||
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
|
|
||||||
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](./docs/configuration.md#mcp-model-context-protocol) for details.
|
|
||||||
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
|
||||||
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
|
||||||
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
|
|
||||||
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
|
||||||
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
|
||||||
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](./docs/configuration.md#providers).
|
|
||||||
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
|
|
||||||
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
|
|
||||||
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
|
|
||||||
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
|
||||||
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
|
|
||||||
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
|
||||||
|
|
||||||
## 💡 Why nanobot
|
## 💡 Why nanobot
|
||||||
|
|
||||||
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
|
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
|
||||||
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email.
|
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, email, and Mattermost.
|
||||||
- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
|
- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
|
||||||
- **Small core**: readable internals with MCP, memory, deployment, and automation built in.
|
- **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.
|
- **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
|
||||||
@@ -237,7 +125,7 @@ Windows PowerShell:
|
|||||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, skip the manual initialize/configure steps below and go straight to **Open the WebUI**.
|
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**.
|
||||||
|
|
||||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||||
|
|
||||||
@@ -358,14 +246,13 @@ For another provider, the same config shape still applies:
|
|||||||
|
|
||||||
**3. Open the WebUI**
|
**3. Open the WebUI**
|
||||||
|
|
||||||
If Quick Start enabled the WebSocket channel, start the gateway:
|
Start the browser workbench:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
|
`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. It binds the first-run WebUI to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot webui --background`, then manage the gateway with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||||
Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
|
||||||
|
|
||||||
For manual or terminal-only setup, test one CLI message:
|
For manual or terminal-only setup, test one CLI message:
|
||||||
|
|
||||||
@@ -399,33 +286,13 @@ The WebUI ships **inside the published wheel** — no extra build step. It is th
|
|||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**1. Enable the WebSocket channel in `~/.nanobot/config.json`**
|
**Open it**
|
||||||
|
|
||||||
Merge this block into your existing config:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"websocket": {
|
|
||||||
"enabled": true,
|
|
||||||
"tokenIssueSecret": "your-webui-password",
|
|
||||||
"websocketRequiresToken": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Start the gateway**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `nanobot gateway --background` for a local background process you can manage later with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
The command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
||||||
|
|
||||||
**3. Open the WebUI**
|
|
||||||
|
|
||||||
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](./docs/webui.md#lan-access).
|
|
||||||
|
|
||||||
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
|
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
|
||||||
|
|
||||||
@@ -467,6 +334,7 @@ The WebUI is served by the WebSocket channel on port `8765` by default. The gate
|
|||||||
|
|
||||||
Browse the [repo docs](./docs/README.md) for the latest features and GitHub development version, or visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview) for the stable release documentation.
|
Browse the [repo docs](./docs/README.md) for the latest features and GitHub development version, or visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview) for the stable release documentation.
|
||||||
|
|
||||||
|
- Use task-oriented guides: [Guides](./docs/guides/README.md)
|
||||||
- Start with no technical background: [Start Without Technical Background](./docs/start-without-technical-background.md)
|
- Start with no technical background: [Start Without Technical Background](./docs/start-without-technical-background.md)
|
||||||
- Start from zero with developer basics: [Install and Quick Start](./docs/quick-start.md)
|
- Start from zero with developer basics: [Install and Quick Start](./docs/quick-start.md)
|
||||||
- Understand the runtime model: [Concepts](./docs/concepts.md)
|
- Understand the runtime model: [Concepts](./docs/concepts.md)
|
||||||
@@ -474,7 +342,8 @@ Browse the [repo docs](./docs/README.md) for the latest features and GitHub deve
|
|||||||
- Choose a provider/model: [Providers and Models](./docs/providers.md)
|
- Choose a provider/model: [Providers and Models](./docs/providers.md)
|
||||||
- Copy provider setup recipes: [Provider Cookbook](./docs/provider-cookbook.md)
|
- Copy provider setup recipes: [Provider Cookbook](./docs/provider-cookbook.md)
|
||||||
- Debug setup and runtime failures: [Troubleshooting](./docs/troubleshooting.md)
|
- Debug setup and runtime failures: [Troubleshooting](./docs/troubleshooting.md)
|
||||||
- Talk to your nanobot with familiar chat apps: [Chat Apps](./docs/chat-apps.md)
|
- Talk to your nanobot with familiar chat apps: [Chat App AI Agent](./docs/guides/chat-app-ai-agent.md) · [Chat Apps](./docs/chat-apps.md)
|
||||||
|
- Schedule or trigger agent work: [Automations](./docs/automations.md)
|
||||||
- Configure providers, web search, MCP, and runtime behavior: [Configuration](./docs/configuration.md)
|
- Configure providers, web search, MCP, and runtime behavior: [Configuration](./docs/configuration.md)
|
||||||
- Integrate nanobot with local tools and automations: [OpenAI-Compatible API](./docs/openai-api.md) · [Python SDK](./docs/python-sdk.md)
|
- Integrate nanobot with local tools and automations: [OpenAI-Compatible API](./docs/openai-api.md) · [Python SDK](./docs/python-sdk.md)
|
||||||
- Run nanobot with Docker or as a Linux service: [Deployment](./docs/deployment.md)
|
- Run nanobot with Docker or as a Linux service: [Deployment](./docs/deployment.md)
|
||||||
@@ -505,19 +374,6 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
|
|||||||
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
|
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
## ⭐ Star History
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<a href="https://star-history.com/#HKUDS/nanobot&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date" style="border-radius: 15px; box-shadow: 0 0 30px rgba(0, 217, 255, 0.3);" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ services:
|
|||||||
command: ["gateway"]
|
command: ["gateway"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 18790:18790
|
- 127.0.0.1:18790:18790
|
||||||
- 8765:8765
|
- 8765:8765
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+47
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
|
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
|
||||||
|
|
||||||
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md) and get one local `nanobot agent -m "Hello!"` reply working before connecting chat apps, WebUI, Docker, or custom tools.
|
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md), open the browser workbench with `nanobot webui`, and use terminal checks when you need lower-level diagnosis.
|
||||||
|
|
||||||
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
|
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
|
||||||
|
|
||||||
@@ -30,6 +30,45 @@ If you find a docs mistake, outdated command, or confusing step, please open an
|
|||||||
| Copy a provider setup recipe | [`provider-cookbook.md`](./provider-cookbook.md) | Pasteable OpenRouter, OpenAI, Anthropic, local model, fallback, and Langfuse setups |
|
| Copy a provider setup recipe | [`provider-cookbook.md`](./provider-cookbook.md) | Pasteable OpenRouter, OpenAI, Anthropic, local model, fallback, and Langfuse setups |
|
||||||
| Fix a first-run or runtime problem | [`troubleshooting.md`](./troubleshooting.md) | A diagnosis order and targeted checks for common failures |
|
| Fix a first-run or runtime problem | [`troubleshooting.md`](./troubleshooting.md) | A diagnosis order and targeted checks for common failures |
|
||||||
|
|
||||||
|
## Task Guides
|
||||||
|
|
||||||
|
Use these pages when you know the workflow you want and do not want to scan the
|
||||||
|
full reference first.
|
||||||
|
|
||||||
|
| Goal | Guide |
|
||||||
|
|---|---|
|
||||||
|
| Build a personal AI agent | [`guides/build-a-personal-ai-agent.md`](./guides/build-a-personal-ai-agent.md) |
|
||||||
|
| Run a self-hosted AI agent | [`guides/self-hosted-ai-agent.md`](./guides/self-hosted-ai-agent.md) |
|
||||||
|
| Use a browser AI agent WebUI | [`guides/ai-agent-webui.md`](./guides/ai-agent-webui.md) |
|
||||||
|
| Connect an AI agent to chat apps | [`guides/chat-app-ai-agent.md`](./guides/chat-app-ai-agent.md) |
|
||||||
|
| Run long-running agent tasks | [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) |
|
||||||
|
| Schedule or trigger agent turns | [`automations.md`](./automations.md) |
|
||||||
|
| Add long-term agent memory | [`guides/ai-agent-memory.md`](./guides/ai-agent-memory.md) |
|
||||||
|
| Add MCP tools to an agent | [`guides/mcp-tools-for-ai-agents.md`](./guides/mcp-tools-for-ai-agents.md) |
|
||||||
|
| Run an agent from Python | [`guides/python-ai-agent-sdk.md`](./guides/python-ai-agent-sdk.md) |
|
||||||
|
| Expose an OpenAI-compatible agent API | [`guides/openai-compatible-agent-api.md`](./guides/openai-compatible-agent-api.md) |
|
||||||
|
| Deploy a long-running agent gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
||||||
|
|
||||||
|
Platform-specific chat guides:
|
||||||
|
[`Telegram`](./guides/telegram-ai-agent.md),
|
||||||
|
[`Discord`](./guides/discord-ai-agent.md),
|
||||||
|
[`Slack`](./guides/slack-ai-agent.md),
|
||||||
|
[`Feishu`](./guides/feishu-ai-agent.md),
|
||||||
|
[`WhatsApp`](./guides/whatsapp-ai-agent.md),
|
||||||
|
[`WeChat`](./guides/wechat-ai-agent.md),
|
||||||
|
[`QQ`](./guides/qq-ai-agent.md),
|
||||||
|
[`Email`](./guides/email-ai-agent.md), and
|
||||||
|
[`Mattermost`](./guides/mattermost-ai-agent.md).
|
||||||
|
|
||||||
|
Configuration guides:
|
||||||
|
[`MCP tools`](./guides/configure-mcp-tools.md),
|
||||||
|
[`web search`](./guides/configure-web-search.md),
|
||||||
|
[`model fallback`](./guides/configure-model-fallback.md),
|
||||||
|
[`OpenAI-compatible providers`](./guides/configure-openai-compatible-provider.md),
|
||||||
|
[`Langfuse`](./guides/configure-langfuse-observability.md),
|
||||||
|
[`local security`](./guides/secure-local-ai-agent.md), and
|
||||||
|
[`gateway deployment`](./guides/deploy-nanobot-gateway.md).
|
||||||
|
|
||||||
## After the First Reply Works
|
## After the First Reply Works
|
||||||
|
|
||||||
Do not configure everything at once. Pick one next surface:
|
Do not configure everything at once. Pick one next surface:
|
||||||
@@ -38,7 +77,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|
|||||||
|
|
||||||
| Next goal | Read | First check |
|
| Next goal | Read | First check |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
|
| Use nanobot in a browser | [`webui.md`](./webui.md) | Run `nanobot webui` and open the local browser workbench |
|
||||||
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
|
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
|
||||||
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
|
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
|
||||||
| Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn |
|
| Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn |
|
||||||
@@ -49,9 +88,10 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|
|||||||
|
|
||||||
| Goal | Read | Outcome |
|
| Goal | Read | Outcome |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
|
| Open the bundled browser UI | [`webui.md`](./webui.md) | `nanobot webui`, chat workspace, Apps, Skills, Automations, and settings |
|
||||||
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
||||||
| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
|
| Use automations | [`automations.md`](./automations.md) | Scheduled automations, local triggers, heartbeat, WebUI management, and delivery behavior |
|
||||||
|
| Use slash commands | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
|
||||||
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
||||||
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
|
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
|
||||||
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
|
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
|
||||||
@@ -64,6 +104,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|
|||||||
| Full configuration schema | [`configuration.md`](./configuration.md) | Exact fields, defaults, provider tables, web tools, MCP, security, and runtime options |
|
| Full configuration schema | [`configuration.md`](./configuration.md) | Exact fields, defaults, provider tables, web tools, MCP, security, and runtime options |
|
||||||
| CLI commands | [`cli-reference.md`](./cli-reference.md) | Command names, common flags, and entrypoints |
|
| CLI commands | [`cli-reference.md`](./cli-reference.md) | Command names, common flags, and entrypoints |
|
||||||
| Architecture | [`architecture.md`](./architecture.md) | Source-level runtime map for core flow, providers, channels, tools, WebUI, memory, security, and extension points |
|
| Architecture | [`architecture.md`](./architecture.md) | Source-level runtime map for core flow, providers, channels, tools, WebUI, memory, security, and extension points |
|
||||||
|
| Release archive | [`release-archive.md`](./release-archive.md) | Older release and daily update highlights moved out of the README |
|
||||||
| Development | [`development.md`](./development.md) | Contributor notes for adding providers and transcription adapters |
|
| Development | [`development.md`](./development.md) | Contributor notes for adding providers and transcription adapters |
|
||||||
| Memory | [`memory.md`](./memory.md) | Session history, Dream consolidation, memory files, and versioning |
|
| Memory | [`memory.md`](./memory.md) | Session history, Dream consolidation, memory files, and versioning |
|
||||||
| Observability | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | Langfuse tracing setup and required environment variables |
|
| Observability | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | Langfuse tracing setup and required environment variables |
|
||||||
@@ -82,6 +123,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|
|||||||
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
|
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
|
||||||
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
|
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
|
||||||
| Python SDK usage | [`python-sdk.md`](./python-sdk.md) |
|
| Python SDK usage | [`python-sdk.md`](./python-sdk.md) |
|
||||||
|
| Scheduled automations and local triggers | [`automations.md`](./automations.md) |
|
||||||
| Multiple configs, workspaces, and ports | [`multiple-instances.md`](./multiple-instances.md) |
|
| Multiple configs, workspaces, and ports | [`multiple-instances.md`](./multiple-instances.md) |
|
||||||
| Security, sandboxing, and SSRF controls | [`configuration.md#security`](./configuration.md#security) |
|
| Security, sandboxing, and SSRF controls | [`configuration.md#security`](./configuration.md#security) |
|
||||||
| Channel plugin development | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
| Channel plugin development | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||||
|
|||||||
@@ -1,10 +1,99 @@
|
|||||||
# Agent Social Network
|
# Agent Social Network
|
||||||
|
|
||||||
🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
|
An agent social network lets a nanobot instance join an external agent community
|
||||||
|
or chat network as a bot identity. After joining, nanobot can receive messages
|
||||||
|
through that network, answer with its normal agent runtime, and use the same
|
||||||
|
workspace, tools, memory, and channel access controls that apply elsewhere.
|
||||||
|
|
||||||
| Platform | How to Join (send this message to your bot) |
|
This page describes the current entry points and the safety model. Treat each
|
||||||
|----------|-------------|
|
network as an external integration: only join networks you trust, keep owner
|
||||||
| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
|
approval narrow, and review the skill instructions before asking nanobot to
|
||||||
| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
|
follow them.
|
||||||
|
|
||||||
Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest.
|
## What is an agent social network?
|
||||||
|
|
||||||
|
In nanobot docs, an agent social network is an external community that publishes
|
||||||
|
setup instructions for nanobot-compatible agents. The setup usually lives in a
|
||||||
|
remote `skill.md` file. You send nanobot a message asking it to read that file
|
||||||
|
and follow the network's registration flow.
|
||||||
|
|
||||||
|
The external network is not part of nanobot core. nanobot provides the runtime:
|
||||||
|
model calls, tools, memory, sessions, and channel delivery.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Remote `skill.md` files are external instructions. Review them before asking
|
||||||
|
> nanobot to follow them, especially when file, shell, network, or chat-delivery
|
||||||
|
> tools are enabled. Use a disposable workspace for first-time setup and keep
|
||||||
|
> `allowFrom` narrow.
|
||||||
|
|
||||||
|
## What nanobot can do after joining
|
||||||
|
|
||||||
|
After setup, the exact behavior depends on the network, but the normal pattern
|
||||||
|
is:
|
||||||
|
|
||||||
|
- receive direct messages or community messages addressed to the bot
|
||||||
|
- reply through the configured network channel
|
||||||
|
- use normal nanobot tools allowed by your configuration
|
||||||
|
- keep session history for conversations that flow through the network
|
||||||
|
- use Dream memory if memory is enabled for the workspace
|
||||||
|
|
||||||
|
## Supported networks
|
||||||
|
|
||||||
|
| Platform | Join message to send to your bot |
|
||||||
|
|---|---|
|
||||||
|
| [Moltbook](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
|
||||||
|
| [ClawdChat](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
|
||||||
|
|
||||||
|
Send the message from the CLI, WebUI, or an already configured chat channel.
|
||||||
|
nanobot will read the public setup instructions and perform the requested setup
|
||||||
|
using its available tools.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
- The remote setup instructions are external content. Read them yourself before
|
||||||
|
running the join prompt if the bot has file, shell, or network tools enabled.
|
||||||
|
- Keep `allowFrom` narrow on the channel you use for setup so only trusted users
|
||||||
|
can issue registration commands.
|
||||||
|
- Keep `tools.restrictToWorkspace` enabled unless the network setup explicitly
|
||||||
|
needs another path.
|
||||||
|
- Avoid `allowFrom: ["*"]` during setup unless the bot is isolated in a test
|
||||||
|
workspace.
|
||||||
|
- Store network tokens through environment variables when the integration
|
||||||
|
supports secrets.
|
||||||
|
|
||||||
|
## Example workflow
|
||||||
|
|
||||||
|
1. Confirm the local agent works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open the WebUI or a trusted chat channel.
|
||||||
|
|
||||||
|
3. Send the join message for the network you want.
|
||||||
|
|
||||||
|
4. Restart the gateway if the setup changes channel configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Send a test message through the external network and confirm the session is
|
||||||
|
routed to the expected workspace and model.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Network features, identity, and moderation rules are controlled by the
|
||||||
|
external network.
|
||||||
|
- Availability depends on the remote setup instructions remaining reachable.
|
||||||
|
- nanobot does not automatically audit remote skills for you.
|
||||||
|
- Some networks may require public callbacks, tokens, or channel-specific
|
||||||
|
account setup.
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [Chat Apps](./chat-apps.md)
|
||||||
|
- [Security configuration](./configuration.md#security)
|
||||||
|
- [Pairing](./configuration.md#pairing)
|
||||||
|
- [Runtime self-inspection](./my-tool.md)
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Automations
|
||||||
|
|
||||||
|
<!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. -->
|
||||||
|
|
||||||
|
Automations are agent turns that run later in a linked chat/session. Use them
|
||||||
|
when nanobot should do work without someone actively typing: reminders,
|
||||||
|
recurring checks, nightly summaries, CI follow-ups, local script reports, or
|
||||||
|
webhook-driven events.
|
||||||
|
|
||||||
|
Create automations from the chat, channel, or WebUI session where the result
|
||||||
|
should appear. That lets nanobot keep the right session history, workspace, and
|
||||||
|
reply target.
|
||||||
|
|
||||||
|
## Choose an Automation Type
|
||||||
|
|
||||||
|
| Type | Starts from | Best for | Created with |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target session to schedule it with the `cron` tool |
|
||||||
|
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target session |
|
||||||
|
| Heartbeat | Protected system schedule | Quiet recurring checks that should only report useful results | Edit `<workspace>/HEARTBEAT.md` |
|
||||||
|
|
||||||
|
The two user-created automation types are scheduled automations and local
|
||||||
|
triggers. Heartbeat uses the same background service but is system-managed and
|
||||||
|
protected from normal automation edits.
|
||||||
|
|
||||||
|
## Before You Create One
|
||||||
|
|
||||||
|
Keep `nanobot gateway` running. The gateway owns background delivery for chat
|
||||||
|
apps, WebUI sessions, scheduled automations, local triggers, heartbeat, and
|
||||||
|
Dream jobs.
|
||||||
|
|
||||||
|
Use the same workspace and config for the gateway and any process that sends
|
||||||
|
local trigger messages. If you run multiple nanobot instances, pass the matching
|
||||||
|
`--config` or `--workspace` option to `nanobot trigger`.
|
||||||
|
|
||||||
|
Create each automation from the target session. An automation without a linked
|
||||||
|
chat/session cannot be enabled or run from the WebUI because nanobot would not
|
||||||
|
know where to deliver the turn.
|
||||||
|
|
||||||
|
## Scheduled Automations
|
||||||
|
|
||||||
|
Scheduled automations are created by the agent's `cron` tool. In practice, ask
|
||||||
|
nanobot from the target chat or WebUI session:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Every weekday at 9am, check open pull requests and summarize blockers here.
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tomorrow at 4pm, remind me to send the release notes.
|
||||||
|
```
|
||||||
|
|
||||||
|
The cron tool supports interval schedules, cron expressions, and one-time
|
||||||
|
scheduled tasks. Cron expressions can include an IANA timezone such as
|
||||||
|
`America/Vancouver`; otherwise nanobot uses the runtime default timezone.
|
||||||
|
|
||||||
|
Scheduled automations normally deliver the result back to the session where they
|
||||||
|
were created. Use them for work that should run on a predictable schedule and
|
||||||
|
report each run.
|
||||||
|
|
||||||
|
For background checks that should stay quiet unless there is something useful to
|
||||||
|
report, use heartbeat instead of a user-created scheduled automation.
|
||||||
|
|
||||||
|
## Local Triggers
|
||||||
|
|
||||||
|
Local triggers let a local script or external service send a message into a
|
||||||
|
specific nanobot session later.
|
||||||
|
|
||||||
|
Create the trigger from the chat or WebUI session where future messages should
|
||||||
|
arrive:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/trigger PR review
|
||||||
|
```
|
||||||
|
|
||||||
|
nanobot replies with a trigger ID and a command shaped like:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the quoted text with the message nanobot should receive. For generated
|
||||||
|
or longer content, pipe stdin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
generate-report | nanobot trigger trg_8K4P2Q9X
|
||||||
|
```
|
||||||
|
|
||||||
|
For multiple instances, use the same config or workspace selector as the
|
||||||
|
gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot trigger --config ./bot-a/config.json trg_8K4P2Q9X "Nightly report"
|
||||||
|
nanobot trigger --workspace ./bot-a/workspace trg_8K4P2Q9X "Nightly report"
|
||||||
|
```
|
||||||
|
|
||||||
|
nanobot does not provide a built-in public webhook receiver for local triggers.
|
||||||
|
If GitHub, CI, or another external system should wake nanobot, run your own
|
||||||
|
small webhook service and have it call `nanobot trigger` after it builds the
|
||||||
|
final message.
|
||||||
|
|
||||||
|
## Heartbeat
|
||||||
|
|
||||||
|
Heartbeat is for recurring workspace checks that should usually stay quiet. It
|
||||||
|
reads `<workspace>/HEARTBEAT.md`, executes active tasks, and sends only useful or
|
||||||
|
actionable results to the most recently active chat target.
|
||||||
|
|
||||||
|
Use heartbeat for checks such as "watch this repo for important failures" or
|
||||||
|
"periodically inspect this workspace and only tell me when action is needed." Use
|
||||||
|
a scheduled automation instead when every run should produce a visible reminder
|
||||||
|
or report.
|
||||||
|
|
||||||
|
Heartbeat is enabled by default when `nanobot gateway` starts. Configure it in
|
||||||
|
[`configuration.md#gateway-heartbeat`](./configuration.md#gateway-heartbeat).
|
||||||
|
|
||||||
|
## Manage Automations
|
||||||
|
|
||||||
|
Use the WebUI Automations view to:
|
||||||
|
|
||||||
|
- filter by all, active, paused, needs-attention, or system jobs;
|
||||||
|
- search by task name, message, trigger command, linked chat, schedule, or
|
||||||
|
status;
|
||||||
|
- sort by next run, last run, updated time, or name;
|
||||||
|
- run scheduled automations now;
|
||||||
|
- pause or resume, rename, or delete user-created automations;
|
||||||
|
- copy the CLI command for local triggers;
|
||||||
|
- inspect protected system automations without changing them.
|
||||||
|
|
||||||
|
Local triggers do not have a WebUI "Run now" action because each run needs a
|
||||||
|
message. Copy the `nanobot trigger ...` command from the WebUI and replace
|
||||||
|
`"message"` with the content that should be delivered.
|
||||||
|
|
||||||
|
## Delivery and Reliability
|
||||||
|
|
||||||
|
Automation delivery is workspace-local. Scheduled jobs and local trigger
|
||||||
|
deliveries use the same workspace as the gateway.
|
||||||
|
|
||||||
|
Local trigger messages are written to a durable queue. If the gateway is not
|
||||||
|
running yet, the message waits in that workspace. If the linked session is
|
||||||
|
already running a turn, the trigger waits until the session becomes idle instead
|
||||||
|
of being injected into the active turn.
|
||||||
|
|
||||||
|
The local trigger queue is at-least-once, not exactly-once. If the gateway exits
|
||||||
|
after claiming a delivery but before the linked turn completes, the next gateway
|
||||||
|
start requeues that delivery. External scripts should make repeated trigger
|
||||||
|
messages safe. If the delivery reaches the agent and the turn fails, the
|
||||||
|
delivery is marked failed instead of retrying forever.
|
||||||
|
|
||||||
|
Each local trigger delivery writes an audit record under
|
||||||
|
`<workspace>/triggers/runs`. Run one gateway consumer per workspace; the local
|
||||||
|
queue is not a distributed multi-consumer queue.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
For a nightly report, ask from the target session:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow.
|
||||||
|
```
|
||||||
|
|
||||||
|
For a CI follow-up, create a trigger once:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/trigger CI follow-up
|
||||||
|
```
|
||||||
|
|
||||||
|
Then have your CI or webhook adapter call:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot trigger <trigger-id> "Build failed on main. Inspect the logs and suggest the next fix."
|
||||||
|
```
|
||||||
|
|
||||||
|
For a local report script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
generate-report | nanobot trigger <trigger-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If an automation does not run, check that `nanobot gateway` is running, the
|
||||||
|
automation is enabled, and it was created from a linked chat/session.
|
||||||
|
|
||||||
|
If a local trigger waits forever, confirm the command uses the same workspace or
|
||||||
|
config as the gateway.
|
||||||
|
|
||||||
|
If a trigger message appears twice after a restart, treat it as expected
|
||||||
|
at-least-once delivery and make the external message idempotent.
|
||||||
|
|
||||||
|
If you need to edit, pause, resume, rename, delete, or inspect automations, use
|
||||||
|
the WebUI Automations view.
|
||||||
|
|
||||||
|
## Related Docs
|
||||||
|
|
||||||
|
- [`webui.md#automations`](./webui.md#automations) for the browser management view
|
||||||
|
- [`chat-commands.md#local-triggers`](./chat-commands.md#local-triggers) for `/trigger`
|
||||||
|
- [`cli-reference.md#local-triggers`](./cli-reference.md#local-triggers) for `nanobot trigger`
|
||||||
|
- [`configuration.md#gateway-heartbeat`](./configuration.md#gateway-heartbeat) for heartbeat settings
|
||||||
|
- [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) for long-running agent work
|
||||||
@@ -155,7 +155,7 @@ The key (`webhook`) becomes the config section name. The value points to your `B
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -e .
|
python -m pip install -e .
|
||||||
nanobot plugins list # verify "Webhook" shows as "plugin"
|
nanobot plugins list # verify the installed example plugin appears as "webhook"
|
||||||
nanobot onboard # auto-adds default config for detected plugins
|
nanobot onboard # auto-adds default config for detected plugins
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ If not overridden, the base class returns `{"enabled": false}`.
|
|||||||
git clone https://github.com/you/nanobot-channel-webhook
|
git clone https://github.com/you/nanobot-channel-webhook
|
||||||
cd nanobot-channel-webhook
|
cd nanobot-channel-webhook
|
||||||
python -m pip install -e .
|
python -m pip install -e .
|
||||||
nanobot plugins list # should show "Webhook" as "plugin"
|
nanobot plugins list # should show the installed example plugin as "webhook"
|
||||||
nanobot gateway # test end-to-end
|
nanobot gateway # test end-to-end
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -561,8 +561,8 @@ nanobot gateway # test end-to-end
|
|||||||
```bash
|
```bash
|
||||||
$ nanobot plugins list
|
$ nanobot plugins list
|
||||||
|
|
||||||
Name Source Enabled
|
Name Type Enabled
|
||||||
telegram builtin yes
|
discord channel no
|
||||||
discord builtin no
|
telegram channel yes
|
||||||
webhook plugin yes
|
webhook channel yes
|
||||||
```
|
```
|
||||||
|
|||||||
+93
-20
@@ -1,6 +1,22 @@
|
|||||||
# Chat Apps
|
# Chat Apps for Self-Hosted AI Agents
|
||||||
|
|
||||||
Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./channel-plugin-guide.md).
|
Connect nanobot to Telegram, Discord, Slack, WeChat, Email, Mattermost, and
|
||||||
|
other chat platforms. This page is the full chat-channel reference. If you want
|
||||||
|
a focused setup path for one platform, start with a guide:
|
||||||
|
|
||||||
|
| Platform | Guide |
|
||||||
|
|---|---|
|
||||||
|
| Telegram | [Build a Telegram AI Agent with nanobot](./guides/telegram-ai-agent.md) |
|
||||||
|
| Discord | [Build a Discord AI Agent with nanobot](./guides/discord-ai-agent.md) |
|
||||||
|
| Slack | [Build a Slack AI Agent with nanobot](./guides/slack-ai-agent.md) |
|
||||||
|
| Feishu | [Build a Feishu AI Agent with nanobot](./guides/feishu-ai-agent.md) |
|
||||||
|
| WhatsApp | [Build a WhatsApp AI Agent with nanobot](./guides/whatsapp-ai-agent.md) |
|
||||||
|
| WeChat | [Build a WeChat AI Agent with nanobot](./guides/wechat-ai-agent.md) |
|
||||||
|
| QQ | [Build a QQ AI Agent with nanobot](./guides/qq-ai-agent.md) |
|
||||||
|
| Email | [Build an Email AI Agent with nanobot](./guides/email-ai-agent.md) |
|
||||||
|
| Mattermost | [Build a Mattermost AI Agent with nanobot](./guides/mattermost-ai-agent.md) |
|
||||||
|
|
||||||
|
Want to build your own channel? See the [Channel Plugin Guide](./channel-plugin-guide.md).
|
||||||
|
|
||||||
Before configuring a chat app, make sure the local CLI path works:
|
Before configuring a chat app, make sure the local CLI path works:
|
||||||
|
|
||||||
@@ -10,7 +26,26 @@ nanobot agent -m "Hello!"
|
|||||||
|
|
||||||
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
|
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
|
||||||
|
|
||||||
Most examples below are snippets to merge into `~/.nanobot/config.json`.
|
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a
|
||||||
|
snippet includes `allowFrom`, it is showing a static allowlist. For
|
||||||
|
pairing-based access on supported channels, omit `allowFrom`; Slack and
|
||||||
|
Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing
|
||||||
|
codes.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If you are upgrading from a version where chat app SDKs were installed by default,
|
||||||
|
> install the channel extra in the same Python environment before enabling or
|
||||||
|
> restarting that channel:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> nanobot plugins enable <channel>
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Replace `<channel>` with names such as `telegram`, `slack`, `feishu`,
|
||||||
|
> `dingtalk`, `matrix`, `qq`, `napcat`, `weixin`, `wecom`, or `msteams`.
|
||||||
|
> To turn a channel off later, run `nanobot plugins disable <channel>`.
|
||||||
|
> nanobot keeps the saved settings, but stops loading that channel after the
|
||||||
|
> next restart.
|
||||||
|
|
||||||
## Common Setup Pattern
|
## Common Setup Pattern
|
||||||
|
|
||||||
@@ -19,24 +54,25 @@ Every chat app uses the same shape:
|
|||||||
1. Create or prepare the bot/account in the chat platform.
|
1. Create or prepare the bot/account in the chat platform.
|
||||||
2. Copy the token, secret, QR login state, webhook URL, or account ID that platform gives you.
|
2. Copy the token, secret, QR login state, webhook URL, or account ID that platform gives you.
|
||||||
3. Merge that platform's JSON snippet into `~/.nanobot/config.json`.
|
3. Merge that platform's JSON snippet into `~/.nanobot/config.json`.
|
||||||
4. Keep access control narrow at first with `allowFrom` or the platform-specific allow list.
|
4. Prefer pairing for DM-capable channels: omit `allowFrom`, let the first DM receive a pairing code, then approve it with `/pairing approve <code>`.
|
||||||
5. Check that nanobot can see the configured channel:
|
5. For channels without pairing, such as Email, keep access narrow with `allowFrom` or the platform-specific allow list.
|
||||||
|
6. Check that nanobot can see the configured channel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot channels status
|
nanobot channels status
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Start the gateway and leave that terminal running:
|
7. Start the gateway and leave that terminal running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Send a message from the allowed account. In group chats, follow that channel's `groupPolicy` behavior: many channels default to mention-only, while Matrix and WhatsApp default to open group replies.
|
8. Send a test DM. If the bot returns a pairing code, approve it and send the message again. In group chats, follow that channel's `groupPolicy` behavior: many channels default to mention-only, while Matrix and WhatsApp default to open group replies.
|
||||||
|
|
||||||
If `nanobot channels status` does not show the channel as enabled, the config snippet is in the wrong place, the channel name is misspelled, or the config file you edited is not the one nanobot is reading. If the channel is enabled but messages do not arrive, run `nanobot gateway --verbose` and compare the platform-side credentials, event permissions, and allow lists.
|
If `nanobot channels status` does not show the channel as enabled, the config snippet is in the wrong place, the channel name is misspelled, or the config file you edited is not the one nanobot is reading. If the channel is enabled but messages do not arrive, run `nanobot gateway --verbose` and compare the platform-side credentials, event permissions, and allow lists.
|
||||||
|
|
||||||
> `["*"]` allows anyone who can reach that channel to talk to the bot. Use it only when that is intentional, or temporarily while testing in a private sandbox.
|
> `allowFrom: ["*"]` bypasses pairing and allows anyone who can reach that channel to talk to the bot. Use it only when that is intentional, or temporarily while testing in a private sandbox.
|
||||||
|
|
||||||
| Channel | What you need |
|
| Channel | What you need |
|
||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
@@ -59,6 +95,12 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b></summary>
|
<summary><b>Telegram</b></summary>
|
||||||
|
|
||||||
|
**Install the optional channel dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable telegram
|
||||||
|
```
|
||||||
|
|
||||||
**1. Create a bot**
|
**1. Create a bot**
|
||||||
- Open Telegram, search `@BotFather`
|
- Open Telegram, search `@BotFather`
|
||||||
- Send `/newbot`, follow prompts
|
- Send `/newbot`, follow prompts
|
||||||
@@ -123,6 +165,14 @@ Telegram uses long polling by default. To receive updates through a webhook, exp
|
|||||||
|
|
||||||
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
|
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
|
||||||
|
|
||||||
|
**Install the optional realtime dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable mochat
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this extra, Mochat still works through HTTP polling.
|
||||||
|
|
||||||
**1. Ask nanobot to set up Mochat for you**
|
**1. Ask nanobot to set up Mochat for you**
|
||||||
|
|
||||||
Simply send this message to nanobot (replace `xxx@xxx` with your real email):
|
Simply send this message to nanobot (replace `xxx@xxx` with your real email):
|
||||||
@@ -233,14 +283,14 @@ nanobot gateway
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Matrix (Element)</b></summary>
|
<summary><b>Matrix (Element)</b></summary>
|
||||||
|
|
||||||
Install Matrix dependencies first:
|
Enable Matrix support first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install "nanobot-ai[matrix]"
|
nanobot plugins enable matrix
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel and is skipped by the `matrix` extra on `sys_platform == 'win32'`. The command above will still succeed on Windows but without `matrix-nio` installed, so enabling the Matrix channel will fail at startup. Use macOS, Linux, or WSL2.
|
> Matrix encryption is disabled by default on Windows because `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel. Use macOS, Linux, or WSL2 if you need Matrix E2EE.
|
||||||
|
|
||||||
**1. Create/choose a Matrix account**
|
**1. Create/choose a Matrix account**
|
||||||
|
|
||||||
@@ -306,9 +356,7 @@ nanobot gateway
|
|||||||
Requires the WhatsApp optional dependencies:
|
Requires the WhatsApp optional dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install "nanobot-ai[whatsapp]"
|
nanobot plugins enable whatsapp
|
||||||
# Source checkout:
|
|
||||||
python -m pip install -e ".[whatsapp]"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**1. Link device with QR**
|
**1. Link device with QR**
|
||||||
@@ -384,6 +432,7 @@ Uses **WebSocket** long connection — no public IP required.
|
|||||||
**Quick setup: QR login**
|
**Quick setup: QR login**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
nanobot plugins enable feishu
|
||||||
nanobot channels login feishu
|
nanobot channels login feishu
|
||||||
# Use --force to create/sign in with a new bot
|
# Use --force to create/sign in with a new bot
|
||||||
```
|
```
|
||||||
@@ -454,6 +503,12 @@ nanobot gateway
|
|||||||
|
|
||||||
Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
|
Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
|
||||||
|
|
||||||
|
**Install the optional channel dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable qq
|
||||||
|
```
|
||||||
|
|
||||||
**1. Register & create bot**
|
**1. Register & create bot**
|
||||||
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
|
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
|
||||||
- Create a new bot application
|
- Create a new bot application
|
||||||
@@ -506,6 +561,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
|
|||||||
- Copy the forward websocket server's token
|
- Copy the forward websocket server's token
|
||||||
- (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts
|
- (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts
|
||||||
|
|
||||||
|
**Install the optional channel dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable napcat
|
||||||
|
```
|
||||||
|
|
||||||
**2. Configure**
|
**2. Configure**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -543,6 +604,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
|
|||||||
|
|
||||||
Uses **Stream Mode** — no public IP required.
|
Uses **Stream Mode** — no public IP required.
|
||||||
|
|
||||||
|
**Install the optional channel dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable dingtalk
|
||||||
|
```
|
||||||
|
|
||||||
**1. Create a DingTalk bot**
|
**1. Create a DingTalk bot**
|
||||||
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
|
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
|
||||||
- Create a new app -> Add **Robot** capability
|
- Create a new app -> Add **Robot** capability
|
||||||
@@ -585,6 +652,12 @@ nanobot gateway
|
|||||||
|
|
||||||
Uses **Socket Mode** — no public URL required.
|
Uses **Socket Mode** — no public URL required.
|
||||||
|
|
||||||
|
**Install the optional channel dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable slack
|
||||||
|
```
|
||||||
|
|
||||||
**1. Create a Slack app**
|
**1. Create a Slack app**
|
||||||
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
|
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
|
||||||
- Pick a name and select your workspace
|
- Pick a name and select your workspace
|
||||||
@@ -695,10 +768,10 @@ nanobot gateway
|
|||||||
|
|
||||||
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
|
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
|
||||||
|
|
||||||
**1. Install with WeChat support**
|
**1. Enable WeChat support**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install "nanobot-ai[weixin]"
|
nanobot plugins enable weixin
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Configure**
|
**2. Configure**
|
||||||
@@ -747,10 +820,10 @@ nanobot gateway
|
|||||||
>
|
>
|
||||||
> Uses **WebSocket** long connection — no public IP required.
|
> Uses **WebSocket** long connection — no public IP required.
|
||||||
|
|
||||||
**1. Install the optional dependency**
|
**1. Enable WeCom support**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install "nanobot-ai[wecom]"
|
nanobot plugins enable wecom
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Create a WeCom AI Bot**
|
**2. Create a WeCom AI Bot**
|
||||||
@@ -786,10 +859,10 @@ nanobot gateway
|
|||||||
> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
|
> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
|
||||||
> Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy.
|
> Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy.
|
||||||
|
|
||||||
**1. Install the optional dependency**
|
**1. Enable Microsoft Teams support**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install "nanobot-ai[msteams]"
|
nanobot plugins enable msteams
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Create a Teams / Azure bot app registration**
|
**2. Create a Teams / Azure bot app registration**
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ These commands work inside chat channels and interactive agent sessions:
|
|||||||
| `/dream-log <sha>` | Show a specific Dream memory change |
|
| `/dream-log <sha>` | Show a specific Dream memory change |
|
||||||
| `/dream-restore` | List recent Dream memory versions |
|
| `/dream-restore` | List recent Dream memory versions |
|
||||||
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
||||||
|
| `/dream-prompt` | Show how Dream is being guided for memory |
|
||||||
|
| `/dream-prompt init` | Create an editable Dream memory guide at `prompts/dream.md` |
|
||||||
| `/skill` | List enabled skills and their descriptions |
|
| `/skill` | List enabled skills and their descriptions |
|
||||||
| `/trigger` | Show local trigger usage |
|
| `/trigger` | Show local trigger usage |
|
||||||
| `/trigger <name>` | Create a named local trigger for the current chat/session |
|
| `/trigger <name>` | Create a named local trigger for the current chat/session |
|
||||||
@@ -116,6 +118,9 @@ Manage triggers from the WebUI Automations view. You can search, pause/resume,
|
|||||||
rename, delete, and copy the trigger command there. A session may have multiple
|
rename, delete, and copy the trigger command there. A session may have multiple
|
||||||
triggers, just like it may have multiple scheduled automations.
|
triggers, just like it may have multiple scheduled automations.
|
||||||
|
|
||||||
|
See [Automations](./automations.md) for how local triggers fit with scheduled
|
||||||
|
automations, heartbeat, and gateway delivery.
|
||||||
|
|
||||||
## Periodic Tasks
|
## Periodic Tasks
|
||||||
|
|
||||||
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
||||||
|
|||||||
+71
-6
@@ -8,14 +8,17 @@ Use this page when you know what you want to run and need the command shape. For
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` |
|
| Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` |
|
||||||
| Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` |
|
| Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` |
|
||||||
|
| Refresh config non-interactively | `nanobot onboard --refresh` | Preserves existing values and adds missing default fields without prompting |
|
||||||
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
|
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
|
||||||
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider |
|
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
|
||||||
|
| Check config without calling a model | `nanobot status` | Summarizes the selected config, workspace, active model, and providers |
|
||||||
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
||||||
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
||||||
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
|
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
|
||||||
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
|
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
|
||||||
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
||||||
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
||||||
|
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
|
||||||
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
||||||
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
|
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ with `--background`, use `nanobot gateway stop`.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot onboard` | Initialize or refresh the default config and workspace |
|
| `nanobot onboard` | Initialize or refresh the default config and workspace |
|
||||||
|
| `nanobot onboard --refresh` | Refresh an existing config without prompting, preserving existing values |
|
||||||
| `nanobot onboard --wizard` | Use the interactive setup wizard |
|
| `nanobot onboard --wizard` | Use the interactive setup wizard |
|
||||||
| `nanobot onboard --config <path> --workspace <path>` | Initialize or refresh a specific instance |
|
| `nanobot onboard --config <path> --workspace <path>` | Initialize or refresh a specific instance |
|
||||||
|
|
||||||
@@ -78,11 +82,26 @@ Default paths:
|
|||||||
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
||||||
| `nanobot agent --logs` | Show runtime logs while chatting |
|
| `nanobot agent --logs` | Show runtime logs while chatting |
|
||||||
|
|
||||||
|
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
||||||
|
|
||||||
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||||
|
|
||||||
|
## WebUI
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
|
||||||
|
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
|
||||||
|
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
||||||
|
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
|
||||||
|
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
|
||||||
|
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; provider credentials still require interactive setup |
|
||||||
|
|
||||||
|
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
|
||||||
|
|
||||||
## Gateway
|
## Gateway
|
||||||
|
|
||||||
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
|
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. Most local browser users should start with `nanobot webui`; use `gateway` directly for service management, chat app operation, and advanced deployment. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -169,6 +188,9 @@ rename, delete, search, and copy the command for each trigger.
|
|||||||
For webhooks or other external systems, run your own small service and have it
|
For webhooks or other external systems, run your own small service and have it
|
||||||
call this CLI after it decides what message nanobot should receive.
|
call this CLI after it decides what message nanobot should receive.
|
||||||
|
|
||||||
|
See [Automations](./automations.md) for the broader automation model, WebUI
|
||||||
|
management, and delivery behavior.
|
||||||
|
|
||||||
## OpenAI-Compatible API
|
## OpenAI-Compatible API
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -187,6 +209,8 @@ Default API endpoint:
|
|||||||
http://127.0.0.1:8900
|
http://127.0.0.1:8900
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Public binds (`0.0.0.0` or `::`) require `api.apiKey`; send it as a Bearer token on API routes.
|
||||||
|
|
||||||
See [`openai-api.md`](./openai-api.md) for request examples.
|
See [`openai-api.md`](./openai-api.md) for request examples.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
@@ -195,7 +219,13 @@ See [`openai-api.md`](./openai-api.md) for request examples.
|
|||||||
nanobot status
|
nanobot status
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows the default config path, workspace path, active model, and provider summary. This command does not currently accept `--config`; use explicit `--config` and `--workspace` on `agent`, `gateway`, or `serve` when debugging a specific instance.
|
Shows the config path, workspace path, active model, and provider summary without calling a model.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `nanobot status` | Inspect the default instance |
|
||||||
|
| `nanobot status --config <path>` | Inspect a specific config |
|
||||||
|
| `nanobot status --config <path> --workspace <path>` | Inspect a specific config with a workspace override |
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
@@ -206,6 +236,7 @@ Shows the default config path, workspace path, active model, and provider summar
|
|||||||
| `nanobot channels login <channel>` | Run interactive login for supported channels |
|
| `nanobot channels login <channel>` | Run interactive login for supported channels |
|
||||||
| `nanobot channels login <channel> --force` | Re-authenticate even if credentials already exist |
|
| `nanobot channels login <channel> --force` | Re-authenticate even if credentials already exist |
|
||||||
| `nanobot channels login <channel> --config <path>` | Use a specific config file |
|
| `nanobot channels login <channel> --config <path>` | Use a specific config file |
|
||||||
|
| `nanobot plugins list --config <path>` | Show plugin/channel enabled state for a specific config |
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -217,12 +248,46 @@ nanobot channels status
|
|||||||
|
|
||||||
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|
||||||
|
|
||||||
|
## Optional Features
|
||||||
|
|
||||||
|
Use these commands when you want nanobot to add or remove a built-in capability
|
||||||
|
without hand-editing JSON. Enabling may install the support package first.
|
||||||
|
Disabling is for channels such as Telegram, Matrix, or Slack; it keeps your
|
||||||
|
saved settings and turns the channel off.
|
||||||
|
|
||||||
|
The `plugins` command name is retained for compatibility, but these entries are
|
||||||
|
nanobot runtime support packages, not the user-invokable tools shown in WebUI
|
||||||
|
Apps. They cannot be attached to a chat turn with `@`.
|
||||||
|
|
||||||
|
| Feature name | What it enables |
|
||||||
|
|---|---|
|
||||||
|
| `api` | Dependencies required by the OpenAI-compatible `nanobot serve` process |
|
||||||
|
| `azure` | Azure identity support for Azure-hosted models |
|
||||||
|
| `bedrock` | AWS Bedrock model provider support |
|
||||||
|
| `langfuse` | Langfuse tracing support for OpenAI-compatible providers |
|
||||||
|
| `olostep` | Olostep web search provider support |
|
||||||
|
| A channel name such as `telegram` or `slack` | The connector package and saved channel enablement |
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `nanobot plugins list` | Show available channels and optional capabilities |
|
||||||
|
| `nanobot plugins enable <name>` | Install missing support and enable the feature or channel |
|
||||||
|
| `nanobot plugins enable <name> --logs` | Show package install logs while enabling |
|
||||||
|
| `nanobot plugins disable <channel>` | Turn off a channel without deleting its saved settings |
|
||||||
|
| `nanobot plugins list --config <path>` | Read a specific config file |
|
||||||
|
| `nanobot plugins enable <name> --config <path>` | Update a specific config file |
|
||||||
|
| `nanobot plugins disable <channel> --config <path>` | Turn off a channel in a specific config file |
|
||||||
|
|
||||||
|
Document and PDF reading are included in the standard installation. The old
|
||||||
|
`nanobot plugins enable documents` and `nanobot plugins enable pdf` commands
|
||||||
|
remain accepted as no-op compatibility aliases.
|
||||||
|
|
||||||
## Provider OAuth
|
## Provider OAuth
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot provider login openai-codex` | Authenticate OpenAI Codex provider |
|
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
||||||
| `nanobot provider login github-copilot` | Authenticate GitHub Copilot provider |
|
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
||||||
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
||||||
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
|
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ nanobot has one small core loop and several ways to enter it:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Agent loop | Builds context, selects the session, calls the provider, runs tools, and publishes replies |
|
| Agent loop | Builds context, selects the session, calls the provider, runs tools, and publishes replies |
|
||||||
| Providers | LLM backends such as OpenRouter, Anthropic, OpenAI, Bedrock, Ollama, vLLM, and other OpenAI-compatible APIs |
|
| Providers | LLM backends such as OpenRouter, Anthropic, OpenAI, Bedrock, Ollama, vLLM, and other OpenAI-compatible APIs |
|
||||||
| Channels | User-facing transports such as CLI, WebUI/WebSocket, Telegram, Discord, Slack, Feishu, WeChat, Email, and others |
|
| Channels | User-facing transports such as CLI, WebUI/WebSocket, Telegram, Discord, Slack, Feishu, WeChat, Email, Mattermost, and others |
|
||||||
| Tools | Capabilities the model may call, including files, shell, web search/fetch, MCP, cron, image generation, and subagents |
|
| Tools | Capabilities the model may call, including files, shell, web search/fetch, MCP, cron, image generation, and subagents |
|
||||||
| Memory | Workspace files and session history that keep useful context across turns |
|
| Memory | Workspace files and session history that keep useful context across turns |
|
||||||
| Gateway | Long-running process that connects enabled channels and serves the health endpoint |
|
| Gateway | Long-running process that connects enabled channels and serves the health endpoint |
|
||||||
|
|||||||
+127
-64
@@ -13,6 +13,61 @@ For setup and runtime failures, follow the diagnosis order in [`troubleshooting.
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
|
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
|
||||||
|
|
||||||
|
## Python Configuration API
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> `nanobot.config.load_config` and `nanobot.config.loader.load_config` have been
|
||||||
|
> removed. There is intentionally no compatibility alias because the old name
|
||||||
|
> did not distinguish the persisted representation from the runtime view.
|
||||||
|
|
||||||
|
Python embedders and plugins must choose the view they need explicitly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.config import (
|
||||||
|
apply_config_runtime_policies,
|
||||||
|
load_effective_config,
|
||||||
|
load_raw_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
path = Path.home() / ".nanobot" / "config.json"
|
||||||
|
|
||||||
|
# For settings editors and persistence flows: preserves ${VAR} placeholders.
|
||||||
|
persisted = load_raw_config(path)
|
||||||
|
|
||||||
|
# For agents, providers, channels, and tools: resolves ${VAR} placeholders.
|
||||||
|
runtime = load_effective_config(path)
|
||||||
|
apply_config_runtime_policies(runtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this migration mapping:
|
||||||
|
|
||||||
|
| Previous call | Replacement |
|
||||||
|
|---|---|
|
||||||
|
| `load_config(path)` used to inspect or edit persisted values | `load_raw_config(path)` |
|
||||||
|
| `resolve_config_env_vars(load_config(path))` used at runtime | `load_effective_config(path)` |
|
||||||
|
| Loading followed by implicit process-policy setup | `load_effective_config(path)`, then `apply_config_runtime_policies(config)` at the runtime boundary |
|
||||||
|
|
||||||
|
This is a Python API breaking change for embedders and plugins that import the
|
||||||
|
old function. The `config.json` format is unchanged, and the bundled CLI,
|
||||||
|
gateway, and WebUI already use the explicit APIs.
|
||||||
|
|
||||||
|
## Configuration Guides
|
||||||
|
|
||||||
|
This page is the complete configuration reference. For task-oriented setup, use
|
||||||
|
the focused guides first and come back here for exact fields and defaults.
|
||||||
|
|
||||||
|
| Task | Guide |
|
||||||
|
|---|---|
|
||||||
|
| Add MCP tools | [`guides/configure-mcp-tools.md`](./guides/configure-mcp-tools.md) |
|
||||||
|
| Enable web search and web fetch | [`guides/configure-web-search.md`](./guides/configure-web-search.md) |
|
||||||
|
| Configure model fallback | [`guides/configure-model-fallback.md`](./guides/configure-model-fallback.md) |
|
||||||
|
| Add an OpenAI-compatible provider | [`guides/configure-openai-compatible-provider.md`](./guides/configure-openai-compatible-provider.md) |
|
||||||
|
| Add Langfuse observability | [`guides/configure-langfuse-observability.md`](./guides/configure-langfuse-observability.md) |
|
||||||
|
| Secure a local AI agent | [`guides/secure-local-ai-agent.md`](./guides/secure-local-ai-agent.md) |
|
||||||
|
| Deploy the gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
||||||
|
|
||||||
## Quick Jump
|
## Quick Jump
|
||||||
|
|
||||||
| Need | Section |
|
| Need | Section |
|
||||||
@@ -41,15 +96,15 @@ If you are not sure where a setting belongs, start from the task you are trying
|
|||||||
| Make the first model reply work | `providers.<name>.apiKey`, optional `providers.<name>.apiBase`, `modelPresets.<preset>`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) |
|
| Make the first model reply work | `providers.<name>.apiKey`, optional `providers.<name>.apiBase`, `modelPresets.<preset>`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) |
|
||||||
| Add fallback models | `modelPresets.<fallback>`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) |
|
| Add fallback models | `modelPresets.<fallback>`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) |
|
||||||
| Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) |
|
| Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) |
|
||||||
| Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot gateway`, then open `http://127.0.0.1:8765` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) |
|
| Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot webui` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) |
|
||||||
| Connect one chat app | `channels.<channel>.enabled`, channel credentials, `channels.<channel>.allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) |
|
| Connect one chat app | `channels.<channel>.enabled`, channel credentials, optional pairing or `channels.<channel>.allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) |
|
||||||
| Enable voice transcription | `transcription.enabled`, `transcription.provider`, matching `providers.<name>.apiKey` | Send or upload a short voice message through a configured surface | [Transcription Settings](#transcription-settings) |
|
| Enable voice transcription | `transcription.enabled`, `transcription.provider`, matching `providers.<name>.apiKey` | Send or upload a short voice message through a configured surface | [Transcription Settings](#transcription-settings) |
|
||||||
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
|
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
|
||||||
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
|
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
|
||||||
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
|
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
|
||||||
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
|
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
|
||||||
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
|
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
|
||||||
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Start each process with explicit paths and run `nanobot status` for the default instance only | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
|
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
|
||||||
| Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) |
|
| Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) |
|
||||||
|
|
||||||
## Environment Variables for Secrets
|
## Environment Variables for Secrets
|
||||||
@@ -198,7 +253,7 @@ nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK
|
|||||||
Install the optional package in the same Python environment that runs nanobot:
|
Install the optional package in the same Python environment that runs nanobot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install langfuse
|
nanobot plugins enable langfuse
|
||||||
```
|
```
|
||||||
|
|
||||||
Set Langfuse credentials before starting `nanobot agent`, `nanobot gateway`, or `nanobot serve`:
|
Set Langfuse credentials before starting `nanobot agent`, `nanobot gateway`, or `nanobot serve`:
|
||||||
@@ -232,7 +287,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
> - **MiniMax thinking mode**: `providers.minimaxAnthropic` is the config block for `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
|
> - **MiniMax thinking mode**: `providers.minimaxAnthropic` is the config block for `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
|
||||||
> - **Kimi Coding Plan**: Use `providers.kimiCoding` with `provider: "kimi_coding"` for Kimi's dedicated Anthropic Messages API endpoint. The endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default, and you can override it with `extraHeaders.User-Agent` if your account requires a different value.
|
> - **Kimi Coding Plan**: Use `providers.kimiCoding` with `provider: "kimi_coding"` for Kimi's dedicated Anthropic Messages API endpoint. The endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default, and you can override it with `extraHeaders.User-Agent` if your account requires a different value.
|
||||||
> - **VolcEngine / BytePlus Coding Plan**: Subscription endpoints are configured through dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan`, separate from the pay-per-use `volcengine` / `byteplus` providers.
|
> - **VolcEngine / BytePlus Coding Plan**: Subscription endpoints are configured through dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan`, separate from the pay-per-use `volcengine` / `byteplus` providers.
|
||||||
> - **OpenCode Zen / Go**: `providers.opencodeZen` and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
|
> - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
|
||||||
> - **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.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
||||||
@@ -246,7 +301,8 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `custom` | Any OpenAI-compatible endpoint | — |
|
| `custom` | Any OpenAI-compatible endpoint | — |
|
||||||
| `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `opencode_zen` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
| `opencode` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
||||||
|
| `opencode_zen` | LLM gateway (legacy alias for OpenCode Zen) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
||||||
| `opencode_go` | LLM gateway (OpenCode Go low-cost coding models) | [opencode.ai/docs/go](https://opencode.ai/docs/go/) |
|
| `opencode_go` | LLM gateway (OpenCode Go low-cost coding models) | [opencode.ai/docs/go](https://opencode.ai/docs/go/) |
|
||||||
| `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) |
|
| `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) |
|
||||||
@@ -282,7 +338,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
|
| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
|
||||||
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
||||||
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
|
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
|
||||||
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
|
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` |
|
||||||
| `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) |
|
||||||
|
|
||||||
@@ -380,7 +436,7 @@ Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultA
|
|||||||
Install the optional dependency:
|
Install the optional dependency:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install 'nanobot-ai[azure]'
|
nanobot plugins enable azure
|
||||||
```
|
```
|
||||||
|
|
||||||
`DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds:
|
`DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds:
|
||||||
@@ -395,7 +451,7 @@ python -m pip install 'nanobot-ai[azure]'
|
|||||||
|
|
||||||
The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request.
|
The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request.
|
||||||
|
|
||||||
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `python -m pip install 'nanobot-ai[azure]'`.
|
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `nanobot plugins enable azure`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -439,6 +495,17 @@ Bedrock uses the native `bedrock-runtime` Converse API, so it can call Bedrock m
|
|||||||
|
|
||||||
This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface.
|
This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface.
|
||||||
|
|
||||||
|
Install Bedrock support first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable bedrock
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If you configured Bedrock before `boto3` became an optional dependency, run
|
||||||
|
> `nanobot plugins enable bedrock` after upgrading. Otherwise the provider will
|
||||||
|
> fail when it first tries to create a Bedrock client.
|
||||||
|
|
||||||
**1. Configure credentials**
|
**1. Configure credentials**
|
||||||
|
|
||||||
Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs:
|
Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs:
|
||||||
@@ -633,61 +700,19 @@ nanobot agent -m "Reply with one short sentence."
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||||
|
|
||||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. `nanobot provider login` stores the OAuth session outside config. A `providers.openai_codex` block is optional and is only needed for provider-specific settings such as a proxy.
|
Codex uses OAuth instead of API keys and requires a ChatGPT Plus or Pro account. Authenticate it and make the current flagship model the active agent model with one command:
|
||||||
|
|
||||||
**1. Login:**
|
|
||||||
```bash
|
```bash
|
||||||
nanobot provider login openai-codex
|
nanobot provider login openai-codex --set-main
|
||||||
```
|
```
|
||||||
|
|
||||||
If the machine running nanobot cannot open a graphical browser, copy the printed URL into a real browser. For remote SSH login, open the URL locally, then paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted.
|
Then run:
|
||||||
|
|
||||||
**2. Optional proxy** (merge into `~/.nanobot/config.json` if Codex OAuth or Codex API traffic must use a proxy):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"openai_codex": {
|
|
||||||
"proxy": "http://127.0.0.1:7890"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The proxy applies to Codex OAuth token refresh, interactive token exchange, and Codex Responses API requests. It does not affect other providers; configure `proxy` separately on each supported provider that needs it.
|
|
||||||
|
|
||||||
**3. Set model** (merge into `~/.nanobot/config.json`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"modelPresets": {
|
|
||||||
"codex": {
|
|
||||||
"provider": "openai_codex",
|
|
||||||
"model": "gpt-5.1-codex",
|
|
||||||
"reasoningEffort": "high"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"modelPreset": "codex"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `reasoningEffort` in the preset to send a Codex reasoning effort such as `"low"`, `"medium"`, `"high"`, or another value supported by the selected model. When `provider` is explicitly `openai_codex`, the model name does not need the `openai-codex/` prefix.
|
|
||||||
|
|
||||||
**4. Chat:**
|
|
||||||
```bash
|
```bash
|
||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
|
|
||||||
# Target a specific workspace/config locally
|
|
||||||
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
|
|
||||||
|
|
||||||
# One-off workspace override on top of that config
|
|
||||||
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Docker users: use `docker run -it` for interactive OAuth login.
|
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -753,6 +778,7 @@ variable, but use separate provider keys and default base URLs:
|
|||||||
|
|
||||||
| Provider | Default API base | Model prefix accepted by nanobot |
|
| Provider | Default API base | Model prefix accepted by nanobot |
|
||||||
|----------|------------------|-----------------------------------|
|
|----------|------------------|-----------------------------------|
|
||||||
|
| `opencode` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
|
||||||
| `opencode_zen` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
|
| `opencode_zen` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
|
||||||
| `opencode_go` | `https://opencode.ai/zen/go/v1` | `opencode-go/<model-id>` |
|
| `opencode_go` | `https://opencode.ai/zen/go/v1` | `opencode-go/<model-id>` |
|
||||||
|
|
||||||
@@ -761,13 +787,13 @@ OpenCode Zen:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
"opencodeZen": {
|
"opencode": {
|
||||||
"apiKey": "${OPENCODE_API_KEY}"
|
"apiKey": "${OPENCODE_API_KEY}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
"opencodeZen": {
|
"opencodeZen": {
|
||||||
"provider": "opencode_zen",
|
"provider": "opencode",
|
||||||
"model": "opencode/deepseek-v4-pro"
|
"model": "opencode/deepseek-v4-pro"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -779,6 +805,8 @@ OpenCode Zen:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`providers.opencodeZen` / `provider: "opencode_zen"` still work as compatibility aliases for existing configs.
|
||||||
|
|
||||||
OpenCode Go:
|
OpenCode Go:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1510,8 +1538,8 @@ 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 / Mattermost 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. |
|
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. PDF, DOCX, XLSX, and PPTX readers are included in the standard installation. 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) |
|
||||||
|
|
||||||
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
|
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
|
||||||
@@ -1585,18 +1613,21 @@ nanobot uses a shared SSRF guard for built-in web fetches and HTTP/SSE MCP conne
|
|||||||
|
|
||||||
Keep whitelist entries as narrow as possible, such as a single host CIDR (`192.168.1.50/32`). The whitelist is global for the shared SSRF guard; it is not limited to one tool or one MCP server.
|
Keep whitelist entries as narrow as possible, such as a single host CIDR (`192.168.1.50/32`). The whitelist is global for the shared SSRF guard; it is not limited to one tool or one MCP server.
|
||||||
|
|
||||||
|
HTTP/SSE MCP connections use the same process-wide proxy environment behavior as `web_fetch`: proxied targets use the configured proxy, and URLs excluded by `NO_PROXY` remain DNS-pinned direct connections.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Use `proxy` in `tools.web` to route all web requests (search + fetch) through a proxy:
|
> Use `proxy` in `tools.web` to route web requests through a proxy:
|
||||||
> ```json
|
> ```json
|
||||||
> { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } }
|
> { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } }
|
||||||
> ```
|
> ```
|
||||||
|
> `web_fetch` applies DNS pinning for direct connections. When an explicit `tools.web.proxy` or a process-wide proxy environment variable applies to the target URL, nanobot still validates the requested URL locally, but DNS resolution for the outbound fetch happens at the proxy; configure only trusted proxies. URLs excluded by `NO_PROXY` keep the DNS-pinned direct path unless `tools.web.proxy` is configured.
|
||||||
|
|
||||||
### `tools.web`
|
### `tools.web`
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) |
|
| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) |
|
||||||
| `proxy` | string or null | `null` | Proxy for all web requests, for example `http://127.0.0.1:7890` |
|
| `proxy` | string or null | `null` | Proxy for web requests, for example `http://127.0.0.1:7890`. `web_fetch` DNS pinning applies only to direct connections; proxied fetches rely on the configured proxy as the trusted network exit. |
|
||||||
| `userAgent` | string or null | `null` | User-Agent header for all web requests. If null, a browser one will be used |
|
| `userAgent` | string or null | `null` | User-Agent header for all web requests. If null, a browser one will be used |
|
||||||
|
|
||||||
### Web Search
|
### Web Search
|
||||||
@@ -1739,6 +1770,22 @@ You can also set `WEB_SEARCH_API_KEY` for compatibility with the Volcengine web-
|
|||||||
|
|
||||||
Keenable search works out of the box with no account, via its token-less public endpoint (free tier, limited to 1,000 requests/hour). Set `apiKey` (or `KEENABLE_API_KEY`) from [keenable.ai](https://keenable.ai) to remove the hourly limit.
|
Keenable search works out of the box with no account, via its token-less public endpoint (free tier, limited to 1,000 requests/hour). Set `apiKey` (or `KEENABLE_API_KEY`) from [keenable.ai](https://keenable.ai) to remove the hourly limit.
|
||||||
|
|
||||||
|
**Serper** (Google Search API):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"provider": "serper",
|
||||||
|
"apiKey": "${SERPER_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a key at [serper.dev](https://serper.dev). You can also set `SERPER_API_KEY` in the environment instead of storing it in config.
|
||||||
|
|
||||||
**SearXNG** (self-hosted, no API key needed):
|
**SearXNG** (self-hosted, no API key needed):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1770,7 +1817,7 @@ Keenable search works out of the box with no account, via its token-less public
|
|||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `kagi`, `olostep`, `bocha`, `volcengine`, `keenable`, `searxng`, `duckduckgo` |
|
| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `kagi`, `olostep`, `bocha`, `volcengine`, `keenable`, `serper`, `searxng`, `duckduckgo` |
|
||||||
| `apiKey` | string | `""` | API key for API-backed search providers |
|
| `apiKey` | string | `""` | API key for API-backed search providers |
|
||||||
| `baseUrl` | string | `""` | Base URL for SearXNG |
|
| `baseUrl` | string | `""` | Base URL for SearXNG |
|
||||||
| `maxResults` | integer | `5` | Results per search (1–10) |
|
| `maxResults` | integer | `5` | Results per search (1–10) |
|
||||||
@@ -1906,6 +1953,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
|||||||
| `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.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.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. |
|
| `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. |
|
||||||
| `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`). |
|
||||||
|
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. |
|
||||||
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
||||||
| `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. |
|
||||||
|
|
||||||
@@ -1918,7 +1966,7 @@ Pairing lets users get access to the bot through a simple code exchange — no c
|
|||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
1. A user sends a DM to the bot on any channel (Telegram, Discord, Slack, etc.) where they aren't yet approved.
|
1. A user sends a DM to the bot on a pairing-capable channel where they aren't yet approved. This includes Telegram, Discord, WeChat, and channels such as Slack or Mattermost when their DM policy is set to `allowlist`.
|
||||||
2. The bot replies with a pairing code (like `ABCD-EFGH`) and tells them to forward it to you.
|
2. The bot replies with a pairing code (like `ABCD-EFGH`) and tells them to forward it to you.
|
||||||
3. You approve the code:
|
3. You approve the code:
|
||||||
|
|
||||||
@@ -1932,7 +1980,7 @@ Pairing only works in **DMs** — unapproved users in group chats are silently i
|
|||||||
|
|
||||||
### Pairing-only mode
|
### Pairing-only mode
|
||||||
|
|
||||||
By default, if you don't set `allowFrom`, anyone who isn't approved yet will get a pairing code when they DM the bot. This means you can skip `allowFrom` entirely and manage all access through pairing:
|
By default, if you don't set `allowFrom`, pairing-capable channels can issue a pairing code when an unapproved user DMs the bot. This means you can skip `allowFrom` entirely and manage access through pairing:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1944,6 +1992,21 @@ By default, if you don't set `allowFrom`, anyone who isn't approved yet will get
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Slack and Mattermost DMs are open by default. To use pairing there, set the
|
||||||
|
channel's `dm.policy` to `"allowlist"` and leave `dm.allowFrom` empty until you
|
||||||
|
approve users:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"slack": {
|
||||||
|
"enabled": true,
|
||||||
|
"dm": { "policy": "allowlist" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
If you prefer to allow everyone without approval:
|
If you prefer to allow everyone without approval:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+8
-4
@@ -13,7 +13,7 @@ Check these once before Docker, systemd, or LaunchAgent:
|
|||||||
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
|
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
|
||||||
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
|
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
|
||||||
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
|
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
|
||||||
| Ports are planned | Gateway health defaults to `18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
|
| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
|
||||||
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
|
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
|
||||||
|
|
||||||
Restart the deployed process after editing `config.json`. Long-running processes read config at startup.
|
Restart the deployed process after editing `config.json`. Long-running processes read config at startup.
|
||||||
@@ -38,14 +38,13 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
|||||||
> 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]
|
> [!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:
|
> 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, bind the WebSocket channel externally and protect bootstrap with a secret:
|
||||||
>
|
>
|
||||||
> ```json
|
> ```json
|
||||||
> {
|
> {
|
||||||
> "gateway": { "host": "0.0.0.0" },
|
> "gateway": { "host": "0.0.0.0" },
|
||||||
> "channels": {
|
> "channels": {
|
||||||
> "websocket": {
|
> "websocket": {
|
||||||
> "enabled": true,
|
|
||||||
> "host": "0.0.0.0",
|
> "host": "0.0.0.0",
|
||||||
> "port": 8765,
|
> "port": 8765,
|
||||||
> "tokenIssueSecret": "your-secret-here"
|
> "tokenIssueSecret": "your-secret-here"
|
||||||
@@ -55,6 +54,11 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
|||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
||||||
|
> The gateway health route itself is intentionally minimal and unauthenticated. When the
|
||||||
|
> container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any
|
||||||
|
> remotely monitored health endpoint behind a firewall or reverse proxy. If another host
|
||||||
|
> must probe it directly, replace `127.0.0.1` in the port mapping with a trusted host
|
||||||
|
> interface and restrict inbound traffic to the monitoring system.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
@@ -94,7 +98,7 @@ docker run \
|
|||||||
--security-opt apparmor=unconfined \
|
--security-opt apparmor=unconfined \
|
||||||
--security-opt seccomp=unconfined \
|
--security-opt seccomp=unconfined \
|
||||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||||
-p 18790:18790 -p 8765:8765 \
|
-p 127.0.0.1:18790:18790 -p 8765:8765 \
|
||||||
nanobot gateway
|
nanobot gateway
|
||||||
|
|
||||||
# Or run a single command
|
# Or run a single command
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# nanobot Guides
|
||||||
|
|
||||||
|
These guides are short task entry points. Use them when you know what you want
|
||||||
|
to build, then follow the linked reference docs for complete option tables and
|
||||||
|
edge cases.
|
||||||
|
|
||||||
|
## Build and operate
|
||||||
|
|
||||||
|
| Goal | Guide |
|
||||||
|
|---|---|
|
||||||
|
| Build a personal AI agent | [Build a personal AI agent](./build-a-personal-ai-agent.md) |
|
||||||
|
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
||||||
|
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
|
||||||
|
| Run long-running tasks | [Long-running AI agent](./long-running-ai-agent.md) |
|
||||||
|
| Add memory | [AI agent memory](./ai-agent-memory.md) |
|
||||||
|
| Deploy a gateway | [Deploy a long-running nanobot AI agent gateway](./deploy-nanobot-gateway.md) |
|
||||||
|
|
||||||
|
## Connect and integrate
|
||||||
|
|
||||||
|
| Goal | Guide |
|
||||||
|
|---|---|
|
||||||
|
| Connect chat apps | [Chat app AI agent](./chat-app-ai-agent.md) |
|
||||||
|
| Connect Telegram | [Telegram AI agent](./telegram-ai-agent.md) |
|
||||||
|
| Connect Discord | [Discord AI agent](./discord-ai-agent.md) |
|
||||||
|
| Connect Slack | [Slack AI agent](./slack-ai-agent.md) |
|
||||||
|
| Connect Feishu | [Feishu AI agent](./feishu-ai-agent.md) |
|
||||||
|
| Connect WhatsApp | [WhatsApp AI agent](./whatsapp-ai-agent.md) |
|
||||||
|
| Connect WeChat | [WeChat AI agent](./wechat-ai-agent.md) |
|
||||||
|
| Connect QQ | [QQ AI agent](./qq-ai-agent.md) |
|
||||||
|
| Connect Email | [Email AI agent](./email-ai-agent.md) |
|
||||||
|
| Connect Mattermost | [Mattermost AI agent](./mattermost-ai-agent.md) |
|
||||||
|
| Run from Python | [Python AI agent SDK](./python-ai-agent-sdk.md) |
|
||||||
|
| Expose `/v1/chat/completions` | [OpenAI-compatible agent API](./openai-compatible-agent-api.md) |
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
| Goal | Guide |
|
||||||
|
|---|---|
|
||||||
|
| Add MCP tools | [Configure MCP tools](./configure-mcp-tools.md) |
|
||||||
|
| Enable web search | [Configure web search](./configure-web-search.md) |
|
||||||
|
| Add model fallback | [Configure model fallback](./configure-model-fallback.md) |
|
||||||
|
| Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) |
|
||||||
|
| Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) |
|
||||||
|
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
|
||||||
|
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# How AI Agent Memory Works in nanobot
|
||||||
|
|
||||||
|
This guide explains how to use nanobot's long-term AI agent memory: session
|
||||||
|
history, compressed archives, durable memory files, Dream consolidation, and
|
||||||
|
Git-backed memory changes.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a workspace with persistent session history
|
||||||
|
- compressed history archives for older turns
|
||||||
|
- durable memory files such as `USER.md` and `MEMORY.md`
|
||||||
|
- a Dream workflow for curating long-term memory
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use memory when an agent should remember stable preferences, project facts,
|
||||||
|
decisions, and recurring context across sessions. Do not use memory as a dumping
|
||||||
|
ground for every raw transcript; nanobot separates short-term messages from
|
||||||
|
curated durable knowledge.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Ask the agent to remember a stable fact in a normal session, then run Dream:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/dream
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect recent memory changes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/dream-log
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact files live in the active workspace, usually under
|
||||||
|
`~/.nanobot/workspace/`.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Use one workspace per project or personal context.
|
||||||
|
- Keep durable facts concise; old session details belong in `history.jsonl`.
|
||||||
|
- Use `/dream-prompt init` when a workspace needs custom memory guidance.
|
||||||
|
- Review Git-backed memory changes when memory affects important workflows.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Memory files may contain sensitive user or project facts.
|
||||||
|
- Avoid sharing workspaces without reviewing `SOUL.md`, `USER.md`, and
|
||||||
|
`memory/MEMORY.md`.
|
||||||
|
- Use separate workspaces for personal and team contexts.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If memory feels stale, run `/dream` and inspect `/dream-log`.
|
||||||
|
- If memory changed incorrectly, use `/dream-restore` to inspect and restore
|
||||||
|
previous versions.
|
||||||
|
- If a new session lacks context, confirm it uses the same workspace.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [AI Agent Memory in nanobot](../memory.md)
|
||||||
|
- [Concepts](../concepts.md)
|
||||||
|
- [Configuration](../configuration.md#auto-compact)
|
||||||
|
- [Chat Commands](../chat-commands.md)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# How to Use an AI Agent WebUI with nanobot
|
||||||
|
|
||||||
|
nanobot includes a browser WebUI for persistent chat sessions, visible agent
|
||||||
|
activity, workspace controls, Apps, MCP presets, Skills, settings, and
|
||||||
|
Automations.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a local browser workbench
|
||||||
|
- one persistent chat session
|
||||||
|
- a visible timeline of agent messages, tool calls, and file edit diffs
|
||||||
|
- a gateway-backed WebSocket connection
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use the WebUI when you want a local AI agent interface that is easier to operate
|
||||||
|
than a terminal, especially for project work, file attachments, model switching,
|
||||||
|
workspace selection, Apps, Skills, and scheduled automations.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
The published wheel already includes the WebUI bundle. You only need the
|
||||||
|
`webui/` source directory when changing the frontend.
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot webui
|
||||||
|
```
|
||||||
|
|
||||||
|
The launcher checks setup, enables the local WebSocket channel after
|
||||||
|
confirmation, starts the gateway, and opens the browser.
|
||||||
|
|
||||||
|
When nanobot edits a file, the WebUI activity timeline can show the changed
|
||||||
|
line counts, a unified diff, and an **Open file** action for a read-only
|
||||||
|
preview. File previews use the chat's current workspace access mode: restricted
|
||||||
|
access stays inside the selected workspace, while Full Access can preview files
|
||||||
|
outside the workspace when the gateway allows it.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Use `nanobot webui --background` when you do not want to keep a terminal open.
|
||||||
|
- Use `nanobot gateway status`, `logs`, `restart`, and `stop` to manage a
|
||||||
|
background gateway.
|
||||||
|
- If you expose the WebUI beyond localhost, set a token issue secret and review
|
||||||
|
workspace/tool access.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- The first-run WebUI path binds to `127.0.0.1` by default.
|
||||||
|
- Do not expose the WebUI on a LAN or public host without an intentional access
|
||||||
|
model.
|
||||||
|
- Keep file and shell tools scoped to the workspace before inviting other users.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- The WebUI is served by the WebSocket channel on port `8765` by default.
|
||||||
|
- The gateway health endpoint is separate from the browser UI.
|
||||||
|
- If the page opens but messages fail, check provider setup with
|
||||||
|
`nanobot agent -m "Hello!"`.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Nanobot WebUI](../webui.md)
|
||||||
|
- [Quick Start](../quick-start.md)
|
||||||
|
- [WebSocket protocol](../websocket.md)
|
||||||
|
- [Configuration](../configuration.md)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# How to Build a Personal AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide builds a personal AI agent you can run locally, talk to from the
|
||||||
|
terminal or browser, and later connect to chat apps, memory, tools, and
|
||||||
|
automations.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a configured nanobot install
|
||||||
|
- one working model provider
|
||||||
|
- one local agent reply
|
||||||
|
- a browser WebUI session for ongoing work
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use this when you want a personal AI agent that you control rather than a hosted
|
||||||
|
chat-only interface. nanobot is useful when the agent needs local workspace
|
||||||
|
access, tool calls, session history, memory, scheduled work, or chat app
|
||||||
|
delivery.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard creates `~/.nanobot/config.json` and helps you choose a provider and
|
||||||
|
model. If terminals and config files are new to you, use
|
||||||
|
[Start Without Technical Background](../start-without-technical-background.md)
|
||||||
|
instead.
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
First prove the runtime can answer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the browser workbench:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot webui
|
||||||
|
```
|
||||||
|
|
||||||
|
The WebUI starts the local gateway, opens a browser, and keeps persistent chat
|
||||||
|
sessions for longer work.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Keep one workspace per project or personal context.
|
||||||
|
- Use `modelPresets` when you want stable names for fast, deep, local, or
|
||||||
|
fallback models.
|
||||||
|
- Keep `nanobot gateway` running for WebUI, chat apps, automations, and the
|
||||||
|
WebSocket channel.
|
||||||
|
- Use the Python SDK or OpenAI-compatible API when another program should call
|
||||||
|
the agent.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Do not store API keys directly in shared files; use environment variables.
|
||||||
|
- Prefer chat app pairing for first setup. Use `allowFrom` only for static
|
||||||
|
allowlists, and keep those lists narrow.
|
||||||
|
- Enable workspace restriction before exposing file or shell tools to other
|
||||||
|
users.
|
||||||
|
- Use a separate workspace for experiments that can modify files.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- `nanobot status` shows the config path, workspace path, and active model.
|
||||||
|
- If `nanobot agent -m "Hello!"` fails, fix provider setup before opening the
|
||||||
|
WebUI or chat apps.
|
||||||
|
- If the WebUI opens but does not answer, check gateway logs and provider
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Quick Start](../quick-start.md)
|
||||||
|
- [Concepts](../concepts.md)
|
||||||
|
- [WebUI](../webui.md)
|
||||||
|
- [Configuration](../configuration.md)
|
||||||
|
- [Troubleshooting](../troubleshooting.md)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# How to Connect an AI Agent to Chat Apps with nanobot
|
||||||
|
|
||||||
|
nanobot can run as a self-hosted chatbot or AI agent in Telegram, Discord,
|
||||||
|
Slack, WeChat, Email, Mattermost, and other chat apps. The gateway receives chat
|
||||||
|
messages, runs the agent, and sends replies back to the same channel.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a working local agent
|
||||||
|
- one enabled chat channel
|
||||||
|
- a running gateway
|
||||||
|
- a pairing-based approval flow or a narrow static allowlist
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use chat apps when the agent should live where users already communicate:
|
||||||
|
private DMs, team channels, group chats, email threads, or bot workspaces.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then choose one platform guide:
|
||||||
|
|
||||||
|
- [Telegram AI agent](./telegram-ai-agent.md)
|
||||||
|
- [Discord AI agent](./discord-ai-agent.md)
|
||||||
|
- [Slack AI agent](./slack-ai-agent.md)
|
||||||
|
- [Feishu AI agent](./feishu-ai-agent.md)
|
||||||
|
- [WhatsApp AI agent](./whatsapp-ai-agent.md)
|
||||||
|
- [WeChat AI agent](./wechat-ai-agent.md)
|
||||||
|
- [QQ AI agent](./qq-ai-agent.md)
|
||||||
|
- [Email AI agent](./email-ai-agent.md)
|
||||||
|
- [Mattermost AI agent](./mattermost-ai-agent.md)
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Every channel follows the same pattern:
|
||||||
|
|
||||||
|
1. Get the platform token, login state, webhook, or mailbox credentials.
|
||||||
|
2. Merge the channel snippet into `~/.nanobot/config.json`.
|
||||||
|
3. Prefer pairing for DM-capable channels: omit `allowFrom`, then approve the
|
||||||
|
first DM's pairing code.
|
||||||
|
4. For channels without pairing, such as Email, keep access narrow with
|
||||||
|
`allowFrom` or platform-specific allow lists.
|
||||||
|
5. Check status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Send a test DM, approve the pairing code when prompted, then send the test
|
||||||
|
message again.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Keep the gateway running as a service for always-on chat apps.
|
||||||
|
- Use mention-only group policies before opening a bot to busy channels.
|
||||||
|
- Use one channel at a time while debugging.
|
||||||
|
- Prefer DMs for first tests; pairing only works in DMs, and group chats add
|
||||||
|
permissions and routing behavior.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Prefer pairing or explicit allowlists; do not use `allowFrom: ["*"]` outside
|
||||||
|
an intentional sandbox.
|
||||||
|
- Rotate bot tokens if they are pasted into logs or shared files.
|
||||||
|
- Review file, shell, and web tool access before inviting other users.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If `nanobot channels status` does not show the channel, the config key or
|
||||||
|
optional dependency is likely missing.
|
||||||
|
- If the first DM returns a pairing code, approve it with
|
||||||
|
`/pairing approve <code>` before expecting normal replies.
|
||||||
|
- If messages do not arrive, run `nanobot gateway --verbose` and compare
|
||||||
|
platform credentials, event permissions, and allow lists.
|
||||||
|
- If group replies are unexpected, review that channel's group policy.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Chat Apps](../chat-apps.md)
|
||||||
|
- [Configuration](../configuration.md#channel-settings)
|
||||||
|
- [Pairing](../configuration.md#pairing)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# How to Configure Langfuse Observability for nanobot
|
||||||
|
|
||||||
|
nanobot can trace supported OpenAI-compatible provider calls through Langfuse's
|
||||||
|
OpenAI SDK wrapper.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- Langfuse installed in the same Python environment as nanobot
|
||||||
|
- Langfuse environment variables set before startup
|
||||||
|
- one traced nanobot model call
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use Langfuse when you need observability for model requests, latency, errors,
|
||||||
|
cost, or prompt behavior during development or production operation.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install nanobot and prove the agent works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install Langfuse:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install langfuse
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Set credentials before starting nanobot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LANGFUSE_SECRET_KEY="sk-lf-..."
|
||||||
|
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
|
||||||
|
export LANGFUSE_BASE_URL="https://cloud.langfuse.com"
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:LANGFUSE_SECRET_KEY = "sk-lf-..."
|
||||||
|
$env:LANGFUSE_PUBLIC_KEY = "pk-lf-..."
|
||||||
|
$env:LANGFUSE_BASE_URL = "https://cloud.langfuse.com"
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Langfuse is configured with environment variables, not `config.json`.
|
||||||
|
- Start services from an environment that exports the same variables.
|
||||||
|
- Add tracing after the provider works; it should not be the first setup step.
|
||||||
|
- Native providers that do not use the OpenAI-compatible client path may not
|
||||||
|
produce Langfuse OpenAI-wrapper traces.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Treat Langfuse projects as observability stores for sensitive prompts and
|
||||||
|
outputs.
|
||||||
|
- Use separate projects for personal, staging, and production traffic.
|
||||||
|
- Keep Langfuse keys out of committed service files.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If no traces appear, confirm the service process sees the environment
|
||||||
|
variables.
|
||||||
|
- Confirm the provider path is OpenAI-compatible.
|
||||||
|
- Run one local `nanobot agent -m "Hello!"` call before debugging service logs.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Configuration: Langfuse Observability](../configuration.md#langfuse-observability)
|
||||||
|
- [Provider Cookbook: Langfuse Tracing](../provider-cookbook.md#recipe-langfuse-tracing)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# How to Configure MCP Tools in nanobot
|
||||||
|
|
||||||
|
This guide adds an MCP server to nanobot so the agent can use external tools
|
||||||
|
through the Model Context Protocol.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a working nanobot agent
|
||||||
|
- one MCP server entry in `~/.nanobot/config.json`
|
||||||
|
- a restricted set of MCP tools exposed to the model
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use MCP when the capability you need already exists as an MCP server, or when
|
||||||
|
you want external tools to be managed outside nanobot core.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the MCP server runtime separately. Many examples use `npx`, `uvx`, or a
|
||||||
|
remote HTTP endpoint.
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Add this to `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcpServers": {
|
||||||
|
"filesystem": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
||||||
|
"enabledTools": ["read_file"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart nanobot and ask a question that requires the MCP tool.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Prefer `enabledTools` over exposing every tool by default.
|
||||||
|
- Use `toolTimeout` for slow MCP operations.
|
||||||
|
- Use HTTP MCP only for endpoints you trust.
|
||||||
|
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Stdio MCP starts a local process; review the command before enabling it.
|
||||||
|
- HTTP/SSE MCP uses nanobot's SSRF guard.
|
||||||
|
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||||
|
- Do not place secrets in command arguments when environment variables or
|
||||||
|
headers can be used.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Run the MCP command outside nanobot first.
|
||||||
|
- Start `nanobot gateway --verbose` and inspect tool registration logs.
|
||||||
|
- If an HTTP MCP URL is blocked, check whether it points to loopback or a
|
||||||
|
private address that needs explicit allowlisting.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [MCP tools for AI agents](./mcp-tools-for-ai-agents.md)
|
||||||
|
- [Configuration: MCP](../configuration.md#mcp-model-context-protocol)
|
||||||
|
- [Security](../configuration.md#security)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# How to Configure Model Fallback in nanobot
|
||||||
|
|
||||||
|
Model fallback lets nanobot try a primary model first, then fall back to one or
|
||||||
|
more named presets when the primary provider fails or rate-limits.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- two or more `modelPresets`
|
||||||
|
- a primary `agents.defaults.modelPreset`
|
||||||
|
- an ordered `agents.defaults.fallbackModels` chain
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use fallback when you want better reliability across rate limits, provider
|
||||||
|
outages, local model downtime, or cost-sensitive routing.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify each provider works before adding it as a fallback.
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Merge this shape into `~/.nanobot/config.json` and replace provider/model names
|
||||||
|
with ones you control:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modelPresets": {
|
||||||
|
"fast": {
|
||||||
|
"label": "Fast",
|
||||||
|
"provider": "primary-provider",
|
||||||
|
"model": "primary-model-id",
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"contextWindowTokens": 65536,
|
||||||
|
"temperature": 0.1
|
||||||
|
},
|
||||||
|
"deep": {
|
||||||
|
"label": "Deep",
|
||||||
|
"provider": "fallback-provider",
|
||||||
|
"model": "fallback-model-id",
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"contextWindowTokens": 200000,
|
||||||
|
"temperature": 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"modelPreset": "fast",
|
||||||
|
"fallbackModels": ["deep"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
String entries in `fallbackModels` are preset names, not raw model IDs.
|
||||||
|
Replace the placeholder model IDs with currently supported model IDs from your
|
||||||
|
provider. The [Provider Cookbook](../provider-cookbook.md) has concrete recipes
|
||||||
|
for common providers.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Keep fallback context windows realistic; smaller fallback windows constrain
|
||||||
|
how much context can fit.
|
||||||
|
- Put cheaper or faster fallbacks before expensive ones when acceptable.
|
||||||
|
- Use `/model <preset>` for runtime switching without editing config.
|
||||||
|
- Keep labels human-readable for WebUI model lists.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Different providers may have different data handling policies.
|
||||||
|
- Do not put provider keys directly in shared config files.
|
||||||
|
- Confirm fallback models can safely receive the same prompts and files.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If a fallback never triggers, confirm the primary error is treated as
|
||||||
|
retryable/fallbackable.
|
||||||
|
- If startup fails, check that each fallback string matches a key under
|
||||||
|
`modelPresets`.
|
||||||
|
- If output is truncated after fallback, review `maxTokens` and
|
||||||
|
`contextWindowTokens`.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Providers and Models](../providers.md)
|
||||||
|
- [Provider Cookbook: Fallback Presets](../provider-cookbook.md#recipe-fallback-presets)
|
||||||
|
- [Configuration: Model Fallbacks](../configuration.md#model-fallbacks)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# How to Configure an OpenAI-Compatible Provider in nanobot
|
||||||
|
|
||||||
|
nanobot can call OpenAI-compatible model providers by configuring an `apiBase`,
|
||||||
|
optional `apiKey`, and a model preset that references that provider name.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a custom provider entry
|
||||||
|
- a model preset pointing at that provider
|
||||||
|
- one successful `nanobot agent` run
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use this for local or hosted services that expose OpenAI-compatible endpoints,
|
||||||
|
including internal gateways, local model servers, and provider proxies that are
|
||||||
|
not already named in nanobot.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the endpoint responds before debugging nanobot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS https://api.example.com/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Merge this into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"custom": {
|
||||||
|
"apiKey": "${CUSTOM_API_KEY}",
|
||||||
|
"apiBase": "https://api.example.com/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modelPresets": {
|
||||||
|
"primary": {
|
||||||
|
"label": "Custom",
|
||||||
|
"provider": "custom",
|
||||||
|
"model": "provider-model-name",
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"contextWindowTokens": 65536,
|
||||||
|
"temperature": 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"modelPreset": "primary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Include the version path in `apiBase` when the service expects `/v1`.
|
||||||
|
- Use separate provider names for separate endpoints.
|
||||||
|
- Use a placeholder key such as `EMPTY` only when the endpoint requires a
|
||||||
|
non-empty key but does not validate it.
|
||||||
|
- Leave `apiType` unset for OpenAI-compatible custom endpoints.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Keep provider keys in environment variables.
|
||||||
|
- Treat internal model gateways as sensitive network services.
|
||||||
|
- Do not point nanobot at untrusted proxy endpoints for private workspaces.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If `curl /models` fails, fix the provider endpoint before changing nanobot.
|
||||||
|
- If nanobot says the model is unknown, check the model ID expected by the
|
||||||
|
provider.
|
||||||
|
- If auth fails, confirm whether the provider wants Bearer auth and whether the
|
||||||
|
key is present in the environment that starts nanobot.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Provider Cookbook: Custom OpenAI-Compatible Provider](../provider-cookbook.md#recipe-custom-openai-compatible-provider)
|
||||||
|
- [Providers: Custom OpenAI-Compatible Endpoint](../providers.md#custom-openai-compatible-endpoint)
|
||||||
|
- [OpenAI-Compatible Agent API](./openai-compatible-agent-api.md)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# How to Configure Web Search for a nanobot AI Agent
|
||||||
|
|
||||||
|
nanobot includes built-in web search and web fetch tools. Search uses
|
||||||
|
DuckDuckGo by default and can be configured for API-backed or self-hosted
|
||||||
|
providers.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- web tools enabled in nanobot
|
||||||
|
- one search provider selected in `config.json`
|
||||||
|
- optional web fetch settings for page reading
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Configure web search when the agent needs current information, public web
|
||||||
|
research, source discovery, or page fetching during a task.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Web tools are enabled by default. Configure them only when you want a specific
|
||||||
|
provider, API key, proxy, fetch behavior, or SSRF allowlist.
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Use the default search provider:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"enable": true,
|
||||||
|
"search": {
|
||||||
|
"provider": "duckduckgo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use an API-backed provider:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"provider": "brave",
|
||||||
|
"apiKey": "${BRAVE_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ask a question that requires current information and inspect the tool activity
|
||||||
|
in the WebUI or logs.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Keep API keys in environment variables.
|
||||||
|
- Set `maxResults` when you need fewer or more search results per query.
|
||||||
|
- Set `tools.web.proxy` only to a proxy you trust.
|
||||||
|
- Use `fetch.useJinaReader: false` if you need local page conversion.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Web fetch and HTTP MCP share an SSRF guard.
|
||||||
|
- Private, loopback, link-local, and cloud metadata addresses are blocked by
|
||||||
|
default.
|
||||||
|
- Add `tools.ssrfWhitelist` only for narrow trusted CIDRs.
|
||||||
|
- Do not give public chat users unrestricted web and shell access without
|
||||||
|
review.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If search returns no results, switch provider or check the provider API key.
|
||||||
|
- If fetch is blocked, inspect the target URL and SSRF whitelist.
|
||||||
|
- If a proxy changes network behavior, verify `NO_PROXY` and proxy settings.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Configuration: Web Tools](../configuration.md#web-tools)
|
||||||
|
- [Security](../configuration.md#security)
|
||||||
|
- [WebUI](../webui.md)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# How to Deploy a Long-Running nanobot AI Agent Gateway
|
||||||
|
|
||||||
|
The nanobot gateway is the long-running self-hosted AI agent process that keeps
|
||||||
|
WebUI sessions, chat apps, automations, local triggers, heartbeat jobs, Dream,
|
||||||
|
and WebSocket delivery online.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a verified nanobot config
|
||||||
|
- a gateway process
|
||||||
|
- a service or container deployment path with Docker, systemd, or macOS
|
||||||
|
LaunchAgent
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use this when nanobot should keep running after a single CLI turn. Chat apps,
|
||||||
|
browser sessions, background automations, local triggers, and server-side
|
||||||
|
integrations all depend on a live gateway.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot status
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Run the gateway in the foreground:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
For WebUI background usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot webui --background
|
||||||
|
nanobot gateway status
|
||||||
|
nanobot gateway logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Docker Compose is the most repeatable Linux container path.
|
||||||
|
- systemd user services are useful for Linux user-level gateway deployments.
|
||||||
|
- macOS LaunchAgent keeps the gateway alive after login.
|
||||||
|
- Persist config, workspace, sessions, memory files, channel login state, and
|
||||||
|
generated artifacts.
|
||||||
|
- Restart the gateway after editing `config.json`.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Plan ports before exposing services. Gateway health defaults to `18790`,
|
||||||
|
WebUI/WebSocket defaults to `8765`, and `nanobot serve` defaults to `8900`.
|
||||||
|
- Bind externally only when you have configured tokens or API keys.
|
||||||
|
- Keep chat access control intentional before deploying.
|
||||||
|
- Use Docker or Linux sandboxing when shell tools are enabled for unattended
|
||||||
|
work.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Use the same `--config` and `--workspace` flags for status checks and service
|
||||||
|
startup.
|
||||||
|
- Check logs with `docker compose logs`, `journalctl`, LaunchAgent logs, or
|
||||||
|
`nanobot gateway --verbose`.
|
||||||
|
- If Docker port publishing does not work, confirm the service is not bound only
|
||||||
|
to container loopback.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
|
- [Multiple Instances](../multiple-instances.md)
|
||||||
|
- [Configuration](../configuration.md)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Build a Discord AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to Discord so a Discord user or server channel can
|
||||||
|
talk to your self-hosted AI agent through the nanobot gateway.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- a Discord bot application
|
||||||
|
- Message Content intent enabled
|
||||||
|
- the `discord` channel enabled in nanobot
|
||||||
|
- one direct message or mention test
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working local nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Access to the Discord Developer Portal.
|
||||||
|
- A Discord server where you can invite a bot.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the Discord channel
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable discord
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a Discord application, add a bot, copy the token, and enable
|
||||||
|
`MESSAGE CONTENT INTENT` in the bot settings.
|
||||||
|
|
||||||
|
Merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allowChannels": [],
|
||||||
|
"groupPolicy": "mention",
|
||||||
|
"streaming": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `allowFrom` enables pairing-only mode. A new user should DM the bot
|
||||||
|
first, get a pairing code, and be approved before using the bot in servers.
|
||||||
|
|
||||||
|
Invite the bot with permissions to read history and send messages.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
Send the bot a DM first. It should return a pairing code. Approve it from a
|
||||||
|
trusted local surface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
After approval, mention it in an allowed server channel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@your-bot Hello from Discord
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Keep `groupPolicy` as `mention` for first deployment.
|
||||||
|
- Use `allowChannels` for server channels where the bot should operate.
|
||||||
|
- Prefer pairing-only mode for user access; add `allowFrom` only when you want a
|
||||||
|
static allowlist.
|
||||||
|
- Avoid open group behavior in busy channels until session routing is clear.
|
||||||
|
- Review tool access before inviting the bot into shared servers.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If no messages arrive, confirm Message Content intent is enabled.
|
||||||
|
- If a DM returns a pairing code, approve it before testing normal replies.
|
||||||
|
- If server messages are ignored, check pairing approval, `allowChannels`, and
|
||||||
|
whether the bot was mentioned.
|
||||||
|
- If the bot cannot reply, confirm the invite permissions and channel overrides.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [Pairing](../configuration.md#pairing)
|
||||||
|
- [AI Agent Memory](./ai-agent-memory.md)
|
||||||
|
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Build an Email AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide turns nanobot into an email AI agent that polls IMAP for accepted
|
||||||
|
messages and replies through SMTP.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- a dedicated mailbox for nanobot
|
||||||
|
- IMAP and SMTP credentials in `config.json`
|
||||||
|
- an allowed sender list
|
||||||
|
- a gateway process that polls and replies
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working local nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- A mailbox for the bot.
|
||||||
|
- IMAP and SMTP access. For Gmail, use an app password rather than your account
|
||||||
|
password.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the Email channel
|
||||||
|
|
||||||
|
Merge this snippet into `~/.nanobot/config.json` and replace the addresses and
|
||||||
|
passwords:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"email": {
|
||||||
|
"enabled": true,
|
||||||
|
"consentGranted": true,
|
||||||
|
"imapHost": "imap.gmail.com",
|
||||||
|
"imapPort": 993,
|
||||||
|
"imapUsername": "my-nanobot@gmail.com",
|
||||||
|
"imapPassword": "your-app-password",
|
||||||
|
"smtpHost": "smtp.gmail.com",
|
||||||
|
"smtpPort": 587,
|
||||||
|
"smtpUsername": "my-nanobot@gmail.com",
|
||||||
|
"smtpPassword": "your-app-password",
|
||||||
|
"fromAddress": "my-nanobot@gmail.com",
|
||||||
|
"allowFrom": ["your-real-email@gmail.com"],
|
||||||
|
"autoReplyEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
Send an email from an address in `allowFrom` to the bot mailbox. Keep the
|
||||||
|
gateway running long enough for the polling interval to receive it.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Use a dedicated mailbox, not your primary personal inbox.
|
||||||
|
- Set `consentGranted` to `false` to fully disable mailbox access.
|
||||||
|
- Email does not use DM pairing. Keep `allowFrom` narrow; `["*"]` accepts mail
|
||||||
|
from anyone.
|
||||||
|
- Use environment variables for mailbox passwords.
|
||||||
|
- Enable attachment types only when the agent needs them.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If login fails, confirm IMAP/SMTP access and app-password setup.
|
||||||
|
- If the bot reads but does not reply, check `autoReplyEnabled`, SMTP settings,
|
||||||
|
and allowed sender addresses.
|
||||||
|
- If attachments are missing, review `allowedAttachmentTypes`, size limits, and
|
||||||
|
gateway logs.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [Secure local AI agent](./secure-local-ai-agent.md)
|
||||||
|
- [AI Agent Memory](./ai-agent-memory.md)
|
||||||
|
- [OpenAI-compatible agent API](./openai-compatible-agent-api.md)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Build a Feishu AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to Feishu or Lark through the `feishu` channel. The
|
||||||
|
channel uses a WebSocket long connection, so the first setup does not require a
|
||||||
|
public webhook URL.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- a Feishu/Lark bot app connected to nanobot
|
||||||
|
- the `feishu` channel enabled in `config.json`
|
||||||
|
- one pairing-approved Feishu or Lark user
|
||||||
|
- mention-only group behavior for first deployment
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working local nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- A Feishu or Lark account that can create or approve bot apps.
|
||||||
|
- Permission to run `nanobot gateway` continuously.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the Feishu channel
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable feishu
|
||||||
|
```
|
||||||
|
|
||||||
|
The easiest path is QR login:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels login feishu
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the printed URL or scan the QR code. nanobot writes the generated `appId`,
|
||||||
|
`appSecret`, `domain`, and `enabled` fields into the active config.
|
||||||
|
|
||||||
|
If QR login is unavailable, create a Feishu/Lark app manually and merge this
|
||||||
|
shape into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "cli_xxx",
|
||||||
|
"appSecret": "xxx",
|
||||||
|
"groupPolicy": "mention",
|
||||||
|
"streaming": true,
|
||||||
|
"domain": "feishu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `allowFrom` enables pairing-only mode. A new user should DM the bot,
|
||||||
|
get a pairing code, and be approved before using the bot normally.
|
||||||
|
|
||||||
|
For manual apps, enable the Bot capability, receive-message events, and Long
|
||||||
|
Connection mode. If your app cannot get the `cardkit:card:write` permission,
|
||||||
|
set `"streaming": false`.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
DM the bot first. It should return a pairing code. Approve it from a trusted
|
||||||
|
local surface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
After approval, DM the bot again or mention it in a group chat:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@nanobot Hello from Feishu
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||||
|
static allowlist.
|
||||||
|
- Keep `groupPolicy` as `"mention"` before inviting the bot into busy groups.
|
||||||
|
- Store app secrets through environment variables for deployed services.
|
||||||
|
- Review file, shell, and web tool access before adding more users.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If QR login is unavailable, use manual app setup from the full chat-apps
|
||||||
|
reference.
|
||||||
|
- If streaming cards fail, confirm `cardkit:card:write` or set
|
||||||
|
`"streaming": false`.
|
||||||
|
- If no messages arrive, check Feishu/Lark event permissions, Long Connection
|
||||||
|
mode, and `nanobot gateway --verbose`.
|
||||||
|
- If a first DM returns a pairing code, approve it before testing normal
|
||||||
|
replies.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [Pairing](../configuration.md#pairing)
|
||||||
|
- [AI Agent Memory](./ai-agent-memory.md)
|
||||||
|
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# How to Run a Long-Running AI Agent with nanobot
|
||||||
|
|
||||||
|
nanobot can keep agent work alive across turns through sustained goals,
|
||||||
|
persistent sessions, scheduled automations, local triggers, and a gateway
|
||||||
|
process that stays running.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a working local agent
|
||||||
|
- a persistent chat session
|
||||||
|
- a long-running goal or automation
|
||||||
|
- a gateway process for background delivery
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use this when the task is not a one-shot answer: project work, recurring checks,
|
||||||
|
scheduled summaries, file maintenance, multi-step research, or local triggers
|
||||||
|
from scripts and build jobs.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Start a gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
From the WebUI or a chat session, start a sustained goal:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/goal Review this workspace, identify missing tests, and propose the smallest next fix.
|
||||||
|
```
|
||||||
|
|
||||||
|
For scheduled or trigger-based runs, create the automation from the target chat
|
||||||
|
so nanobot can link it to the correct session and workspace.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Keep the gateway running for chat apps, WebUI sessions, automations, and local
|
||||||
|
triggers.
|
||||||
|
- Use stable session keys or chat sessions for work that should preserve context.
|
||||||
|
- Keep goals bounded and explicit about done-ness.
|
||||||
|
- Review Automations in the WebUI before relying on a schedule.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Treat long-running goals as delegated work with real tool access.
|
||||||
|
- Restrict workspaces and shell execution before scheduling unattended tasks.
|
||||||
|
- Keep chat access narrow so unknown users cannot create goals or automations.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If a goal appears stuck, inspect the active session and gateway logs.
|
||||||
|
- If an automation does not run, check that it is linked to a chat/session and
|
||||||
|
that the gateway is still running.
|
||||||
|
- If a local trigger fails, check the command copied from the WebUI Automations
|
||||||
|
view.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Automations](../automations.md)
|
||||||
|
- [WebUI Automations](../webui.md#automations)
|
||||||
|
- [Chat Commands](../chat-commands.md)
|
||||||
|
- [Memory](../memory.md)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Build a Mattermost AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to Mattermost through the built-in Mattermost
|
||||||
|
channel, using WebSocket events and the Mattermost REST API.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- a Mattermost bot account or token
|
||||||
|
- the `mattermost` channel enabled in nanobot
|
||||||
|
- mention-only group behavior for first deployment
|
||||||
|
- one pairing-approved DM or mention test
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working local nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- A Mattermost server URL.
|
||||||
|
- A bot token or personal access token for the bot account.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the Mattermost channel
|
||||||
|
|
||||||
|
Merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"mattermost": {
|
||||||
|
"enabled": true,
|
||||||
|
"serverUrl": "https://mattermost.example.com",
|
||||||
|
"token": "YOUR_MATTERMOST_TOKEN",
|
||||||
|
"teamId": "YOUR_TEAM_ID",
|
||||||
|
"groupPolicy": "mention",
|
||||||
|
"replyInThread": true,
|
||||||
|
"dm": {
|
||||||
|
"policy": "allowlist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`teamId` scopes the channel to a Mattermost team. Keep `groupPolicy` as
|
||||||
|
`mention` for the first test.
|
||||||
|
|
||||||
|
Mattermost DMs are open by default. Setting `dm.policy` to `"allowlist"` with no
|
||||||
|
`dm.allowFrom` entries makes new DM senders receive a pairing code. Approve the
|
||||||
|
code before using the bot normally.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
DM the bot account. It should return a pairing code. Approve it from a trusted
|
||||||
|
local surface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then DM the bot again, or mention it in a channel where the bot has access:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@nanobot Hello from Mattermost
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Store the Mattermost token in an environment variable for deployed services.
|
||||||
|
- Keep `dm.policy` as `"allowlist"` when you want pairing-based approval.
|
||||||
|
- Use mention-only group behavior before opening the bot to busy channels.
|
||||||
|
- Review file and shell tools before inviting broad channel access.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If startup logs say `serverUrl and token must be configured`, check the
|
||||||
|
camelCase config keys.
|
||||||
|
- If DMs are ignored, review the `dm` policy and pairing approval state.
|
||||||
|
- If channel messages are ignored, confirm the bot is mentioned and belongs to
|
||||||
|
the team/channel.
|
||||||
|
- If thread replies are surprising, review `replyInThread` and
|
||||||
|
`includeThreadContext`.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [Pairing](../configuration.md#pairing)
|
||||||
|
- [Long-running AI Agent](./long-running-ai-agent.md)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# How to Add MCP Tools to an AI Agent with nanobot
|
||||||
|
|
||||||
|
nanobot can connect MCP servers and expose their tools to the agent alongside
|
||||||
|
built-in file, shell, web, cron, image generation, and subagent tools.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a working nanobot agent
|
||||||
|
- one MCP server configured in `config.json`
|
||||||
|
- a restricted set of tools available to the model
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use MCP when a tool already exists as an MCP server, when another application
|
||||||
|
publishes an MCP adapter, or when you want a clean boundary between nanobot and
|
||||||
|
external tool logic.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the MCP server's own runtime separately. For example, many local MCP
|
||||||
|
servers use `npx` or `uvx`.
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Add a stdio MCP server to `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcpServers": {
|
||||||
|
"filesystem": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
||||||
|
"enabledTools": ["read_file"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart nanobot, then ask a question that needs the MCP tool.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Use `enabledTools` to expose only the tools the agent actually needs.
|
||||||
|
- Set `toolTimeout` for slow MCP servers.
|
||||||
|
- Prefer stdio MCP for local tools and HTTP MCP for trusted remote services.
|
||||||
|
- Keep MCP server install/update steps outside nanobot config when possible.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- HTTP/SSE MCP URLs use the same SSRF guard as web fetch.
|
||||||
|
- Local/private HTTP endpoints require an explicit `tools.ssrfWhitelist` entry.
|
||||||
|
- Stdio MCP servers run local processes; review their command and arguments.
|
||||||
|
- Do not pass secrets in command-line args when environment variables or headers
|
||||||
|
are available.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Start `nanobot gateway --verbose` and check MCP startup logs.
|
||||||
|
- Confirm the MCP command works by itself before debugging nanobot.
|
||||||
|
- If an HTTP MCP server is blocked, review the SSRF whitelist and use a narrow
|
||||||
|
host CIDR.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||||
|
- [Configuration: MCP](../configuration.md#mcp-model-context-protocol)
|
||||||
|
- [Security](../configuration.md#security)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# How to Run an OpenAI-Compatible Agent API with nanobot
|
||||||
|
|
||||||
|
nanobot can expose a local OpenAI-compatible endpoint behind
|
||||||
|
`/v1/chat/completions`. This lets existing OpenAI-style clients talk to a
|
||||||
|
tool-using nanobot agent instead of a raw model.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a working nanobot agent
|
||||||
|
- a local API server on `127.0.0.1:8900`
|
||||||
|
- a `/v1/chat/completions` request
|
||||||
|
- optional session isolation with `session_id`
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use this when an existing client, another language, or a separate process
|
||||||
|
already knows how to call an OpenAI-compatible API. Use the Python SDK when you
|
||||||
|
want in-process access to sessions, memory, runtime helpers, and hooks.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot plugins enable api
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Start the API server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Call the chat endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8900/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"session_id": "demo"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Pass `session_id` to isolate users, jobs, or workflows.
|
||||||
|
- Streaming uses Server-Sent Events when `stream` is `true`.
|
||||||
|
- `/v1/models` reports the fixed model surface expected by compatible clients.
|
||||||
|
- File uploads are supported through JSON base64 or multipart form data.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Local `127.0.0.1` usage does not require an API key.
|
||||||
|
- If `api.host` is `0.0.0.0` or `::`, configure `api.apiKey` before startup.
|
||||||
|
- Treat the API as agent access, not just model access: tools and workspace
|
||||||
|
permissions still matter.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If `/v1/chat/completions` fails, test `nanobot agent -m "Hello!"` first.
|
||||||
|
- If remote clients cannot connect, check `api.host`, `api.port`, firewall, and
|
||||||
|
API key configuration.
|
||||||
|
- If sessions mix together, pass unique `session_id` values.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Nanobot OpenAI-Compatible API](../openai-api.md)
|
||||||
|
- [Python SDK](../python-sdk.md)
|
||||||
|
- [Configuration](../configuration.md)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Nanobot Python SDK: Run an AI Agent from Python
|
||||||
|
|
||||||
|
This guide shows when to use the Nanobot Python SDK instead of calling a model
|
||||||
|
directly. The SDK runs the same agent runtime used by the CLI: model routing,
|
||||||
|
tools, workspace access, session history, memory, streaming events, and runtime
|
||||||
|
helpers.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a Python script that creates a `Nanobot`
|
||||||
|
- one agent run from code
|
||||||
|
- an optional streamed run with tool visibility
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use the Python SDK for notebooks, evals, product backends, local scripts,
|
||||||
|
workflow runners, and integrations that need direct access to agent sessions,
|
||||||
|
memory, hooks, runtime state, or structured run results.
|
||||||
|
|
||||||
|
Use the OpenAI-compatible API instead when another language or process should
|
||||||
|
call nanobot over HTTP.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from nanobot import Nanobot
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with Nanobot.from_config() as bot:
|
||||||
|
result = await bot.run("List the top-level files in this workspace.")
|
||||||
|
print(result.content)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Reuse one `Nanobot` instance for related work.
|
||||||
|
- Pass `session_key` when a user, job, or eval case needs persistent history.
|
||||||
|
- Use `bot.stream(...)` when the caller needs live text, tool, or failure
|
||||||
|
events.
|
||||||
|
- Use hooks for audit logs or custom observability.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- The SDK uses the same config, workspace, tools, and secrets as the CLI.
|
||||||
|
- Do not run untrusted prompts with broad file or shell access.
|
||||||
|
- Keep separate config/workspace paths for separate products or tenants.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If SDK code fails, first run `nanobot agent -m "Hello!"` in the same
|
||||||
|
environment.
|
||||||
|
- Print `bot.runtime.workspace` and `bot.runtime.model` to confirm the expected
|
||||||
|
config loaded.
|
||||||
|
- Use explicit `config_path` and `workspace` when scripts run from services.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Nanobot Python SDK](../python-sdk.md)
|
||||||
|
- [OpenAI-Compatible API](../openai-api.md)
|
||||||
|
- [Configuration](../configuration.md)
|
||||||
|
- [Concepts](../concepts.md)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Build a QQ AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to QQ through the official `qq` channel. The
|
||||||
|
official channel uses the botpy SDK and currently focuses on private messages.
|
||||||
|
For QQ group chat and OneBot v11 workflows, use the Napcat section in the full
|
||||||
|
chat-apps reference.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- a QQ bot application
|
||||||
|
- the `qq` channel enabled in nanobot
|
||||||
|
- one pairing-approved QQ private sender
|
||||||
|
- a running nanobot gateway
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working local nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Access to the QQ Open Platform.
|
||||||
|
- A QQ account added to the bot sandbox for testing.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the QQ channel
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable qq
|
||||||
|
```
|
||||||
|
|
||||||
|
In the QQ Open Platform, create a bot application and copy the AppID and
|
||||||
|
AppSecret. Add your QQ account to the sandbox test members, then merge this
|
||||||
|
snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"qq": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "YOUR_APP_ID",
|
||||||
|
"secret": "YOUR_APP_SECRET",
|
||||||
|
"msgFormat": "plain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `allowFrom` enables pairing-only mode. A new private sender should get
|
||||||
|
a pairing code before normal agent access.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
Send the QQ bot a private message from a sandbox account. It should return a
|
||||||
|
pairing code. Approve it from a trusted local surface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Send the message again after approval.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||||
|
static allowlist.
|
||||||
|
- Keep sandbox testing separate from production publishing.
|
||||||
|
- Store QQ AppSecret through environment variables for deployed services.
|
||||||
|
- Use Napcat only when you intentionally need a QQ account bridge and group chat
|
||||||
|
features.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If private messages do not arrive, confirm the sender is in the QQ bot sandbox
|
||||||
|
and the gateway is running.
|
||||||
|
- If output formatting is unreliable, keep `msgFormat` as `"plain"`.
|
||||||
|
- If a first private message returns a pairing code, approve it before testing
|
||||||
|
normal replies.
|
||||||
|
- If you need QQ groups, see the Napcat section in the full chat-apps reference.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [Pairing](../configuration.md#pairing)
|
||||||
|
- [AI Agent Memory](./ai-agent-memory.md)
|
||||||
|
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# How to Secure a Local AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide covers the practical controls to review before letting a nanobot
|
||||||
|
agent access files, shell commands, web fetch, chat apps, or remote users.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a workspace-scoped agent setup
|
||||||
|
- narrow channel access
|
||||||
|
- safer secrets handling
|
||||||
|
- optional shell sandboxing on Linux
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use this before exposing nanobot to teammates, chat apps, public networks, broad
|
||||||
|
web access, or unattended automations.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
Start with workspace restriction:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"restrictToWorkspace": true,
|
||||||
|
"exec": {
|
||||||
|
"enable": true,
|
||||||
|
"sandbox": "bwrap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`bwrap` is Linux-only and requires bubblewrap. On macOS or Windows, keep
|
||||||
|
`restrictToWorkspace` enabled and review shell access carefully.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Use environment variables for provider keys, bot tokens, and mailbox
|
||||||
|
passwords.
|
||||||
|
- Keep one workspace per trust boundary.
|
||||||
|
- Prefer pairing for DM-capable chat apps, use narrow `allowFrom` lists only
|
||||||
|
when static allowlists are intentional, and keep group policy mention-only at
|
||||||
|
first.
|
||||||
|
- Bind WebUI, WebSocket, and API services to localhost unless remote access is
|
||||||
|
intentional.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- `restrictToWorkspace` is an application-level guard, not an OS sandbox.
|
||||||
|
- `tools.exec.enable: false` removes shell execution entirely.
|
||||||
|
- HTTP web fetch and HTTP MCP use SSRF protections by default.
|
||||||
|
- Adding broad `tools.ssrfWhitelist` ranges increases exposure.
|
||||||
|
- `allowFrom: ["*"]` bypasses pairing and means anyone who can reach that
|
||||||
|
channel can talk to the bot.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If a needed file cannot be read, confirm the active workspace path.
|
||||||
|
- If a shell command fails under `bwrap`, check whether the command needs files
|
||||||
|
outside the sandbox.
|
||||||
|
- If local HTTP tools are blocked, review the SSRF whitelist and use a narrow
|
||||||
|
CIDR.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Configuration: Security](../configuration.md#security)
|
||||||
|
- [Pairing](../configuration.md#pairing)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
|
- [Chat Apps](../chat-apps.md)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# How to Run a Self-Hosted AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide sets up nanobot as a self-hosted AI agent runtime on your own
|
||||||
|
machine or server. The result is a gateway process that can serve the WebUI,
|
||||||
|
chat apps, automations, and API integrations.
|
||||||
|
|
||||||
|
## What you will build
|
||||||
|
|
||||||
|
- a nanobot config and workspace under your control
|
||||||
|
- a model provider connected through `config.json`
|
||||||
|
- a long-running `nanobot gateway`
|
||||||
|
- optional browser, chat app, and API access
|
||||||
|
|
||||||
|
## When to use this
|
||||||
|
|
||||||
|
Use this path when you want local or server-side ownership of the agent process,
|
||||||
|
workspace files, memory files, and provider keys. It is also the right path when
|
||||||
|
the agent must keep running after one terminal command finishes.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Complete the CLI check before deploying the gateway. A deployment problem is
|
||||||
|
much easier to debug after the provider and model are known to work.
|
||||||
|
|
||||||
|
## Minimal working example
|
||||||
|
|
||||||
|
For chat apps, automations, and WebSocket delivery, start the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
For the browser surface, use the WebUI launcher instead. It can start and manage
|
||||||
|
the local gateway for you:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot webui
|
||||||
|
```
|
||||||
|
|
||||||
|
Or connect a channel in `~/.nanobot/config.json`, then keep the same gateway
|
||||||
|
process running for messages.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Use Docker, systemd, or a macOS LaunchAgent when the process should survive
|
||||||
|
terminal exits.
|
||||||
|
- Give every deployed instance a distinct config path, workspace path, and port
|
||||||
|
set.
|
||||||
|
- Keep secrets in environment variables and start the service from the same
|
||||||
|
environment.
|
||||||
|
- Use health checks against the gateway or API process, not chat app delivery as
|
||||||
|
the only signal.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Bind local-only services to `127.0.0.1` unless you intentionally expose them.
|
||||||
|
- Set an API key before binding the OpenAI-compatible API to a public interface.
|
||||||
|
- Prefer pairing for DM-capable chat apps, and keep any static `allowFrom`
|
||||||
|
allowlists strict.
|
||||||
|
- Enable `tools.restrictToWorkspace`; on Linux, use the bubblewrap sandbox for
|
||||||
|
shell execution.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Run `nanobot status` with the same `--config` and `--workspace` flags used by
|
||||||
|
the service.
|
||||||
|
- Run `nanobot gateway --verbose` while debugging channel startup.
|
||||||
|
- Check port conflicts if the WebUI, WebSocket channel, or API endpoint fails to
|
||||||
|
bind.
|
||||||
|
|
||||||
|
## Related nanobot docs
|
||||||
|
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
|
- [Multiple Instances](../multiple-instances.md)
|
||||||
|
- [Configuration](../configuration.md)
|
||||||
|
- [Chat Apps](../chat-apps.md)
|
||||||
|
- [OpenAI-Compatible API](../openai-api.md)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Build a Slack AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to Slack through Socket Mode. No public webhook URL
|
||||||
|
is required for the first working setup.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- a Slack app with Socket Mode
|
||||||
|
- a bot token and app-level token
|
||||||
|
- the `slack` channel enabled in nanobot
|
||||||
|
- a DM pairing flow and mention test from an approved Slack user
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Permission to create a Slack app in a workspace.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the Slack channel
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable slack
|
||||||
|
```
|
||||||
|
|
||||||
|
In Slack, create an app, enable Socket Mode, create an app-level token with
|
||||||
|
`connections:write`, add bot scopes, subscribe to bot events, and install the
|
||||||
|
app to your workspace.
|
||||||
|
|
||||||
|
Merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"slack": {
|
||||||
|
"enabled": true,
|
||||||
|
"botToken": "xoxb-...",
|
||||||
|
"appToken": "xapp-...",
|
||||||
|
"groupPolicy": "mention",
|
||||||
|
"dm": {
|
||||||
|
"policy": "allowlist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Slack DMs are open by default. Setting `dm.policy` to `"allowlist"` with no
|
||||||
|
`dm.allowFrom` entries makes new DM senders receive a pairing code. Approve the
|
||||||
|
code before using the bot normally.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
DM the Slack bot directly. It should return a pairing code. Approve it from a
|
||||||
|
trusted local surface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then DM the bot again, or mention it in a channel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@nanobot Hello from Slack
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Keep `groupPolicy` as `mention` unless the bot is intentionally listening to
|
||||||
|
every channel message.
|
||||||
|
- Keep `dm.policy` as `"allowlist"` when you want pairing-based approval.
|
||||||
|
- Use `groupAllowFrom` with allowlist mode for approved channels.
|
||||||
|
- Reinstall the Slack app after changing scopes.
|
||||||
|
- Keep bot and app tokens out of committed config files.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If Socket Mode fails, confirm the app-level token starts with `xapp-`.
|
||||||
|
- If the bot cannot send files, add `files:write`, reinstall the app, and
|
||||||
|
restart nanobot.
|
||||||
|
- If a DM responds normally without pairing, check that `dm.policy` is
|
||||||
|
`"allowlist"`.
|
||||||
|
- If channel messages are ignored, check event subscriptions and group policy.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [Configure web search](./configure-web-search.md)
|
||||||
|
- [Long-running AI Agent](./long-running-ai-agent.md)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Build a Telegram AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to Telegram so a paired Telegram user can message a
|
||||||
|
self-hosted AI agent backed by your normal nanobot config, tools, memory, and
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- a Telegram bot created through BotFather
|
||||||
|
- the `telegram` channel enabled in nanobot
|
||||||
|
- a running nanobot gateway
|
||||||
|
- one pairing-approved Telegram account
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working nanobot CLI reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- A Telegram account.
|
||||||
|
- A bot token from `@BotFather`.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the Telegram channel
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
|
||||||
|
gets a pairing code instead of agent access.
|
||||||
|
|
||||||
|
Telegram uses long polling by default. Webhook mode is available for public
|
||||||
|
HTTPS deployments; start with long polling for the first test.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the gateway running while you test messages.
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
Open Telegram, DM the bot, and send:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hello from Telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
The bot should reply with a pairing code. Approve it from an already trusted
|
||||||
|
surface, such as the local CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Send the message again after approval. The reply should use the same model and
|
||||||
|
workspace as your local CLI check.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||||
|
static allowlist instead of code approval.
|
||||||
|
- Do not use `allowFrom: ["*"]` unless the bot is isolated or intentionally public.
|
||||||
|
- Rotate the BotFather token if it is pasted into logs or shared files.
|
||||||
|
- Review tool access before adding group chats or more users.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If the channel is not listed, run `nanobot plugins enable telegram` again in
|
||||||
|
the same Python environment.
|
||||||
|
- If messages do not arrive, run `nanobot gateway --verbose` and check the bot
|
||||||
|
token.
|
||||||
|
- If a first DM returns a pairing code, that is expected. Approve the code before
|
||||||
|
testing normal agent replies.
|
||||||
|
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [AI Agent Memory](./ai-agent-memory.md)
|
||||||
|
- [Long-running AI Agent](./long-running-ai-agent.md)
|
||||||
|
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Build a WeChat AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to WeChat through the `weixin` channel. The channel
|
||||||
|
uses HTTP long polling with QR-code login through the supported upstream API.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- the `weixin` channel enabled in nanobot
|
||||||
|
- a QR-code login session
|
||||||
|
- one pairing-approved WeChat sender
|
||||||
|
- a running gateway for message delivery
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working local nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- A WeChat account that can complete QR-code login.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the WeChat channel
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"weixin": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `allowFrom` enables pairing-only mode. The first private WeChat message
|
||||||
|
from a new sender gets a pairing code instead of agent access.
|
||||||
|
|
||||||
|
Log in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels login weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--force` if you need to discard saved login state and authenticate again.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
Send a private WeChat message to the bot. It should reply with a pairing code.
|
||||||
|
Approve it from a trusted local surface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Send the message again after approval and watch gateway logs for the sender ID
|
||||||
|
and reply.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||||
|
static allowlist.
|
||||||
|
- Treat saved login state as sensitive account access.
|
||||||
|
- Avoid connecting personal accounts to untrusted workspaces or broad tool
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If login fails, rerun `nanobot channels login weixin --force`.
|
||||||
|
- If a first private message returns a pairing code, that is expected. Approve
|
||||||
|
the code before testing normal agent replies.
|
||||||
|
- If messages are denied without a pairing code, check gateway logs for whether
|
||||||
|
WeChat provided the context token required for nanobot to reply.
|
||||||
|
- If polling disconnects, restart the gateway and check network reachability to
|
||||||
|
the upstream service.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [AI Agent Memory](./ai-agent-memory.md)
|
||||||
|
- [Secure local AI agent](./secure-local-ai-agent.md)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Build a WhatsApp AI Agent with nanobot
|
||||||
|
|
||||||
|
This guide connects nanobot to WhatsApp through the `whatsapp` channel. The
|
||||||
|
channel links as a WhatsApp device and uses the same nanobot agent runtime,
|
||||||
|
tools, memory, and workspace as the CLI and WebUI.
|
||||||
|
|
||||||
|
## What this guide builds
|
||||||
|
|
||||||
|
- WhatsApp optional dependencies installed
|
||||||
|
- a linked WhatsApp device session
|
||||||
|
- the `whatsapp` channel enabled in `config.json`
|
||||||
|
- one pairing-approved WhatsApp sender
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A working local nanobot reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
- A WhatsApp account that can link a new device.
|
||||||
|
- A machine that can keep `nanobot gateway` running.
|
||||||
|
|
||||||
|
## Install nanobot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable the WhatsApp channel
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable whatsapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Link WhatsApp as a device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels login whatsapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Scan the QR code from WhatsApp -> Settings -> Linked Devices.
|
||||||
|
|
||||||
|
Merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"groupPolicy": "mention"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `allowFrom` enables pairing-only mode for private chats. `groupPolicy`
|
||||||
|
defaults to `"open"` in the channel, but `"mention"` is safer for a first
|
||||||
|
deployment.
|
||||||
|
|
||||||
|
## Run nanobot gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot channels status
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a message
|
||||||
|
|
||||||
|
Send the bot a private WhatsApp message. It should return a pairing code.
|
||||||
|
Approve it from a trusted local surface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Send the message again after approval. The reply should use the same model and
|
||||||
|
workspace as your local CLI check.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Treat the WhatsApp session database as account access.
|
||||||
|
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||||
|
static allowlist.
|
||||||
|
- Keep `groupPolicy` as `"mention"` before adding the bot to groups.
|
||||||
|
- Avoid `allowFrom: ["*"]` unless the bot is intentionally public or isolated.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If QR linking fails, rerun `nanobot channels login whatsapp`.
|
||||||
|
- If you are migrating from the old bridge, remove `bridgeUrl` and
|
||||||
|
`bridgeToken`, then re-login.
|
||||||
|
- If a sender appears as a LID instead of a phone number, let nanobot learn the
|
||||||
|
mapping at runtime or use `lidMappings` in the full reference.
|
||||||
|
- If a first private message returns a pairing code, approve it before testing
|
||||||
|
normal replies.
|
||||||
|
|
||||||
|
## Next: memory, automations, MCP tools
|
||||||
|
|
||||||
|
- [Chat Apps reference](../chat-apps.md)
|
||||||
|
- [Pairing](../configuration.md#pairing)
|
||||||
|
- [Secure local AI agent](./secure-local-ai-agent.md)
|
||||||
|
- [Deployment](../deployment.md)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Image Generation
|
# Image Generation
|
||||||
|
|
||||||
nanobot can generate and edit images through the `generate_image` tool. In the WebUI, users can enable **Image Generation** from the composer, choose an aspect ratio, and keep iterating on generated images inside the same chat.
|
nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
|
||||||
|
|
||||||
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
|
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
|
||||||
|
|
||||||
@@ -32,11 +32,9 @@ See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Oll
|
|||||||
|
|
||||||
## WebUI Usage
|
## WebUI Usage
|
||||||
|
|
||||||
In the WebUI composer:
|
1. Open Settings and enable **Image Generation** with a configured provider and model.
|
||||||
|
2. Describe the image or edit you want in chat.
|
||||||
1. Click **Image Generation**.
|
3. Include an aspect ratio or size in the request when the configured defaults are not suitable.
|
||||||
2. Choose an aspect ratio: `Auto`, `1:1`, `3:4`, `9:16`, `4:3`, or `16:9`.
|
|
||||||
3. Describe the image or the edit you want.
|
|
||||||
4. Attach reference images when editing an existing image.
|
4. Attach reference images when editing an existing image.
|
||||||
|
|
||||||
Generated images are rendered as assistant media in the chat. Follow-up prompts such as "make it warmer", "change the background", or "try a 16:9 version" can reuse the most recent generated artifact.
|
Generated images are rendered as assistant media in the chat. Follow-up prompts such as "make it warmer", "change the background", or "try a 16:9 version" can reuse the most recent generated artifact.
|
||||||
|
|||||||
+32
-1
@@ -1,4 +1,8 @@
|
|||||||
# Memory in nanobot
|
# AI Agent Memory in nanobot
|
||||||
|
|
||||||
|
This page explains how nanobot implements long-term AI agent memory: session
|
||||||
|
history, compressed archives, durable knowledge files, Dream consolidation, and
|
||||||
|
Git-backed memory changes.
|
||||||
|
|
||||||
nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic.
|
nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic.
|
||||||
|
|
||||||
@@ -64,6 +68,9 @@ This is why nanobot's memory is not just archival. It is interpretive.
|
|||||||
workspace/
|
workspace/
|
||||||
├── SOUL.md # The bot's long-term voice and communication style
|
├── SOUL.md # The bot's long-term voice and communication style
|
||||||
├── USER.md # Stable knowledge about the user
|
├── USER.md # Stable knowledge about the user
|
||||||
|
├── prompts/
|
||||||
|
│ ├── README.md # Notes for memory guidance files
|
||||||
|
│ └── dream.md # Optional instructions for how Dream organizes memory
|
||||||
└── memory/
|
└── memory/
|
||||||
├── MEMORY.md # Project facts, decisions, and durable context
|
├── MEMORY.md # Project facts, decisions, and durable context
|
||||||
├── history.jsonl # Append-only history summaries
|
├── history.jsonl # Append-only history summaries
|
||||||
@@ -120,6 +127,8 @@ Memory is not hidden behind the curtain. Users can inspect and guide it.
|
|||||||
| `/dream-log <sha>` | Show a specific Dream change |
|
| `/dream-log <sha>` | Show a specific Dream change |
|
||||||
| `/dream-restore` | List recent Dream memory versions |
|
| `/dream-restore` | List recent Dream memory versions |
|
||||||
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
||||||
|
| `/dream-prompt` | Show how Dream is being guided for memory |
|
||||||
|
| `/dream-prompt init` | Create an editable Dream memory guide at `prompts/dream.md` |
|
||||||
|
|
||||||
These commands exist for a reason: automatic memory is powerful, but users should always retain the right to inspect, understand, and restore it.
|
These commands exist for a reason: automatic memory is powerful, but users should always retain the right to inspect, understand, and restore it.
|
||||||
|
|
||||||
@@ -135,6 +144,28 @@ This gives memory a history of its own:
|
|||||||
|
|
||||||
That turns memory from a silent mutation into an auditable process.
|
That turns memory from a silent mutation into an auditable process.
|
||||||
|
|
||||||
|
## Guiding Dream
|
||||||
|
|
||||||
|
Dream decides what to keep, update, or forget using nanobot's built-in memory instructions. Most users can leave this alone.
|
||||||
|
|
||||||
|
If one workspace needs a different memory style, create an editable guide:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/dream-prompt init
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace/prompts/dream.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit that file in plain Markdown. When it has content, Dream follows it for this workspace before reading the latest conversation history. You do not need to paste history into the file; Dream adds the current `## Conversation History` block automatically.
|
||||||
|
|
||||||
|
To return to nanobot's default behavior, delete `prompts/dream.md` or leave it empty.
|
||||||
|
|
||||||
|
Each workspace has its own guide. Changing this file does not affect other nanobot workspaces.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Dream is configured under `agents.defaults.dream`:
|
Dream is configured under `agents.defaults.dream`:
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. w
|
|||||||
**Run instances:**
|
**Run instances:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Check one instance before starting it
|
||||||
|
nanobot status --config ~/.nanobot-telegram/config.json
|
||||||
|
|
||||||
# Instance A - Telegram bot
|
# Instance A - Telegram bot
|
||||||
nanobot gateway --config ~/.nanobot-telegram/config.json
|
nanobot gateway --config ~/.nanobot-telegram/config.json
|
||||||
|
|
||||||
@@ -42,6 +45,9 @@ To open a CLI session against one of these instances locally:
|
|||||||
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
|
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
|
||||||
nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
|
nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
|
||||||
|
|
||||||
|
# Open the browser workbench for a specific instance
|
||||||
|
nanobot webui -c ~/.nanobot-telegram/config.json
|
||||||
|
|
||||||
# Optional one-off workspace override
|
# Optional one-off workspace override
|
||||||
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|
||||||
```
|
```
|
||||||
@@ -94,6 +100,7 @@ The copied base config can keep using the same `modelPresets` and `agents.defaul
|
|||||||
Start separate instances:
|
Start separate instances:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
nanobot status --config ~/.nanobot-telegram/config.json
|
||||||
nanobot gateway --config ~/.nanobot-telegram/config.json
|
nanobot gateway --config ~/.nanobot-telegram/config.json
|
||||||
nanobot gateway --config ~/.nanobot-discord/config.json
|
nanobot gateway --config ~/.nanobot-discord/config.json
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ Without parameters, returns a key config overview:
|
|||||||
my(action="check")
|
my(action="check")
|
||||||
# → max_iterations: 40
|
# → max_iterations: 40
|
||||||
# context_window_tokens: 200000
|
# context_window_tokens: 200000
|
||||||
# model: 'anthropic/claude-sonnet-4-20250514'
|
# model: 'anthropic/claude-sonnet-4-6'
|
||||||
# workspace: PosixPath('/tmp/workspace')
|
# workspace: PosixPath('/tmp/workspace')
|
||||||
# provider_retry_mode: 'standard'
|
# provider_retry_mode: 'standard'
|
||||||
# max_tool_result_chars: 16000
|
# max_tool_result_chars: 16000
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
# OpenAI-Compatible API
|
# Nanobot OpenAI-Compatible API: Run a Local Agent Behind /v1/chat/completions
|
||||||
|
|
||||||
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
|
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install "nanobot-ai[api]"
|
nanobot plugins enable api
|
||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
nanobot serve
|
nanobot serve
|
||||||
```
|
```
|
||||||
|
|||||||
+7
-26
@@ -418,38 +418,19 @@ See [`configuration.md#providers`](./configuration.md#providers) for Bedrock-spe
|
|||||||
|
|
||||||
Some providers do not use API keys in `config.json`.
|
Some providers do not use API keys in `config.json`.
|
||||||
|
|
||||||
|
For OpenAI Codex:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot provider login openai-codex
|
nanobot provider login openai-codex --set-main
|
||||||
nanobot provider login github-copilot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
|
For GitHub Copilot:
|
||||||
|
|
||||||
For OpenAI Codex, add `providers.openai_codex.proxy` only when Codex OAuth/token refresh or Codex API requests must use a proxy:
|
```bash
|
||||||
|
nanobot provider login github-copilot --set-main
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"openai_codex": {
|
|
||||||
"proxy": "http://127.0.0.1:7890"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"modelPresets": {
|
|
||||||
"codex": {
|
|
||||||
"provider": "openai_codex",
|
|
||||||
"model": "gpt-5.1-codex",
|
|
||||||
"reasoningEffort": "high"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"modelPreset": "codex"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you run the login command on a remote/headless machine and open the authorization URL in a local browser, paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted. See [`configuration.md#providers`](./configuration.md#providers) for the full OAuth provider notes.
|
Each command authenticates the selected provider and makes its current default model active. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
|
||||||
|
|
||||||
## Provider Resolution
|
## Provider Resolution
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -1,4 +1,4 @@
|
|||||||
# Python SDK
|
# Nanobot Python SDK: Run an AI Agent from Python
|
||||||
|
|
||||||
Use nanobot as a Python library. The SDK gives you the same agent runtime used
|
Use nanobot as a Python library. The SDK gives you the same agent runtime used
|
||||||
by the CLI, but from code: model routing, tools, workspace access, conversation
|
by the CLI, but from code: model routing, tools, workspace access, conversation
|
||||||
@@ -599,7 +599,8 @@ async with Nanobot.from_config() as bot:
|
|||||||
| `await ingest(session_key, messages, metadata=None, source=None, save=True)` | Import existing transcript messages without running the model. |
|
| `await ingest(session_key, messages, metadata=None, source=None, save=True)` | Import existing transcript messages without running the model. |
|
||||||
| `get(session_key)` | Return a `SessionSnapshot`, or `None` if missing. |
|
| `get(session_key)` | Return a `SessionSnapshot`, or `None` if missing. |
|
||||||
| `list()` | Return compact `SessionInfo` rows. |
|
| `list()` | Return compact `SessionInfo` rows. |
|
||||||
| `export(session_key)` | Return a full `SessionSnapshot` suitable for JSON serialization. |
|
| `export(session_key)` | Return a trusted full `SessionSnapshot`, including model-only runtime context, suitable for JSON serialization. |
|
||||||
|
| `await restore(snapshot, session_key=None, save=True)` | Restore a trusted exported snapshot into an empty session; the returned snapshot is display-safe. |
|
||||||
| `clear(session_key)` | Clear and persist one session. |
|
| `clear(session_key)` | Clear and persist one session. |
|
||||||
| `delete(session_key)` | Delete one session from disk and cache. |
|
| `delete(session_key)` | Delete one session from disk and cache. |
|
||||||
| `flush()` | Flush cached sessions to durable storage. |
|
| `flush()` | Flush cached sessions to durable storage. |
|
||||||
@@ -608,6 +609,11 @@ Ingested messages must include `role` and `content`. Roles may be `user`,
|
|||||||
`assistant`, `tool`, or `system`. Other fields, such as `timestamp`,
|
`assistant`, `tool`, or `system`. Other fields, such as `timestamp`,
|
||||||
`source_session_id`, or `source_date`, are persisted as message metadata.
|
`source_session_id`, or `source_date`, are persisted as message metadata.
|
||||||
|
|
||||||
|
`get()` and snapshots returned by ordinary SDK operations are display-safe and omit
|
||||||
|
model-only runtime context. `export()` is an explicit backup boundary and includes
|
||||||
|
that internal context so `restore()` can preserve the exact model-visible history.
|
||||||
|
Do not expose exported snapshots directly to chat users.
|
||||||
|
|
||||||
### `bot.memory`
|
### `bot.memory`
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|
|||||||
+10
-8
@@ -32,7 +32,7 @@ On Windows PowerShell:
|
|||||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, go straight to [Open the WebUI](#5-open-the-webui).
|
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, go straight to [Open the WebUI](#5-open-the-webui).
|
||||||
|
|
||||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ Initialization creates:
|
|||||||
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API |
|
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API |
|
||||||
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
|
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
|
||||||
|
|
||||||
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values.
|
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values. Use `nanobot onboard --refresh` to do the same refresh without an interactive prompt.
|
||||||
|
|
||||||
## 3. Configure a Provider
|
## 3. Configure a Provider
|
||||||
|
|
||||||
@@ -235,13 +235,13 @@ Read it like this:
|
|||||||
|
|
||||||
## 5. Open the WebUI
|
## 5. Open the WebUI
|
||||||
|
|
||||||
If Quick Start enabled the WebSocket channel, start the gateway:
|
Start the browser workbench:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
|
`nanobot webui` prepares the local WebSocket channel and WebUI bootstrap secret if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal.
|
||||||
|
|
||||||
## 6. Test One CLI Message
|
## 6. Test One CLI Message
|
||||||
|
|
||||||
@@ -278,6 +278,8 @@ Then update ~/.nanobot/config.json to add a model preset named "primary" for my
|
|||||||
Tell me exactly what changed and whether I need to run /restart.
|
Tell me exactly what changed and whether I need to run /restart.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
||||||
|
|
||||||
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||||
|
|
||||||
## 7. Choose Your Next Step
|
## 7. Choose Your Next Step
|
||||||
@@ -288,7 +290,7 @@ Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
|||||||
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
|
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
|
||||||
| Understand provider/model matching | [`providers.md`](./providers.md) |
|
| Understand provider/model matching | [`providers.md`](./providers.md) |
|
||||||
| Open the bundled browser UI | [`webui.md`](./webui.md) |
|
| Open the bundled browser UI | [`webui.md`](./webui.md) |
|
||||||
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [`chat-apps.md`](./chat-apps.md) |
|
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [`chat-apps.md`](./chat-apps.md) |
|
||||||
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
|
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
|
||||||
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
|
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
|
||||||
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
|
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
|
||||||
@@ -329,7 +331,7 @@ nanobot --version
|
|||||||
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -e ".[whatsapp]"
|
nanobot plugins enable whatsapp
|
||||||
```
|
```
|
||||||
|
|
||||||
## First-Run Troubleshooting
|
## First-Run Troubleshooting
|
||||||
@@ -342,6 +344,6 @@ python -m pip install -e ".[whatsapp]"
|
|||||||
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
|
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
|
||||||
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
|
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
|
||||||
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
|
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
|
||||||
| WebUI does not open | Enable the WebSocket channel and open port `8765`, not the gateway health port `18790`. |
|
| WebUI does not open | Run `nanobot webui`; the browser UI uses port `8765`, not the gateway health port `18790`. |
|
||||||
|
|
||||||
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).
|
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Release Archive
|
||||||
|
|
||||||
|
This page keeps release and daily update history outside the README so the project homepage can stay focused on what nanobot is, what it can do, and how to start.
|
||||||
|
|
||||||
|
For tagged releases, see [GitHub Releases](https://github.com/HKUDS/nanobot/releases).
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access.
|
||||||
|
- **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
||||||
|
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
|
||||||
|
- **2026-07-09** 📝 Live file-edit diffs, safer localhost setup, Matrix image fixes.
|
||||||
|
- **2026-07-08** 🔐 Safer WebUI/API setup, onboard refresh, responsive prompt rail.
|
||||||
|
- **2026-07-07** ⌨️ CLI multiline input, steadier slash commands, safer web fetching.
|
||||||
|
- **2026-07-06** 💬 Mattermost channel, Serper search, safer Windows shells.
|
||||||
|
- **2026-07-04** 🔌 MCP reconnects, safer Copilot refresh, Windows shutdown fixes.
|
||||||
|
- **2026-07-03** 🧙 Guided WebUI setup, plugin controls, Claude Sonnet 4.6 default.
|
||||||
|
- **2026-07-02** ⏰ Local triggers with recovery, audit history, WebUI pending status.
|
||||||
|
- **2026-07-01** 🛡️ API keys for remote binds, `$skill` shortcuts, clearer tool errors.
|
||||||
|
- **2026-06-30** 🌐 Provider proxies, Copilot Enterprise, steadier WhatsApp and Weixin.
|
||||||
|
- **2026-06-29** 🧠 Context replay scaled to model windows, without fixed message caps.
|
||||||
|
- **2026-06-28** 🖼️ MCP images, steadier WebUI reconnects, safer tool calls.
|
||||||
|
- **2026-06-27** 🔒 Collision-safe sessions, safer shells, Neonize WhatsApp.
|
||||||
|
- **2026-06-25** 🎛️ Thinking controls, MiMo voice input, opt-in Telegram rich messages.
|
||||||
|
- **2026-06-24** 🌙 Kimi Coding and OpenCode, steadier reasoning and Anthropic tool calls.
|
||||||
|
- **2026-06-22** 🚀 Released **v0.2.2** — **The Durability Release** makes nanobot sturdier for daily agent work: segmented WebUI transcripts, first-class Python SDK runtime controls, automation management, richer search/STT providers, and stronger gateway/session/provider reliability. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2) for details.
|
||||||
|
- **2026-06-21** 🧰 Python SDK runtime controls, optional Keenable key, cleaner run hooks.
|
||||||
|
- **2026-06-20** 💬 Telegram rich messages, safer SDK concurrency, smoother Quick Start.
|
||||||
|
- **2026-06-19** 🔎 Firecrawl app, OpenAI image edits, safer session deletion.
|
||||||
|
- **2026-06-18** 💬 Feishu recovery, Keenable search, Mistral polish, workspace-aware git.
|
||||||
|
- **2026-06-17** 🧠 Default idle auto-compact, clearer `/dream`, macOS installer fixes.
|
||||||
|
- **2026-06-16** 🎯 Fresher goal context, Kimi K2.7 thinking, cleaner API retries.
|
||||||
|
- **2026-06-15** 📱 Mobile WebUI polish, optional file tools, real API usage.
|
||||||
|
- **2026-06-14** 🖼️ Themed cover, partner links, stronger Codex image streaming.
|
||||||
|
- **2026-06-13** 🗓️ Session-bound automations, sturdier WhatsApp, faster WebUI startup.
|
||||||
|
|
||||||
|
- **2026-06-12** 💬 Slack allowlisted channels can require mentions.
|
||||||
|
- **2026-06-11** ✂️ Fenced-code message splitting.
|
||||||
|
- **2026-06-10** 📜 Segmented transcripts, Exa/Bocha search, StepFun/SiliconFlow ASR.
|
||||||
|
- **2026-06-09** 🎙️ Shared voice input, more STT providers, TeX and email polish.
|
||||||
|
- **2026-06-08** 🧮 Token heatmap fix, safer MCP HTTP probing, docs cleanup.
|
||||||
|
- **2026-06-06** 🧰 SDK MCP cleanup, removable OpenAI image defaults.
|
||||||
|
- **2026-06-05** 🖼️ Azure AAD, custom image providers, `/skill`, steadier pairing.
|
||||||
|
- **2026-06-04** 🔌 MCP reconnects, `uv pip` install fallback, QQ pairing.
|
||||||
|
- **2026-06-03** 🧠 Hidden-history recovery, quieter email progress handling.
|
||||||
|
- **2026-06-02** 📬 Email attachments, Napcat QQ, Volcengine search, simpler Dream.
|
||||||
|
- **2026-06-01** 🚀 Released **v0.2.1** — **The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details.
|
||||||
|
- **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.
|
||||||
|
- **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-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-11** 🖥️ NVIDIA NIM support, terminal bot name and icon, streamed reasoning and MiMo toggle clarity.
|
||||||
|
- **2026-05-09** 🖼️ Sharper image replay, BYO web-search keys in Settings, Feishu threads routed cleanly.
|
||||||
|
- **2026-05-08** ✨ Inline chat image, redesigned Settings and keys, Dream memory aligned with visible history.
|
||||||
|
- **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-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
|
||||||
|
- **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-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
|
||||||
|
- **2026-05-01** ☁️ Native AWS Bedrock provider, tighter helper handoffs and scoped session files.
|
||||||
|
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp bridge refresh on source edits.
|
||||||
|
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
|
||||||
|
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
|
||||||
|
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
|
||||||
|
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
|
||||||
|
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
|
||||||
|
- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup.
|
||||||
|
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
|
||||||
|
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
|
||||||
|
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
||||||
|
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
||||||
|
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
||||||
|
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
|
||||||
|
- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
|
||||||
|
- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
|
||||||
|
- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
|
||||||
|
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
|
||||||
|
- **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-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
||||||
|
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
|
||||||
|
- **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-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
||||||
|
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
|
||||||
|
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
|
||||||
|
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
|
||||||
|
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
|
||||||
|
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
|
||||||
|
- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix.
|
||||||
|
- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes.
|
||||||
|
- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks.
|
||||||
|
- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API.
|
||||||
|
- **2026-03-28** 📚 Provider docs refresh; skill template wording fix.
|
||||||
|
- **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details.
|
||||||
|
- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries.
|
||||||
|
- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures.
|
||||||
|
- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured.
|
||||||
|
- **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI.
|
||||||
|
- **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command.
|
||||||
|
- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
|
||||||
|
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
|
||||||
|
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
|
||||||
|
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
|
||||||
|
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
|
||||||
|
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
|
||||||
|
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
|
||||||
|
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
|
||||||
|
- **2026-03-13** 🌐 Multi-provider web search, LangSmith, and broader reliability improvements.
|
||||||
|
- **2026-03-12** 🚀 VolcEngine support, Telegram reply context, `/restart`, and sturdier memory.
|
||||||
|
- **2026-03-11** 🔌 WeCom, Ollama, cleaner discovery, and safer tool behavior.
|
||||||
|
- **2026-03-10** 🧠 Token-based memory, shared retries, and cleaner gateway and Telegram behavior.
|
||||||
|
- **2026-03-09** 💬 Slack thread polish and better Feishu audio compatibility.
|
||||||
|
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
|
||||||
|
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
|
||||||
|
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
|
||||||
|
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
|
||||||
|
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
|
||||||
|
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
|
||||||
|
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
|
||||||
|
- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
|
||||||
|
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
|
||||||
|
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
|
||||||
|
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
|
||||||
|
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
|
||||||
|
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
|
||||||
|
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
|
||||||
|
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
|
||||||
|
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
|
||||||
|
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
|
||||||
|
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
|
||||||
|
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
|
||||||
|
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
|
||||||
|
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
|
||||||
|
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
|
||||||
|
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](./configuration.md#mcp-model-context-protocol) for details.
|
||||||
|
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||||
|
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
||||||
|
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
|
||||||
|
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
||||||
|
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
||||||
|
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](./configuration.md#providers).
|
||||||
|
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
|
||||||
|
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
|
||||||
|
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
|
||||||
|
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
||||||
|
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
|
||||||
|
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
|
||||||
@@ -181,11 +181,11 @@ For the first setup, choose `[Q] Quick Start`. It configures the recommended loc
|
|||||||
4. Paste your API key if the wizard asks for one.
|
4. Paste your API key if the wizard asks for one.
|
||||||
5. Paste the provider base URL if the wizard asks for one.
|
5. Paste the provider base URL if the wizard asks for one.
|
||||||
6. Paste a model ID that provider can run.
|
6. Paste a model ID that provider can run.
|
||||||
7. Confirm that Quick Start should enable the WebSocket channel for the local WebUI.
|
7. Confirm that Quick Start should configure the local WebUI.
|
||||||
8. Set the WebUI password when prompted.
|
8. Set the WebUI password when prompted.
|
||||||
9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
|
9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
|
||||||
|
|
||||||
The recommended path enables `channels.websocket` for the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
|
The recommended path configures the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
|
||||||
|
|
||||||
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
|
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
|
||||||
|
|
||||||
@@ -225,7 +225,6 @@ Merge them into one object:
|
|||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"tokenIssueSecret": "your-webui-password",
|
"tokenIssueSecret": "your-webui-password",
|
||||||
"websocketRequiresToken": true
|
"websocketRequiresToken": true
|
||||||
}
|
}
|
||||||
@@ -288,7 +287,6 @@ If this is a brand-new install and you have not configured anything else yet, re
|
|||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"tokenIssueSecret": "your-webui-password",
|
"tokenIssueSecret": "your-webui-password",
|
||||||
"websocketRequiresToken": true
|
"websocketRequiresToken": true
|
||||||
}
|
}
|
||||||
@@ -317,10 +315,10 @@ It is normal for most providers to say `not set`. Only the provider you selected
|
|||||||
Start the local browser UI:
|
Start the local browser UI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard or the `tokenIssueSecret` value from your manual config.
|
This starts nanobot and opens `http://127.0.0.1:8765` in your browser. Leave the terminal open while you use the WebUI. Enter the WebUI password you set in the wizard if the browser asks for one.
|
||||||
|
|
||||||
Send this first message in the browser:
|
Send this first message in the browser:
|
||||||
|
|
||||||
@@ -337,10 +335,10 @@ Hello! How can I help you today?
|
|||||||
If `nanobot` is not found, run:
|
If `nanobot` is not found, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m nanobot gateway
|
python -m nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `python3 -m nanobot gateway` or `py -m nanobot gateway` if that is the Python command that worked in step 2.
|
Use `python3 -m nanobot webui` or `py -m nanobot webui` if that is the Python command that worked in step 2.
|
||||||
|
|
||||||
Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app.
|
Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app.
|
||||||
|
|
||||||
@@ -369,21 +367,21 @@ Skip these until the first local message works:
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot gateway` open whenever you use the WebUI or a chat app.
|
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot webui` open whenever you use the WebUI. Chat apps use the same gateway service underneath.
|
||||||
|
|
||||||
### Open the Browser UI Again
|
### Open the Browser UI Again
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser.
|
Leave that terminal open; the browser should open automatically.
|
||||||
|
|
||||||
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
|
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
|
||||||
|
|
||||||
If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
|
If `nanobot` is not found, run `python -m nanobot webui`, `python3 -m nanobot webui`, or `py -m nanobot webui`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
|
||||||
|
|
||||||
### Connect a Chat App
|
### Connect a Chat App
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -31,7 +31,7 @@ If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram,
|
|||||||
|
|
||||||
## How to Read `nanobot status`
|
## How to Read `nanobot status`
|
||||||
|
|
||||||
`nanobot status` does not call a model. It only checks whether nanobot can find the default config, default workspace, active model or preset, and provider setup summary.
|
`nanobot status` does not call a model. It only checks whether nanobot can find the selected config, selected workspace, active model or preset, and provider setup summary.
|
||||||
|
|
||||||
The output has this shape:
|
The output has this shape:
|
||||||
|
|
||||||
@@ -90,9 +90,10 @@ Default workspace path:
|
|||||||
~/.nanobot/workspace/
|
~/.nanobot/workspace/
|
||||||
```
|
```
|
||||||
|
|
||||||
`nanobot status` reads the default config. Use explicit paths on commands that support them when debugging multiple instances:
|
`nanobot status` reads the default config unless you pass explicit paths. Use the same `--config` and `--workspace` across status checks and runtime commands when debugging multiple instances:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
nanobot status --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||||
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
|
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
|
||||||
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
|
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||||
```
|
```
|
||||||
@@ -110,10 +111,10 @@ Common config mistakes:
|
|||||||
To refresh missing defaults without overwriting existing settings, run:
|
To refresh missing defaults without overwriting existing settings, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot onboard
|
nanobot onboard --refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
When prompted about overwriting the config, choose the option that keeps current values and merges missing defaults.
|
For an interactive choice between resetting and refreshing, run `nanobot onboard` and choose the option that keeps current values and merges missing defaults.
|
||||||
|
|
||||||
## Provider and Model Problems
|
## Provider and Model Problems
|
||||||
|
|
||||||
@@ -134,7 +135,12 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
|
|||||||
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
|
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
|
||||||
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
|
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
|
||||||
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
||||||
| OAuth provider fails | Run `nanobot provider login openai-codex` or `nanobot provider login github-copilot`, then select the provider explicitly. |
|
| OAuth provider fails | Run `nanobot provider login openai-codex --set-main` or `nanobot provider login github-copilot --set-main`. |
|
||||||
|
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
|
||||||
|
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
|
||||||
|
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
|
||||||
|
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
|
||||||
|
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
|
||||||
|
|
||||||
## Langfuse Problems
|
## Langfuse Problems
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -16,13 +16,13 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs
|
|||||||
|
|
||||||
### 1. Configure
|
### 1. Configure
|
||||||
|
|
||||||
Add to `config.json` under `channels.websocket`:
|
The WebSocket channel is enabled by default. Add only the fields you want to
|
||||||
|
override under `channels.websocket`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"path": "/",
|
"path": "/",
|
||||||
@@ -208,7 +208,7 @@ All fields go under `channels.websocket` in `config.json`.
|
|||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `enabled` | bool | `false` | Enable the WebSocket server. |
|
| `enabled` | bool | `true` | Enable the WebSocket server. Set to `false` only when you intentionally do not want the bundled WebUI/WebSocket surface. |
|
||||||
| `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
|
| `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
|
||||||
| `port` | int | `8765` | Listen port. |
|
| `port` | int | `8765` | Listen port. |
|
||||||
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
|
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
|
||||||
@@ -221,7 +221,7 @@ All fields go under `channels.websocket` in `config.json`.
|
|||||||
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
||||||
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
||||||
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
||||||
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). |
|
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` still issues WebUI REST API tokens for same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret` or `token`. |
|
||||||
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
@@ -266,13 +266,17 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
|
|||||||
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
|
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
|
||||||
4. The token is consumed (single use) and cannot be reused.
|
4. The token is consumed (single use) and cannot be reused.
|
||||||
|
|
||||||
|
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
||||||
|
It returns a separate `api_token` for REST routes to same-machine localhost
|
||||||
|
browser requests, or after the request proves knowledge of `tokenIssueSecret`
|
||||||
|
or the static `token`.
|
||||||
|
|
||||||
### Example setup
|
### Example setup
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"path": "/ws",
|
"path": "/ws",
|
||||||
"tokenIssuePath": "/auth/token",
|
"tokenIssuePath": "/auth/token",
|
||||||
@@ -367,7 +371,6 @@ Outbound `message` events may include a `media` field containing local filesyste
|
|||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"websocketRequiresToken": false,
|
"websocketRequiresToken": false,
|
||||||
@@ -384,7 +387,6 @@ Outbound `message` events may include a `media` field containing local filesyste
|
|||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"token": "my-shared-secret",
|
"token": "my-shared-secret",
|
||||||
"allowFrom": ["alice", "bob"]
|
"allowFrom": ["alice", "bob"]
|
||||||
}
|
}
|
||||||
@@ -400,7 +402,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
|
|||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"path": "/ws",
|
"path": "/ws",
|
||||||
@@ -421,7 +422,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
|
|||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"path": "/chat/ws",
|
"path": "/chat/ws",
|
||||||
"allowFrom": ["*"]
|
"allowFrom": ["*"]
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-44
@@ -1,28 +1,48 @@
|
|||||||
# WebUI
|
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||||
|
|
||||||
The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already
|
<!-- Meta description: Run nanobot from a browser WebUI with persistent chat sessions, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||||
works, when you want a persistent chat workspace, visible agent activity,
|
|
||||||
workspace controls, Apps, Skills, settings, and Automations in one place.
|
The WebUI is nanobot's browser workbench for persistent chat sessions, visible
|
||||||
|
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||||
|
one place.
|
||||||
|
|
||||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||||
the `webui/` source directory when you are changing the frontend itself.
|
the `webui/` source directory when you are changing the frontend itself.
|
||||||
|
|
||||||
## Open the WebUI
|
## Open the WebUI
|
||||||
|
|
||||||
First confirm your provider and model can answer:
|
Use the launcher:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot agent -m "Hello!"
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Then merge the WebSocket channel into your existing `~/.nanobot/config.json`.
|
`nanobot webui` creates the config/workspace when needed, checks provider setup,
|
||||||
Set `tokenIssueSecret` to the password you will enter in the WebUI login form:
|
offers Quick Start when the model provider is not ready, enables the local
|
||||||
|
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
|
||||||
|
one is missing, starts the gateway, and opens the browser. The first-run path
|
||||||
|
binds the WebUI to `127.0.0.1` by default, so it is not available from other
|
||||||
|
devices on your LAN.
|
||||||
|
|
||||||
|
Run it in the background when you do not want to keep a terminal open:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot webui --background
|
||||||
|
```
|
||||||
|
|
||||||
|
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||||
|
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||||
|
|
||||||
|
Manual config still works. Same-machine localhost WebUI access can run without
|
||||||
|
a browser password. Set `tokenIssueSecret` when you intentionally expose the
|
||||||
|
WebUI beyond localhost or want a browser password:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"host": "127.0.0.1",
|
||||||
"tokenIssueSecret": "your-webui-password",
|
"tokenIssueSecret": "your-webui-password",
|
||||||
"websocketRequiresToken": true
|
"websocketRequiresToken": true
|
||||||
}
|
}
|
||||||
@@ -30,27 +50,15 @@ Set `tokenIssueSecret` to the password you will enter in the WebUI login form:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are new to JSON snippets, see
|
The WebUI is served by the WebSocket channel on port `8765` by default. The
|
||||||
[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets).
|
gateway health endpoint, `18790` by default, is not the browser UI.
|
||||||
|
|
||||||
Start the gateway:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
Leave the gateway running and open
|
|
||||||
[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the
|
|
||||||
WebSocket channel on port `8765` by default. The gateway health endpoint,
|
|
||||||
`18790` by default, is not the browser UI.
|
|
||||||
Enter `tokenIssueSecret` when the WebUI asks for a password.
|
|
||||||
|
|
||||||
## What It Is For
|
## What It Is For
|
||||||
|
|
||||||
| Area | Use it for |
|
| Area | Use it for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Chat | Start, switch, search, fork, and delete browser sessions |
|
| Chat | Start, switch, search, fork, and delete browser sessions |
|
||||||
| Agent activity | See thinking, tool calls, file activity, command output, and generated artifacts in context |
|
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
||||||
@@ -69,6 +77,16 @@ without changing the original thread.
|
|||||||
The message timeline shows both user-visible replies and agent activity. Long
|
The message timeline shows both user-visible replies and agent activity. Long
|
||||||
tool or reasoning sections can be expanded when you need the details.
|
tool or reasoning sections can be expanded when you need the details.
|
||||||
|
|
||||||
|
When the agent writes or edits files, the activity item shows the target path,
|
||||||
|
status, changed line counts, and, when available, a unified diff. Use **View
|
||||||
|
diff** to expand the change; large diffs may hide unchanged lines or truncate the
|
||||||
|
inline preview. Use **Open file** from a file edit to open the read-only file
|
||||||
|
preview panel.
|
||||||
|
|
||||||
|
File previews follow the active session access mode. Restricted workspace access
|
||||||
|
previews only files under the selected workspace. Full Access can preview files
|
||||||
|
outside the workspace when that access mode is allowed by the gateway.
|
||||||
|
|
||||||
## Workspace and Access
|
## Workspace and Access
|
||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
Use the workspace picker before starting project-specific work. This gives the
|
||||||
@@ -80,6 +98,10 @@ chat. It does not bypass your gateway, provider, shell sandbox, or operating
|
|||||||
system configuration; it only selects among the capabilities that are already
|
system configuration; it only selects among the capabilities that are already
|
||||||
available to this WebUI session.
|
available to this WebUI session.
|
||||||
|
|
||||||
|
Remote WebUI sessions may reduce access for the current workspace. Selecting a
|
||||||
|
different workspace or enabling Full Access remains limited to local and native
|
||||||
|
clients.
|
||||||
|
|
||||||
## Composer
|
## Composer
|
||||||
|
|
||||||
The composer supports plain messages, image attachments, voice input when
|
The composer supports plain messages, image attachments, voice input when
|
||||||
@@ -93,10 +115,22 @@ for provider setup and output behavior.
|
|||||||
|
|
||||||
## Apps
|
## Apps
|
||||||
|
|
||||||
Open Apps from the sidebar or settings navigation to manage integrations that
|
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||||
nanobot can call from a chat. CLI Apps install local adapters that nanobot runs
|
turn. The default **Ready** view shows only tools that can be used immediately:
|
||||||
on your machine; they do not modify the native apps themselves. MCP presets add
|
|
||||||
predefined MCP server configurations.
|
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||||
|
Installing an adapter does not modify the native desktop or web app it
|
||||||
|
connects to.
|
||||||
|
- **Integrations** are MCP servers. Presets provide known configurations, and
|
||||||
|
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
||||||
|
|
||||||
|
Apps intentionally does not list nanobot runtime support packages such as
|
||||||
|
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||||
|
are not tools that can be attached to a turn with `@`. Manage them from
|
||||||
|
**System**, **Models**, or **Web**. PDF and common Office document readers are
|
||||||
|
included in nanobot and activate automatically when a file is attached. The
|
||||||
|
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
||||||
|
[`cli-reference.md`](./cli-reference.md#optional-features).
|
||||||
|
|
||||||
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
||||||
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
||||||
@@ -104,8 +138,8 @@ extraction tools without requiring an API key. This does not replace nanobot's
|
|||||||
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
|
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
|
||||||
turn needs Firecrawl's richer web data tools.
|
turn needs Firecrawl's richer web data tools.
|
||||||
|
|
||||||
After an App or MCP preset is available, mention it from the composer with `@`
|
After an App or integration is available, mention it from the composer with
|
||||||
to attach that capability to the next message.
|
`@` to attach that tool to the next message.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
@@ -121,6 +155,9 @@ be created from the chat, channel, or session where they are supposed to run so
|
|||||||
nanobot keeps the correct target context. When an automation runs, it normally
|
nanobot keeps the correct target context. When an automation runs, it normally
|
||||||
delivers the result back to that linked chat.
|
delivers the result back to that linked chat.
|
||||||
|
|
||||||
|
For the full automation model, creation flow, trigger CLI usage, and delivery
|
||||||
|
semantics, see [`automations.md`](./automations.md).
|
||||||
|
|
||||||
There are two user-facing automation types:
|
There are two user-facing automation types:
|
||||||
|
|
||||||
- Scheduled automations, created by the agent's cron tool, run at a time,
|
- Scheduled automations, created by the agent's cron tool, run at a time,
|
||||||
@@ -128,19 +165,6 @@ There are two user-facing automation types:
|
|||||||
- Local triggers, created with `/trigger <name>`, run when you call a local
|
- Local triggers, created with `/trigger <name>`, run when you call a local
|
||||||
command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`.
|
command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`.
|
||||||
|
|
||||||
If a GitHub webhook, CI system, or another service should wake nanobot up, keep
|
|
||||||
that webhook/service outside nanobot and have it call the trigger command with
|
|
||||||
the final message.
|
|
||||||
|
|
||||||
Trigger deliveries use the same workspace as the gateway. They survive gateway
|
|
||||||
restarts and are requeued if the process exits before the linked turn completes.
|
|
||||||
If the linked session is already running a turn, the local trigger waits until
|
|
||||||
that session is idle instead of being injected into the active turn. This is an
|
|
||||||
at-least-once local queue, so repeated delivery is possible after an interrupted
|
|
||||||
process. A delivered trigger is recorded as an automation turn in the linked
|
|
||||||
session; if the agent receives it but the turn fails, Automations marks the run
|
|
||||||
failed instead of retrying indefinitely.
|
|
||||||
|
|
||||||
For recurring background checks that should stay quiet unless there is something
|
For recurring background checks that should stay quiet unless there is something
|
||||||
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
||||||
instead of creating a chat automation.
|
instead of creating a chat automation.
|
||||||
@@ -178,6 +202,9 @@ Some settings take effect immediately. Runtime settings that affect the gateway
|
|||||||
or agent process may require a restart; the WebUI shows that requirement next to
|
or agent process may require a restart; the WebUI shows that requirement next to
|
||||||
the relevant control.
|
the relevant control.
|
||||||
|
|
||||||
|
Browser-only display preferences, such as file edit display mode, take effect
|
||||||
|
immediately for the current browser and do not change gateway configuration.
|
||||||
|
|
||||||
## LAN Access
|
## LAN Access
|
||||||
|
|
||||||
To open the WebUI from another device on the same network, bind the WebSocket
|
To open the WebUI from another device on the same network, bind the WebSocket
|
||||||
@@ -187,7 +214,6 @@ channel to all interfaces and set a token or token issue secret:
|
|||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": true,
|
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"tokenIssueSecret": "your-secret-here"
|
"tokenIssueSecret": "your-secret-here"
|
||||||
@@ -201,12 +227,36 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
|||||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||||
form.
|
form.
|
||||||
|
|
||||||
|
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||||
|
install missing nanobot support packages, such as adding a channel dependency,
|
||||||
|
are blocked by default. To let trusted remote administrators change the Python
|
||||||
|
environment through the WebUI, opt in explicitly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"webuiAllowRemotePackageInstall": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this only for a private deployment where every authenticated WebUI user is
|
||||||
|
trusted to change the Python environment that nanobot runs in. If you publish
|
||||||
|
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
||||||
|
as remote access and leave package installs disabled unless that is intentional.
|
||||||
|
|
||||||
|
Optional feature installs use pip's configured package index, including
|
||||||
|
`PIP_INDEX_URL`.
|
||||||
|
|
||||||
|
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||||
|
private, trusted network.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If the page does not open, check these in order:
|
If the page does not open, check these in order:
|
||||||
|
|
||||||
1. `nanobot agent -m "Hello!"` works in the same Python environment.
|
1. `nanobot agent -m "Hello!"` works in the same Python environment.
|
||||||
2. The WebSocket channel is enabled in `~/.nanobot/config.json`.
|
2. `~/.nanobot/config.json` does not explicitly set `channels.websocket.enabled` to `false`.
|
||||||
3. `nanobot gateway` is still running.
|
3. `nanobot gateway` is still running.
|
||||||
4. You are opening port `8765`, not the gateway health port.
|
4. You are opening port `8765`, not the gateway health port.
|
||||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||||
|
|||||||
+32
-30
@@ -4,7 +4,7 @@ Triggered automatically by `python -m build` (and any other hatch-driven build)
|
|||||||
so published wheels and sdists ship a fresh webui without requiring developers
|
so published wheels and sdists ship a fresh webui without requiring developers
|
||||||
to remember `cd webui && bun run build` beforehand.
|
to remember `cd webui && bun run build` beforehand.
|
||||||
|
|
||||||
Behaviour:
|
Behavior:
|
||||||
|
|
||||||
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
|
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
|
||||||
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
|
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
|
||||||
@@ -12,7 +12,7 @@ Behaviour:
|
|||||||
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
|
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
|
||||||
already contains a prebuilt `nanobot/web/dist/`).
|
already contains a prebuilt `nanobot/web/dist/`).
|
||||||
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
|
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
|
||||||
- Skips when `nanobot/web/dist/index.html` already exists, unless
|
- Reuses `nanobot/web/dist/` only when it is already fresh, unless
|
||||||
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
|
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
|
||||||
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
|
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
|
||||||
performs `install` followed by `run build`.
|
performs `install` followed by `run build`.
|
||||||
@@ -21,12 +21,22 @@ Behaviour:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import sys
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
_PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
if str(_PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_webui_build_module() -> ModuleType:
|
||||||
|
from nanobot.webui import build as webui_build
|
||||||
|
|
||||||
|
return webui_build
|
||||||
|
|
||||||
|
|
||||||
class WebUIBuildHook(BuildHookInterface):
|
class WebUIBuildHook(BuildHookInterface):
|
||||||
PLUGIN_NAME = "webui-build"
|
PLUGIN_NAME = "webui-build"
|
||||||
@@ -58,24 +68,32 @@ class WebUIBuildHook(BuildHookInterface):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
webui_build = _load_webui_build_module()
|
||||||
|
status = webui_build.inspect_webui_bundle(source_dir=webui_dir, dist_dir=dist_dir)
|
||||||
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
|
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
|
||||||
if index_html.is_file() and not force:
|
if not status.needs_build and not force:
|
||||||
self.app.display_info(
|
self.app.display_info(
|
||||||
f"[webui-build] reusing existing build at {dist_dir} "
|
f"[webui-build] reusing existing build at {dist_dir} "
|
||||||
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
|
"(already fresh; set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
runner = self._pick_runner()
|
if status.needs_build and not force:
|
||||||
if runner is None:
|
self.app.display_info(
|
||||||
raise RuntimeError(
|
f"[webui-build] {webui_build.describe_webui_bundle_status(status)}"
|
||||||
"[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")
|
try:
|
||||||
self._run([runner, "install"], cwd=webui_dir)
|
webui_build.build_webui_bundle(
|
||||||
self._run([runner, "run", "build"], cwd=webui_dir)
|
source_dir=webui_dir,
|
||||||
|
dist_dir=dist_dir,
|
||||||
|
output=self.app.display_info,
|
||||||
|
)
|
||||||
|
except webui_build.WebUIBuildError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"[webui-build] "
|
||||||
|
f"{exc}. Install `bun` or `npm`, or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
|
||||||
|
) from exc
|
||||||
|
|
||||||
if not index_html.is_file():
|
if not index_html.is_file():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -83,19 +101,3 @@ class WebUIBuildHook(BuildHookInterface):
|
|||||||
"check webui/vite.config.ts outDir."
|
"check webui/vite.config.ts outDir."
|
||||||
)
|
)
|
||||||
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
|
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
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"""Agent core module."""
|
"""Agent core module."""
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext, CompositeHook
|
from nanobot.agent.hook import (
|
||||||
|
AgentHook,
|
||||||
|
AgentHookContext,
|
||||||
|
AgentRunHookContext,
|
||||||
|
AgentTurnHookContext,
|
||||||
|
AgentTurnHookFactory,
|
||||||
|
CompositeHook,
|
||||||
|
)
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
@@ -11,6 +18,8 @@ __all__ = [
|
|||||||
"AgentHook",
|
"AgentHook",
|
||||||
"AgentHookContext",
|
"AgentHookContext",
|
||||||
"AgentRunHookContext",
|
"AgentRunHookContext",
|
||||||
|
"AgentTurnHookContext",
|
||||||
|
"AgentTurnHookFactory",
|
||||||
"AgentLoop",
|
"AgentLoop",
|
||||||
"CompositeHook",
|
"CompositeHook",
|
||||||
"ContextBuilder",
|
"ContextBuilder",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from nanobot.session.manager import Session, SessionManager
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.memory import Consolidator
|
from nanobot.agent.memory import Consolidator
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
class AutoCompact:
|
class AutoCompact:
|
||||||
@@ -62,8 +63,12 @@ class AutoCompact:
|
|||||||
def _is_internal_session(cls, key: str) -> bool:
|
def _is_internal_session(cls, key: str) -> bool:
|
||||||
return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
|
return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
|
||||||
|
|
||||||
def check_expired(self, schedule_background: Callable[[Coroutine], None],
|
def check_expired(
|
||||||
active_session_keys: Collection[str] = ()) -> None:
|
self,
|
||||||
|
schedule_background: Callable[[Coroutine], None],
|
||||||
|
resolve_runtime: Callable[[], LLMRuntime],
|
||||||
|
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."""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
for info in self.sessions.list_sessions():
|
for info in self.sessions.list_sessions():
|
||||||
@@ -74,16 +79,19 @@ class AutoCompact:
|
|||||||
continue
|
continue
|
||||||
updated_at = info.get("updated_at")
|
updated_at = info.get("updated_at")
|
||||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||||
|
runtime = resolve_runtime()
|
||||||
self._archiving.add(key)
|
self._archiving.add(key)
|
||||||
schedule_background(self._archive(key))
|
schedule_background(self._archive(key, runtime=runtime))
|
||||||
|
|
||||||
async def _archive(self, key: str) -> None:
|
async def _archive(self, key: str, *, runtime: LLMRuntime) -> None:
|
||||||
if self._is_internal_session(key):
|
if self._is_internal_session(key):
|
||||||
self._archiving.discard(key)
|
self._archiving.discard(key)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
summary = await self.consolidator.compact_idle_session(
|
summary = await self.consolidator.compact_idle_session(
|
||||||
key, self._RECENT_SUFFIX_MESSAGES,
|
key,
|
||||||
|
runtime=runtime,
|
||||||
|
max_suffix=self._RECENT_SUFFIX_MESSAGES,
|
||||||
)
|
)
|
||||||
if summary and summary != "(nothing)":
|
if summary and summary != "(nothing)":
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class AutomationTurnCoordinator:
|
|||||||
return await future
|
return await future
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
except AutomationTurnError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
|
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
|
||||||
finally:
|
finally:
|
||||||
@@ -118,6 +120,8 @@ class AutomationTurnCoordinator:
|
|||||||
if future is None or future.done():
|
if future is None or future.done():
|
||||||
return
|
return
|
||||||
if error is not None:
|
if error is not None:
|
||||||
|
if isinstance(error, asyncio.CancelledError):
|
||||||
|
error = AutomationTurnError(str(error) or error.__class__.__name__)
|
||||||
future.set_exception(error)
|
future.set_exception(error)
|
||||||
else:
|
else:
|
||||||
future.set_result(response)
|
future.set_result(response)
|
||||||
|
|||||||
+29
-64
@@ -12,9 +12,14 @@ from nanobot.agent.tools import mcp as mcp_tools
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.cli import utils as cli_app_utils
|
from nanobot.apps.cli import utils as cli_app_utils
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.session.goal_state import goal_state_runtime_lines
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_END,
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
|
RUNTIME_CONTEXT_TAG,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
append_runtime_context,
|
||||||
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
current_time_str,
|
|
||||||
detect_image_mime,
|
detect_image_mime,
|
||||||
load_bundled_template,
|
load_bundled_template,
|
||||||
truncate_text_to_tokens,
|
truncate_text_to_tokens,
|
||||||
@@ -27,23 +32,14 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
|
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:
|
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||||
await mcp_tools.connect_missing_servers(state, tools)
|
await mcp_tools.connect_missing_servers(state, tools)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_mcp(state: Any) -> None:
|
||||||
|
await mcp_tools.close_mcp_servers(state)
|
||||||
|
|
||||||
|
|
||||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||||
return await mcp_tools.handle_runtime_control(state, msg, tools)
|
return await mcp_tools.handle_runtime_control(state, msg, tools)
|
||||||
|
|
||||||
@@ -52,10 +48,10 @@ 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"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
||||||
|
|
||||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@@ -131,28 +127,14 @@ class ContextBuilder:
|
|||||||
channel=channel or "",
|
channel=channel or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_runtime_context(
|
|
||||||
channel: str | None,
|
|
||||||
chat_id: str | None,
|
|
||||||
timezone: str | None = None,
|
|
||||||
sender_id: str | None = None,
|
|
||||||
supplemental_lines: Sequence[str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Build untrusted runtime metadata block appended after user content."""
|
|
||||||
lines = [f"Current Time: {current_time_str(timezone)}"]
|
|
||||||
if channel and chat_id:
|
|
||||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
|
||||||
if sender_id:
|
|
||||||
lines += [f"Sender ID: {sender_id}"]
|
|
||||||
if supplemental_lines:
|
|
||||||
lines.extend(supplemental_lines)
|
|
||||||
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||||
if isinstance(left, str) and isinstance(right, str):
|
if isinstance(left, str) and isinstance(right, str):
|
||||||
return f"{left}\n\n{right}" if left else right
|
if not left:
|
||||||
|
return right
|
||||||
|
if not right:
|
||||||
|
return left
|
||||||
|
return f"{left}\n\n{right}"
|
||||||
|
|
||||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
@@ -196,41 +178,17 @@ 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,
|
||||||
current_runtime_lines: Sequence[str] | None = None,
|
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
runtime_state: Any | None = None,
|
|
||||||
inbound_message: Any | None = None,
|
|
||||||
skip_runtime_lines: bool = False,
|
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: 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."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
extra = [
|
|
||||||
*goal_state_runtime_lines(session_metadata),
|
|
||||||
]
|
|
||||||
if runtime_state is not None and inbound_message is not None:
|
|
||||||
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
|
|
||||||
if current_runtime_lines:
|
|
||||||
extra.extend(line for line in current_runtime_lines if line)
|
|
||||||
runtime_ctx = self._build_runtime_context(
|
|
||||||
channel,
|
|
||||||
chat_id,
|
|
||||||
self.timezone,
|
|
||||||
sender_id=sender_id,
|
|
||||||
supplemental_lines=extra or None,
|
|
||||||
)
|
|
||||||
user_content = self._build_user_content(current_message, media)
|
user_content = self._build_user_content(current_message, media)
|
||||||
|
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
||||||
# Merge runtime context and user content into a single user message
|
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
||||||
# to avoid consecutive same-role messages that some providers reject.
|
|
||||||
# Runtime context is appended to keep the user-content prefix stable
|
|
||||||
# for prompt-cache hits (the context changes every turn due to time).
|
|
||||||
if isinstance(user_content, str):
|
|
||||||
merged = f"{user_content}\n\n{runtime_ctx}"
|
|
||||||
else:
|
|
||||||
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@@ -249,9 +207,16 @@ class ContextBuilder:
|
|||||||
if messages[-1].get("role") == current_role:
|
if messages[-1].get("role") == current_role:
|
||||||
last = dict(messages[-1])
|
last = dict(messages[-1])
|
||||||
last["content"] = self._merge_message_content(last.get("content"), merged)
|
last["content"] = self._merge_message_content(last.get("content"), merged)
|
||||||
|
if current_role == "user" and runtime_context_meta is not None:
|
||||||
|
internal_meta = dict(last.get("_meta") or {})
|
||||||
|
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = runtime_context_meta
|
||||||
|
last["_meta"] = internal_meta
|
||||||
messages[-1] = last
|
messages[-1] = last
|
||||||
return messages
|
return messages
|
||||||
messages.append({"role": current_role, "content": merged})
|
current = {"role": current_role, "content": merged}
|
||||||
|
if current_role == "user" and runtime_context_meta is not None:
|
||||||
|
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
|
||||||
|
messages.append(current)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ class ContextGovernor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _summary_for(message: dict[str, Any]) -> str:
|
def _summary_for(message: dict[str, Any]) -> str:
|
||||||
name = message.get("name", "tool")
|
name = message.get("name", "tool")
|
||||||
return f"[{name} result omitted from context]"
|
return f"[Prior {name} result compacted to fit context; the tool call already completed.]"
|
||||||
|
|
||||||
def _legal_history_tail(
|
def _legal_history_tail(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Turn-local permission for explicit sustained-goal mutations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
_GOAL_MUTATION_ALLOWED: ContextVar[bool] = ContextVar(
|
||||||
|
"nanobot_goal_mutation_allowed",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def goal_mutation_allowed() -> bool:
|
||||||
|
return _GOAL_MUTATION_ALLOWED.get()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_goal_mutation_permission() -> None:
|
||||||
|
_GOAL_MUTATION_ALLOWED.set(False)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def goal_mutation_permission(allowed: bool):
|
||||||
|
"""Bind goal permission for one agent-run or direct tool execution scope."""
|
||||||
|
token = _GOAL_MUTATION_ALLOWED.set(allowed)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_GOAL_MUTATION_ALLOWED.reset(token)
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -44,6 +46,20 @@ class AgentRunHookContext:
|
|||||||
exception: BaseException | None = None
|
exception: BaseException | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AgentTurnHookContext:
|
||||||
|
"""Turn-local inputs available when constructing per-turn hooks."""
|
||||||
|
|
||||||
|
on_progress: Callable[..., Awaitable[None]] | None = None
|
||||||
|
workspace: Path | None = None
|
||||||
|
channel: str = "cli"
|
||||||
|
chat_id: str = "direct"
|
||||||
|
message_id: str | None = None
|
||||||
|
session_key: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
ephemeral: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AgentHook:
|
class AgentHook:
|
||||||
"""Minimal lifecycle surface for shared runner customization."""
|
"""Minimal lifecycle surface for shared runner customization."""
|
||||||
|
|
||||||
@@ -77,6 +93,35 @@ class AgentHook:
|
|||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def before_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def after_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
result: Any,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def on_execute_tool_error(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
error: Any,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -95,6 +140,9 @@ class AgentHook:
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
AgentTurnHookFactory = Callable[[AgentTurnHookContext], AgentHook | None]
|
||||||
|
|
||||||
|
|
||||||
class CompositeHook(AgentHook):
|
class CompositeHook(AgentHook):
|
||||||
"""Fan-out hook that delegates to an ordered list of hooks.
|
"""Fan-out hook that delegates to an ordered list of hooks.
|
||||||
|
|
||||||
@@ -147,6 +195,49 @@ class CompositeHook(AgentHook):
|
|||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
await self._for_each_hook_safe("before_execute_tools", context)
|
await self._for_each_hook_safe("before_execute_tools", context)
|
||||||
|
|
||||||
|
async def before_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
) -> None:
|
||||||
|
await self._for_each_hook_safe("before_execute_tool", context, tool_call, tool, params)
|
||||||
|
|
||||||
|
async def after_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
result: Any,
|
||||||
|
) -> None:
|
||||||
|
await self._for_each_hook_safe(
|
||||||
|
"after_execute_tool",
|
||||||
|
context,
|
||||||
|
tool_call,
|
||||||
|
tool,
|
||||||
|
params,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_execute_tool_error(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
error: Any,
|
||||||
|
) -> None:
|
||||||
|
await self._for_each_hook_safe(
|
||||||
|
"on_execute_tool_error",
|
||||||
|
context,
|
||||||
|
tool_call,
|
||||||
|
tool,
|
||||||
|
params,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||||
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
|
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Concrete agent hook implementations."""
|
||||||
|
|
||||||
|
from nanobot.agent.hooks.file_edit_activity import (
|
||||||
|
FileEditActivityHook,
|
||||||
|
create_file_edit_activity_hook,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FileEditActivityHook",
|
||||||
|
"create_file_edit_activity_hook",
|
||||||
|
]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Agent hook that observes file-editing tools and emits file-edit activity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.agent.hook import (
|
||||||
|
AgentHook,
|
||||||
|
AgentHookContext,
|
||||||
|
AgentRunHookContext,
|
||||||
|
AgentTurnHookContext,
|
||||||
|
)
|
||||||
|
from nanobot.providers.base import ToolCallRequest
|
||||||
|
from nanobot.utils.file_edit_events import (
|
||||||
|
FileEditTracker,
|
||||||
|
build_file_edit_end_event,
|
||||||
|
build_file_edit_error_event,
|
||||||
|
build_file_edit_start_event,
|
||||||
|
prepare_file_edit_trackers,
|
||||||
|
)
|
||||||
|
from nanobot.utils.progress_events import (
|
||||||
|
invoke_file_edit_progress,
|
||||||
|
on_progress_accepts_file_edit_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FileEditActivityHook(AgentHook):
|
||||||
|
"""Translate file-editing tool lifecycle events into WebUI progress events."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
on_progress: Callable[..., Awaitable[None]] | None,
|
||||||
|
workspace: Path | None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._on_progress = (
|
||||||
|
on_progress
|
||||||
|
if on_progress is not None and on_progress_accepts_file_edit_events(on_progress)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._workspace = workspace
|
||||||
|
self._trackers_by_call: dict[str, list[FileEditTracker]] = {}
|
||||||
|
|
||||||
|
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||||
|
self._trackers_by_call.clear()
|
||||||
|
|
||||||
|
async def before_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
) -> None:
|
||||||
|
if self._on_progress is None or not isinstance(params, dict):
|
||||||
|
return
|
||||||
|
trackers = prepare_file_edit_trackers(
|
||||||
|
call_id=tool_call.id,
|
||||||
|
tool_name=tool_call.name,
|
||||||
|
tool=tool,
|
||||||
|
workspace=self._workspace,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
if not trackers:
|
||||||
|
return
|
||||||
|
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
|
||||||
|
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers])
|
||||||
|
|
||||||
|
async def after_execute_tool(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
result: Any,
|
||||||
|
) -> None:
|
||||||
|
key = self._tool_call_key(tool_call)
|
||||||
|
trackers = self._trackers_by_call.get(key, [])
|
||||||
|
if trackers:
|
||||||
|
await self._emit([build_file_edit_end_event(tracker) for tracker in trackers])
|
||||||
|
self._trackers_by_call.pop(key, None)
|
||||||
|
|
||||||
|
async def on_execute_tool_error(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
tool: Any,
|
||||||
|
params: Any,
|
||||||
|
error: Any,
|
||||||
|
) -> None:
|
||||||
|
key = self._tool_call_key(tool_call)
|
||||||
|
trackers = self._trackers_by_call.get(key, [])
|
||||||
|
if trackers:
|
||||||
|
await self._emit([
|
||||||
|
build_file_edit_error_event(tracker, str(error)) for tracker in trackers
|
||||||
|
])
|
||||||
|
self._trackers_by_call.pop(key, None)
|
||||||
|
|
||||||
|
async def on_finally(self, context: AgentRunHookContext) -> None:
|
||||||
|
if context.stop_reason != "cancelled" or not self._trackers_by_call:
|
||||||
|
return
|
||||||
|
trackers = [
|
||||||
|
tracker
|
||||||
|
for trackers in self._trackers_by_call.values()
|
||||||
|
for tracker in trackers
|
||||||
|
]
|
||||||
|
self._trackers_by_call.clear()
|
||||||
|
await self._emit([
|
||||||
|
build_file_edit_error_event(
|
||||||
|
tracker,
|
||||||
|
"Task interrupted before this tool finished.",
|
||||||
|
)
|
||||||
|
for tracker in trackers
|
||||||
|
])
|
||||||
|
|
||||||
|
async def _emit(self, events: list[dict[str, Any]]) -> None:
|
||||||
|
if self._on_progress is not None:
|
||||||
|
await invoke_file_edit_progress(self._on_progress, events)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tool_call_key(tool_call: ToolCallRequest) -> str:
|
||||||
|
call_id = getattr(tool_call, "id", "") or ""
|
||||||
|
return f"{call_id}|{tool_call.name}" if call_id else f"{id(tool_call)}|{tool_call.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_file_edit_activity_hook(context: AgentTurnHookContext) -> AgentHook | None:
|
||||||
|
"""Create the default file-edit observer for one agent turn."""
|
||||||
|
if context.on_progress is None:
|
||||||
|
return None
|
||||||
|
return FileEditActivityHook(
|
||||||
|
on_progress=context.on_progress,
|
||||||
|
workspace=context.workspace,
|
||||||
|
)
|
||||||
+293
-211
@@ -6,7 +6,8 @@ import asyncio
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from contextlib import AsyncExitStack, nullcontext, suppress
|
from collections.abc import Mapping
|
||||||
|
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from functools import partial
|
from functools import partial
|
||||||
@@ -21,9 +22,9 @@ from nanobot.agent.autocompact import AutoCompact
|
|||||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.cron_turns import CronTurnCoordinator
|
from nanobot.agent.cron_turns import CronTurnCoordinator
|
||||||
from nanobot.agent.hook import AgentHook, CompositeHook
|
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
|
||||||
from nanobot.agent.memory import Consolidator
|
from nanobot.agent.memory import Consolidator
|
||||||
from nanobot.agent.progress_hook import AgentProgressHook
|
from nanobot.agent.model_runtime import ModelRuntimeResolver
|
||||||
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.context import RequestContext, bind_request_context, reset_request_context
|
||||||
@@ -31,6 +32,7 @@ from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, res
|
|||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
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.agent.turn_hooks import AgentTurnHookSpec, build_agent_turn_hook
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
RetryWaitEvent,
|
RetryWaitEvent,
|
||||||
@@ -50,6 +52,14 @@ 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.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
RuntimeContextProvider,
|
||||||
|
append_runtime_context,
|
||||||
|
resolve_runtime_context,
|
||||||
|
)
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WorkspaceScopeResolver,
|
WorkspaceScopeResolver,
|
||||||
bind_workspace_scope,
|
bind_workspace_scope,
|
||||||
@@ -63,7 +73,7 @@ from nanobot.session.goal_state import (
|
|||||||
sustained_goal_active,
|
sustained_goal_active,
|
||||||
)
|
)
|
||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||||
from nanobot.session.manager import (
|
from nanobot.session.manager import (
|
||||||
Session,
|
Session,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
@@ -73,13 +83,13 @@ from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
|||||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
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.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
from nanobot.utils.runtime import (
|
from nanobot.utils.runtime import (
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.mcp import MCPConnection
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
ChannelsConfig,
|
ChannelsConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
@@ -113,10 +123,14 @@ class TurnContext:
|
|||||||
session_key: str
|
session_key: str
|
||||||
state: TurnState
|
state: TurnState
|
||||||
turn_id: str
|
turn_id: str
|
||||||
|
runtime: LLMRuntime
|
||||||
|
original_user_text: str | None = None
|
||||||
session: Session | None = None
|
session: Session | None = None
|
||||||
|
|
||||||
history: list[dict[str, Any]] = field(default_factory=list)
|
history: list[dict[str, Any]] = field(default_factory=list)
|
||||||
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
request_context: RequestContext | None = None
|
||||||
|
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
||||||
|
|
||||||
final_content: str | None = None
|
final_content: str | None = None
|
||||||
tools_used: list[str] = field(default_factory=list)
|
tools_used: list[str] = field(default_factory=list)
|
||||||
@@ -141,6 +155,8 @@ class TurnContext:
|
|||||||
ephemeral: bool = False
|
ephemeral: bool = False
|
||||||
run_extra_hooks_for_ephemeral: bool = False
|
run_extra_hooks_for_ephemeral: bool = False
|
||||||
hooks: list[AgentHook] = field(default_factory=list)
|
hooks: list[AgentHook] = field(default_factory=list)
|
||||||
|
hook_factories: list[AgentTurnHookFactory] = field(default_factory=list)
|
||||||
|
turn_scopes: list[AbstractContextManager[Any]] = field(default_factory=list)
|
||||||
tools: ToolRegistry | None = None
|
tools: ToolRegistry | None = None
|
||||||
|
|
||||||
turn_wall_started_at: float = field(default_factory=time.time)
|
turn_wall_started_at: float = field(default_factory=time.time)
|
||||||
@@ -170,10 +186,49 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
return self.tools.tool_names
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider(self) -> LLMProvider:
|
||||||
|
"""Provider selected for future turn admissions."""
|
||||||
|
return self.runtime_resolver.runtime.provider
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
"""Model selected for future turn admissions."""
|
||||||
|
return self.runtime_resolver.runtime.model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context_window_tokens(self) -> int:
|
||||||
|
"""Context limit selected for future turn admissions."""
|
||||||
|
return self.runtime_resolver.runtime.context_window_tokens
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
|
||||||
|
"""Configured model presets exposed for selection and display."""
|
||||||
|
return self.runtime_resolver.model_presets
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_preset(self) -> str | None:
|
||||||
|
return self.runtime_resolver.model_preset
|
||||||
|
|
||||||
|
@model_preset.setter
|
||||||
|
def model_preset(self, name: str | None) -> None:
|
||||||
|
self.set_model_preset(name)
|
||||||
|
|
||||||
def llm_runtime(self) -> LLMRuntime:
|
def llm_runtime(self) -> LLMRuntime:
|
||||||
"""Return the current provider/model pair owned by this loop."""
|
"""Resolve the immutable default used to admit the next turn."""
|
||||||
self._refresh_provider_snapshot()
|
previous = self.runtime_resolver.runtime
|
||||||
return LLMRuntime(self.provider, self.model)
|
try:
|
||||||
|
runtime = self.runtime_resolver.current(refresh=True)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to refresh model runtime")
|
||||||
|
return previous
|
||||||
|
if (
|
||||||
|
runtime.model != previous.model
|
||||||
|
or runtime.model_preset != previous.model_preset
|
||||||
|
or runtime.snapshot_signature != previous.snapshot_signature
|
||||||
|
):
|
||||||
|
self._publish_runtime_selection(runtime)
|
||||||
|
return runtime
|
||||||
|
|
||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
@@ -214,6 +269,7 @@ class AgentLoop:
|
|||||||
session_ttl_minutes: int = 0,
|
session_ttl_minutes: int = 0,
|
||||||
consolidation_ratio: float = 0.5,
|
consolidation_ratio: float = 0.5,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
disabled_skills: list[str] | None = None,
|
disabled_skills: list[str] | None = None,
|
||||||
tools_config: ToolsConfig | None = None,
|
tools_config: ToolsConfig | None = None,
|
||||||
@@ -238,22 +294,29 @@ class AgentLoop:
|
|||||||
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
||||||
self.channels_config = channels_config
|
self.channels_config = channels_config
|
||||||
self.restart_mode = restart_mode
|
self.restart_mode = restart_mode
|
||||||
self.provider = provider
|
|
||||||
self._provider_snapshot_loader = provider_snapshot_loader
|
|
||||||
self._preset_snapshot_loader = preset_snapshot_loader
|
|
||||||
self._runtime_model_publisher = runtime_model_publisher
|
self._runtime_model_publisher = runtime_model_publisher
|
||||||
self._provider_signature = provider_signature
|
|
||||||
self._default_selection_signature = preset_helpers.default_selection_signature(provider_signature)
|
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.model = model or provider.get_default_model()
|
initial_model = model or provider.get_default_model()
|
||||||
self.max_iterations = (
|
self.max_iterations = (
|
||||||
max_iterations if max_iterations is not None else defaults.max_tool_iterations
|
max_iterations if max_iterations is not None else defaults.max_tool_iterations
|
||||||
)
|
)
|
||||||
self.context_window_tokens = (
|
initial_context_window = (
|
||||||
context_window_tokens
|
context_window_tokens
|
||||||
if context_window_tokens is not None
|
if context_window_tokens is not None
|
||||||
else defaults.context_window_tokens
|
else defaults.context_window_tokens
|
||||||
)
|
)
|
||||||
|
configured_presets = model_presets or {}
|
||||||
|
self.runtime_resolver = ModelRuntimeResolver(
|
||||||
|
LLMRuntime.capture(
|
||||||
|
provider,
|
||||||
|
initial_model,
|
||||||
|
context_window_tokens=initial_context_window,
|
||||||
|
snapshot_signature=provider_signature,
|
||||||
|
),
|
||||||
|
model_presets=configured_presets,
|
||||||
|
provider_snapshot_loader=provider_snapshot_loader,
|
||||||
|
preset_snapshot_loader=preset_snapshot_loader,
|
||||||
|
)
|
||||||
self.context_block_limit = context_block_limit
|
self.context_block_limit = context_block_limit
|
||||||
self.max_tool_result_chars = (
|
self.max_tool_result_chars = (
|
||||||
max_tool_result_chars
|
max_tool_result_chars
|
||||||
@@ -284,6 +347,7 @@ class AgentLoop:
|
|||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
self._last_usage: dict[str, int] = {}
|
self._last_usage: dict[str, int] = {}
|
||||||
self._extra_hooks: list[AgentHook] = hooks or []
|
self._extra_hooks: list[AgentHook] = hooks or []
|
||||||
|
self._hook_factories: list[AgentTurnHookFactory] = hook_factories or []
|
||||||
|
|
||||||
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)
|
||||||
@@ -291,12 +355,10 @@ class AgentLoop:
|
|||||||
# 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.
|
||||||
self._file_state_store = FileStateStore()
|
self._file_state_store = FileStateStore()
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner()
|
||||||
self.subagents = SubagentManager(
|
self.subagents = SubagentManager(
|
||||||
provider=provider,
|
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
model=self.model,
|
|
||||||
tools_config=_tc,
|
tools_config=_tc,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
@@ -307,12 +369,11 @@ class AgentLoop:
|
|||||||
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
|
||||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
|
||||||
self._running = False
|
self._running = False
|
||||||
self._mcp_servers = mcp_servers or {}
|
self._mcp_servers = mcp_servers or {}
|
||||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
self._mcp_stacks: dict[str, MCPConnection] = {}
|
||||||
self._mcp_connected = False
|
|
||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
||||||
self._background_tasks: list[asyncio.Task] = []
|
self._background_tasks: list[asyncio.Task] = []
|
||||||
self._session_locks: dict[str, asyncio.Lock] = {}
|
self._session_locks: dict[str, asyncio.Lock] = {}
|
||||||
@@ -344,13 +405,9 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self.consolidator = Consolidator(
|
self.consolidator = Consolidator(
|
||||||
store=self.context.memory,
|
store=self.context.memory,
|
||||||
provider=provider,
|
|
||||||
model=self.model,
|
|
||||||
sessions=self.sessions,
|
sessions=self.sessions,
|
||||||
context_window_tokens=self.context_window_tokens,
|
|
||||||
build_messages=self.context.build_messages,
|
build_messages=self.context.build_messages,
|
||||||
get_tool_definitions=self.tools.get_definitions,
|
get_tool_definitions=self.tools.get_definitions,
|
||||||
max_completion_tokens=provider.generation.max_tokens,
|
|
||||||
consolidation_ratio=consolidation_ratio,
|
consolidation_ratio=consolidation_ratio,
|
||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
)
|
)
|
||||||
@@ -359,11 +416,9 @@ class AgentLoop:
|
|||||||
consolidator=self.consolidator,
|
consolidator=self.consolidator,
|
||||||
session_ttl_minutes=session_ttl_minutes,
|
session_ttl_minutes=session_ttl_minutes,
|
||||||
)
|
)
|
||||||
self.model_presets: dict[str, ModelPresetConfig] = model_presets or {}
|
|
||||||
self._active_preset: str | None = None
|
|
||||||
if model_preset:
|
if model_preset:
|
||||||
self.set_model_preset(model_preset, publish_update=False)
|
self.set_model_preset(model_preset, publish_update=False)
|
||||||
self._register_default_tools()
|
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||||
self._runtime_vars: dict[str, Any] = {}
|
self._runtime_vars: dict[str, Any] = {}
|
||||||
self._current_iteration: int = 0
|
self._current_iteration: int = 0
|
||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
@@ -430,89 +485,51 @@ class AgentLoop:
|
|||||||
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
||||||
self.subagents.max_iterations = self.max_iterations
|
self.subagents.max_iterations = self.max_iterations
|
||||||
|
|
||||||
def _apply_provider_snapshot(
|
def _publish_runtime_selection(
|
||||||
self,
|
self,
|
||||||
snapshot: ProviderSnapshot,
|
runtime: LLMRuntime,
|
||||||
*,
|
*,
|
||||||
publish_update: bool = True,
|
publish_update: bool = True,
|
||||||
model_preset: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Swap model/provider for future turns without disturbing an active one."""
|
if not publish_update:
|
||||||
provider = snapshot.provider
|
return
|
||||||
model = snapshot.model
|
if self._runtime_model_publisher is not None:
|
||||||
context_window_tokens = snapshot.context_window_tokens
|
self._runtime_model_publisher(runtime.model, runtime.model_preset)
|
||||||
old_model = self.model
|
|
||||||
self.provider = provider
|
|
||||||
self.model = model
|
|
||||||
self.context_window_tokens = context_window_tokens
|
|
||||||
self.runner.provider = provider
|
|
||||||
self.subagents.set_provider(provider, model)
|
|
||||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
|
||||||
self._sync_replay_max_messages()
|
|
||||||
self._provider_signature = snapshot.signature
|
|
||||||
if publish_update and self._runtime_model_publisher is not None:
|
|
||||||
self._runtime_model_publisher(
|
|
||||||
self.model,
|
|
||||||
model_preset if model_preset is not None else self.model_preset,
|
|
||||||
)
|
|
||||||
if publish_update:
|
|
||||||
self._runtime_events().runtime_model_changed(
|
self._runtime_events().runtime_model_changed(
|
||||||
self.model,
|
runtime.model,
|
||||||
model_preset if model_preset is not None else self.model_preset,
|
runtime.model_preset,
|
||||||
)
|
|
||||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
|
||||||
|
|
||||||
def _sync_replay_max_messages(self) -> None:
|
|
||||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
|
||||||
|
|
||||||
def _refresh_provider_snapshot(self) -> None:
|
|
||||||
if self._provider_snapshot_loader is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
snapshot = self._provider_snapshot_loader()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to refresh provider config")
|
|
||||||
return
|
|
||||||
default_selection = preset_helpers.default_selection_signature(snapshot.signature)
|
|
||||||
if self._active_preset and self._default_selection_signature in (None, default_selection):
|
|
||||||
self._default_selection_signature = default_selection
|
|
||||||
try:
|
|
||||||
snapshot = self._build_model_preset_snapshot(self._active_preset)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to refresh active model preset")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
self._active_preset = None
|
|
||||||
self._default_selection_signature = default_selection
|
|
||||||
if snapshot.signature == self._provider_signature:
|
|
||||||
return
|
|
||||||
self._default_selection_signature = preset_helpers.default_selection_signature(snapshot.signature)
|
|
||||||
self._apply_provider_snapshot(snapshot)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_preset(self) -> str | None:
|
|
||||||
return self._active_preset
|
|
||||||
|
|
||||||
@model_preset.setter
|
|
||||||
def model_preset(self, name: str | None) -> None:
|
|
||||||
self.set_model_preset(name)
|
|
||||||
|
|
||||||
def _build_model_preset_snapshot(self, name: str) -> ProviderSnapshot:
|
|
||||||
return preset_helpers.build_runtime_preset_snapshot(
|
|
||||||
name=name,
|
|
||||||
presets=self.model_presets,
|
|
||||||
provider=self.provider,
|
|
||||||
loader=self._preset_snapshot_loader,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_model_preset(self, name: str | None, *, publish_update: bool = True) -> None:
|
def set_model_preset(
|
||||||
"""Resolve a preset by name and apply all runtime model dependents."""
|
self,
|
||||||
name = preset_helpers.normalize_preset_name(name, self.model_presets)
|
name: str | None,
|
||||||
snapshot = self._build_model_preset_snapshot(name)
|
*,
|
||||||
self._apply_provider_snapshot(snapshot, publish_update=publish_update, model_preset=name)
|
publish_update: bool = True,
|
||||||
self._active_preset = name
|
) -> LLMRuntime:
|
||||||
|
"""Select a named default runtime for future turns."""
|
||||||
|
old_model = self.model
|
||||||
|
runtime = self.runtime_resolver.select_preset(name)
|
||||||
|
self._publish_runtime_selection(runtime, publish_update=publish_update)
|
||||||
|
logger.info(
|
||||||
|
"Runtime model switched for next turn: {} -> {}",
|
||||||
|
old_model,
|
||||||
|
runtime.model,
|
||||||
|
)
|
||||||
|
return runtime
|
||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def set_runtime_model(self, model: str) -> LLMRuntime:
|
||||||
|
"""Select a model on the current provider for future turns."""
|
||||||
|
return self.runtime_resolver.select_model(model)
|
||||||
|
|
||||||
|
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime:
|
||||||
|
"""Select a context limit for future turns."""
|
||||||
|
return self.runtime_resolver.select_context_window(context_window_tokens)
|
||||||
|
|
||||||
|
def _register_default_tools(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
|
||||||
|
) -> None:
|
||||||
"""Register the default set of tools via plugin loader."""
|
"""Register the default set of tools via plugin loader."""
|
||||||
from nanobot.agent.tools.context import ToolContext
|
from nanobot.agent.tools.context import ToolContext
|
||||||
from nanobot.agent.tools.loader import ToolLoader
|
from nanobot.agent.tools.loader import ToolLoader
|
||||||
@@ -524,7 +541,7 @@ class AgentLoop:
|
|||||||
subagent_manager=self.subagents,
|
subagent_manager=self.subagents,
|
||||||
cron_service=self.cron_service,
|
cron_service=self.cron_service,
|
||||||
sessions=self.sessions,
|
sessions=self.sessions,
|
||||||
provider_snapshot_loader=self._provider_snapshot_loader,
|
provider_snapshot_loader=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,
|
workspace_sandbox=self.workspace_scopes.sandbox_status,
|
||||||
@@ -546,31 +563,13 @@ class AgentLoop:
|
|||||||
"""Connect configured MCP servers."""
|
"""Connect configured MCP servers."""
|
||||||
await agent_context.connect_mcp(self, self.tools)
|
await agent_context.connect_mcp(self, self.tools)
|
||||||
|
|
||||||
def _set_tool_context(
|
def register_runtime_context_provider(
|
||||||
self, channel: str, chat_id: str,
|
self,
|
||||||
message_id: str | None = None, metadata: dict | None = None,
|
provider: RuntimeContextProvider,
|
||||||
session_key: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update context for all tools that need routing info."""
|
"""Register a provider resolved once before each inbound model turn."""
|
||||||
from nanobot.agent.tools.context import ContextAware
|
if provider not in self._runtime_context_providers:
|
||||||
|
self._runtime_context_providers.append(provider)
|
||||||
effective_key = session_key or session_key_for_channel(
|
|
||||||
channel,
|
|
||||||
chat_id,
|
|
||||||
unified_session=self._unified_session,
|
|
||||||
)
|
|
||||||
request_ctx = RequestContext(
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
message_id=message_id,
|
|
||||||
session_key=effective_key,
|
|
||||||
metadata=dict(metadata or {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in self.tools.tool_names:
|
|
||||||
tool = self.tools.get(name)
|
|
||||||
if tool and isinstance(tool, ContextAware):
|
|
||||||
tool.set_context(request_ctx)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _runtime_chat_id(msg: InboundMessage) -> str:
|
def _runtime_chat_id(msg: InboundMessage) -> str:
|
||||||
@@ -626,6 +625,7 @@ class AgentLoop:
|
|||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Persist the triggering user message before the turn starts.
|
"""Persist the triggering user message before the turn starts.
|
||||||
@@ -636,7 +636,7 @@ class AgentLoop:
|
|||||||
return False
|
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 or runtime_context_blocks:
|
||||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
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 ""
|
||||||
@@ -644,6 +644,12 @@ class AgentLoop:
|
|||||||
if text_override is not None:
|
if text_override is not None:
|
||||||
text = text_override
|
text = text_override
|
||||||
extra.update(automation_extra)
|
extra.update(automation_extra)
|
||||||
|
text, runtime_context_meta = append_runtime_context(
|
||||||
|
text,
|
||||||
|
runtime_context_blocks or (),
|
||||||
|
)
|
||||||
|
if runtime_context_meta is not None:
|
||||||
|
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
self._mark_pending_user_turn(session)
|
self._mark_pending_user_turn(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
@@ -657,12 +663,13 @@ class AgentLoop:
|
|||||||
history: list[dict[str, Any]],
|
history: list[dict[str, Any]],
|
||||||
pending_summary: str | None,
|
pending_summary: str | None,
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
|
runtime_context_blocks: list[RuntimeContextBlock] | None = 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)
|
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=msg.content,
|
||||||
media=msg.media if msg.media else None,
|
media=msg.media if msg.media else None,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=self._runtime_chat_id(msg),
|
chat_id=self._runtime_chat_id(msg),
|
||||||
@@ -670,13 +677,39 @@ class AgentLoop:
|
|||||||
session_summary=pending_summary,
|
session_summary=pending_summary,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata,
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_state=self,
|
runtime_context_blocks=runtime_context_blocks,
|
||||||
inbound_message=msg,
|
|
||||||
include_memory_recent_history=include_memory_recent_history,
|
include_memory_recent_history=include_memory_recent_history,
|
||||||
session_key=session.key,
|
session_key=session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||||
|
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
||||||
|
return RequestContext(
|
||||||
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
|
message_id=ctx.msg.metadata.get("message_id"),
|
||||||
|
session_key=ctx.session_key,
|
||||||
|
original_user_text=ctx.original_user_text,
|
||||||
|
runtime=ctx.runtime,
|
||||||
|
metadata=dict(ctx.msg.metadata or {}),
|
||||||
|
sender_id=ctx.msg.sender_id,
|
||||||
|
turn_id=ctx.turn_id,
|
||||||
|
workspace=scope.project_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resolve_runtime_context_for_turn(
|
||||||
|
self,
|
||||||
|
ctx: TurnContext,
|
||||||
|
) -> list[RuntimeContextBlock]:
|
||||||
|
tools = ctx.tools or self.tools
|
||||||
|
providers = [
|
||||||
|
*tools.get_runtime_context_providers(),
|
||||||
|
*self._runtime_context_providers,
|
||||||
|
]
|
||||||
|
assert ctx.request_context is not None
|
||||||
|
return await resolve_runtime_context(providers, ctx.request_context)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
@@ -711,17 +744,18 @@ class AgentLoop:
|
|||||||
return UNIFIED_SESSION_KEY
|
return UNIFIED_SESSION_KEY
|
||||||
return msg.session_key
|
return msg.session_key
|
||||||
|
|
||||||
def _replay_token_budget(self) -> int:
|
@staticmethod
|
||||||
|
def _replay_token_budget(runtime: LLMRuntime) -> int:
|
||||||
"""Derive a token budget for session history replay from the context window."""
|
"""Derive a token budget for session history replay from the context window."""
|
||||||
if self.context_window_tokens <= 0:
|
if runtime.context_window_tokens <= 0:
|
||||||
return 0
|
return 0
|
||||||
max_output = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
|
max_output = runtime.generation.max_tokens
|
||||||
try:
|
try:
|
||||||
reserved_output = int(max_output)
|
reserved_output = int(max_output)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
reserved_output = 4096
|
reserved_output = 4096
|
||||||
budget = self.context_window_tokens - max(1, reserved_output) - 1024
|
budget = runtime.context_window_tokens - max(1, reserved_output) - 1024
|
||||||
return budget if budget > 0 else max(128, self.context_window_tokens // 2)
|
return budget if budget > 0 else max(128, runtime.context_window_tokens // 2)
|
||||||
|
|
||||||
async def _run_agent_loop(
|
async def _run_agent_loop(
|
||||||
self,
|
self,
|
||||||
@@ -731,17 +765,22 @@ class AgentLoop:
|
|||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
*,
|
*,
|
||||||
|
runtime: LLMRuntime,
|
||||||
session: Session | None = None,
|
session: Session | None = None,
|
||||||
channel: str = "cli",
|
channel: str = "cli",
|
||||||
chat_id: str = "direct",
|
chat_id: str = "direct",
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
|
original_user_text: str | None = None,
|
||||||
pending_queue: asyncio.Queue | None = None,
|
pending_queue: asyncio.Queue | None = None,
|
||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
run_extra_hooks_for_ephemeral: bool = False,
|
run_extra_hooks_for_ephemeral: bool = False,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
|
turn_scopes: list[AbstractContextManager[Any]] | None = None,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
|
request_context: RequestContext | None = None,
|
||||||
) -> tuple[str | None, list[str], list[dict], str, bool]:
|
) -> tuple[str | None, list[str], list[dict], str, bool]:
|
||||||
"""Run the agent iteration loop.
|
"""Run the agent iteration loop.
|
||||||
|
|
||||||
@@ -754,24 +793,6 @@ class AgentLoop:
|
|||||||
"""
|
"""
|
||||||
self._sync_subagent_runtime_limits()
|
self._sync_subagent_runtime_limits()
|
||||||
|
|
||||||
loop_hook = AgentProgressHook(
|
|
||||||
on_progress=on_progress,
|
|
||||||
on_stream=on_stream,
|
|
||||||
on_stream_end=on_stream_end,
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
message_id=message_id,
|
|
||||||
metadata=metadata,
|
|
||||||
session_key=session_key,
|
|
||||||
tool_hint_max_length=self.tool_hint_max_length,
|
|
||||||
set_tool_context=self._set_tool_context,
|
|
||||||
on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration),
|
|
||||||
)
|
|
||||||
run_hooks = [*self._extra_hooks, *(hooks or [])]
|
|
||||||
hook: AgentHook = loop_hook
|
|
||||||
if run_hooks and (not ephemeral or run_extra_hooks_for_ephemeral):
|
|
||||||
hook = CompositeHook([loop_hook, *run_hooks])
|
|
||||||
|
|
||||||
async def _checkpoint(payload: dict[str, Any]) -> None:
|
async def _checkpoint(payload: dict[str, Any]) -> None:
|
||||||
if session is None:
|
if session is None:
|
||||||
return
|
return
|
||||||
@@ -847,17 +868,22 @@ class AgentLoop:
|
|||||||
message_metadata=metadata,
|
message_metadata=metadata,
|
||||||
session_metadata=session.metadata if session is not None else None,
|
session_metadata=session.metadata if session is not None else None,
|
||||||
)
|
)
|
||||||
request_ctx = RequestContext(
|
effective_tools = tools or self.tools
|
||||||
|
request_ctx = request_context or RequestContext(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
session_key=active_session_key,
|
session_key=active_session_key,
|
||||||
|
original_user_text=original_user_text,
|
||||||
|
runtime=runtime,
|
||||||
metadata=dict(metadata or {}),
|
metadata=dict(metadata or {}),
|
||||||
|
workspace=effective_scope.project_path,
|
||||||
)
|
)
|
||||||
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)
|
request_token = bind_request_context(request_ctx)
|
||||||
workspace_token = bind_workspace_scope(effective_scope)
|
workspace_token = bind_workspace_scope(effective_scope)
|
||||||
# Compute lazily because long_task may create goal metadata during this run.
|
turn_scope_stack = ExitStack()
|
||||||
|
# Compute lazily because create_goal may create goal metadata during this run.
|
||||||
def _goal_continue() -> str | None:
|
def _goal_continue() -> str | None:
|
||||||
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
|
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
|
||||||
if not _goal_lines:
|
if not _goal_lines:
|
||||||
@@ -866,15 +892,36 @@ class AgentLoop:
|
|||||||
"You have an active sustained goal:\n\n"
|
"You have an active sustained goal:\n\n"
|
||||||
+ "\n".join(_goal_lines)
|
+ "\n".join(_goal_lines)
|
||||||
+ "\n\nPlease continue working toward the objective using your tools, "
|
+ "\n\nPlease continue working toward the objective using your tools, "
|
||||||
"or call complete_goal if the work is truly finished."
|
"or call update_goal with action='complete' if the work is truly finished."
|
||||||
)
|
)
|
||||||
|
|
||||||
session_metadata = session.metadata if session is not None else None
|
session_metadata = session.metadata if session is not None else None
|
||||||
try:
|
try:
|
||||||
|
for scope in turn_scopes or ():
|
||||||
|
turn_scope_stack.enter_context(scope)
|
||||||
|
hook = build_agent_turn_hook(AgentTurnHookSpec(
|
||||||
|
on_progress=on_progress,
|
||||||
|
on_stream=on_stream,
|
||||||
|
on_stream_end=on_stream_end,
|
||||||
|
channel=channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
metadata=metadata,
|
||||||
|
session_key=active_session_key,
|
||||||
|
workspace=effective_scope.project_path,
|
||||||
|
tool_hint_max_length=self.tool_hint_max_length,
|
||||||
|
on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration),
|
||||||
|
registered_hook_factories=self._hook_factories,
|
||||||
|
turn_hook_factories=list(hook_factories or []),
|
||||||
|
registered_hooks=self._extra_hooks,
|
||||||
|
turn_hooks=list(hooks or []),
|
||||||
|
ephemeral=ephemeral,
|
||||||
|
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
||||||
|
))
|
||||||
result = await self.runner.run(AgentRunSpec(
|
result = await self.runner.run(AgentRunSpec(
|
||||||
initial_messages=initial_messages,
|
initial_messages=initial_messages,
|
||||||
tools=tools or self.tools,
|
tools=effective_tools,
|
||||||
model=self.model,
|
runtime=runtime,
|
||||||
max_iterations=self.max_iterations,
|
max_iterations=self.max_iterations,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
hook=hook,
|
hook=hook,
|
||||||
@@ -882,7 +929,6 @@ class AgentLoop:
|
|||||||
concurrent_tools=True,
|
concurrent_tools=True,
|
||||||
workspace=effective_scope.project_path,
|
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_block_limit=self.context_block_limit,
|
context_block_limit=self.context_block_limit,
|
||||||
provider_retry_mode=self.provider_retry_mode,
|
provider_retry_mode=self.provider_retry_mode,
|
||||||
progress_callback=on_progress,
|
progress_callback=on_progress,
|
||||||
@@ -907,6 +953,7 @@ class AgentLoop:
|
|||||||
),
|
),
|
||||||
))
|
))
|
||||||
finally:
|
finally:
|
||||||
|
turn_scope_stack.close()
|
||||||
reset_workspace_scope(workspace_token)
|
reset_workspace_scope(workspace_token)
|
||||||
reset_request_context(request_token)
|
reset_request_context(request_token)
|
||||||
reset_file_states(file_state_token)
|
reset_file_states(file_state_token)
|
||||||
@@ -941,6 +988,7 @@ class AgentLoop:
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
self.auto_compact.check_expired(
|
self.auto_compact.check_expired(
|
||||||
self._schedule_background,
|
self._schedule_background,
|
||||||
|
self.llm_runtime,
|
||||||
active_session_keys=self._pending_queues.keys(),
|
active_session_keys=self._pending_queues.keys(),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -1190,12 +1238,7 @@ class AgentLoop:
|
|||||||
if self._background_tasks:
|
if self._background_tasks:
|
||||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||||
self._background_tasks.clear()
|
self._background_tasks.clear()
|
||||||
for name, stack in self._mcp_stacks.items():
|
await agent_context.close_mcp(self)
|
||||||
try:
|
|
||||||
await stack.aclose()
|
|
||||||
except (RuntimeError, BaseExceptionGroup):
|
|
||||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
|
||||||
self._mcp_stacks.clear()
|
|
||||||
|
|
||||||
def _schedule_background(self, coro) -> None:
|
def _schedule_background(self, coro) -> None:
|
||||||
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
||||||
@@ -1211,11 +1254,14 @@ class AgentLoop:
|
|||||||
async def _process_system_message(
|
async def _process_system_message(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
*,
|
||||||
|
runtime: LLMRuntime,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||||
pending_queue: asyncio.Queue | None = None,
|
pending_queue: asyncio.Queue | None = None,
|
||||||
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Process a system inbound message (e.g. subagent announce)."""
|
"""Process a system inbound message (e.g. subagent announce)."""
|
||||||
channel, chat_id = (
|
channel, chat_id = (
|
||||||
@@ -1224,6 +1270,7 @@ class AgentLoop:
|
|||||||
logger.info("Processing system message from {}", msg.sender_id)
|
logger.info("Processing system message from {}", msg.sender_id)
|
||||||
key = msg.session_key_override or f"{channel}:{chat_id}"
|
key = msg.session_key_override or f"{channel}:{chat_id}"
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
|
self._runtime_events().record_turn_runtime(key, runtime)
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if self._restore_pending_user_turn(session):
|
if self._restore_pending_user_turn(session):
|
||||||
@@ -1235,20 +1282,19 @@ class AgentLoop:
|
|||||||
|
|
||||||
await self.consolidator.maybe_consolidate_by_tokens(
|
await self.consolidator.maybe_consolidate_by_tokens(
|
||||||
session,
|
session,
|
||||||
replay_max_messages=self._max_messages,
|
runtime=runtime,
|
||||||
|
replay_max_messages=replay_max_messages_for_context(
|
||||||
|
runtime.context_window_tokens
|
||||||
|
),
|
||||||
)
|
)
|
||||||
is_subagent = msg.sender_id == "subagent"
|
is_subagent = msg.sender_id == "subagent"
|
||||||
if is_subagent and self._persist_subagent_followup(session, msg):
|
if is_subagent and self._persist_subagent_followup(session, msg):
|
||||||
logger.debug("Subagent result persisted for session {}", key)
|
logger.debug("Subagent result persisted for session {}", key)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
self._set_tool_context(
|
|
||||||
channel, chat_id, msg.metadata.get("message_id"),
|
|
||||||
msg.metadata, session_key=key,
|
|
||||||
)
|
|
||||||
current_role = "assistant" if is_subagent else "user"
|
current_role = "assistant" if is_subagent else "user"
|
||||||
_hist_kwargs: dict[str, Any] = {
|
_hist_kwargs: dict[str, Any] = {
|
||||||
"max_messages": self._max_messages,
|
"max_messages": replay_max_messages_for_context(runtime.context_window_tokens),
|
||||||
"max_tokens": self._replay_token_budget(),
|
"max_tokens": self._replay_token_budget(runtime),
|
||||||
"extend_to_user": is_subagent,
|
"extend_to_user": is_subagent,
|
||||||
}
|
}
|
||||||
history = session.get_history(**_hist_kwargs)
|
history = session.get_history(**_hist_kwargs)
|
||||||
@@ -1264,19 +1310,19 @@ class AgentLoop:
|
|||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata,
|
||||||
workspace=workspace_scope.project_path,
|
workspace=workspace_scope.project_path,
|
||||||
runtime_state=self,
|
|
||||||
inbound_message=msg,
|
|
||||||
skip_runtime_lines=is_subagent,
|
|
||||||
session_key=key,
|
session_key=key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
)
|
)
|
||||||
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(
|
||||||
messages, session=session, channel=channel, chat_id=chat_id,
|
messages, session=session, channel=channel, chat_id=chat_id,
|
||||||
|
runtime=runtime,
|
||||||
message_id=msg.metadata.get("message_id"),
|
message_id=msg.metadata.get("message_id"),
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
session_key=key,
|
session_key=key,
|
||||||
|
original_user_text=None,
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
|
hook_factories=hook_factories,
|
||||||
)
|
)
|
||||||
wall_done = time.time()
|
wall_done = time.time()
|
||||||
latency_ms = max(0, int((wall_done - t_wall) * 1000))
|
latency_ms = max(0, int((wall_done - t_wall) * 1000))
|
||||||
@@ -1290,7 +1336,10 @@ class AgentLoop:
|
|||||||
self._schedule_background(
|
self._schedule_background(
|
||||||
self.consolidator.maybe_consolidate_by_tokens(
|
self.consolidator.maybe_consolidate_by_tokens(
|
||||||
session,
|
session,
|
||||||
replay_max_messages=self._max_messages,
|
runtime=runtime,
|
||||||
|
replay_max_messages=replay_max_messages_for_context(
|
||||||
|
runtime.context_window_tokens
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
content = final_content or "Background task completed."
|
content = final_content or "Background task completed."
|
||||||
@@ -1317,19 +1366,24 @@ class AgentLoop:
|
|||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
run_extra_hooks_for_ephemeral: bool = False,
|
run_extra_hooks_for_ephemeral: bool = False,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
|
runtime: LLMRuntime | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Process a single inbound message and return the response."""
|
"""Process a single inbound message and return the response."""
|
||||||
self._refresh_provider_snapshot()
|
if runtime is None:
|
||||||
|
runtime = self.llm_runtime()
|
||||||
|
|
||||||
if msg.channel == "system":
|
if msg.channel == "system":
|
||||||
return await self._process_system_message(
|
return await self._process_system_message(
|
||||||
msg,
|
msg,
|
||||||
|
runtime=runtime,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
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,
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
|
hook_factories=hook_factories,
|
||||||
)
|
)
|
||||||
|
|
||||||
key = session_key or msg.session_key
|
key = session_key or msg.session_key
|
||||||
@@ -1340,6 +1394,12 @@ class AgentLoop:
|
|||||||
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()}",
|
||||||
|
runtime=runtime,
|
||||||
|
original_user_text=(
|
||||||
|
None
|
||||||
|
if turn_continuation.internal_continuation_inbound(msg.metadata)
|
||||||
|
else msg.content
|
||||||
|
),
|
||||||
turn_wall_started_at=t0,
|
turn_wall_started_at=t0,
|
||||||
visible_run_started_at=turn_continuation.internal_continuation_run_started_at(
|
visible_run_started_at=turn_continuation.internal_continuation_run_started_at(
|
||||||
msg.metadata,
|
msg.metadata,
|
||||||
@@ -1351,6 +1411,7 @@ class AgentLoop:
|
|||||||
ephemeral=ephemeral,
|
ephemeral=ephemeral,
|
||||||
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
||||||
hooks=list(hooks or []),
|
hooks=list(hooks or []),
|
||||||
|
hook_factories=list(hook_factories or []),
|
||||||
tools=tools,
|
tools=tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1486,8 +1547,22 @@ class AgentLoop:
|
|||||||
|
|
||||||
async def _state_command(self, ctx: TurnContext) -> str:
|
async def _state_command(self, ctx: TurnContext) -> str:
|
||||||
raw = ctx.msg.content.strip()
|
raw = ctx.msg.content.strip()
|
||||||
|
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
||||||
|
is_user_turn = (
|
||||||
|
ctx.original_user_text is not None
|
||||||
|
and not automation_metadata
|
||||||
|
and ctx.msg.channel != "system"
|
||||||
|
and ctx.msg.sender_id != "subagent"
|
||||||
|
)
|
||||||
cmd_ctx = CommandContext(
|
cmd_ctx = CommandContext(
|
||||||
msg=ctx.msg, session=ctx.session, key=ctx.session_key, raw=raw, loop=self
|
msg=ctx.msg,
|
||||||
|
session=ctx.session,
|
||||||
|
key=ctx.session_key,
|
||||||
|
raw=raw,
|
||||||
|
loop=self,
|
||||||
|
runtime=ctx.runtime,
|
||||||
|
is_user_turn=is_user_turn,
|
||||||
|
turn_scopes=ctx.turn_scopes,
|
||||||
)
|
)
|
||||||
result = await self.commands.dispatch(cmd_ctx)
|
result = await self.commands.dispatch(cmd_ctx)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -1510,42 +1585,44 @@ class AgentLoop:
|
|||||||
return "dispatch"
|
return "dispatch"
|
||||||
|
|
||||||
async def _state_build(self, ctx: TurnContext) -> str:
|
async def _state_build(self, ctx: TurnContext) -> str:
|
||||||
|
replay_max_messages = replay_max_messages_for_context(
|
||||||
|
ctx.runtime.context_window_tokens
|
||||||
|
)
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
await self.consolidator.maybe_consolidate_by_tokens(
|
await self.consolidator.maybe_consolidate_by_tokens(
|
||||||
ctx.session,
|
ctx.session,
|
||||||
replay_max_messages=self._max_messages,
|
runtime=ctx.runtime,
|
||||||
)
|
replay_max_messages=replay_max_messages,
|
||||||
self._set_tool_context(
|
|
||||||
ctx.msg.channel,
|
|
||||||
ctx.msg.chat_id,
|
|
||||||
ctx.msg.metadata.get("message_id"),
|
|
||||||
ctx.msg.metadata,
|
|
||||||
session_key=ctx.session_key,
|
|
||||||
)
|
)
|
||||||
if message_tool := self.tools.get("message"):
|
if message_tool := self.tools.get("message"):
|
||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_tool.start_turn()
|
message_tool.start_turn()
|
||||||
|
|
||||||
_hist_kwargs: dict[str, Any] = {
|
_hist_kwargs: dict[str, Any] = {
|
||||||
"max_messages": self._max_messages,
|
"max_messages": replay_max_messages,
|
||||||
"max_tokens": self._replay_token_budget(),
|
"max_tokens": self._replay_token_budget(ctx.runtime),
|
||||||
"extend_to_user": False,
|
"extend_to_user": False,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||||
self._runtime_events().record_turn_runtime(
|
self._runtime_events().record_turn_runtime(
|
||||||
ctx.session_key,
|
ctx.session_key,
|
||||||
self.llm_runtime(),
|
ctx.runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ctx.request_context = self._request_context_for_turn(ctx)
|
||||||
|
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
||||||
ctx.initial_messages = self._build_initial_messages(
|
ctx.initial_messages = self._build_initial_messages(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.session,
|
ctx.session,
|
||||||
ctx.history,
|
ctx.history,
|
||||||
ctx.pending_summary,
|
ctx.pending_summary,
|
||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
)
|
)
|
||||||
|
|
||||||
if ctx.on_progress is None:
|
if ctx.on_progress is None:
|
||||||
@@ -1566,6 +1643,7 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
result = await self._run_agent_loop(
|
result = await self._run_agent_loop(
|
||||||
ctx.initial_messages,
|
ctx.initial_messages,
|
||||||
|
runtime=ctx.runtime,
|
||||||
on_progress=ctx.on_progress,
|
on_progress=ctx.on_progress,
|
||||||
on_stream=ctx.on_stream,
|
on_stream=ctx.on_stream,
|
||||||
on_stream_end=ctx.on_stream_end,
|
on_stream_end=ctx.on_stream_end,
|
||||||
@@ -1576,11 +1654,15 @@ class AgentLoop:
|
|||||||
message_id=ctx.msg.metadata.get("message_id"),
|
message_id=ctx.msg.metadata.get("message_id"),
|
||||||
metadata=ctx.msg.metadata,
|
metadata=ctx.msg.metadata,
|
||||||
session_key=ctx.session_key,
|
session_key=ctx.session_key,
|
||||||
|
original_user_text=ctx.original_user_text,
|
||||||
pending_queue=ctx.pending_queue,
|
pending_queue=ctx.pending_queue,
|
||||||
ephemeral=ctx.ephemeral,
|
ephemeral=ctx.ephemeral,
|
||||||
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
|
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
|
||||||
hooks=ctx.hooks,
|
hooks=ctx.hooks,
|
||||||
|
hook_factories=ctx.hook_factories,
|
||||||
|
turn_scopes=ctx.turn_scopes,
|
||||||
tools=ctx.tools,
|
tools=ctx.tools,
|
||||||
|
request_context=ctx.request_context,
|
||||||
)
|
)
|
||||||
final_content, tools_used, all_msgs, stop_reason, had_injections = result
|
final_content, tools_used, all_msgs, stop_reason, had_injections = result
|
||||||
ctx.final_content = final_content
|
ctx.final_content = final_content
|
||||||
@@ -1622,7 +1704,10 @@ class AgentLoop:
|
|||||||
self._schedule_background(
|
self._schedule_background(
|
||||||
self.consolidator.maybe_consolidate_by_tokens(
|
self.consolidator.maybe_consolidate_by_tokens(
|
||||||
ctx.session,
|
ctx.session,
|
||||||
replay_max_messages=self._max_messages,
|
runtime=ctx.runtime,
|
||||||
|
replay_max_messages=replay_max_messages_for_context(
|
||||||
|
ctx.runtime.context_window_tokens
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(ctx.session)
|
||||||
@@ -1652,7 +1737,6 @@ class AgentLoop:
|
|||||||
content: list[dict[str, Any]],
|
content: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
should_truncate_text: bool = False,
|
should_truncate_text: bool = False,
|
||||||
drop_runtime: bool = False,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Strip volatile multimodal payloads before writing session history."""
|
"""Strip volatile multimodal payloads before writing session history."""
|
||||||
filtered: list[dict[str, Any]] = []
|
filtered: list[dict[str, Any]] = []
|
||||||
@@ -1661,14 +1745,6 @@ class AgentLoop:
|
|||||||
filtered.append(block)
|
filtered.append(block)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if (
|
|
||||||
drop_runtime
|
|
||||||
and block.get("type") == "text"
|
|
||||||
and isinstance(block.get("text"), str)
|
|
||||||
and block["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if block.get("type") == "image_url" and block.get("image_url", {}).get(
|
if block.get("type") == "image_url" and block.get("image_url", {}).get(
|
||||||
"url", ""
|
"url", ""
|
||||||
).startswith("data:image/"):
|
).startswith("data:image/"):
|
||||||
@@ -1708,6 +1784,12 @@ class AgentLoop:
|
|||||||
last_assistant_idx: int | None = None
|
last_assistant_idx: int | None = None
|
||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
|
internal_meta = entry.pop("_meta", None)
|
||||||
|
runtime_context_meta = (
|
||||||
|
internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||||
|
if isinstance(internal_meta, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
role, content = entry.get("role"), entry.get("content")
|
role, content = entry.get("role"), entry.get("content")
|
||||||
if role == "assistant" and not content and not entry.get("tool_calls"):
|
if role == "assistant" and not content and not entry.get("tool_calls"):
|
||||||
continue # skip empty assistant messages — they poison session context
|
continue # skip empty assistant messages — they poison session context
|
||||||
@@ -1732,19 +1814,13 @@ class AgentLoop:
|
|||||||
]
|
]
|
||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
elif role == "user":
|
elif role == "user":
|
||||||
if isinstance(content, str) and ContextBuilder._RUNTIME_CONTEXT_TAG in content:
|
|
||||||
# Strip the runtime-context block appended at the end.
|
|
||||||
tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG)
|
|
||||||
before = content[:tag_pos].rstrip("\n ")
|
|
||||||
if before:
|
|
||||||
entry["content"] = before
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True)
|
filtered = self._sanitize_persisted_blocks(content)
|
||||||
if not filtered:
|
if not filtered:
|
||||||
continue
|
continue
|
||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
|
if isinstance(runtime_context_meta, dict):
|
||||||
|
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
entry.setdefault("timestamp", datetime.now().isoformat())
|
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||||
session.messages.append(entry)
|
session.messages.append(entry)
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
@@ -1897,8 +1973,10 @@ class AgentLoop:
|
|||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
_run_extra_hooks_for_ephemeral: bool = False,
|
_run_extra_hooks_for_ephemeral: bool = False,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
persist_user_message: bool = True,
|
persist_user_message: bool = True,
|
||||||
|
runtime: LLMRuntime | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Process a message directly and return the outbound payload."""
|
"""Process a message directly and return the outbound payload."""
|
||||||
await self._connect_mcp()
|
await self._connect_mcp()
|
||||||
@@ -1924,8 +2002,12 @@ class AgentLoop:
|
|||||||
kwargs["run_extra_hooks_for_ephemeral"] = True
|
kwargs["run_extra_hooks_for_ephemeral"] = True
|
||||||
if hooks is not None:
|
if hooks is not None:
|
||||||
kwargs["hooks"] = hooks
|
kwargs["hooks"] = hooks
|
||||||
|
if hook_factories is not None:
|
||||||
|
kwargs["hook_factories"] = hook_factories
|
||||||
if tools is not None:
|
if tools is not None:
|
||||||
kwargs["tools"] = tools
|
kwargs["tools"] = tools
|
||||||
|
if runtime is not None:
|
||||||
|
kwargs["runtime"] = runtime
|
||||||
return await self._process_message(
|
return await self._process_message(
|
||||||
msg,
|
msg,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|||||||
+167
-57
@@ -15,7 +15,8 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.session.manager import Session
|
from nanobot.runtime_context import public_history_messages
|
||||||
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
@@ -30,8 +31,7 @@ from nanobot.utils.helpers import (
|
|||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
from nanobot.session.manager import SessionManager
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# MemoryStore — pure file I/O layer
|
# MemoryStore — pure file I/O layer
|
||||||
@@ -41,6 +41,14 @@ class MemoryStore:
|
|||||||
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
|
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
|
||||||
|
|
||||||
_DEFAULT_MAX_HISTORY = 1000
|
_DEFAULT_MAX_HISTORY = 1000
|
||||||
|
# Durable files whose real working-tree delta grounds Dream commit messages
|
||||||
|
# and the cursor-advance gate. Deliberately excludes memory/.dream_cursor so
|
||||||
|
# that advancing the cursor itself is never mistaken for a productive edit.
|
||||||
|
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
|
||||||
|
# Per-file cap when embedding current contents into the Dream prompt. The
|
||||||
|
# durable files are tiny in practice (~5 KB total), but a runaway file must
|
||||||
|
# not unbounded the prompt.
|
||||||
|
_DREAM_FILE_EMBED_CAP = 8000
|
||||||
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
|
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
|
||||||
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
|
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
|
||||||
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
|
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
|
||||||
@@ -63,6 +71,7 @@ class MemoryStore:
|
|||||||
self._corruption_logged = False # rate-limit invalid cursor warning
|
self._corruption_logged = False # rate-limit invalid cursor warning
|
||||||
self._malformed_entry_logged = False # rate-limit bad history shape warning
|
self._malformed_entry_logged = False # rate-limit bad history shape warning
|
||||||
self._oversize_logged = False # rate-limit oversized-entry warning
|
self._oversize_logged = False # rate-limit oversized-entry warning
|
||||||
|
self._dream_prompt_oversize_logged = False
|
||||||
self._append_lock = threading.Lock() # serialize cursor allocation + append
|
self._append_lock = threading.Lock() # serialize cursor allocation + append
|
||||||
self._git = GitStore(workspace, tracked_files=[
|
self._git = GitStore(workspace, tracked_files=[
|
||||||
"SOUL.md", "USER.md", "memory/MEMORY.md", "memory/.dream_cursor",
|
"SOUL.md", "USER.md", "memory/MEMORY.md", "memory/.dream_cursor",
|
||||||
@@ -481,13 +490,54 @@ class MemoryStore:
|
|||||||
def get_latest_cursor(self) -> int:
|
def get_latest_cursor(self) -> int:
|
||||||
return max(self._next_cursor() - 1, 0)
|
return max(self._next_cursor() - 1, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dream_prompt_file(self) -> Path:
|
||||||
|
return self.workspace / "prompts" / "dream.md"
|
||||||
|
|
||||||
|
def has_dream_prompt_override(self) -> bool:
|
||||||
|
with suppress(OSError):
|
||||||
|
return self.dream_prompt_file.is_file() and bool(
|
||||||
|
self.dream_prompt_file.read_text(encoding="utf-8").strip()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def default_dream_prompt() -> str:
|
||||||
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"agent/dream.md",
|
||||||
|
strip=True,
|
||||||
|
skill_creator_path=str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dream_template(self) -> str:
|
||||||
|
with suppress(OSError):
|
||||||
|
text = self.dream_prompt_file.read_text(encoding="utf-8")
|
||||||
|
if text.strip():
|
||||||
|
text = text.rstrip()
|
||||||
|
if len(text) > _DREAM_PROMPT_MAX_CHARS:
|
||||||
|
if not self._dream_prompt_oversize_logged:
|
||||||
|
self._dream_prompt_oversize_logged = True
|
||||||
|
logger.warning(
|
||||||
|
"workspace Dream prompt exceeds {} chars ({}); truncating. "
|
||||||
|
"Further occurrences suppressed.",
|
||||||
|
_DREAM_PROMPT_MAX_CHARS, len(text),
|
||||||
|
)
|
||||||
|
return truncate_text(text, _DREAM_PROMPT_MAX_CHARS)
|
||||||
|
return text
|
||||||
|
return self.default_dream_prompt()
|
||||||
|
|
||||||
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
||||||
"""Build the Dream prompt with unprocessed history context.
|
"""Build the Dream prompt with unprocessed history context.
|
||||||
|
|
||||||
Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process.
|
Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process.
|
||||||
"""
|
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
|
||||||
|
|
||||||
|
The current contents of the durable memory files (SOUL.md, USER.md,
|
||||||
|
memory/MEMORY.md) are embedded so the model edits the real files rather
|
||||||
|
than a stale mental model — eliminating a class of failed/out-of-bounds
|
||||||
|
edits that previously produced hallucinated audit records.
|
||||||
|
"""
|
||||||
last_cursor = self.get_last_dream_cursor()
|
last_cursor = self.get_last_dream_cursor()
|
||||||
entries = self.read_unprocessed_history(since_cursor=last_cursor)
|
entries = self.read_unprocessed_history(since_cursor=last_cursor)
|
||||||
if not entries:
|
if not entries:
|
||||||
@@ -498,13 +548,47 @@ class MemoryStore:
|
|||||||
f"[{e['timestamp']}] {truncate_text(e['content'], 500)}"
|
f"[{e['timestamp']}] {truncate_text(e['content'], 500)}"
|
||||||
for e in batch
|
for e in batch
|
||||||
)
|
)
|
||||||
skill_creator_path = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
template = self._dream_template()
|
||||||
template = render_template(
|
files_section = self._render_current_memory_files()
|
||||||
"agent/dream.md", strip=True, skill_creator_path=skill_creator_path,
|
prompt = (
|
||||||
|
f"{template}\n\n{files_section}\n\n"
|
||||||
|
f"## Conversation History\n{history_text}"
|
||||||
)
|
)
|
||||||
prompt = f"{template}\n\n## Conversation History\n{history_text}"
|
|
||||||
return (prompt, batch[-1]["cursor"])
|
return (prompt, batch[-1]["cursor"])
|
||||||
|
|
||||||
|
def _render_current_memory_files(self) -> str:
|
||||||
|
"""Render the durable memory files' current contents for the Dream prompt.
|
||||||
|
|
||||||
|
Missing files render as ``(empty)``; oversized files are capped. The
|
||||||
|
section is the ground truth the model must edit against.
|
||||||
|
"""
|
||||||
|
files = [
|
||||||
|
("SOUL.md", self.soul_file),
|
||||||
|
("USER.md", self.user_file),
|
||||||
|
("memory/MEMORY.md", self.memory_file),
|
||||||
|
]
|
||||||
|
blocks = []
|
||||||
|
for label, path in files:
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
except OSError:
|
||||||
|
content = ""
|
||||||
|
if len(content) > self._DREAM_FILE_EMBED_CAP:
|
||||||
|
content = truncate_text(content, self._DREAM_FILE_EMBED_CAP) + "\n...[truncated]"
|
||||||
|
blocks.append(f"### {label}\n{content}" if content.strip() else f"### {label}\n(empty)")
|
||||||
|
return "## Current Memory Files\n" + "\n\n".join(blocks)
|
||||||
|
|
||||||
|
def dream_content_diff(self) -> str:
|
||||||
|
"""Structured summary of uncommitted changes to the durable memory files.
|
||||||
|
|
||||||
|
Returns "" when git is unavailable or no content file changed. This is
|
||||||
|
the ground-truth input for diff-grounded Dream commit messages and for
|
||||||
|
gating cursor advance on real edits (never on LLM self-report).
|
||||||
|
"""
|
||||||
|
if not self._git.is_initialized():
|
||||||
|
return ""
|
||||||
|
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
|
||||||
|
|
||||||
def build_dream_tools(self):
|
def build_dream_tools(self):
|
||||||
"""Build the restricted tool registry used by Dream runs."""
|
"""Build the restricted tool registry used by Dream runs."""
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
@@ -576,7 +660,10 @@ class MemoryStore:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||||
formatted = truncate_text(self._format_messages(messages), limit)
|
formatted = truncate_text(
|
||||||
|
self._format_messages(public_history_messages(messages)),
|
||||||
|
limit,
|
||||||
|
)
|
||||||
self.append_history(
|
self.append_history(
|
||||||
f"[RAW] {len(messages)} messages\n"
|
f"[RAW] {len(messages)} messages\n"
|
||||||
f"{formatted}",
|
f"{formatted}",
|
||||||
@@ -596,23 +683,36 @@ class MemoryStore:
|
|||||||
return f"dream:{datetime.now():%Y%m%d-%H%M%S}"
|
return f"dream:{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_dream_commit_message(prefix: str, resp: object | None) -> str:
|
def build_dream_commit_message(prefix: str, diff_body: str) -> str:
|
||||||
"""Build a Dream auto-commit message, appending the LLM summary if present."""
|
"""Build a Dream commit message grounded in the real working-tree diff.
|
||||||
msg = prefix
|
|
||||||
if resp is not None and getattr(resp, "content", None):
|
*diff_body* is a structured, machine-derived summary of the actual file
|
||||||
msg = f"{msg}\n\n{resp.content.strip()}"
|
changes (see :meth:`dream_content_diff` /
|
||||||
return msg
|
:meth:`GitStore.summarize_working_tree`). The LLM narrative is
|
||||||
|
deliberately excluded so the audit record (``/dream-log``) reflects the
|
||||||
|
filesystem's truth, not the model's self-report.
|
||||||
|
|
||||||
|
An empty *diff_body* yields the bare *prefix*, which ``auto_commit``
|
||||||
|
turns into a no-op when there is nothing to stage.
|
||||||
|
"""
|
||||||
|
diff_body = (diff_body or "").strip()
|
||||||
|
if not diff_body:
|
||||||
|
return prefix
|
||||||
|
return f"{prefix}\n\n{diff_body}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
|
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
|
||||||
"""Remove the oldest Dream session files, keeping only the N most recent.
|
"""Remove the oldest Dream session files, keeping only the N most recent.
|
||||||
|
|
||||||
Only files matching ``dream_*.jsonl`` are considered. Non-dream session
|
Only current base64url-encoded Dream session keys are considered.
|
||||||
files are never touched.
|
Non-dream session files are never touched.
|
||||||
"""
|
"""
|
||||||
dream_files = sorted(
|
dream_files = []
|
||||||
sessions_dir.glob("dream_*.jsonl"), key=lambda p: p.stat().st_mtime,
|
for path in sessions_dir.glob("*.jsonl"):
|
||||||
)
|
decoded_key = SessionManager._decode_storage_key(path.stem)
|
||||||
|
if decoded_key is not None and decoded_key.startswith("dream:"):
|
||||||
|
dream_files.append(path)
|
||||||
|
dream_files.sort(key=lambda p: p.stat().st_mtime)
|
||||||
if len(dream_files) <= keep:
|
if len(dream_files) <= keep:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -634,6 +734,7 @@ class MemoryStore:
|
|||||||
# that catches any new caller that forgot to set its own cap.
|
# that catches any new caller that forgot to set its own cap.
|
||||||
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
|
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
|
||||||
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
|
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
|
||||||
|
_DREAM_PROMPT_MAX_CHARS = 32_000 # workspace-local Dream prompt override
|
||||||
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
||||||
|
|
||||||
|
|
||||||
@@ -647,22 +748,14 @@ class Consolidator:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
store: MemoryStore,
|
store: MemoryStore,
|
||||||
provider: LLMProvider,
|
|
||||||
model: str,
|
|
||||||
sessions: SessionManager,
|
sessions: SessionManager,
|
||||||
context_window_tokens: int,
|
|
||||||
build_messages: Callable[..., list[dict[str, Any]]],
|
build_messages: Callable[..., list[dict[str, Any]]],
|
||||||
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
||||||
max_completion_tokens: int = 4096,
|
|
||||||
consolidation_ratio: float = 0.5,
|
consolidation_ratio: float = 0.5,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
):
|
):
|
||||||
self.store = store
|
self.store = store
|
||||||
self.provider = provider
|
|
||||||
self.model = model
|
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
self.context_window_tokens = context_window_tokens
|
|
||||||
self.max_completion_tokens = max_completion_tokens
|
|
||||||
self.consolidation_ratio = consolidation_ratio
|
self.consolidation_ratio = consolidation_ratio
|
||||||
self.unified_session = unified_session
|
self.unified_session = unified_session
|
||||||
self._build_messages = build_messages
|
self._build_messages = build_messages
|
||||||
@@ -671,17 +764,6 @@ class Consolidator:
|
|||||||
weakref.WeakValueDictionary()
|
weakref.WeakValueDictionary()
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_provider(
|
|
||||||
self,
|
|
||||||
provider: LLMProvider,
|
|
||||||
model: str,
|
|
||||||
context_window_tokens: int,
|
|
||||||
) -> None:
|
|
||||||
self.provider = provider
|
|
||||||
self.model = model
|
|
||||||
self.context_window_tokens = context_window_tokens
|
|
||||||
self.max_completion_tokens = provider.generation.max_tokens
|
|
||||||
|
|
||||||
def get_lock(self, session_key: str) -> asyncio.Lock:
|
def get_lock(self, session_key: str) -> asyncio.Lock:
|
||||||
"""Return the shared consolidation lock for one session."""
|
"""Return the shared consolidation lock for one session."""
|
||||||
return self._locks.setdefault(session_key, asyncio.Lock())
|
return self._locks.setdefault(session_key, asyncio.Lock())
|
||||||
@@ -759,6 +841,8 @@ class Consolidator:
|
|||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
replay_max_messages: int | None,
|
replay_max_messages: int | None,
|
||||||
|
*,
|
||||||
|
runtime: LLMRuntime,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Archive messages that would be hidden by the replay message window."""
|
"""Archive messages that would be hidden by the replay message window."""
|
||||||
end_idx = self._replay_overflow_boundary(session, replay_max_messages)
|
end_idx = self._replay_overflow_boundary(session, replay_max_messages)
|
||||||
@@ -773,7 +857,11 @@ class Consolidator:
|
|||||||
len(chunk),
|
len(chunk),
|
||||||
replay_max_messages,
|
replay_max_messages,
|
||||||
)
|
)
|
||||||
summary = await self.archive(chunk, session_key=session.key)
|
summary = await self.archive(
|
||||||
|
chunk,
|
||||||
|
runtime=runtime,
|
||||||
|
session_key=session.key,
|
||||||
|
)
|
||||||
session.last_consolidated = end_idx
|
session.last_consolidated = end_idx
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return summary
|
return summary
|
||||||
@@ -789,6 +877,8 @@ class Consolidator:
|
|||||||
def estimate_session_prompt_tokens(
|
def estimate_session_prompt_tokens(
|
||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
*,
|
||||||
|
runtime: LLMRuntime,
|
||||||
) -> tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||||
history = self._full_unconsolidated_history(session)
|
history = self._full_unconsolidated_history(session)
|
||||||
@@ -808,20 +898,23 @@ class Consolidator:
|
|||||||
unified_session=self.unified_session,
|
unified_session=self.unified_session,
|
||||||
)
|
)
|
||||||
return estimate_prompt_tokens_chain(
|
return estimate_prompt_tokens_chain(
|
||||||
self.provider,
|
runtime.provider,
|
||||||
self.model,
|
runtime.model,
|
||||||
probe_messages,
|
probe_messages,
|
||||||
self._get_tool_definitions(),
|
self._get_tool_definitions(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
def _input_token_budget(self, runtime: LLMRuntime) -> int:
|
||||||
def _input_token_budget(self) -> int:
|
|
||||||
"""Available input token budget for consolidation LLM."""
|
"""Available input token budget for consolidation LLM."""
|
||||||
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
return (
|
||||||
|
runtime.context_window_tokens
|
||||||
|
- runtime.generation.max_tokens
|
||||||
|
- self._SAFETY_BUFFER
|
||||||
|
)
|
||||||
|
|
||||||
def _truncate_to_token_budget(self, text: str) -> str:
|
def _truncate_to_token_budget(self, text: str, *, runtime: LLMRuntime) -> str:
|
||||||
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
||||||
budget = self._input_token_budget
|
budget = self._input_token_budget(runtime)
|
||||||
if budget <= 0:
|
if budget <= 0:
|
||||||
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||||
return truncate_text_to_tokens(text, budget)
|
return truncate_text_to_tokens(text, budget)
|
||||||
@@ -830,6 +923,7 @@ class Consolidator:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
*,
|
*,
|
||||||
|
runtime: LLMRuntime,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
summary_messages: list[dict] | None = None,
|
summary_messages: list[dict] | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -844,12 +938,14 @@ class Consolidator:
|
|||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
messages_to_summarize = summary_messages if summary_messages is not None else messages
|
messages_to_summarize = public_history_messages(
|
||||||
|
summary_messages if summary_messages is not None else messages
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
formatted = MemoryStore._format_messages(messages_to_summarize)
|
formatted = MemoryStore._format_messages(messages_to_summarize)
|
||||||
formatted = self._truncate_to_token_budget(formatted)
|
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
||||||
response = await self.provider.chat_with_retry(
|
response = await runtime.provider.chat_with_retry(
|
||||||
model=self.model,
|
model=runtime.model,
|
||||||
messages=[
|
messages=[
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@@ -862,6 +958,9 @@ class Consolidator:
|
|||||||
],
|
],
|
||||||
tools=None,
|
tools=None,
|
||||||
tool_choice=None,
|
tool_choice=None,
|
||||||
|
temperature=runtime.generation.temperature,
|
||||||
|
max_tokens=runtime.generation.max_tokens,
|
||||||
|
reasoning_effort=runtime.generation.reasoning_effort,
|
||||||
)
|
)
|
||||||
if response.finish_reason == "error":
|
if response.finish_reason == "error":
|
||||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||||
@@ -881,6 +980,7 @@ class Consolidator:
|
|||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
runtime: LLMRuntime,
|
||||||
replay_max_messages: int | None = None,
|
replay_max_messages: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Loop: archive old messages until prompt fits within safe budget.
|
"""Loop: archive old messages until prompt fits within safe budget.
|
||||||
@@ -888,7 +988,7 @@ 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 self.context_window_tokens <= 0:
|
if runtime.context_window_tokens <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
lock = self.get_lock(session.key)
|
lock = self.get_lock(session.key)
|
||||||
@@ -900,15 +1000,17 @@ class Consolidator:
|
|||||||
if not session.messages:
|
if not session.messages:
|
||||||
return
|
return
|
||||||
|
|
||||||
budget = self._input_token_budget
|
budget = self._input_token_budget(runtime)
|
||||||
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(
|
||||||
session,
|
session,
|
||||||
replay_max_messages,
|
replay_max_messages,
|
||||||
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
estimated, source = self.estimate_session_prompt_tokens(
|
estimated, source = self.estimate_session_prompt_tokens(
|
||||||
session,
|
session,
|
||||||
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
logger.exception("Token estimation failed for {}", session.key)
|
||||||
@@ -922,7 +1024,7 @@ class Consolidator:
|
|||||||
"Token consolidation idle {}: {}/{} via {}, msgs={}",
|
"Token consolidation idle {}: {}/{} via {}, msgs={}",
|
||||||
session.key,
|
session.key,
|
||||||
estimated,
|
estimated,
|
||||||
self.context_window_tokens,
|
runtime.context_window_tokens,
|
||||||
source,
|
source,
|
||||||
unconsolidated_count,
|
unconsolidated_count,
|
||||||
)
|
)
|
||||||
@@ -953,11 +1055,15 @@ class Consolidator:
|
|||||||
round_num,
|
round_num,
|
||||||
session.key,
|
session.key,
|
||||||
estimated,
|
estimated,
|
||||||
self.context_window_tokens,
|
runtime.context_window_tokens,
|
||||||
source,
|
source,
|
||||||
len(chunk),
|
len(chunk),
|
||||||
)
|
)
|
||||||
summary = await self.archive(chunk, session_key=session.key)
|
summary = await self.archive(
|
||||||
|
chunk,
|
||||||
|
runtime=runtime,
|
||||||
|
session_key=session.key,
|
||||||
|
)
|
||||||
# Advance the cursor either way: on success the chunk was
|
# Advance the cursor either way: on success the chunk was
|
||||||
# summarized; on failure archive() already raw-archived it as
|
# summarized; on failure archive() already raw-archived it as
|
||||||
# a breadcrumb. Re-archiving the same chunk on the next call
|
# a breadcrumb. Re-archiving the same chunk on the next call
|
||||||
@@ -974,6 +1080,7 @@ class Consolidator:
|
|||||||
try:
|
try:
|
||||||
estimated, source = self.estimate_session_prompt_tokens(
|
estimated, source = self.estimate_session_prompt_tokens(
|
||||||
session,
|
session,
|
||||||
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
logger.exception("Token estimation failed for {}", session.key)
|
||||||
@@ -989,6 +1096,8 @@ class Consolidator:
|
|||||||
async def compact_idle_session(
|
async def compact_idle_session(
|
||||||
self,
|
self,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
|
*,
|
||||||
|
runtime: LLMRuntime,
|
||||||
max_suffix: int = 8,
|
max_suffix: int = 8,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Hard-truncate an idle session under the consolidation lock.
|
"""Hard-truncate an idle session under the consolidation lock.
|
||||||
@@ -1031,6 +1140,7 @@ class Consolidator:
|
|||||||
# the messages that are no longer kept in the live session.
|
# the messages that are no longer kept in the live session.
|
||||||
summary = await self.archive(
|
summary = await self.archive(
|
||||||
messages_to_remove,
|
messages_to_remove,
|
||||||
|
runtime=runtime,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
summary_messages=messages_to_summarize,
|
summary_messages=messages_to_summarize,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ def build_static_preset_snapshot(
|
|||||||
name: str,
|
name: str,
|
||||||
preset: ModelPresetConfig,
|
preset: ModelPresetConfig,
|
||||||
) -> ProviderSnapshot:
|
) -> ProviderSnapshot:
|
||||||
provider.generation = preset.to_generation_settings()
|
|
||||||
return ProviderSnapshot(
|
return ProviderSnapshot(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=preset.model,
|
model=preset.model,
|
||||||
context_window_tokens=preset.context_window_tokens,
|
context_window_tokens=preset.context_window_tokens,
|
||||||
signature=("model_preset", name, preset.model_dump_json()),
|
signature=("model_preset", name, preset.model_dump_json()),
|
||||||
|
generation=preset.to_generation_settings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Public resolution boundary for default and overridden LLM runtimes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from nanobot.agent import model_presets as preset_helpers
|
||||||
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime, runtime_from_provider_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRuntimeResolver:
|
||||||
|
"""Own model selection and resolve it to immutable execution values.
|
||||||
|
|
||||||
|
The resolver is deliberately independent of ``AgentLoop``. Command, SDK,
|
||||||
|
and tool admission layers can depend on this public service without reading
|
||||||
|
or mutating private loop state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
initial_runtime: LLMRuntime,
|
||||||
|
*,
|
||||||
|
model_presets: Mapping[str, ModelPresetConfig] | None = None,
|
||||||
|
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
||||||
|
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._runtime = initial_runtime
|
||||||
|
self._model_presets = dict(model_presets or {})
|
||||||
|
self._provider_snapshot_loader = provider_snapshot_loader
|
||||||
|
self._preset_snapshot_loader = preset_snapshot_loader
|
||||||
|
self._tracks_provider_generation = initial_runtime.model_preset is None
|
||||||
|
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||||
|
initial_runtime.snapshot_signature
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime(self) -> LLMRuntime:
|
||||||
|
"""Return the current immutable default without refreshing configuration."""
|
||||||
|
return self._runtime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
|
||||||
|
return self._model_presets
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_preset(self) -> str | None:
|
||||||
|
return self._runtime.model_preset
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider_signature(self) -> tuple[object, ...] | None:
|
||||||
|
return self._runtime.snapshot_signature
|
||||||
|
|
||||||
|
def current(self, *, refresh: bool = False) -> LLMRuntime:
|
||||||
|
"""Return the selected runtime, optionally refreshing the default source."""
|
||||||
|
if refresh:
|
||||||
|
self.refresh()
|
||||||
|
self._refresh_provider_generation()
|
||||||
|
return self._runtime
|
||||||
|
|
||||||
|
def resolve_snapshot(
|
||||||
|
self,
|
||||||
|
snapshot: ProviderSnapshot,
|
||||||
|
*,
|
||||||
|
model_preset: str | None = None,
|
||||||
|
) -> LLMRuntime:
|
||||||
|
"""Resolve a factory snapshot without changing the selected default."""
|
||||||
|
return runtime_from_provider_snapshot(snapshot, model_preset=model_preset)
|
||||||
|
|
||||||
|
def adopt_snapshot(
|
||||||
|
self,
|
||||||
|
snapshot: ProviderSnapshot,
|
||||||
|
*,
|
||||||
|
model_preset: str | None = None,
|
||||||
|
) -> LLMRuntime:
|
||||||
|
"""Select a snapshot as the default for future turns."""
|
||||||
|
runtime = self.resolve_snapshot(snapshot, model_preset=model_preset)
|
||||||
|
self._runtime = runtime
|
||||||
|
self._tracks_provider_generation = model_preset is None
|
||||||
|
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||||
|
runtime.snapshot_signature
|
||||||
|
)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
def resolve_preset(self, name: str | None) -> LLMRuntime:
|
||||||
|
"""Resolve a named preset without changing the selected default."""
|
||||||
|
normalized = preset_helpers.normalize_preset_name(name, self._model_presets)
|
||||||
|
snapshot = preset_helpers.build_runtime_preset_snapshot(
|
||||||
|
name=normalized,
|
||||||
|
presets=self._model_presets,
|
||||||
|
provider=self._runtime.provider,
|
||||||
|
loader=self._preset_snapshot_loader,
|
||||||
|
)
|
||||||
|
return self.resolve_snapshot(snapshot, model_preset=normalized)
|
||||||
|
|
||||||
|
def select_preset(self, name: str | None) -> LLMRuntime:
|
||||||
|
"""Select a named preset as the default for future turns."""
|
||||||
|
runtime = self.resolve_preset(name)
|
||||||
|
self._runtime = runtime
|
||||||
|
self._tracks_provider_generation = False
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
def select_model(self, model: str) -> LLMRuntime:
|
||||||
|
"""Change the default model without reconstructing downstream consumers."""
|
||||||
|
if not isinstance(model, str) or not model.strip():
|
||||||
|
raise ValueError("model must be a non-empty string")
|
||||||
|
self._runtime = replace(
|
||||||
|
self._runtime,
|
||||||
|
model=model.strip(),
|
||||||
|
model_preset=None,
|
||||||
|
)
|
||||||
|
return self._runtime
|
||||||
|
|
||||||
|
def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
|
||||||
|
"""Change the default context limit for future admissions."""
|
||||||
|
if not isinstance(context_window_tokens, int) or isinstance(
|
||||||
|
context_window_tokens,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError("context_window_tokens must be an integer")
|
||||||
|
self._runtime = replace(
|
||||||
|
self._runtime,
|
||||||
|
context_window_tokens=context_window_tokens,
|
||||||
|
)
|
||||||
|
return self._runtime
|
||||||
|
|
||||||
|
def _refresh_provider_generation(self) -> LLMRuntime | None:
|
||||||
|
"""Adopt direct provider-default changes only for provider-backed defaults."""
|
||||||
|
if not self._tracks_provider_generation:
|
||||||
|
return None
|
||||||
|
runtime = self._runtime
|
||||||
|
captured = LLMRuntime.capture(
|
||||||
|
runtime.provider,
|
||||||
|
runtime.model,
|
||||||
|
context_window_tokens=runtime.context_window_tokens,
|
||||||
|
model_preset=runtime.model_preset,
|
||||||
|
snapshot_signature=runtime.snapshot_signature,
|
||||||
|
)
|
||||||
|
if captured.generation == runtime.generation:
|
||||||
|
return None
|
||||||
|
self._runtime = replace(runtime, generation=captured.generation)
|
||||||
|
return self._runtime
|
||||||
|
|
||||||
|
def refresh(self) -> LLMRuntime | None:
|
||||||
|
"""Refresh configured defaults and return the replacement when changed."""
|
||||||
|
if self._provider_snapshot_loader is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
snapshot = self._provider_snapshot_loader()
|
||||||
|
default_selection = preset_helpers.default_selection_signature(snapshot.signature)
|
||||||
|
active_preset = self._runtime.model_preset
|
||||||
|
if active_preset and self._default_selection_signature in (None, default_selection):
|
||||||
|
runtime = self.resolve_preset(active_preset)
|
||||||
|
else:
|
||||||
|
active_preset = None
|
||||||
|
runtime = self.resolve_snapshot(snapshot)
|
||||||
|
|
||||||
|
unchanged = (
|
||||||
|
runtime.snapshot_signature == self._runtime.snapshot_signature
|
||||||
|
and runtime.model_preset == self._runtime.model_preset
|
||||||
|
)
|
||||||
|
if unchanged:
|
||||||
|
self._default_selection_signature = default_selection
|
||||||
|
return None
|
||||||
|
(
|
||||||
|
self._runtime,
|
||||||
|
self._tracks_provider_generation,
|
||||||
|
self._default_selection_signature,
|
||||||
|
) = (
|
||||||
|
runtime,
|
||||||
|
active_preset is None,
|
||||||
|
default_selection,
|
||||||
|
)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
def resolve_override(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model: str | None,
|
||||||
|
model_preset: str | None,
|
||||||
|
config: Config | None = None,
|
||||||
|
) -> LLMRuntime | None:
|
||||||
|
"""Resolve an SDK-style per-run override without mutating the default."""
|
||||||
|
if model is not None and model_preset is not None:
|
||||||
|
raise ValueError("model and model_preset are mutually exclusive")
|
||||||
|
if model_preset is not None:
|
||||||
|
return self.resolve_preset(model_preset)
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
if config is None:
|
||||||
|
return LLMRuntime(
|
||||||
|
provider=self._runtime.provider,
|
||||||
|
model=model,
|
||||||
|
generation=self._runtime.generation,
|
||||||
|
context_window_tokens=self._runtime.context_window_tokens,
|
||||||
|
snapshot_signature=("model_override", model),
|
||||||
|
)
|
||||||
|
|
||||||
|
base = config.resolve_preset(self.model_preset)
|
||||||
|
preset = base.model_copy(update={"model": model, "provider": "auto"})
|
||||||
|
return self.resolve_snapshot(build_provider_snapshot(config, preset=preset))
|
||||||
@@ -28,26 +28,16 @@ class AgentProgressHook(AgentHook):
|
|||||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||||
*,
|
*,
|
||||||
channel: str = "cli",
|
|
||||||
chat_id: str = "direct",
|
|
||||||
message_id: str | None = None,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
tool_hint_max_length: int = 40,
|
tool_hint_max_length: int = 40,
|
||||||
set_tool_context: Callable[..., None] | None = None,
|
|
||||||
on_iteration: Callable[[int], None] | None = None,
|
on_iteration: Callable[[int], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(reraise=True)
|
super().__init__(reraise=True)
|
||||||
self._on_progress = on_progress
|
self._on_progress = on_progress
|
||||||
self._on_stream = on_stream
|
self._on_stream = on_stream
|
||||||
self._on_stream_end = on_stream_end
|
self._on_stream_end = on_stream_end
|
||||||
self._channel = channel
|
|
||||||
self._chat_id = chat_id
|
|
||||||
self._message_id = message_id
|
|
||||||
self._metadata = metadata or {}
|
|
||||||
self._session_key = session_key
|
self._session_key = session_key
|
||||||
self._tool_hint_max_length = tool_hint_max_length
|
self._tool_hint_max_length = tool_hint_max_length
|
||||||
self._set_tool_context = set_tool_context
|
|
||||||
self._on_iteration = on_iteration
|
self._on_iteration = on_iteration
|
||||||
self._stream_buf = ""
|
self._stream_buf = ""
|
||||||
self._think_extractor = IncrementalThinkExtractor()
|
self._think_extractor = IncrementalThinkExtractor()
|
||||||
@@ -124,15 +114,6 @@ class AgentProgressHook(AgentHook):
|
|||||||
for tc in context.tool_calls:
|
for tc in context.tool_calls:
|
||||||
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
||||||
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
||||||
if self._set_tool_context:
|
|
||||||
self._set_tool_context(
|
|
||||||
self._channel,
|
|
||||||
self._chat_id,
|
|
||||||
self._message_id,
|
|
||||||
self._metadata,
|
|
||||||
session_key=self._session_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||||
"""Publish a reasoning chunk; channel plugins decide whether to render."""
|
"""Publish a reasoning chunk; channel plugins decide whether to render."""
|
||||||
if (
|
if (
|
||||||
|
|||||||
+53
-124
@@ -21,16 +21,6 @@ from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
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,
|
||||||
@@ -40,10 +30,7 @@ from nanobot.utils.helpers import (
|
|||||||
strip_reasoning_tags,
|
strip_reasoning_tags,
|
||||||
strip_think,
|
strip_think,
|
||||||
)
|
)
|
||||||
from nanobot.utils.progress_events import (
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
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,
|
||||||
@@ -68,10 +55,6 @@ _MAX_EMPTY_RETRIES = 2
|
|||||||
_MAX_LENGTH_RECOVERIES = 3
|
_MAX_LENGTH_RECOVERIES = 3
|
||||||
_MAX_INJECTIONS_PER_TURN = 3
|
_MAX_INJECTIONS_PER_TURN = 3
|
||||||
_MAX_INJECTION_CYCLES = 5
|
_MAX_INJECTION_CYCLES = 5
|
||||||
# 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)
|
||||||
class AgentRunSpec:
|
class AgentRunSpec:
|
||||||
@@ -79,12 +62,9 @@ class AgentRunSpec:
|
|||||||
|
|
||||||
initial_messages: list[dict[str, Any]]
|
initial_messages: list[dict[str, Any]]
|
||||||
tools: ToolRegistry
|
tools: ToolRegistry
|
||||||
model: str
|
runtime: LLMRuntime
|
||||||
max_iterations: int
|
max_iterations: int
|
||||||
max_tool_result_chars: int
|
max_tool_result_chars: int
|
||||||
temperature: float | None = None
|
|
||||||
max_tokens: int | None = None
|
|
||||||
reasoning_effort: str | None = None
|
|
||||||
hook: AgentHook | None = None
|
hook: AgentHook | None = None
|
||||||
error_message: str | None = _DEFAULT_ERROR_MESSAGE
|
error_message: str | None = _DEFAULT_ERROR_MESSAGE
|
||||||
max_iterations_message: str | None = None
|
max_iterations_message: str | None = None
|
||||||
@@ -92,7 +72,6 @@ class AgentRunSpec:
|
|||||||
fail_on_tool_error: bool = False
|
fail_on_tool_error: bool = False
|
||||||
workspace: Path | None = None
|
workspace: Path | None = None
|
||||||
session_key: str | None = None
|
session_key: str | None = None
|
||||||
context_window_tokens: int | None = None
|
|
||||||
context_block_limit: int | None = None
|
context_block_limit: int | None = None
|
||||||
provider_retry_mode: str = "standard"
|
provider_retry_mode: str = "standard"
|
||||||
progress_callback: Any | None = None
|
progress_callback: Any | None = None
|
||||||
@@ -123,8 +102,7 @@ class AgentRunResult:
|
|||||||
class AgentRunner:
|
class AgentRunner:
|
||||||
"""Run a tool-capable LLM loop without product-layer concerns."""
|
"""Run a tool-capable LLM loop without product-layer concerns."""
|
||||||
|
|
||||||
def __init__(self, provider: LLMProvider):
|
def __init__(self) -> None:
|
||||||
self.provider = provider
|
|
||||||
self.context_governor = ContextGovernor()
|
self.context_governor = ContextGovernor()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -207,7 +185,7 @@ class AgentRunner:
|
|||||||
{
|
{
|
||||||
"phase": "final_response",
|
"phase": "final_response",
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": spec.model,
|
"model": spec.runtime.model,
|
||||||
"assistant_message": assistant_message,
|
"assistant_message": assistant_message,
|
||||||
"completed_tool_results": [],
|
"completed_tool_results": [],
|
||||||
"pending_tool_calls": [],
|
"pending_tool_calls": [],
|
||||||
@@ -362,15 +340,15 @@ class AgentRunner:
|
|||||||
injection_cycles = 0
|
injection_cycles = 0
|
||||||
compacted_tool_call_ids: set[str] = set()
|
compacted_tool_call_ids: set[str] = set()
|
||||||
governance_config = ContextGovernanceConfig(
|
governance_config = ContextGovernanceConfig(
|
||||||
provider=self.provider,
|
provider=spec.runtime.provider,
|
||||||
model=spec.model,
|
model=spec.runtime.model,
|
||||||
tools=spec.tools,
|
tools=spec.tools,
|
||||||
workspace=spec.workspace,
|
workspace=spec.workspace,
|
||||||
session_key=spec.session_key,
|
session_key=spec.session_key,
|
||||||
max_tool_result_chars=spec.max_tool_result_chars,
|
max_tool_result_chars=spec.max_tool_result_chars,
|
||||||
context_window_tokens=spec.context_window_tokens,
|
context_window_tokens=spec.runtime.context_window_tokens,
|
||||||
context_block_limit=spec.context_block_limit,
|
context_block_limit=spec.context_block_limit,
|
||||||
max_tokens=spec.max_tokens,
|
max_tokens=spec.runtime.generation.max_tokens,
|
||||||
inflight_start_index=len(spec.initial_messages),
|
inflight_start_index=len(spec.initial_messages),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -447,7 +425,7 @@ class AgentRunner:
|
|||||||
{
|
{
|
||||||
"phase": "awaiting_tools",
|
"phase": "awaiting_tools",
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": spec.model,
|
"model": spec.runtime.model,
|
||||||
"assistant_message": assistant_message,
|
"assistant_message": assistant_message,
|
||||||
"completed_tool_results": [],
|
"completed_tool_results": [],
|
||||||
"pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls],
|
"pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls],
|
||||||
@@ -461,6 +439,8 @@ class AgentRunner:
|
|||||||
response.tool_calls,
|
response.tool_calls,
|
||||||
external_lookup_counts,
|
external_lookup_counts,
|
||||||
workspace_violation_counts,
|
workspace_violation_counts,
|
||||||
|
hook,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
tool_events.extend(new_events)
|
tool_events.extend(new_events)
|
||||||
tools_used.extend(
|
tools_used.extend(
|
||||||
@@ -507,7 +487,7 @@ class AgentRunner:
|
|||||||
{
|
{
|
||||||
"phase": "tools_completed",
|
"phase": "tools_completed",
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": spec.model,
|
"model": spec.runtime.model,
|
||||||
"assistant_message": assistant_message,
|
"assistant_message": assistant_message,
|
||||||
"completed_tool_results": completed_tool_results,
|
"completed_tool_results": completed_tool_results,
|
||||||
"pending_tool_calls": [],
|
"pending_tool_calls": [],
|
||||||
@@ -661,7 +641,7 @@ class AgentRunner:
|
|||||||
{
|
{
|
||||||
"phase": "final_response",
|
"phase": "final_response",
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": spec.model,
|
"model": spec.runtime.model,
|
||||||
"assistant_message": messages[-1],
|
"assistant_message": messages[-1],
|
||||||
"completed_tool_results": [],
|
"completed_tool_results": [],
|
||||||
"pending_tool_calls": [],
|
"pending_tool_calls": [],
|
||||||
@@ -718,16 +698,14 @@ class AgentRunner:
|
|||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
"model": spec.model,
|
"model": spec.runtime.model,
|
||||||
"retry_mode": spec.provider_retry_mode,
|
"retry_mode": spec.provider_retry_mode,
|
||||||
"on_retry_wait": spec.retry_wait_callback,
|
"on_retry_wait": spec.retry_wait_callback,
|
||||||
}
|
}
|
||||||
if spec.temperature is not None:
|
generation = spec.runtime.generation
|
||||||
kwargs["temperature"] = spec.temperature
|
kwargs["temperature"] = generation.temperature
|
||||||
if spec.max_tokens is not None:
|
kwargs["max_tokens"] = generation.max_tokens
|
||||||
kwargs["max_tokens"] = spec.max_tokens
|
kwargs["reasoning_effort"] = generation.reasoning_effort
|
||||||
if spec.reasoning_effort is not None:
|
|
||||||
kwargs["reasoning_effort"] = spec.reasoning_effort
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
async def _request_model(
|
async def _request_model(
|
||||||
@@ -762,28 +740,10 @@ class AgentRunner:
|
|||||||
not wants_streaming
|
not wants_streaming
|
||||||
and spec.stream_progress_deltas
|
and spec.stream_progress_deltas
|
||||||
and spec.progress_callback is not None
|
and spec.progress_callback is not None
|
||||||
and getattr(self.provider, "supports_progress_deltas", False) is True
|
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
||||||
thinking_buf = ""
|
thinking_buf = ""
|
||||||
@@ -808,11 +768,10 @@ class AgentRunner:
|
|||||||
async def _stream_recover() -> None:
|
async def _stream_recover() -> None:
|
||||||
await hook.on_stream_end(context, resuming=True)
|
await hook.on_stream_end(context, resuming=True)
|
||||||
|
|
||||||
coro = self.provider.chat_stream_with_retry(
|
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||||
**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,
|
|
||||||
on_stream_recover=_stream_recover,
|
on_stream_recover=_stream_recover,
|
||||||
)
|
)
|
||||||
elif wants_progress_streaming:
|
elif wants_progress_streaming:
|
||||||
@@ -840,13 +799,12 @@ class AgentRunner:
|
|||||||
context.streamed_content = True
|
context.streamed_content = True
|
||||||
await spec.progress_callback(incremental)
|
await spec.progress_callback(incremental)
|
||||||
|
|
||||||
coro = self.provider.chat_stream_with_retry(
|
coro = spec.runtime.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 = spec.runtime.provider.chat_with_retry(**kwargs)
|
||||||
|
|
||||||
# Streaming requests already have provider-level idle timeouts
|
# Streaming requests already have provider-level idle timeouts
|
||||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
|
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
|
||||||
@@ -858,14 +816,6 @@ 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(
|
||||||
@@ -1029,7 +979,7 @@ class AgentRunner:
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
kwargs = self._build_request_kwargs(spec, messages, tools=None)
|
kwargs = self._build_request_kwargs(spec, messages, tools=None)
|
||||||
return await self.provider.chat_with_retry(**kwargs)
|
return await spec.runtime.provider.chat_with_retry(**kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _budget_exhausted_finalization_messages(
|
def _budget_exhausted_finalization_messages(
|
||||||
@@ -1077,7 +1027,12 @@ class AgentRunner:
|
|||||||
tools = spec.tools.get_definitions()
|
tools = spec.tools.get_definitions()
|
||||||
except Exception:
|
except Exception:
|
||||||
tools = None
|
tools = None
|
||||||
prompt_tokens, _ = estimate_prompt_tokens_chain(self.provider, spec.model, messages, tools)
|
prompt_tokens, _ = estimate_prompt_tokens_chain(
|
||||||
|
spec.runtime.provider,
|
||||||
|
spec.runtime.model,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
)
|
||||||
assistant_message = build_assistant_message(
|
assistant_message = build_assistant_message(
|
||||||
response.content or "",
|
response.content or "",
|
||||||
tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls],
|
tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls],
|
||||||
@@ -1131,14 +1086,23 @@ class AgentRunner:
|
|||||||
tool_calls: list[ToolCallRequest],
|
tool_calls: list[ToolCallRequest],
|
||||||
external_lookup_counts: dict[str, int],
|
external_lookup_counts: dict[str, int],
|
||||||
workspace_violation_counts: dict[str, int],
|
workspace_violation_counts: dict[str, int],
|
||||||
|
hook: AgentHook | None = None,
|
||||||
|
context: AgentHookContext | None = None,
|
||||||
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
|
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
|
||||||
|
hook = hook or AgentHook()
|
||||||
|
context = context or AgentHookContext(iteration=0, messages=[])
|
||||||
batches = self._partition_tool_batches(spec, tool_calls)
|
batches = self._partition_tool_batches(spec, tool_calls)
|
||||||
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
||||||
for batch in batches:
|
for batch in batches:
|
||||||
if spec.concurrent_tools and len(batch) > 1:
|
if spec.concurrent_tools and len(batch) > 1:
|
||||||
batch_results = await asyncio.gather(*(
|
batch_results = await asyncio.gather(*(
|
||||||
self._run_tool(
|
self._run_tool(
|
||||||
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
spec,
|
||||||
|
tool_call,
|
||||||
|
external_lookup_counts,
|
||||||
|
workspace_violation_counts,
|
||||||
|
hook,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
for tool_call in batch
|
for tool_call in batch
|
||||||
))
|
))
|
||||||
@@ -1147,7 +1111,12 @@ class AgentRunner:
|
|||||||
batch_results = []
|
batch_results = []
|
||||||
for tool_call in batch:
|
for tool_call in batch:
|
||||||
result = await self._run_tool(
|
result = await self._run_tool(
|
||||||
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
spec,
|
||||||
|
tool_call,
|
||||||
|
external_lookup_counts,
|
||||||
|
workspace_violation_counts,
|
||||||
|
hook,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
batch_results.append(result)
|
batch_results.append(result)
|
||||||
@@ -1168,7 +1137,11 @@ class AgentRunner:
|
|||||||
tool_call: ToolCallRequest,
|
tool_call: ToolCallRequest,
|
||||||
external_lookup_counts: dict[str, int],
|
external_lookup_counts: dict[str, int],
|
||||||
workspace_violation_counts: dict[str, int],
|
workspace_violation_counts: dict[str, int],
|
||||||
|
hook: AgentHook | None = None,
|
||||||
|
context: AgentHookContext | None = None,
|
||||||
) -> tuple[Any, dict[str, str], BaseException | None]:
|
) -> tuple[Any, dict[str, str], BaseException | None]:
|
||||||
|
hook = hook or AgentHook()
|
||||||
|
context = context or AgentHookContext(iteration=0, messages=[])
|
||||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||||
lookup_error = repeated_external_lookup_error(
|
lookup_error = repeated_external_lookup_error(
|
||||||
tool_call.name,
|
tool_call.name,
|
||||||
@@ -1209,30 +1182,7 @@ 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 = (
|
await hook.before_execute_tool(context, tool_call, tool, params)
|
||||||
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)
|
||||||
@@ -1241,14 +1191,7 @@ 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 hook.on_execute_tool_error(context, tool_call, tool, params, exc)
|
||||||
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",
|
||||||
@@ -1270,14 +1213,7 @@ class AgentRunner:
|
|||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if is_tool_error_result(tool_call.name, result):
|
if is_tool_error_result(tool_call.name, result):
|
||||||
if file_edit_trackers and progress_callback is not None:
|
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
||||||
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",
|
||||||
@@ -1296,14 +1232,7 @@ 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 hook.after_execute_tool(context, tool_call, tool, params, result)
|
||||||
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()
|
||||||
|
|||||||
+89
-20
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import warnings
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -12,7 +13,12 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.tools.context import ToolContext
|
from nanobot.agent.tools.context import (
|
||||||
|
RequestContext,
|
||||||
|
ToolContext,
|
||||||
|
bind_request_context,
|
||||||
|
reset_request_context,
|
||||||
|
)
|
||||||
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
|
||||||
@@ -26,6 +32,7 @@ from nanobot.security.workspace_access import (
|
|||||||
reset_workspace_scope,
|
reset_workspace_scope,
|
||||||
workspace_sandbox_status,
|
workspace_sandbox_status,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
|
|
||||||
@@ -76,10 +83,10 @@ class SubagentManager:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
provider: LLMProvider,
|
provider: LLMProvider | None = None,
|
||||||
workspace: Path,
|
workspace: Path | None = None,
|
||||||
bus: MessageBus,
|
bus: MessageBus | None = None,
|
||||||
max_tool_result_chars: int,
|
max_tool_result_chars: int | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
tools_config: ToolsConfig | None = None,
|
tools_config: ToolsConfig | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
@@ -89,11 +96,33 @@ class SubagentManager:
|
|||||||
fail_on_tool_error: bool | None = None,
|
fail_on_tool_error: bool | 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,
|
||||||
):
|
):
|
||||||
|
if workspace is None:
|
||||||
|
raise TypeError("SubagentManager.__init__() missing required argument: 'workspace'")
|
||||||
|
if bus is None:
|
||||||
|
raise TypeError("SubagentManager.__init__() missing required argument: 'bus'")
|
||||||
|
if max_tool_result_chars is None:
|
||||||
|
raise TypeError(
|
||||||
|
"SubagentManager.__init__() missing required argument: 'max_tool_result_chars'"
|
||||||
|
)
|
||||||
|
if model is not None and provider is None:
|
||||||
|
raise TypeError("SubagentManager model compatibility argument requires provider")
|
||||||
|
|
||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.provider = provider
|
self._compat_runtime: LLMRuntime | None = None
|
||||||
|
if provider is not None:
|
||||||
|
warnings.warn(
|
||||||
|
"SubagentManager provider/model constructor arguments are deprecated; "
|
||||||
|
"pass runtime=... to spawn() instead",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
self._compat_runtime = LLMRuntime.capture(
|
||||||
|
provider,
|
||||||
|
model or provider.get_default_model(),
|
||||||
|
context_window_tokens=defaults.context_window_tokens,
|
||||||
|
)
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.model = model or provider.get_default_model()
|
|
||||||
self.tools_config = tools_config or ToolsConfig()
|
self.tools_config = tools_config or ToolsConfig()
|
||||||
self.max_tool_result_chars = max_tool_result_chars
|
self.max_tool_result_chars = max_tool_result_chars
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
@@ -113,12 +142,47 @@ class SubagentManager:
|
|||||||
if fail_on_tool_error is not None
|
if fail_on_tool_error is not None
|
||||||
else defaults.fail_on_tool_error
|
else defaults.fail_on_tool_error
|
||||||
)
|
)
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner()
|
||||||
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]] = {}
|
||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||||
|
|
||||||
|
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||||
|
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||||
|
warnings.warn(
|
||||||
|
"SubagentManager.set_provider() is deprecated; pass runtime=... to spawn() instead",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
context_window_tokens = (
|
||||||
|
self._compat_runtime.context_window_tokens
|
||||||
|
if self._compat_runtime is not None
|
||||||
|
else AgentDefaults().context_window_tokens
|
||||||
|
)
|
||||||
|
self._compat_runtime = LLMRuntime.capture(
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
context_window_tokens=context_window_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compat_spawn_runtime(self) -> LLMRuntime:
|
||||||
|
runtime = self._compat_runtime
|
||||||
|
if runtime is None:
|
||||||
|
raise TypeError(
|
||||||
|
"SubagentManager.spawn() missing required keyword-only argument: 'runtime'"
|
||||||
|
)
|
||||||
|
warnings.warn(
|
||||||
|
"SubagentManager.spawn() without runtime is deprecated; pass runtime=... explicitly",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return LLMRuntime.capture(
|
||||||
|
runtime.provider,
|
||||||
|
runtime.model,
|
||||||
|
context_window_tokens=runtime.context_window_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
def _subagent_tools_config(self) -> ToolsConfig:
|
def _subagent_tools_config(self) -> ToolsConfig:
|
||||||
"""Build a ToolsConfig scoped for subagent use."""
|
"""Build a ToolsConfig scoped for subagent use."""
|
||||||
return ToolsConfig(
|
return ToolsConfig(
|
||||||
@@ -149,11 +213,6 @@ class SubagentManager:
|
|||||||
ToolLoader().load(ctx, registry, scope="subagent")
|
ToolLoader().load(ctx, registry, scope="subagent")
|
||||||
return registry
|
return registry
|
||||||
|
|
||||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
|
||||||
self.provider = provider
|
|
||||||
self.model = model
|
|
||||||
self.runner.provider = provider
|
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self,
|
||||||
task: str,
|
task: str,
|
||||||
@@ -164,8 +223,14 @@ class SubagentManager:
|
|||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
workspace_scope: WorkspaceScope | None = None,
|
workspace_scope: WorkspaceScope | None = None,
|
||||||
|
*,
|
||||||
|
runtime: LLMRuntime | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Spawn a subagent to execute a task in the background."""
|
"""Spawn a subagent to execute a task in the background."""
|
||||||
|
if runtime is None:
|
||||||
|
runtime = self._compat_spawn_runtime()
|
||||||
|
if temperature is not None:
|
||||||
|
runtime = runtime.with_generation_overrides(temperature=temperature)
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||||
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
|
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
|
||||||
@@ -185,8 +250,8 @@ class SubagentManager:
|
|||||||
display_label,
|
display_label,
|
||||||
origin,
|
origin,
|
||||||
status,
|
status,
|
||||||
|
runtime,
|
||||||
origin_message_id,
|
origin_message_id,
|
||||||
temperature,
|
|
||||||
workspace_scope,
|
workspace_scope,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -214,8 +279,8 @@ class SubagentManager:
|
|||||||
label: str,
|
label: str,
|
||||||
origin: dict[str, str],
|
origin: dict[str, str],
|
||||||
status: SubagentStatus,
|
status: SubagentStatus,
|
||||||
|
runtime: LLMRuntime,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
temperature: float | None = None,
|
|
||||||
workspace_scope: WorkspaceScope | 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."""
|
||||||
@@ -244,13 +309,19 @@ class SubagentManager:
|
|||||||
if self._llm_wall_timeout_for_session
|
if self._llm_wall_timeout_for_session
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
request_token = bind_request_context(RequestContext(
|
||||||
|
channel=origin["channel"],
|
||||||
|
chat_id=origin["chat_id"],
|
||||||
|
message_id=origin_message_id,
|
||||||
|
session_key=sess_key,
|
||||||
|
runtime=runtime,
|
||||||
|
))
|
||||||
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
|
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
|
||||||
try:
|
try:
|
||||||
result = await self.runner.run(AgentRunSpec(
|
result = await self.runner.run(AgentRunSpec(
|
||||||
initial_messages=messages,
|
initial_messages=messages,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
model=self.model,
|
runtime=runtime,
|
||||||
temperature=temperature,
|
|
||||||
max_iterations=self.max_iterations,
|
max_iterations=self.max_iterations,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
hook=_SubagentHook(task_id, status),
|
hook=_SubagentHook(task_id, status),
|
||||||
@@ -266,6 +337,7 @@ class SubagentManager:
|
|||||||
finally:
|
finally:
|
||||||
if token is not None:
|
if token is not None:
|
||||||
reset_workspace_scope(token)
|
reset_workspace_scope(token)
|
||||||
|
reset_request_context(request_token)
|
||||||
status.phase = "done"
|
status.phase = "done"
|
||||||
status.stop_reason = result.stop_reason
|
status.stop_reason = result.stop_reason
|
||||||
|
|
||||||
@@ -361,10 +433,8 @@ class SubagentManager:
|
|||||||
|
|
||||||
def _build_subagent_prompt(self, workspace: Path | None = None) -> 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.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
time_ctx = ContextBuilder._build_runtime_context(None, None)
|
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
skills_summary = SkillsLoader(
|
skills_summary = SkillsLoader(
|
||||||
root,
|
root,
|
||||||
@@ -372,7 +442,6 @@ class SubagentManager:
|
|||||||
).build_skills_summary()
|
).build_skills_summary()
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/subagent_system.md",
|
"agent/subagent_system.md",
|
||||||
time_ctx=time_ctx,
|
|
||||||
workspace=str(root),
|
workspace=str(root),
|
||||||
skills_summary=skills_summary or "",
|
skills_summary=skills_summary or "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ if typing.TYPE_CHECKING:
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.agent.tools.context import ToolContext
|
from nanobot.agent.tools.context import ToolContext
|
||||||
|
from nanobot.runtime_context import RuntimeContextProvider
|
||||||
|
|
||||||
_ToolT = TypeVar("_ToolT", bound="Tool")
|
_ToolT = TypeVar("_ToolT", bound="Tool")
|
||||||
|
|
||||||
@@ -206,6 +207,10 @@ class Tool(ABC):
|
|||||||
def create(cls, ctx: ToolContext) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls()
|
return cls()
|
||||||
|
|
||||||
|
def runtime_context_provider(self) -> RuntimeContextProvider | None:
|
||||||
|
"""Return optional per-turn prompt context owned by this tool."""
|
||||||
|
return None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def execute(self, **kwargs: Any) -> Any:
|
async def execute(self, **kwargs: Any) -> Any:
|
||||||
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
@@ -16,7 +17,9 @@ from nanobot.agent.tools.schema import (
|
|||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
from nanobot.apps.cli.utils import runtime_lines_for_request
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
|
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
||||||
from nanobot.security.workspace_access import current_tool_workspace
|
from nanobot.security.workspace_access import current_tool_workspace
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +115,23 @@ class CliAppsTool(Tool):
|
|||||||
+ installed_note
|
+ installed_note
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def runtime_context_provider(self):
|
||||||
|
return self._provide_runtime_context
|
||||||
|
|
||||||
|
async def _provide_runtime_context(
|
||||||
|
self,
|
||||||
|
request: RequestContext,
|
||||||
|
) -> RuntimeContextBlock | None:
|
||||||
|
lines = runtime_lines_for_request(
|
||||||
|
request.original_user_text or "",
|
||||||
|
request.metadata,
|
||||||
|
request.workspace or self.workspace,
|
||||||
|
)
|
||||||
|
content = wrap_runtime_context_lines(lines)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
return RuntimeContextBlock(source="cli_apps", content=content)
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""Runtime context for tool construction."""
|
"""Runtime context for tool construction."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Protocol, runtime_checkable
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
|
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
|
||||||
"nanobot_tool_request_context",
|
"nanobot_tool_request_context",
|
||||||
@@ -18,7 +23,12 @@ class RequestContext:
|
|||||||
chat_id: str
|
chat_id: str
|
||||||
message_id: str | None = None
|
message_id: str | None = None
|
||||||
session_key: str | None = None
|
session_key: str | None = None
|
||||||
|
original_user_text: str | None = None
|
||||||
|
runtime: LLMRuntime | None = None
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
sender_id: str | None = None
|
||||||
|
turn_id: str | None = None
|
||||||
|
workspace: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -35,6 +45,16 @@ def reset_request_context(token: Token[RequestContext | None]) -> None:
|
|||||||
_CURRENT_REQUEST_CONTEXT.reset(token)
|
_CURRENT_REQUEST_CONTEXT.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def request_context(ctx: RequestContext):
|
||||||
|
"""Bind one immutable request snapshot and restore the previous value."""
|
||||||
|
token = bind_request_context(ctx)
|
||||||
|
try:
|
||||||
|
yield ctx
|
||||||
|
finally:
|
||||||
|
reset_request_context(token)
|
||||||
|
|
||||||
|
|
||||||
def current_request_context() -> RequestContext | None:
|
def current_request_context() -> RequestContext | None:
|
||||||
return _CURRENT_REQUEST_CONTEXT.get()
|
return _CURRENT_REQUEST_CONTEXT.get()
|
||||||
|
|
||||||
|
|||||||
+12
-19
@@ -7,7 +7,7 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import current_request_context
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
StringSchema,
|
StringSchema,
|
||||||
@@ -51,19 +51,12 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
|||||||
|
|
||||||
|
|
||||||
@tool_parameters(_CRON_PARAMETERS)
|
@tool_parameters(_CRON_PARAMETERS)
|
||||||
class CronTool(Tool, ContextAware):
|
class CronTool(Tool):
|
||||||
"""Tool to schedule reminders and recurring tasks."""
|
"""Tool to schedule reminders and recurring tasks."""
|
||||||
|
|
||||||
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
|
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
|
||||||
self._cron = cron_service
|
self._cron = cron_service
|
||||||
self._default_timezone = default_timezone
|
self._default_timezone = default_timezone
|
||||||
self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="")
|
|
||||||
self._origin_channel: ContextVar[str] = ContextVar("cron_origin_channel", default="")
|
|
||||||
self._origin_chat_id: ContextVar[str] = ContextVar("cron_origin_chat_id", default="")
|
|
||||||
self._origin_metadata: ContextVar[dict[str, Any] | None] = ContextVar(
|
|
||||||
"cron_origin_metadata",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -74,15 +67,17 @@ class CronTool(Tool, ContextAware):
|
|||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
|
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
|
||||||
|
|
||||||
def set_context(self, ctx: RequestContext) -> None:
|
@staticmethod
|
||||||
"""Set the current session context for scheduled cron job ownership."""
|
def _request_route() -> tuple[str, str, str, dict[str, Any]]:
|
||||||
|
"""Return routing from the authoritative request snapshot."""
|
||||||
|
ctx = current_request_context()
|
||||||
|
if ctx is None:
|
||||||
|
return "", "", "", {}
|
||||||
raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
|
raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
|
||||||
self._session_key.set(
|
session_key = (
|
||||||
raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
|
raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
|
||||||
)
|
)
|
||||||
self._origin_channel.set(ctx.channel or "")
|
return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
|
||||||
self._origin_chat_id.set(ctx.chat_id or "")
|
|
||||||
self._origin_metadata.set(dict(ctx.metadata or {}))
|
|
||||||
|
|
||||||
def set_cron_context(self, active: bool):
|
def set_cron_context(self, active: bool):
|
||||||
"""Mark whether the tool is executing inside a cron job callback."""
|
"""Mark whether the tool is executing inside a cron job callback."""
|
||||||
@@ -171,11 +166,9 @@ class CronTool(Tool, ContextAware):
|
|||||||
"describing what to do when the job triggers "
|
"describing what to do when the job triggers "
|
||||||
"(e.g. the reminder text). Retry including message=\"...\"."
|
"(e.g. the reminder text). Retry including message=\"...\"."
|
||||||
)
|
)
|
||||||
session_key = self._session_key.get()
|
session_key, origin_channel, origin_chat_id, origin_metadata = self._request_route()
|
||||||
if not session_key:
|
if not session_key:
|
||||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
||||||
origin_channel = self._origin_channel.get()
|
|
||||||
origin_chat_id = self._origin_chat_id.get()
|
|
||||||
if not origin_channel or not origin_chat_id:
|
if not origin_channel or not origin_chat_id:
|
||||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
||||||
if tz and not cron_expr:
|
if tz and not cron_expr:
|
||||||
@@ -218,7 +211,7 @@ class CronTool(Tool, ContextAware):
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
origin_channel=origin_channel,
|
origin_channel=origin_channel,
|
||||||
origin_chat_id=origin_chat_id,
|
origin_chat_id=origin_chat_id,
|
||||||
origin_metadata=dict(self._origin_metadata.get() or {}),
|
origin_metadata=origin_metadata,
|
||||||
)
|
)
|
||||||
return f"Created job '{job.name}' (id: {job.id})"
|
return f"Created job '{job.name}' (id: {job.id})"
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ class _ExecSession:
|
|||||||
asyncio.gather(self._stdout_task, self._stderr_task),
|
asyncio.gather(self._stdout_task, self._stderr_task),
|
||||||
timeout=2.0,
|
timeout=2.0,
|
||||||
)
|
)
|
||||||
|
# Safety-net reap after normal exit.
|
||||||
|
from nanobot.agent.tools.shell import _reap_pid
|
||||||
|
_reap_pid(self.process.pid)
|
||||||
elif yield_time_ms > 0:
|
elif yield_time_ms > 0:
|
||||||
await self._wait_for_buffered_output()
|
await self._wait_for_buffered_output()
|
||||||
|
|
||||||
@@ -171,8 +174,14 @@ class _ExecSession:
|
|||||||
if self.process.returncode is not None:
|
if self.process.returncode is not None:
|
||||||
return
|
return
|
||||||
self.process.kill()
|
self.process.kill()
|
||||||
|
try:
|
||||||
with suppress(asyncio.TimeoutError):
|
with suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
||||||
|
finally:
|
||||||
|
# Safety-net waitpid — prevent zombie if asyncio's child watcher
|
||||||
|
# did not reap the process (common in containers).
|
||||||
|
from nanobot.agent.tools.shell import _reap_pid
|
||||||
|
_reap_pid(self.process.pid)
|
||||||
|
|
||||||
async def _wait_for_buffered_output(self) -> None:
|
async def _wait_for_buffered_output(self) -> None:
|
||||||
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
||||||
|
|||||||
@@ -194,15 +194,21 @@ def _is_blocked_device(path: str | Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
|
def _builtin_skill_read_path(path: str) -> Path | None:
|
||||||
"""Parse a page range like '2-5' into 0-based (start, end) inclusive."""
|
"""Map workspace-relative skills/<name>/... reads onto bundled skills."""
|
||||||
parts = pages.strip().split("-")
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
if len(parts) == 1:
|
|
||||||
p = int(parts[0])
|
requested = Path(path)
|
||||||
return max(0, p - 1), min(p - 1, total - 1)
|
if requested.is_absolute():
|
||||||
start = int(parts[0])
|
return None
|
||||||
end = int(parts[1])
|
parts = requested.parts
|
||||||
return max(0, start - 1), min(end - 1, total - 1)
|
if len(parts) < 2 or parts[0] != "skills":
|
||||||
|
return None
|
||||||
|
root = BUILTIN_SKILLS_DIR.resolve()
|
||||||
|
candidate = (root / Path(*parts[1:])).resolve()
|
||||||
|
if candidate != root and root not in candidate.parents:
|
||||||
|
return None
|
||||||
|
return candidate if candidate.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
@@ -275,6 +281,8 @@ class ReadFileTool(_FsTool):
|
|||||||
return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
|
return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
|
||||||
|
|
||||||
fp = self._resolve_read(path)
|
fp = self._resolve_read(path)
|
||||||
|
if not fp.exists():
|
||||||
|
fp = _builtin_skill_read_path(path) or fp
|
||||||
if _is_blocked_device(fp):
|
if _is_blocked_device(fp):
|
||||||
return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
|
return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
|
||||||
if not fp.exists():
|
if not fp.exists():
|
||||||
@@ -386,49 +394,33 @@ class ReadFileTool(_FsTool):
|
|||||||
return ToolResult.error(f"Error reading file: {e}")
|
return ToolResult.error(f"Error reading file: {e}")
|
||||||
|
|
||||||
def _read_pdf(self, fp: Path, pages: str | None) -> str:
|
def _read_pdf(self, fp: Path, pages: str | None) -> str:
|
||||||
try:
|
from nanobot.utils.document import PdfPageRangeError, PdfSafetyError, extract_pdf_pages
|
||||||
import fitz # pymupdf
|
|
||||||
except ImportError:
|
|
||||||
return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = fitz.open(str(fp))
|
extraction = extract_pdf_pages(
|
||||||
|
fp,
|
||||||
|
pages=pages,
|
||||||
|
max_pages=self._MAX_PDF_PAGES,
|
||||||
|
max_chars=self._MAX_CHARS,
|
||||||
|
)
|
||||||
|
except PdfPageRangeError:
|
||||||
|
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
|
||||||
|
except PdfSafetyError as e:
|
||||||
|
return ToolResult.error(f"Error reading PDF: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error reading PDF: {e}")
|
return ToolResult.error(f"Error reading PDF: {e}")
|
||||||
|
|
||||||
total_pages = len(doc)
|
if not extraction.text:
|
||||||
if pages:
|
|
||||||
try:
|
|
||||||
start, end = _parse_page_range(pages, total_pages)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
doc.close()
|
|
||||||
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
|
|
||||||
if start > end or start >= total_pages:
|
|
||||||
doc.close()
|
|
||||||
return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
|
|
||||||
else:
|
|
||||||
start = 0
|
|
||||||
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
|
|
||||||
|
|
||||||
if end - start + 1 > self._MAX_PDF_PAGES:
|
|
||||||
end = start + self._MAX_PDF_PAGES - 1
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
for i in range(start, end + 1):
|
|
||||||
page = doc[i]
|
|
||||||
text = page.get_text().strip()
|
|
||||||
if text:
|
|
||||||
parts.append(f"--- Page {i + 1} ---\n{text}")
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
if not parts:
|
|
||||||
return f"(PDF has no extractable text: {fp})"
|
return f"(PDF has no extractable text: {fp})"
|
||||||
|
|
||||||
result = "\n\n".join(parts)
|
result = extraction.text
|
||||||
if end < total_pages - 1:
|
if extraction.end_page < extraction.total_pages - 1:
|
||||||
result += f"\n\n(Showing pages {start + 1}-{end + 1} of {total_pages}. Use pages='{end + 2}-{min(end + 1 + self._MAX_PDF_PAGES, total_pages)}' to continue.)"
|
next_start = extraction.end_page + 2
|
||||||
if len(result) > self._MAX_CHARS:
|
next_end = min(extraction.end_page + 1 + self._MAX_PDF_PAGES, extraction.total_pages)
|
||||||
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
|
result += (
|
||||||
|
f"\n\n(Showing pages {extraction.start_page + 1}-{extraction.end_page + 1} "
|
||||||
|
f"of {extraction.total_pages}. Use pages='{next_start}-{next_end}' to continue.)"
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _read_office_doc(self, fp: Path) -> str:
|
def _read_office_doc(self, fp: Path) -> str:
|
||||||
@@ -602,6 +594,15 @@ class _MatchSpan:
|
|||||||
line: int
|
line: int
|
||||||
|
|
||||||
|
|
||||||
|
def _match_end_line(match: _MatchSpan) -> int:
|
||||||
|
comparable = match.text[:-1] if match.text.endswith("\n") else match.text
|
||||||
|
return match.line + comparable.count("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _match_covers_line(match: _MatchSpan, line: int) -> bool:
|
||||||
|
return match.line <= line <= _match_end_line(match)
|
||||||
|
|
||||||
|
|
||||||
def _find_exact_matches(content: str, old_text: str) -> list[_MatchSpan]:
|
def _find_exact_matches(content: str, old_text: str) -> list[_MatchSpan]:
|
||||||
matches: list[_MatchSpan] = []
|
matches: list[_MatchSpan] = []
|
||||||
start = 0
|
start = 0
|
||||||
@@ -775,7 +776,10 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
),
|
),
|
||||||
line_hint=IntegerSchema(
|
line_hint=IntegerSchema(
|
||||||
1,
|
1,
|
||||||
description="Optional 1-based line hint used to choose the nearest match.",
|
description=(
|
||||||
|
"Optional exact 1-based target line copied from read_file. "
|
||||||
|
"The selected old_text match must cover this line."
|
||||||
|
),
|
||||||
minimum=1,
|
minimum=1,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
@@ -807,8 +811,9 @@ class EditFileTool(_FsTool):
|
|||||||
"with old_text copied from read_file. For multi-file, structural, "
|
"with old_text copied from read_file. For multi-file, structural, "
|
||||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||||
"multiple times, provide more context or set occurrence, line_hint, "
|
"multiple times, provide more context or set occurrence, line_hint, "
|
||||||
"replace_all, and expected_replacements. Shows closest-match "
|
"replace_all, and expected_replacements. When editing from numbered "
|
||||||
"diagnostics on failure."
|
"read_file output, set line_hint to the exact target line. "
|
||||||
|
"Shows closest-match diagnostics on failure."
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -883,22 +888,12 @@ class EditFileTool(_FsTool):
|
|||||||
return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
|
return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
|
||||||
if occurrence is not None and line_hint is not None:
|
if occurrence is not None and line_hint is not None:
|
||||||
return ToolResult.error("Error: line_hint cannot be used with occurrence.")
|
return ToolResult.error("Error: line_hint cannot be used with occurrence.")
|
||||||
if count > 1 and not replace_all:
|
if occurrence is not None and occurrence > count:
|
||||||
if occurrence is not None:
|
|
||||||
if occurrence > count:
|
|
||||||
return ToolResult.error(
|
return ToolResult.error(
|
||||||
f"Error: occurrence {occurrence} is out of range; "
|
f"Error: occurrence {occurrence} is out of range; "
|
||||||
f"old_text appears {count} times."
|
f"old_text appears {count} time(s)."
|
||||||
)
|
)
|
||||||
elif line_hint is not None:
|
if count > 1 and not replace_all and occurrence is None and line_hint is 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 ToolResult.error(
|
|
||||||
f"Error: line_hint {line_hint} is ambiguous; "
|
|
||||||
f"old_text appears {count} times."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
line_numbers = [match.line for match in matches]
|
line_numbers = [match.line for match in matches]
|
||||||
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
||||||
if len(line_numbers) > 3:
|
if len(line_numbers) > 3:
|
||||||
@@ -909,11 +904,6 @@ class EditFileTool(_FsTool):
|
|||||||
"Provide more context, set occurrence to choose one match, "
|
"Provide more context, set occurrence to choose one match, "
|
||||||
"or set replace_all=true."
|
"or set replace_all=true."
|
||||||
)
|
)
|
||||||
elif occurrence is not None and occurrence > count:
|
|
||||||
return ToolResult.error(
|
|
||||||
f"Error: occurrence {occurrence} is out of range; "
|
|
||||||
f"old_text appears {count} time."
|
|
||||||
)
|
|
||||||
|
|
||||||
norm_new = new_text.replace("\r\n", "\n")
|
norm_new = new_text.replace("\r\n", "\n")
|
||||||
|
|
||||||
@@ -923,10 +913,27 @@ class EditFileTool(_FsTool):
|
|||||||
|
|
||||||
if replace_all:
|
if replace_all:
|
||||||
selected = matches
|
selected = matches
|
||||||
|
elif occurrence is not None:
|
||||||
|
selected = [matches[occurrence - 1]]
|
||||||
elif line_hint is not None:
|
elif line_hint is not None:
|
||||||
selected = [min(matches, key=lambda match: abs(match.line - line_hint))]
|
candidates = [match for match in matches if _match_covers_line(match, line_hint)]
|
||||||
|
if not candidates:
|
||||||
|
locations = ", ".join(f"line {match.line}" for match in matches[:3])
|
||||||
|
if len(matches) > 3:
|
||||||
|
locations += ", ..."
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: line_hint {line_hint} does not match the old_text location. "
|
||||||
|
f"old_text appears at {locations}. Re-read the intended region and "
|
||||||
|
"copy old_text that covers the target line."
|
||||||
|
)
|
||||||
|
if len(candidates) > 1:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: line_hint {line_hint} is ambiguous; "
|
||||||
|
f"old_text appears {len(candidates)} times on that line."
|
||||||
|
)
|
||||||
|
selected = candidates
|
||||||
else:
|
else:
|
||||||
selected = [matches[occurrence - 1 if occurrence else 0]]
|
selected = [matches[0]]
|
||||||
if expected_replacements is not None and len(selected) != expected_replacements:
|
if expected_replacements is not None and len(selected) != expected_replacements:
|
||||||
return ToolResult.error(
|
return ToolResult.error(
|
||||||
f"Error: expected {expected_replacements} replacements but "
|
f"Error: expected {expected_replacements} replacements but "
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ class _LegacyErrorPrefixTool(Tool):
|
|||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
return self._wrapped.parameters
|
return self._wrapped.parameters
|
||||||
|
|
||||||
|
def runtime_context_provider(self):
|
||||||
|
return self._wrapped.runtime_context_provider()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def read_only(self) -> bool:
|
def read_only(self) -> bool:
|
||||||
return self._wrapped.read_only
|
return self._wrapped.read_only
|
||||||
|
|||||||
+261
-142
@@ -1,46 +1,54 @@
|
|||||||
"""Sustained goal tools on the main agent (Codex-style).
|
"""Sustained-goal tools with explicit user opt-in at the execution boundary."""
|
||||||
|
|
||||||
Follow the built-in **long-goal** skill for lifecycle rules and how to phrase
|
|
||||||
objectives (especially **idempotent**, compaction-safe goals). Load that skill
|
|
||||||
from the skills listing (path shown there) before composing ``long_task.goal`` text.
|
|
||||||
|
|
||||||
``long_task`` registers an objective on the session (JSON-serializable metadata).
|
|
||||||
Active objectives are mirrored each turn into the Runtime Context block (see
|
|
||||||
``nanobot.session.goal_state.goal_state_runtime_lines``) so compaction cannot hide them.
|
|
||||||
Work proceeds in ordinary agent turns (same runner, compaction as configured).
|
|
||||||
Call ``complete_goal`` when the sustained objective should stop being tracked:
|
|
||||||
finished successfully, or cancelled / superseded / redirected—in every case the recap should match reality.
|
|
||||||
|
|
||||||
There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui`` stream.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextvars import ContextVar
|
from copy import deepcopy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from nanobot.agent.goal_permission import (
|
||||||
|
goal_mutation_allowed,
|
||||||
|
revoke_goal_mutation_permission,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import RequestContext, current_request_context
|
||||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||||
|
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
GOAL_STATE_KEY,
|
GOAL_STATE_KEY,
|
||||||
|
MAX_GOAL_OBJECTIVE_CHARS,
|
||||||
discard_legacy_goal_state_key,
|
discard_legacy_goal_state_key,
|
||||||
|
explicit_goal_requested,
|
||||||
goal_state_raw,
|
goal_state_raw,
|
||||||
|
goal_state_runtime_lines,
|
||||||
parse_goal_state,
|
parse_goal_state,
|
||||||
|
sustained_goal_active,
|
||||||
)
|
)
|
||||||
|
from nanobot.session.turn_continuation import reset_goal_continuation_rounds
|
||||||
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
_GOAL_ACTIONS = ("complete", "cancel", "block", "replace")
|
||||||
|
_CREATE_UNAVAILABLE_ERROR = (
|
||||||
|
"Error: create_goal is unavailable for this turn. Ask the user to submit the complete "
|
||||||
|
"objective as `/goal <task>`."
|
||||||
|
)
|
||||||
|
_REPLACE_UNAVAILABLE_ERROR = (
|
||||||
|
"Error: replacing the goal is unavailable for this turn. Ask the user to submit the "
|
||||||
|
"replacement objective as `/goal <task>`."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _iso_now() -> str:
|
def _iso_now() -> str:
|
||||||
return datetime.now().isoformat()
|
return datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
class _GoalToolsMixin(ContextAware):
|
class _GoalToolsMixin:
|
||||||
"""Shared routing context + Session lookup."""
|
"""Shared routing context and session lookup."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -49,19 +57,9 @@ class _GoalToolsMixin(ContextAware):
|
|||||||
) -> None:
|
) -> None:
|
||||||
self._sessions = sessions
|
self._sessions = sessions
|
||||||
self._runtime_events = runtime_events
|
self._runtime_events = runtime_events
|
||||||
# Each subclass gets its own ContextVar so concurrent tasks across
|
|
||||||
# different tool types (LongTaskTool vs CompleteGoalTool) do not
|
|
||||||
# interfere with each other.
|
|
||||||
self._request_ctx: ContextVar[RequestContext | None] = ContextVar(
|
|
||||||
f"{self.__class__.__name__}_request_ctx",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_context(self, ctx: RequestContext) -> None:
|
|
||||||
self._request_ctx.set(ctx)
|
|
||||||
|
|
||||||
def _session(self):
|
def _session(self):
|
||||||
request_ctx = self._request_ctx.get()
|
request_ctx = current_request_context()
|
||||||
if request_ctx is None:
|
if request_ctx is None:
|
||||||
return None
|
return None
|
||||||
key = request_ctx.session_key
|
key = request_ctx.session_key
|
||||||
@@ -69,10 +67,31 @@ class _GoalToolsMixin(ContextAware):
|
|||||||
return None
|
return None
|
||||||
return self._sessions.get_or_create(key)
|
return self._sessions.get_or_create(key)
|
||||||
|
|
||||||
|
def _goal_mutation_allowed(self) -> bool:
|
||||||
|
return current_request_context() is not None and goal_mutation_allowed()
|
||||||
|
|
||||||
|
def _save_goal_state(
|
||||||
|
self,
|
||||||
|
sess: Any,
|
||||||
|
blob: dict[str, Any],
|
||||||
|
*,
|
||||||
|
reset_continuation: bool = False,
|
||||||
|
) -> None:
|
||||||
|
previous_metadata = deepcopy(sess.metadata)
|
||||||
|
sess.metadata[GOAL_STATE_KEY] = blob
|
||||||
|
discard_legacy_goal_state_key(sess.metadata)
|
||||||
|
if reset_continuation:
|
||||||
|
reset_goal_continuation_rounds(sess.metadata)
|
||||||
|
try:
|
||||||
|
self._sessions.save(sess)
|
||||||
|
except BaseException:
|
||||||
|
sess.metadata.clear()
|
||||||
|
sess.metadata.update(previous_metadata)
|
||||||
|
raise
|
||||||
|
|
||||||
async def _publish_goal_state_changed(self, metadata: dict[str, Any]) -> None:
|
async def _publish_goal_state_changed(self, metadata: dict[str, Any]) -> None:
|
||||||
"""Publish authoritative goal metadata as a runtime event."""
|
|
||||||
runtime_events = self._runtime_events
|
runtime_events = self._runtime_events
|
||||||
rc = self._request_ctx.get()
|
rc = current_request_context()
|
||||||
if runtime_events is None or rc is None:
|
if runtime_events is None or rc is None:
|
||||||
return
|
return
|
||||||
cid = (rc.chat_id or "").strip()
|
cid = (rc.chat_id or "").strip()
|
||||||
@@ -93,105 +112,23 @@ class _GoalToolsMixin(ContextAware):
|
|||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
goal=StringSchema(
|
objective=StringSchema(
|
||||||
"Sustained objective for this chat thread. First read the built-in **long-goal** skill, "
|
"The sustained objective for this session. It may consolidate a plan from earlier "
|
||||||
"especially its Start fast section, then call this promptly once the user's intent is clear. "
|
"discussion, but must be self-contained, bounded, safe under repetition, and "
|
||||||
"The goal must still be idempotent, self-contained, bounded, and explicit about done-ness; "
|
"explicit about done-ness.",
|
||||||
"do not delay this tool call to over-plan, research, or decide execution details.",
|
min_length=1,
|
||||||
max_length=12_000,
|
max_length=MAX_GOAL_OBJECTIVE_CHARS,
|
||||||
),
|
),
|
||||||
ui_summary=StringSchema(
|
ui_summary=StringSchema(
|
||||||
"Optional one-line label for session lists / logs (≤120 chars).",
|
"Optional one-line display label for session lists and logs. It is not load-bearing.",
|
||||||
max_length=120,
|
max_length=120,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
required=["goal"],
|
required=["objective"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
class LongTaskTool(Tool, _GoalToolsMixin):
|
class CreateGoalTool(Tool, _GoalToolsMixin):
|
||||||
"""Begin or replace focus on a long-running objective stored on the session."""
|
"""Create one explicit sustained objective for the current session."""
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
sessions: Any,
|
|
||||||
runtime_events: RuntimeEventBus | None = None,
|
|
||||||
) -> None:
|
|
||||||
_GoalToolsMixin.__init__(self, sessions, runtime_events)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, ctx: Any) -> Tool:
|
|
||||||
sess = getattr(ctx, "sessions", None)
|
|
||||||
assert sess is not None # guarded by enabled()
|
|
||||||
return cls(
|
|
||||||
sessions=sess,
|
|
||||||
runtime_events=getattr(ctx, "runtime_events", None),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
|
||||||
return getattr(ctx, "sessions", None) is not None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "long_task"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Mark this thread as a sustained long-running task. "
|
|
||||||
"First read the built-in **long-goal** skill, especially its Start fast section; then call this "
|
|
||||||
"as soon as the user's intent is clear. Write a good idempotent goal, but do not delay the tool "
|
|
||||||
"call with long planning, research, or execution-detail thinking. "
|
|
||||||
"The active goal is mirrored in Runtime Context each turn. Use normal tools until done, then call "
|
|
||||||
"complete_goal when the objective is satisfied, cancelled, or replaced. "
|
|
||||||
"If a goal is already active, finish it or call complete_goal before registering another."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
|
|
||||||
sess = self._session()
|
|
||||||
if sess is None:
|
|
||||||
return ToolResult.error(
|
|
||||||
"Error: long_task requires an active chat session (missing routing context)."
|
|
||||||
)
|
|
||||||
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
|
||||||
if isinstance(prior, dict) and prior.get("status") == "active":
|
|
||||||
return ToolResult.error(
|
|
||||||
"Error: a sustained goal is already active. "
|
|
||||||
"Use complete_goal when finished, or ask the user before replacing it."
|
|
||||||
)
|
|
||||||
|
|
||||||
summary = (ui_summary or "").strip()[:120]
|
|
||||||
blob = {
|
|
||||||
"status": "active",
|
|
||||||
"objective": goal.strip(),
|
|
||||||
"ui_summary": summary,
|
|
||||||
"started_at": _iso_now(),
|
|
||||||
}
|
|
||||||
sess.metadata[GOAL_STATE_KEY] = blob
|
|
||||||
discard_legacy_goal_state_key(sess.metadata)
|
|
||||||
self._sessions.save(sess)
|
|
||||||
await self._publish_goal_state_changed(sess.metadata)
|
|
||||||
extra = f"\nSummary line: {summary}" if summary else ""
|
|
||||||
return (
|
|
||||||
"Goal recorded. Keep working toward the objective using ordinary tools. "
|
|
||||||
"When fully done (verified against what was asked), call complete_goal with a "
|
|
||||||
f"short recap.{extra}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
|
||||||
tool_parameters_schema(
|
|
||||||
recap=StringSchema(
|
|
||||||
"Brief recap for the user (plain text). When the goal succeeded, confirm outcomes; "
|
|
||||||
"if the user cancelled, pivoted, or replaced the objective, say so honestly.",
|
|
||||||
max_length=8000,
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
required=[],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
class CompleteGoalTool(Tool, _GoalToolsMixin):
|
|
||||||
"""Mark the active sustained goal finished after all required work is verified."""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -215,37 +152,219 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "complete_goal"
|
return "create_goal"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"End bookkeeping for the active sustained goal. "
|
"Create one sustained goal for the current session when Goal Runtime Guidance asks "
|
||||||
"Use when the objective is fully achieved and verified—recap what was delivered. "
|
"you to record it. Consolidate relevant prior discussion into a durable objective "
|
||||||
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
|
"that is self-contained, bounded, safe under repetition, and explicit about "
|
||||||
"what actually happened (not necessarily success). "
|
"completion criteria. Do not retry after a successful creation."
|
||||||
"If no goal is active, the tool reports that and leaves metadata unchanged."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
|
def runtime_context_provider(self):
|
||||||
|
return self._provide_runtime_context
|
||||||
|
|
||||||
|
async def _provide_runtime_context(
|
||||||
|
self,
|
||||||
|
request: RequestContext,
|
||||||
|
) -> RuntimeContextBlock | None:
|
||||||
|
if not request.session_key:
|
||||||
|
return None
|
||||||
|
session = self._sessions.get_or_create(request.session_key)
|
||||||
|
goal_start_requested = explicit_goal_requested(request.metadata)
|
||||||
|
goal_active = sustained_goal_active(session.metadata)
|
||||||
|
if not goal_start_requested and not goal_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
guidance = render_template(
|
||||||
|
"agent/goal_runtime.md",
|
||||||
|
strip=True,
|
||||||
|
goal_start_requested=goal_start_requested,
|
||||||
|
goal_active=goal_active,
|
||||||
|
)
|
||||||
|
state = wrap_runtime_context_lines(goal_state_runtime_lines(session.metadata))
|
||||||
|
content = "\n\n".join(part for part in (guidance, state) if part)
|
||||||
|
return RuntimeContextBlock(source="goal", content=content)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
objective: str,
|
||||||
|
ui_summary: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
sess = self._session()
|
sess = self._session()
|
||||||
if sess is None:
|
if sess is None:
|
||||||
return ToolResult.error("Error: complete_goal requires an active chat session.")
|
return ToolResult.error(
|
||||||
|
"Error: create_goal requires an active chat session (missing routing context)."
|
||||||
|
)
|
||||||
|
if not self._goal_mutation_allowed():
|
||||||
|
return ToolResult.error(_CREATE_UNAVAILABLE_ERROR)
|
||||||
|
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
||||||
|
if isinstance(prior, dict) and prior.get("status") == "active":
|
||||||
|
return ToolResult.error(
|
||||||
|
"Error: a sustained goal is already active. Use update_goal with "
|
||||||
|
"action='replace' only if the user explicitly changes the objective."
|
||||||
|
)
|
||||||
|
|
||||||
|
objective_text = objective.strip()
|
||||||
|
if not objective_text:
|
||||||
|
return ToolResult.error("Error: objective must not be empty.")
|
||||||
|
if len(objective_text) > MAX_GOAL_OBJECTIVE_CHARS:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: objective must not exceed {MAX_GOAL_OBJECTIVE_CHARS} characters."
|
||||||
|
)
|
||||||
|
summary = (ui_summary or "").strip()[:120]
|
||||||
|
blob = {
|
||||||
|
"status": "active",
|
||||||
|
"objective": objective_text,
|
||||||
|
"ui_summary": summary,
|
||||||
|
"started_at": _iso_now(),
|
||||||
|
}
|
||||||
|
self._save_goal_state(sess, blob, reset_continuation=True)
|
||||||
|
await self._publish_goal_state_changed(sess.metadata)
|
||||||
|
extra = f"\nSummary line: {summary}" if summary else ""
|
||||||
|
return (
|
||||||
|
"Goal recorded. Keep working toward the objective using ordinary tools. "
|
||||||
|
"When fully done and verified, call update_goal with action='complete'."
|
||||||
|
f"{extra}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
action=StringSchema(
|
||||||
|
"How to update the active goal.",
|
||||||
|
enum=_GOAL_ACTIONS,
|
||||||
|
),
|
||||||
|
recap=StringSchema(
|
||||||
|
"Brief honest recap for the user. Required in practice for complete, cancel, and block.",
|
||||||
|
max_length=8000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
objective=StringSchema(
|
||||||
|
"Replacement objective. Required only when action is 'replace'; make it durable, "
|
||||||
|
"self-contained, bounded, and explicit about done-ness.",
|
||||||
|
max_length=MAX_GOAL_OBJECTIVE_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
ui_summary=StringSchema(
|
||||||
|
"Optional one-line display label for a replacement goal.",
|
||||||
|
max_length=120,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
required=["action"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class UpdateGoalTool(Tool, _GoalToolsMixin):
|
||||||
|
"""Complete, cancel, block, or replace the active sustained goal."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sessions: Any,
|
||||||
|
runtime_events: RuntimeEventBus | None = None,
|
||||||
|
) -> None:
|
||||||
|
_GoalToolsMixin.__init__(self, sessions, runtime_events)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: Any) -> Tool:
|
||||||
|
sess = getattr(ctx, "sessions", None)
|
||||||
|
assert sess is not None
|
||||||
|
return cls(
|
||||||
|
sessions=sess,
|
||||||
|
runtime_events=getattr(ctx, "runtime_events", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: Any) -> bool:
|
||||||
|
return getattr(ctx, "sessions", None) is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "update_goal"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Update the active sustained goal. Use action='complete' only after the objective "
|
||||||
|
"is actually achieved and verified. Use action='cancel' when the user cancels, "
|
||||||
|
"action='block' when progress is genuinely blocked, and action='replace' only when "
|
||||||
|
"the requested objective changes."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
recap: str | None = None,
|
||||||
|
objective: str | None = None,
|
||||||
|
ui_summary: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
sess = self._session()
|
||||||
|
if sess is None:
|
||||||
|
return ToolResult.error("Error: update_goal requires an active chat session.")
|
||||||
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
||||||
if not isinstance(prior, dict) or prior.get("status") != "active":
|
if not isinstance(prior, dict) or prior.get("status") != "active":
|
||||||
return "No active goal to complete."
|
return "No active goal to update."
|
||||||
|
|
||||||
ended = _iso_now()
|
normalized = (action or "").strip().lower()
|
||||||
sess.metadata[GOAL_STATE_KEY] = {
|
if normalized not in _GOAL_ACTIONS:
|
||||||
**prior,
|
return ToolResult.error(
|
||||||
"status": "completed",
|
"Error: action must be one of complete, cancel, block, or replace."
|
||||||
"completed_at": ended,
|
)
|
||||||
|
|
||||||
|
if normalized == "replace":
|
||||||
|
if not self._goal_mutation_allowed():
|
||||||
|
return ToolResult.error(_REPLACE_UNAVAILABLE_ERROR)
|
||||||
|
objective_text = (objective or "").strip()
|
||||||
|
if not objective_text:
|
||||||
|
return ToolResult.error(
|
||||||
|
"Error: update_goal action='replace' requires a replacement objective."
|
||||||
|
)
|
||||||
|
if len(objective_text) > MAX_GOAL_OBJECTIVE_CHARS:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: objective must not exceed {MAX_GOAL_OBJECTIVE_CHARS} characters."
|
||||||
|
)
|
||||||
|
summary = (ui_summary or "").strip()[:120]
|
||||||
|
blob = {
|
||||||
|
"status": "active",
|
||||||
|
"objective": objective_text,
|
||||||
|
"ui_summary": summary,
|
||||||
|
"started_at": _iso_now(),
|
||||||
|
"replaced_at": _iso_now(),
|
||||||
|
"previous_objective": str(prior.get("objective") or ""),
|
||||||
"recap": (recap or "").strip(),
|
"recap": (recap or "").strip(),
|
||||||
}
|
}
|
||||||
discard_legacy_goal_state_key(sess.metadata)
|
self._save_goal_state(sess, blob, reset_continuation=True)
|
||||||
self._sessions.save(sess)
|
|
||||||
await self._publish_goal_state_changed(sess.metadata)
|
await self._publish_goal_state_changed(sess.metadata)
|
||||||
|
extra = f"\nSummary line: {summary}" if summary else ""
|
||||||
|
return "Goal replaced. Continue toward the new objective using ordinary tools." + extra
|
||||||
|
|
||||||
|
ended = _iso_now()
|
||||||
|
status = {
|
||||||
|
"complete": "completed",
|
||||||
|
"cancel": "cancelled",
|
||||||
|
"block": "blocked",
|
||||||
|
}[normalized]
|
||||||
|
blob = {
|
||||||
|
**prior,
|
||||||
|
"status": status,
|
||||||
|
"ended_at": ended,
|
||||||
|
"recap": (recap or "").strip(),
|
||||||
|
}
|
||||||
|
if normalized == "complete":
|
||||||
|
blob["completed_at"] = ended
|
||||||
|
self._save_goal_state(sess, blob)
|
||||||
|
revoke_goal_mutation_permission()
|
||||||
|
await self._publish_goal_state_changed(sess.metadata)
|
||||||
|
|
||||||
tail = (recap or "").strip()
|
tail = (recap or "").strip()
|
||||||
|
label = {
|
||||||
|
"complete": "complete",
|
||||||
|
"cancel": "cancelled",
|
||||||
|
"block": "blocked",
|
||||||
|
}[normalized]
|
||||||
if tail:
|
if tail:
|
||||||
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
return f"Goal marked {label} ({ended}). Recap:\n{tail}"
|
||||||
return f"Goal marked complete ({ended})."
|
return f"Goal marked {label} ({ended})."
|
||||||
|
|||||||
+190
-95
@@ -1,6 +1,7 @@
|
|||||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -8,7 +9,7 @@ import shutil
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import AsyncExitStack, suppress
|
from contextlib import AsyncExitStack, suppress
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping, Protocol
|
||||||
from weakref import WeakKeyDictionary
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -22,7 +23,13 @@ from nanobot.bus.events import (
|
|||||||
RUNTIME_CONTROL_MCP_RELOAD,
|
RUNTIME_CONTROL_MCP_RELOAD,
|
||||||
InboundMessage,
|
InboundMessage,
|
||||||
)
|
)
|
||||||
from nanobot.security.network import validate_url_target
|
from nanobot.security.network import (
|
||||||
|
PinnedDNSAsyncTransport,
|
||||||
|
env_proxy_applies_to_url,
|
||||||
|
httpx_env_proxy_mounts,
|
||||||
|
resolve_url_target,
|
||||||
|
validate_url_target,
|
||||||
|
)
|
||||||
|
|
||||||
# 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
|
||||||
@@ -47,6 +54,26 @@ _RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
|||||||
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
|
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
|
||||||
|
|
||||||
|
|
||||||
|
class MCPConnection(Protocol):
|
||||||
|
async def aclose(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _OwnedMCPConnection:
|
||||||
|
"""Close an MCP transport from the task that originally opened it."""
|
||||||
|
|
||||||
|
def __init__(self, owner: asyncio.Task[None], close_requested: asyncio.Event) -> None:
|
||||||
|
self._owner = owner
|
||||||
|
self._close_requested = close_requested
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
self._close_requested.set()
|
||||||
|
try:
|
||||||
|
await asyncio.shield(self._owner)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not self._owner.cancelled():
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _is_malformed_mcp_progress_notification(message: Any) -> bool:
|
def _is_malformed_mcp_progress_notification(message: Any) -> bool:
|
||||||
payload = _mcp_jsonrpc_payload(message)
|
payload = _mcp_jsonrpc_payload(message)
|
||||||
if _payload_value(payload, "method") != "notifications/progress":
|
if _payload_value(payload, "method") != "notifications/progress":
|
||||||
@@ -122,6 +149,25 @@ def _sanitize_name(name: str) -> str:
|
|||||||
return _SANITIZE_RE.sub("_", re.sub(r"[^a-zA-Z0-9_-]", "_", name))
|
return _SANITIZE_RE.sub("_", re.sub(r"[^a-zA-Z0-9_-]", "_", name))
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_TOOL_NAME_LENGTH = 64
|
||||||
|
_HASH_LENGTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_tool_name(name: str, max_length: int = _MAX_TOOL_NAME_LENGTH) -> str:
|
||||||
|
"""Limit a tool name while keeping short names unchanged."""
|
||||||
|
if len(name) <= max_length:
|
||||||
|
return name
|
||||||
|
|
||||||
|
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:_HASH_LENGTH]
|
||||||
|
prefix_length = max_length - _HASH_LENGTH - 1
|
||||||
|
return f"{name[:prefix_length]}_{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mcp_tool_name(name: str) -> str:
|
||||||
|
"""Sanitize and limit an MCP-derived tool name."""
|
||||||
|
return _limit_tool_name(_sanitize_name(name))
|
||||||
|
|
||||||
|
|
||||||
def _is_transient(exc: BaseException) -> bool:
|
def _is_transient(exc: BaseException) -> bool:
|
||||||
"""Check if an exception looks like a transient connection error."""
|
"""Check if an exception looks like a transient connection error."""
|
||||||
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
||||||
@@ -129,6 +175,8 @@ def _is_transient(exc: BaseException) -> bool:
|
|||||||
|
|
||||||
def _is_session_terminated(exc: BaseException) -> bool:
|
def _is_session_terminated(exc: BaseException) -> bool:
|
||||||
"""Return True when the MCP SDK reports a dead client session."""
|
"""Return True when the MCP SDK reports a dead client session."""
|
||||||
|
if _is_transient(exc):
|
||||||
|
return True
|
||||||
messages = [str(exc)]
|
messages = [str(exc)]
|
||||||
error = getattr(exc, "error", None)
|
error = getattr(exc, "error", None)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
@@ -153,9 +201,15 @@ async def _probe_http_url(url: str, timeout: float = 3.0) -> bool:
|
|||||||
port = parsed.port
|
port = parsed.port
|
||||||
if not port:
|
if not port:
|
||||||
port = 443 if parsed.scheme == "https" else 80
|
port = 443 if parsed.scheme == "https" else 80
|
||||||
|
ok, _, resolved_ips = resolve_url_target(url)
|
||||||
|
if not ok:
|
||||||
|
return False
|
||||||
|
if env_proxy_applies_to_url(url):
|
||||||
|
return True
|
||||||
|
for target_host in resolved_ips or (host,):
|
||||||
try:
|
try:
|
||||||
reader, writer = await asyncio.wait_for(
|
_reader, writer = await asyncio.wait_for(
|
||||||
asyncio.open_connection(host, port),
|
asyncio.open_connection(target_host, port),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
writer.close()
|
writer.close()
|
||||||
@@ -163,6 +217,7 @@ async def _probe_http_url(url: str, timeout: float = 3.0) -> bool:
|
|||||||
await asyncio.wait_for(writer.wait_closed(), timeout=0.2)
|
await asyncio.wait_for(writer.wait_closed(), timeout=0.2)
|
||||||
return True
|
return True
|
||||||
except (OSError, asyncio.TimeoutError):
|
except (OSError, asyncio.TimeoutError):
|
||||||
|
continue
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +240,14 @@ def _redact_url(url: str) -> str:
|
|||||||
return "<redacted-url>"
|
return "<redacted-url>"
|
||||||
|
|
||||||
|
|
||||||
|
def _pinned_transport_kwargs() -> dict[str, object]:
|
||||||
|
kwargs: dict[str, object] = {"transport": PinnedDNSAsyncTransport()}
|
||||||
|
mounts = httpx_env_proxy_mounts()
|
||||||
|
if mounts:
|
||||||
|
kwargs["mounts"] = mounts
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
async def _validate_mcp_request_url(request: httpx.Request) -> None:
|
async def _validate_mcp_request_url(request: httpx.Request) -> None:
|
||||||
"""Validate each outgoing MCP HTTP request, including redirect targets."""
|
"""Validate each outgoing MCP HTTP request, including redirect targets."""
|
||||||
ok, error = validate_url_target(str(request.url))
|
ok, error = validate_url_target(str(request.url))
|
||||||
@@ -387,7 +450,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
|
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
self._original_name = tool_def.name
|
self._original_name = tool_def.name
|
||||||
self._name = _sanitize_name(f"mcp_{server_name}_{tool_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
|
||||||
self._description = tool_def.description or tool_def.name
|
self._description = tool_def.description or tool_def.name
|
||||||
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
|
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
|
||||||
self._parameters = _normalize_schema_for_openai(raw_schema)
|
self._parameters = _normalize_schema_for_openai(raw_schema)
|
||||||
@@ -418,7 +481,9 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"MCP tool '{}' timed out after {}s", self._name, self._tool_timeout
|
"MCP tool '{}' timed out after {}s", self._name, self._tool_timeout
|
||||||
)
|
)
|
||||||
return f"(MCP tool call timed out after {self._tool_timeout}s)"
|
return ToolResult.error(
|
||||||
|
f"(MCP tool call timed out after {self._tool_timeout}s)"
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
|
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
|
||||||
# Re-raise only if our task was externally cancelled (e.g. /stop).
|
# Re-raise only if our task was externally cancelled (e.g. /stop).
|
||||||
@@ -426,7 +491,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
if task is not None and task.cancelling() > 0:
|
if task is not None and task.cancelling() > 0:
|
||||||
raise
|
raise
|
||||||
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
|
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
|
||||||
return "(MCP tool call was cancelled)"
|
return ToolResult.error("(MCP tool call was cancelled)")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if await self._refresh_session_after_termination(
|
if await self._refresh_session_after_termination(
|
||||||
exc,
|
exc,
|
||||||
@@ -451,22 +516,35 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
self._name,
|
self._name,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
)
|
)
|
||||||
return f"(MCP tool call failed after retry: {type(exc).__name__})"
|
return ToolResult.error(
|
||||||
|
f"(MCP tool call failed after retry: {type(exc).__name__})"
|
||||||
|
)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"MCP tool '{}' failed: {}: {}",
|
"MCP tool '{}' failed: {}: {}",
|
||||||
self._name,
|
self._name,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return f"(MCP tool call failed: {type(exc).__name__})"
|
return ToolResult.error(
|
||||||
|
f"(MCP tool call failed: {type(exc).__name__})"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Success — extract text and persist any image content as artifacts.
|
# Success — extract text and persist any image content as artifacts.
|
||||||
|
try:
|
||||||
rendered = self._render_call_result(result.content, kwargs)
|
rendered = self._render_call_result(result.content, kwargs)
|
||||||
if getattr(result, "isError", False):
|
if getattr(result, "isError", False):
|
||||||
return ToolResult.error(rendered)
|
return ToolResult.error(rendered)
|
||||||
return rendered
|
return rendered
|
||||||
|
except Exception as exc:
|
||||||
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
|
logger.exception(
|
||||||
|
"MCP tool '{}' failed while rendering result: {}: {}",
|
||||||
|
self._name,
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return ToolResult.error(
|
||||||
|
f"(MCP tool returned malformed content: {type(exc).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
def _render_call_result(self, content: Any, arguments: Mapping[str, Any]) -> str:
|
def _render_call_result(self, content: Any, arguments: Mapping[str, Any]) -> str:
|
||||||
"""Turn MCP content blocks into a tool result string.
|
"""Turn MCP content blocks into a tool result string.
|
||||||
@@ -529,7 +607,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
|
|||||||
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30):
|
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
self._uri = resource_def.uri
|
self._uri = resource_def.uri
|
||||||
self._name = _sanitize_name(f"mcp_{server_name}_resource_{resource_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_resource_{resource_def.name}")
|
||||||
desc = resource_def.description or resource_def.name
|
desc = resource_def.description or resource_def.name
|
||||||
self._description = f"[MCP Resource] {desc}\nURI: {self._uri}"
|
self._description = f"[MCP Resource] {desc}\nURI: {self._uri}"
|
||||||
self._parameters: dict[str, Any] = {
|
self._parameters: dict[str, Any] = {
|
||||||
@@ -619,8 +697,6 @@ class MCPResourceWrapper(_MCPWrapperBase):
|
|||||||
parts.append(str(block))
|
parts.append(str(block))
|
||||||
return "\n".join(parts) or "(no output)"
|
return "\n".join(parts) or "(no output)"
|
||||||
|
|
||||||
return "(MCP resource read failed)" # Unreachable
|
|
||||||
|
|
||||||
|
|
||||||
class MCPPromptWrapper(_MCPWrapperBase):
|
class MCPPromptWrapper(_MCPWrapperBase):
|
||||||
"""Wraps an MCP prompt as a read-only nanobot Tool."""
|
"""Wraps an MCP prompt as a read-only nanobot Tool."""
|
||||||
@@ -630,7 +706,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30):
|
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
self._prompt_name = prompt_def.name
|
self._prompt_name = prompt_def.name
|
||||||
self._name = _sanitize_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
|
||||||
desc = prompt_def.description or prompt_def.name
|
desc = prompt_def.description or prompt_def.name
|
||||||
self._description = (
|
self._description = (
|
||||||
f"[MCP Prompt] {desc}\n"
|
f"[MCP Prompt] {desc}\n"
|
||||||
@@ -755,24 +831,22 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
parts.append(str(content))
|
parts.append(str(content))
|
||||||
return "\n".join(parts) or "(no output)"
|
return "\n".join(parts) or "(no output)"
|
||||||
|
|
||||||
return "(MCP prompt call failed)" # Unreachable
|
|
||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
async def connect_mcp_servers(
|
||||||
mcp_servers: dict, registry: ToolRegistry
|
mcp_servers: dict, registry: ToolRegistry
|
||||||
) -> dict[str, AsyncExitStack]:
|
) -> dict[str, MCPConnection]:
|
||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||||
|
|
||||||
Returns a dict mapping server name -> its dedicated AsyncExitStack.
|
Returns one connection handle per server. Each handle keeps the task that
|
||||||
Each server gets its own stack to prevent cancel scope conflicts
|
entered the MCP SDK contexts alive so reconnect and shutdown can close
|
||||||
when multiple MCP servers are configured.
|
AnyIO cancel scopes from their owning task.
|
||||||
"""
|
"""
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def connect_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]:
|
async def open_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]:
|
||||||
server_stack = AsyncExitStack()
|
server_stack = AsyncExitStack()
|
||||||
await server_stack.__aenter__()
|
await server_stack.__aenter__()
|
||||||
|
|
||||||
@@ -837,6 +911,7 @@ async def connect_mcp_servers(
|
|||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
|
**_pinned_transport_kwargs(),
|
||||||
)
|
)
|
||||||
|
|
||||||
read, write = await server_stack.enter_async_context(
|
read, write = await server_stack.enter_async_context(
|
||||||
@@ -854,6 +929,7 @@ async def connect_mcp_servers(
|
|||||||
event_hooks={"request": [_validate_mcp_request_url]},
|
event_hooks={"request": [_validate_mcp_request_url]},
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||||
|
**_pinned_transport_kwargs(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
read, write, _ = await server_stack.enter_async_context(
|
read, write, _ = await server_stack.enter_async_context(
|
||||||
@@ -874,9 +950,9 @@ async def connect_mcp_servers(
|
|||||||
registered_count = 0
|
registered_count = 0
|
||||||
matched_enabled_tools: set[str] = set()
|
matched_enabled_tools: set[str] = set()
|
||||||
available_raw_names = [tool_def.name for tool_def in tools.tools]
|
available_raw_names = [tool_def.name for tool_def in tools.tools]
|
||||||
available_wrapped_names = [_sanitize_name(f"mcp_{name}_{tool_def.name}") for tool_def in tools.tools]
|
available_wrapped_names = [_sanitize_mcp_tool_name(f"mcp_{name}_{tool_def.name}") for tool_def in tools.tools]
|
||||||
for tool_def in tools.tools:
|
for tool_def in tools.tools:
|
||||||
wrapped_name = _sanitize_name(f"mcp_{name}_{tool_def.name}")
|
wrapped_name = _sanitize_mcp_tool_name(f"mcp_{name}_{tool_def.name}")
|
||||||
if (
|
if (
|
||||||
not allow_all_tools
|
not allow_all_tools
|
||||||
and tool_def.name not in enabled_tools
|
and tool_def.name not in enabled_tools
|
||||||
@@ -989,7 +1065,43 @@ async def connect_mcp_servers(
|
|||||||
await server_stack.aclose()
|
await server_stack.aclose()
|
||||||
return name, None
|
return name, None
|
||||||
|
|
||||||
server_stacks: dict[str, AsyncExitStack] = {}
|
async def connect_single_server(name: str, cfg) -> tuple[str, MCPConnection | None]:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
ready: asyncio.Future[bool] = loop.create_future()
|
||||||
|
close_requested = asyncio.Event()
|
||||||
|
|
||||||
|
async def own_connection() -> None:
|
||||||
|
stack: AsyncExitStack | None = None
|
||||||
|
try:
|
||||||
|
_, stack = await open_single_server(name, cfg)
|
||||||
|
if not ready.done():
|
||||||
|
ready.set_result(stack is not None)
|
||||||
|
if stack is not None:
|
||||||
|
await close_requested.wait()
|
||||||
|
except BaseException as exc:
|
||||||
|
if not ready.done():
|
||||||
|
ready.set_exception(exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if stack is not None:
|
||||||
|
await stack.aclose()
|
||||||
|
|
||||||
|
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||||
|
connection = _OwnedMCPConnection(owner, close_requested)
|
||||||
|
try:
|
||||||
|
connected = await ready
|
||||||
|
except BaseException:
|
||||||
|
close_requested.set()
|
||||||
|
owner.cancel()
|
||||||
|
with suppress(BaseException):
|
||||||
|
await asyncio.shield(owner)
|
||||||
|
raise
|
||||||
|
if not connected:
|
||||||
|
await connection.aclose()
|
||||||
|
return name, None
|
||||||
|
return name, connection
|
||||||
|
|
||||||
|
server_stacks: dict[str, MCPConnection] = {}
|
||||||
|
|
||||||
for name, cfg in mcp_servers.items():
|
for name, cfg in mcp_servers.items():
|
||||||
try:
|
try:
|
||||||
@@ -1009,65 +1121,11 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
|
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:
|
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||||
"""Connect configured MCP servers that are not currently live."""
|
"""Connect configured MCP servers that are not currently live."""
|
||||||
|
async with _reload_lock(state):
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
return
|
||||||
missing_servers = {
|
missing_servers = {
|
||||||
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
||||||
}
|
}
|
||||||
@@ -1076,19 +1134,20 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
|||||||
state._mcp_connecting = True
|
state._mcp_connecting = True
|
||||||
try:
|
try:
|
||||||
connected = await connect_mcp_servers(missing_servers, registry)
|
connected = await connect_mcp_servers(missing_servers, registry)
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
for connection in connected.values():
|
||||||
|
await connection.aclose()
|
||||||
|
return
|
||||||
state._mcp_stacks.update(connected)
|
state._mcp_stacks.update(connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
state._mcp_connected = bool(state._mcp_stacks)
|
|
||||||
if connected:
|
if connected:
|
||||||
logger.info("MCP connected servers: {}", sorted(connected))
|
logger.info("MCP connected servers: {}", sorted(connected))
|
||||||
else:
|
else:
|
||||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.warning("MCP connection cancelled (will retry next message)")
|
logger.warning("MCP connection cancelled (will retry next message)")
|
||||||
state._mcp_connected = bool(state._mcp_stacks)
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
state._mcp_connected = bool(state._mcp_stacks)
|
|
||||||
finally:
|
finally:
|
||||||
state._mcp_connecting = False
|
state._mcp_connecting = False
|
||||||
|
|
||||||
@@ -1096,10 +1155,16 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
|||||||
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||||
"""Reconcile live MCP connections with the current config file."""
|
"""Reconcile live MCP connections with the current config file."""
|
||||||
async with _reload_lock(state):
|
async with _reload_lock(state):
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP connections are shutting down.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
config = load_effective_config()
|
||||||
next_servers = dict(config.tools.mcp_servers)
|
next_servers = dict(config.tools.mcp_servers)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
@@ -1134,13 +1199,20 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
||||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
connected: dict[str, AsyncExitStack] = {}
|
connected: dict[str, MCPConnection] = {}
|
||||||
if to_connect:
|
if to_connect:
|
||||||
connected = await connect_mcp_servers(to_connect, registry)
|
connected = await connect_mcp_servers(to_connect, registry)
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
for connection in connected.values():
|
||||||
|
await connection.aclose()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP connections are shutting down.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
state._mcp_stacks.update(connected)
|
state._mcp_stacks.update(connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
|
|
||||||
state._mcp_connected = bool(state._mcp_stacks)
|
|
||||||
failed = sorted(set(to_connect) - set(connected))
|
failed = sorted(set(to_connect) - set(connected))
|
||||||
unchanged = not removed and not added and not changed and not retry_missing
|
unchanged = not removed and not added and not changed and not retry_missing
|
||||||
ok = not failed
|
ok = not failed
|
||||||
@@ -1255,11 +1327,10 @@ def _attach_reconnect_handlers(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for server_name in server_names:
|
for server_name in server_names:
|
||||||
prefix = _tool_prefix(server_name)
|
|
||||||
for tool_name in list(registry.tool_names):
|
for tool_name in list(registry.tool_names):
|
||||||
if not tool_name.startswith(prefix):
|
|
||||||
continue
|
|
||||||
tool = registry.get(tool_name)
|
tool = registry.get(tool_name)
|
||||||
|
if not _tool_belongs_to_server(tool, tool_name, server_name):
|
||||||
|
continue
|
||||||
if isinstance(tool, _MCPWrapperBase):
|
if isinstance(tool, _MCPWrapperBase):
|
||||||
tool.set_reconnect_handler(reconnect)
|
tool.set_reconnect_handler(reconnect)
|
||||||
|
|
||||||
@@ -1272,6 +1343,8 @@ async def _refresh_terminated_server(
|
|||||||
stale_tool: Tool,
|
stale_tool: Tool,
|
||||||
) -> Tool | None:
|
) -> Tool | None:
|
||||||
async with _reload_lock(state):
|
async with _reload_lock(state):
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
return None
|
||||||
cfg = state._mcp_servers.get(server_name)
|
cfg = state._mcp_servers.get(server_name)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1293,9 +1366,12 @@ async def _refresh_terminated_server(
|
|||||||
await _close_server(state, server_name)
|
await _close_server(state, server_name)
|
||||||
|
|
||||||
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
||||||
|
if getattr(state, "_mcp_closing", False):
|
||||||
|
for connection in connected.values():
|
||||||
|
await connection.aclose()
|
||||||
|
return None
|
||||||
state._mcp_stacks.update(connected)
|
state._mcp_stacks.update(connected)
|
||||||
_attach_reconnect_handlers(state, registry, connected)
|
_attach_reconnect_handlers(state, registry, connected)
|
||||||
state._mcp_connected = bool(state._mcp_stacks)
|
|
||||||
if server_name not in connected:
|
if server_name not in connected:
|
||||||
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
|
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
|
||||||
return None
|
return None
|
||||||
@@ -1312,11 +1388,17 @@ def _tool_prefix(server_name: str) -> str:
|
|||||||
return _sanitize_name(f"mcp_{server_name}_")
|
return _sanitize_name(f"mcp_{server_name}_")
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_belongs_to_server(tool: Tool | None, tool_name: str, server_name: str) -> bool:
|
||||||
|
if isinstance(tool, _MCPWrapperBase):
|
||||||
|
return getattr(tool, "_server_name", None) == server_name
|
||||||
|
return tool_name.startswith(_tool_prefix(server_name))
|
||||||
|
|
||||||
|
|
||||||
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
|
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
|
||||||
prefix = _tool_prefix(server_name)
|
|
||||||
removed = 0
|
removed = 0
|
||||||
for tool_name in list(registry.tool_names):
|
for tool_name in list(registry.tool_names):
|
||||||
if tool_name.startswith(prefix):
|
tool = registry.get(tool_name)
|
||||||
|
if _tool_belongs_to_server(tool, tool_name, server_name):
|
||||||
registry.unregister(tool_name)
|
registry.unregister(tool_name)
|
||||||
removed += 1
|
removed += 1
|
||||||
return removed
|
return removed
|
||||||
@@ -1330,3 +1412,16 @@ async def _close_server(state: Any, server_name: str) -> None:
|
|||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
except (RuntimeError, BaseExceptionGroup):
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_mcp_servers(state: Any) -> None:
|
||||||
|
"""Close every MCP connection while excluding reconnect and hot reload."""
|
||||||
|
state._mcp_closing = True
|
||||||
|
async with _reload_lock(state):
|
||||||
|
connections = list(state._mcp_stacks.items())
|
||||||
|
state._mcp_stacks.clear()
|
||||||
|
for name, connection in connections:
|
||||||
|
try:
|
||||||
|
await connection.aclose()
|
||||||
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any, Awaitable, Callable
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import current_request_context
|
||||||
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.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -45,7 +45,7 @@ from nanobot.security.workspace_access import current_tool_workspace
|
|||||||
required=["content"],
|
required=["content"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
class MessageTool(Tool, ContextAware):
|
class MessageTool(Tool):
|
||||||
"""Tool to send messages to users on chat channels."""
|
"""Tool to send messages to users on chat channels."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -62,20 +62,10 @@ class MessageTool(Tool, ContextAware):
|
|||||||
Path(workspace).expanduser() if workspace is not None else get_workspace_path()
|
Path(workspace).expanduser() if workspace is not None else get_workspace_path()
|
||||||
)
|
)
|
||||||
self._restrict_to_workspace = restrict_to_workspace
|
self._restrict_to_workspace = restrict_to_workspace
|
||||||
self._default_channel: ContextVar[str] = ContextVar(
|
self._fallback_channel = default_channel
|
||||||
"message_default_channel", default=default_channel
|
self._fallback_chat_id = default_chat_id
|
||||||
)
|
self._fallback_message_id = default_message_id
|
||||||
self._default_chat_id: ContextVar[str] = ContextVar(
|
self._fallback_metadata: dict[str, Any] = {}
|
||||||
"message_default_chat_id", default=default_chat_id
|
|
||||||
)
|
|
||||||
self._default_message_id: ContextVar[str | None] = ContextVar(
|
|
||||||
"message_default_message_id",
|
|
||||||
default=default_message_id,
|
|
||||||
)
|
|
||||||
self._default_metadata: ContextVar[dict[str, Any]] = ContextVar(
|
|
||||||
"message_default_metadata",
|
|
||||||
default={},
|
|
||||||
)
|
|
||||||
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
||||||
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
|
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
|
||||||
"message_turn_delivered_media",
|
"message_turn_delivered_media",
|
||||||
@@ -99,13 +89,6 @@ class MessageTool(Tool, ContextAware):
|
|||||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_context(self, ctx: RequestContext) -> None:
|
|
||||||
"""Set the current message context."""
|
|
||||||
self._default_channel.set(ctx.channel)
|
|
||||||
self._default_chat_id.set(ctx.chat_id)
|
|
||||||
self._default_message_id.set(ctx.message_id)
|
|
||||||
self._default_metadata.set(dict(ctx.metadata or {}))
|
|
||||||
|
|
||||||
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
||||||
"""Set the callback for sending messages."""
|
"""Set the callback for sending messages."""
|
||||||
self._send_callback = callback
|
self._send_callback = callback
|
||||||
@@ -199,8 +182,23 @@ class MessageTool(Tool, ContextAware):
|
|||||||
for row in buttons
|
for row in buttons
|
||||||
):
|
):
|
||||||
return ToolResult.error("Error: buttons must be a list of list of strings")
|
return ToolResult.error("Error: buttons must be a list of list of strings")
|
||||||
default_channel = self._default_channel.get()
|
request_ctx = current_request_context()
|
||||||
default_chat_id = self._default_chat_id.get()
|
default_channel = (
|
||||||
|
request_ctx.channel if request_ctx is not None else self._fallback_channel
|
||||||
|
)
|
||||||
|
default_chat_id = (
|
||||||
|
request_ctx.chat_id if request_ctx is not None else self._fallback_chat_id
|
||||||
|
)
|
||||||
|
default_message_id = (
|
||||||
|
request_ctx.message_id
|
||||||
|
if request_ctx is not None
|
||||||
|
else self._fallback_message_id
|
||||||
|
)
|
||||||
|
default_metadata = (
|
||||||
|
request_ctx.metadata
|
||||||
|
if request_ctx is not None
|
||||||
|
else self._fallback_metadata
|
||||||
|
)
|
||||||
channel = channel or default_channel
|
channel = channel or default_channel
|
||||||
explicit_chat_id = chat_id
|
explicit_chat_id = chat_id
|
||||||
if (
|
if (
|
||||||
@@ -224,7 +222,7 @@ class MessageTool(Tool, ContextAware):
|
|||||||
# to the wrong chat entirely.
|
# to the wrong chat entirely.
|
||||||
same_target = channel == default_channel and chat_id == default_chat_id
|
same_target = channel == default_channel and chat_id == default_chat_id
|
||||||
if same_target:
|
if same_target:
|
||||||
message_id = message_id or self._default_message_id.get()
|
message_id = message_id or default_message_id
|
||||||
else:
|
else:
|
||||||
message_id = None
|
message_id = None
|
||||||
|
|
||||||
@@ -240,7 +238,7 @@ class MessageTool(Tool, ContextAware):
|
|||||||
except (OSError, PermissionError, ValueError) as e:
|
except (OSError, PermissionError, ValueError) as e:
|
||||||
return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
|
return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
|
||||||
|
|
||||||
metadata = dict(self._default_metadata.get()) if same_target else {}
|
metadata = dict(default_metadata) if same_target else {}
|
||||||
if message_id:
|
if message_id:
|
||||||
metadata["message_id"] = message_id
|
metadata["message_id"] = message_id
|
||||||
if self._record_channel_delivery_var.get() or media:
|
if self._record_channel_delivery_var.get() or media:
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"""Tool registry for dynamic tool management."""
|
"""Tool registry for dynamic tool management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
|
from nanobot.agent.tools.context import ContextAware, current_request_context
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.runtime_context import RuntimeContextProvider
|
||||||
|
|
||||||
|
|
||||||
def is_tool_error_result(name: str, result: Any) -> bool:
|
def is_tool_error_result(name: str, result: Any) -> bool:
|
||||||
@@ -35,6 +41,15 @@ class ToolRegistry:
|
|||||||
"""Get a tool by name."""
|
"""Get a tool by name."""
|
||||||
return self._tools.get(name)
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
|
||||||
|
"""Return tool-owned providers in stable tool-name order."""
|
||||||
|
providers: list[RuntimeContextProvider] = []
|
||||||
|
for name in sorted(self._tools):
|
||||||
|
provider = self._tools[name].runtime_context_provider()
|
||||||
|
if provider is not None:
|
||||||
|
providers.append(provider)
|
||||||
|
return providers
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _lookup_key(name: str) -> str:
|
def _lookup_key(name: str) -> str:
|
||||||
"""Normalize names for suggestions only; never for execution."""
|
"""Normalize names for suggestions only; never for execution."""
|
||||||
@@ -109,6 +124,12 @@ class ToolRegistry:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Compatibility for external tools that still implement the legacy
|
||||||
|
# setter protocol. Built-ins read the authoritative ContextVar
|
||||||
|
# directly and never copy routing state.
|
||||||
|
if isinstance(tool, ContextAware) and (ctx := current_request_context()) is not None:
|
||||||
|
tool.set_context(ctx)
|
||||||
|
|
||||||
params = self._coerce_params(tool, params)
|
params = self._coerce_params(tool, params)
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
return tool, params, (
|
return tool, params, (
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ class RuntimeState(Protocol):
|
|||||||
|
|
||||||
def _sync_subagent_runtime_limits(self) -> None: ...
|
def _sync_subagent_runtime_limits(self) -> None: ...
|
||||||
|
|
||||||
|
def set_runtime_model(self, model: str) -> Any: ...
|
||||||
|
|
||||||
|
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_preset(self) -> str | None: ...
|
def model_preset(self) -> str | None: ...
|
||||||
|
|
||||||
_active_preset: str | None
|
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ def _bwrap(command: str, workspace: str, cwd: str) -> str:
|
|||||||
"/lib64",
|
"/lib64",
|
||||||
"/etc/alternatives",
|
"/etc/alternatives",
|
||||||
"/etc/ssl/certs",
|
"/etc/ssl/certs",
|
||||||
|
"/etc/pki/tls/certs",
|
||||||
|
"/etc/pki/ca-trust",
|
||||||
|
"/etc/crypto-policies",
|
||||||
"/etc/resolv.conf",
|
"/etc/resolv.conf",
|
||||||
"/etc/ld.so.cache",
|
"/etc/ld.so.cache",
|
||||||
]
|
]
|
||||||
|
|||||||
+61
-21
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import current_request_context
|
||||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ def _is_subagent_status(value: Any) -> bool:
|
|||||||
return isinstance(value, SubagentStatus)
|
return isinstance(value, SubagentStatus)
|
||||||
|
|
||||||
|
|
||||||
class MyTool(Tool, ContextAware):
|
class MyTool(Tool):
|
||||||
"""Check and set the agent loop's runtime configuration."""
|
"""Check and set the agent loop's runtime configuration."""
|
||||||
|
|
||||||
_plugin_discoverable = False # Requires AgentLoop reference; registered manually
|
_plugin_discoverable = False # Requires AgentLoop reference; registered manually
|
||||||
@@ -57,7 +57,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
|
|
||||||
BLOCKED = frozenset({
|
BLOCKED = frozenset({
|
||||||
# Core infrastructure
|
# Core infrastructure
|
||||||
"bus", "provider", "_running", "tools",
|
"bus", "provider", "runtime_resolver", "_running", "tools",
|
||||||
# Config management
|
# Config management
|
||||||
"_runtime_vars",
|
"_runtime_vars",
|
||||||
# Subsystems
|
# Subsystems
|
||||||
@@ -68,7 +68,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
"_session_locks", "_active_tasks", "_background_tasks",
|
"_session_locks", "_active_tasks", "_background_tasks",
|
||||||
# Security boundaries (inspect + modify both blocked)
|
# Security boundaries (inspect + modify both blocked)
|
||||||
"restrict_to_workspace", "channels_config",
|
"restrict_to_workspace", "channels_config",
|
||||||
"_concurrency_gate", "_unified_session", "_extra_hooks",
|
"_concurrency_gate", "_unified_session", "_extra_hooks", "_hook_factories",
|
||||||
})
|
})
|
||||||
|
|
||||||
READ_ONLY = frozenset({
|
READ_ONLY = frozenset({
|
||||||
@@ -77,8 +77,11 @@ class MyTool(Tool, ContextAware):
|
|||||||
"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
|
"workspace_sandbox", # read-only view of workspace enforcement level
|
||||||
|
"request", # current message routing metadata
|
||||||
})
|
})
|
||||||
|
|
||||||
|
_REQUEST_FIELDS = ("channel", "chat_id", "sender_id")
|
||||||
|
|
||||||
_DENIED_ATTRS = frozenset({
|
_DENIED_ATTRS = frozenset({
|
||||||
"__class__", "__dict__", "__bases__", "__subclasses__", "__mro__",
|
"__class__", "__dict__", "__bases__", "__subclasses__", "__mro__",
|
||||||
"__init__", "__new__", "__reduce__", "__getstate__", "__setstate__",
|
"__init__", "__new__", "__reduce__", "__getstate__", "__setstate__",
|
||||||
@@ -107,12 +110,15 @@ class MyTool(Tool, ContextAware):
|
|||||||
}
|
}
|
||||||
|
|
||||||
_MAX_RUNTIME_KEYS = 64
|
_MAX_RUNTIME_KEYS = 64
|
||||||
|
_MODEL_RUNTIME_FIELDS = frozenset({
|
||||||
|
"model",
|
||||||
|
"model_preset",
|
||||||
|
"context_window_tokens",
|
||||||
|
})
|
||||||
|
|
||||||
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
||||||
self._runtime_state = runtime_state
|
self._runtime_state = runtime_state
|
||||||
self._modify_allowed = modify_allowed
|
self._modify_allowed = modify_allowed
|
||||||
self._channel = ""
|
|
||||||
self._chat_id = ""
|
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||||
cls = self.__class__
|
cls = self.__class__
|
||||||
@@ -120,14 +126,8 @@ class MyTool(Tool, ContextAware):
|
|||||||
memo[id(self)] = result
|
memo[id(self)] = result
|
||||||
result._runtime_state = self._runtime_state
|
result._runtime_state = self._runtime_state
|
||||||
result._modify_allowed = self._modify_allowed
|
result._modify_allowed = self._modify_allowed
|
||||||
result._channel = self._channel
|
|
||||||
result._chat_id = self._chat_id
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def set_context(self, ctx: RequestContext) -> None:
|
|
||||||
self._channel = ctx.channel
|
|
||||||
self._chat_id = ctx.chat_id
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "my"
|
return "my"
|
||||||
@@ -144,6 +144,8 @@ class MyTool(Tool, ContextAware):
|
|||||||
"Scratchpad keys persist across turns but not restarts.\n"
|
"Scratchpad keys persist across turns but not restarts.\n"
|
||||||
"Key values: _current_iteration (current progress), "
|
"Key values: _current_iteration (current progress), "
|
||||||
"max_iterations - _current_iteration = remaining iterations.\n"
|
"max_iterations - _current_iteration = remaining iterations.\n"
|
||||||
|
"Current routing metadata is available read-only via request.channel, "
|
||||||
|
"request.chat_id, and request.sender_id.\n"
|
||||||
"Note: web_config and exec_config are readable but read-only.\n"
|
"Note: web_config and exec_config are readable but read-only.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"When to use:\n"
|
"When to use:\n"
|
||||||
@@ -176,6 +178,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
"key": {
|
"key": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. "
|
"description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. "
|
||||||
|
"Use 'request.channel', 'request.chat_id', or 'request.sender_id' for current routing metadata. "
|
||||||
"Use 'model_preset' to switch named model presets. For check without key, shows all config values.",
|
"Use 'model_preset' to switch named model presets. For check without key, shows all config values.",
|
||||||
},
|
},
|
||||||
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model/model_preset)."},
|
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model/model_preset)."},
|
||||||
@@ -184,7 +187,12 @@ class MyTool(Tool, ContextAware):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _audit(self, action: str, detail: str) -> None:
|
def _audit(self, action: str, detail: str) -> None:
|
||||||
session = f"{self._channel}:{self._chat_id}" if self._channel else "unknown"
|
ctx = current_request_context()
|
||||||
|
session = (
|
||||||
|
ctx.session_key or f"{ctx.channel}:{ctx.chat_id}"
|
||||||
|
if ctx is not None and ctx.channel
|
||||||
|
else "unknown"
|
||||||
|
)
|
||||||
logger.info("self.{} | {} | session:{}", action, detail, session)
|
logger.info("self.{} | {} | session:{}", action, detail, session)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -328,9 +336,33 @@ class MyTool(Tool, ContextAware):
|
|||||||
|
|
||||||
# -- inspect --
|
# -- inspect --
|
||||||
|
|
||||||
|
def _current_runtime_value(self, key: str) -> tuple[bool, Any]:
|
||||||
|
request_ctx = current_request_context()
|
||||||
|
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||||
|
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||||
|
return False, None
|
||||||
|
return True, getattr(runtime, key)
|
||||||
|
|
||||||
def _inspect(self, key: str | None) -> str:
|
def _inspect(self, key: str | None) -> str:
|
||||||
if not key:
|
if not key:
|
||||||
return self._inspect_all()
|
return self._inspect_all()
|
||||||
|
if key == "request" or key.startswith("request."):
|
||||||
|
request_ctx = current_request_context()
|
||||||
|
if request_ctx is None:
|
||||||
|
return ToolResult.error("Error: current request context is unavailable")
|
||||||
|
if key == "request":
|
||||||
|
return self._format_value(
|
||||||
|
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
field = key.removeprefix("request.")
|
||||||
|
if field not in self._REQUEST_FIELDS:
|
||||||
|
return ToolResult.error(f"Error: '{key}' not found")
|
||||||
|
return self._format_value(getattr(request_ctx, field), key)
|
||||||
|
if "." not in key:
|
||||||
|
found, value = self._current_runtime_value(key)
|
||||||
|
if found:
|
||||||
|
return self._format_value(value, key)
|
||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||||
@@ -356,8 +388,13 @@ class MyTool(Tool, ContextAware):
|
|||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
# RESTRICTED keys
|
# RESTRICTED keys
|
||||||
for k in self.RESTRICTED:
|
for k in self.RESTRICTED:
|
||||||
parts.append(self._format_value(getattr(state, k, None), k))
|
found, value = self._current_runtime_value(k)
|
||||||
parts.append(self._format_value(state.model_preset, "model_preset"))
|
parts.append(self._format_value(value if found else getattr(state, k, None), k))
|
||||||
|
found, value = self._current_runtime_value("model_preset")
|
||||||
|
parts.append(self._format_value(
|
||||||
|
value if found else 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", "workspace_sandbox", "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):
|
||||||
@@ -435,13 +472,16 @@ class MyTool(Tool, ContextAware):
|
|||||||
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
|
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
|
||||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
|
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
|
||||||
setattr(self._runtime_state, key, value)
|
|
||||||
if key == "model":
|
if key == "model":
|
||||||
self._runtime_state._active_preset = None
|
self._runtime_state.set_runtime_model(value)
|
||||||
sync_replay = getattr(self._runtime_state, "_sync_replay_max_messages", None)
|
elif key == "context_window_tokens":
|
||||||
if key == "context_window_tokens" and callable(sync_replay):
|
self._runtime_state.set_runtime_context_window(value)
|
||||||
sync_replay()
|
else:
|
||||||
if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"):
|
setattr(self._runtime_state, key, value)
|
||||||
|
if key == "max_iterations" and hasattr(
|
||||||
|
self._runtime_state,
|
||||||
|
"_sync_subagent_runtime_limits",
|
||||||
|
):
|
||||||
self._runtime_state._sync_subagent_runtime_limits()
|
self._runtime_state._sync_subagent_runtime_limits()
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|||||||
+127
-17
@@ -9,7 +9,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path, PureWindowsPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -41,6 +41,30 @@ from nanobot.security.workspace_policy import is_path_within
|
|||||||
_IS_WINDOWS = sys.platform == "win32"
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
def _reap_pid(pid: int) -> None:
|
||||||
|
"""Best-effort ``waitpid`` to reap a child and prevent zombies.
|
||||||
|
|
||||||
|
Call this after killing or after normal completion of any subprocess
|
||||||
|
as a safety net — asyncio's child-watcher *should* have reaped it,
|
||||||
|
but in containers / edge-cases it sometimes doesn't.
|
||||||
|
|
||||||
|
Uses ``os`` capability checks rather than ``_IS_WINDOWS`` so this is
|
||||||
|
safe when tests patch the platform flag while still running on Windows
|
||||||
|
(``os.waitpid`` / ``os.WNOHANG`` do not exist there).
|
||||||
|
"""
|
||||||
|
waitpid = getattr(os, "waitpid", None)
|
||||||
|
wnohang = getattr(os, "WNOHANG", None)
|
||||||
|
if waitpid is None or wnohang is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
waitpid(pid, wnohang)
|
||||||
|
except (ProcessLookupError, ChildProcessError):
|
||||||
|
# Already reaped, or not our child — both are fine.
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug("_reap_pid({}): {}", pid, exc)
|
||||||
|
|
||||||
|
|
||||||
# Policy note appended to recoverable workspace-boundary guard errors.
|
# Policy note appended to recoverable workspace-boundary guard errors.
|
||||||
_WORKSPACE_BOUNDARY_NOTE = (
|
_WORKSPACE_BOUNDARY_NOTE = (
|
||||||
"\n\nNote: this is a hard policy boundary, not a transient failure. "
|
"\n\nNote: this is a hard policy boundary, not a transient failure. "
|
||||||
@@ -89,7 +113,15 @@ class _PreparedCommand:
|
|||||||
maximum=600,
|
maximum=600,
|
||||||
),
|
),
|
||||||
shell=StringSchema(
|
shell=StringSchema(
|
||||||
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
(
|
||||||
|
"Override the Windows shell only when needed. Omit to use "
|
||||||
|
"PowerShell by default (pwsh when available, else powershell). "
|
||||||
|
"Pass 'cmd' only for cmd.exe syntax or cmd built-ins."
|
||||||
|
if _IS_WINDOWS
|
||||||
|
else "Override the Unix shell only when needed. Omit to use "
|
||||||
|
"bash by default. Pass 'sh' for POSIX sh or 'zsh' for "
|
||||||
|
"zsh-specific syntax."
|
||||||
|
),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
login=BooleanSchema(
|
login=BooleanSchema(
|
||||||
@@ -227,6 +259,13 @@ class ExecTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
|
platform_note = (
|
||||||
|
"On Windows, use PowerShell syntax by default; pass shell='cmd' "
|
||||||
|
"only for cmd-specific commands. "
|
||||||
|
if _IS_WINDOWS
|
||||||
|
else "On Unix, commands run through bash by default; pass shell='sh' "
|
||||||
|
"or shell='zsh' when needed. "
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
"Execute a shell command and return its output. "
|
"Execute a shell command and return its output. "
|
||||||
"Use this for tests, builds, package commands, git commands, and "
|
"Use this for tests, builds, package commands, git commands, and "
|
||||||
@@ -234,6 +273,7 @@ class ExecTool(Tool):
|
|||||||
"inspection and apply_patch/write_file/edit_file for file changes "
|
"inspection and apply_patch/write_file/edit_file for file changes "
|
||||||
"instead of cat, shell find/grep, echo, or sed. "
|
"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. "
|
||||||
|
f"{platform_note}"
|
||||||
"For long-running or interactive commands, pass yield_time_ms; "
|
"For long-running or interactive commands, pass yield_time_ms; "
|
||||||
"if the command keeps running, exec returns a session_id that can "
|
"if the command keeps running, exec returns a session_id that can "
|
||||||
"be polled or written to with write_stdin. Output is truncated at "
|
"be polled or written to with write_stdin. Output is truncated at "
|
||||||
@@ -267,6 +307,7 @@ class ExecTool(Tool):
|
|||||||
if yield_time_ms is not None:
|
if yield_time_ms is not None:
|
||||||
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
||||||
|
|
||||||
|
process: asyncio.subprocess.Process | None = None
|
||||||
try:
|
try:
|
||||||
process = await self._spawn(
|
process = await self._spawn(
|
||||||
prepared.command,
|
prepared.command,
|
||||||
@@ -288,6 +329,11 @@ class ExecTool(Tool):
|
|||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# Safety-net reap: asyncio *should* have reaped the child via
|
||||||
|
# communicate(), but in containers the child-watcher sometimes
|
||||||
|
# misses it, leaving a zombie.
|
||||||
|
_reap_pid(process.pid)
|
||||||
|
|
||||||
output_parts = []
|
output_parts = []
|
||||||
|
|
||||||
if stdout:
|
if stdout:
|
||||||
@@ -314,6 +360,10 @@ class ExecTool(Tool):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Kill and reap the child if it was spawned but an unexpected
|
||||||
|
# error prevented communicate() from completing.
|
||||||
|
if process is not None:
|
||||||
|
await self._kill_process(process)
|
||||||
return ToolResult.error(f"Error executing command: {str(e)}")
|
return ToolResult.error(f"Error executing command: {str(e)}")
|
||||||
|
|
||||||
async def _execute_session(
|
async def _execute_session(
|
||||||
@@ -468,17 +518,26 @@ class ExecTool(Tool):
|
|||||||
) -> 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:
|
||||||
if "\n" in command:
|
# Default to PowerShell so single-line and multi-line commands
|
||||||
return await asyncio.create_subprocess_exec(
|
# share the same shell semantics. cmd.exe is reachable via the
|
||||||
"powershell", "-NoProfile", "-Command", command,
|
# explicit shell="cmd" parameter (see _resolve_shell).
|
||||||
|
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||||
|
program = shell_program or default_program
|
||||||
|
program_name = PureWindowsPath(program).name.lower()
|
||||||
|
if program_name in ("cmd", "cmd.exe"):
|
||||||
|
cmd_env = {**env, "COMSPEC": program}
|
||||||
|
return await asyncio.create_subprocess_shell(
|
||||||
|
command,
|
||||||
stdin=stdin,
|
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=cmd_env,
|
||||||
)
|
)
|
||||||
return await asyncio.create_subprocess_shell(
|
command = ExecTool._normalize_powershell_command(command)
|
||||||
command,
|
command = f"{command}\nif ($LASTEXITCODE -ne $null) {{ exit $LASTEXITCODE }}"
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
@@ -500,14 +559,60 @@ class ExecTool(Tool):
|
|||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_powershell_command(command: str) -> str:
|
||||||
|
stripped = command.lstrip()
|
||||||
|
if not stripped or stripped[0] not in {"'", '"'}:
|
||||||
|
return command
|
||||||
|
|
||||||
|
quote = stripped[0]
|
||||||
|
end = stripped.find(quote, 1)
|
||||||
|
if end == -1 or end + 1 >= len(stripped) or not stripped[end + 1].isspace():
|
||||||
|
return command
|
||||||
|
|
||||||
|
executable = stripped[1:end]
|
||||||
|
looks_like_windows_executable = (
|
||||||
|
bool(re.match(r"^[A-Za-z]:[\\/]", executable))
|
||||||
|
or executable.startswith(r"\\")
|
||||||
|
or executable.lower().endswith((".exe", ".cmd", ".bat", ".ps1"))
|
||||||
|
)
|
||||||
|
if not looks_like_windows_executable:
|
||||||
|
return command
|
||||||
|
|
||||||
|
leading = command[: len(command) - len(stripped)]
|
||||||
|
return f"{leading}& {stripped}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
|
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
|
||||||
if not shell:
|
if not shell:
|
||||||
return None, None
|
return None, None
|
||||||
if _IS_WINDOWS:
|
|
||||||
return None, ToolResult.error("Error: shell parameter is not supported on Windows")
|
|
||||||
if "\0" in shell or "\n" in shell or "\r" in shell:
|
if "\0" in shell or "\n" in shell or "\r" in shell:
|
||||||
return None, ToolResult.error("Error: shell contains invalid characters")
|
return None, ToolResult.error("Error: shell contains invalid characters")
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
win_allowed = {"powershell", "powershell.exe", "pwsh", "pwsh.exe", "cmd", "cmd.exe"}
|
||||||
|
path = Path(shell).expanduser()
|
||||||
|
if path.is_absolute():
|
||||||
|
name = path.name.lower()
|
||||||
|
if name not in win_allowed:
|
||||||
|
return None, ToolResult.error(
|
||||||
|
f"Error: unsupported shell {shell!r}. "
|
||||||
|
"Allowed: powershell, pwsh, cmd"
|
||||||
|
)
|
||||||
|
if not path.is_file():
|
||||||
|
return None, ToolResult.error(f"Error: shell is not found: {shell}")
|
||||||
|
return str(path), None
|
||||||
|
if "/" in shell or "\\" in shell:
|
||||||
|
return None, ToolResult.error("Error: shell must be a shell name or absolute path")
|
||||||
|
if shell.lower() not in win_allowed:
|
||||||
|
return None, ToolResult.error(
|
||||||
|
f"Error: unsupported shell {shell!r}. "
|
||||||
|
"Allowed: powershell, pwsh, cmd"
|
||||||
|
)
|
||||||
|
if shell.lower() in ("cmd", "cmd.exe"):
|
||||||
|
resolved = os.environ.get("COMSPEC") or shutil.which("cmd") or "cmd"
|
||||||
|
return resolved, None
|
||||||
|
resolved = shutil.which(shell) or shell
|
||||||
|
return resolved, None
|
||||||
allowed = {"sh", "bash", "zsh"}
|
allowed = {"sh", "bash", "zsh"}
|
||||||
path = Path(shell).expanduser()
|
path = Path(shell).expanduser()
|
||||||
if path.is_absolute():
|
if path.is_absolute():
|
||||||
@@ -527,17 +632,22 @@ class ExecTool(Tool):
|
|||||||
|
|
||||||
@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.
|
||||||
process.kill()
|
|
||||||
|
Safe to call when the process has already exited (e.g. generic
|
||||||
|
exception handlers after a successful ``communicate()``): skips
|
||||||
|
``kill()`` and only runs the safety-net reap.
|
||||||
|
"""
|
||||||
|
if process.returncode is not None:
|
||||||
|
_reap_pid(process.pid)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
process.kill()
|
||||||
with suppress(asyncio.TimeoutError):
|
with suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||||
finally:
|
finally:
|
||||||
if not _IS_WINDOWS:
|
_reap_pid(process.pid)
|
||||||
try:
|
|
||||||
os.waitpid(process.pid, os.WNOHANG)
|
|
||||||
except (ProcessLookupError, ChildProcessError) as e:
|
|
||||||
logger.debug("Process already reaped or not found: {}", e)
|
|
||||||
|
|
||||||
def _build_env(self) -> dict[str, str]:
|
def _build_env(self) -> dict[str, str]:
|
||||||
"""Build a minimal environment for subprocess execution.
|
"""Build a minimal environment for subprocess execution.
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextvars import ContextVar
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import current_request_context
|
||||||
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
||||||
from nanobot.security.workspace_access import current_workspace_scope
|
from nanobot.security.workspace_access import current_workspace_scope
|
||||||
|
|
||||||
@@ -30,30 +29,16 @@ if TYPE_CHECKING:
|
|||||||
required=["task"],
|
required=["task"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
class SpawnTool(Tool, ContextAware):
|
class SpawnTool(Tool):
|
||||||
"""Tool to spawn a subagent for background task execution."""
|
"""Tool to spawn a subagent for background task execution."""
|
||||||
|
|
||||||
def __init__(self, manager: "SubagentManager"):
|
def __init__(self, manager: "SubagentManager"):
|
||||||
self._manager = manager
|
self._manager = manager
|
||||||
self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli")
|
|
||||||
self._origin_chat_id: ContextVar[str] = ContextVar("spawn_origin_chat_id", default="direct")
|
|
||||||
self._session_key: ContextVar[str] = ContextVar("spawn_session_key", default="cli:direct")
|
|
||||||
self._origin_message_id: ContextVar[str | None] = ContextVar(
|
|
||||||
"spawn_origin_message_id",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
return cls(manager=ctx.subagent_manager)
|
return cls(manager=ctx.subagent_manager)
|
||||||
|
|
||||||
def set_context(self, ctx: RequestContext) -> None:
|
|
||||||
"""Set the origin context for subagent announcements."""
|
|
||||||
self._origin_channel.set(ctx.channel)
|
|
||||||
self._origin_chat_id.set(ctx.chat_id)
|
|
||||||
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}")
|
|
||||||
self._origin_message_id.set(ctx.message_id)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "spawn"
|
return "spawn"
|
||||||
@@ -84,13 +69,20 @@ class SpawnTool(Tool, ContextAware):
|
|||||||
f"({running}/{limit} running). Wait for a running subagent "
|
f"({running}/{limit} running). Wait for a running subagent "
|
||||||
f"to complete before spawning a new one."
|
f"to complete before spawning a new one."
|
||||||
)
|
)
|
||||||
|
request_ctx = current_request_context()
|
||||||
|
if request_ctx is None or request_ctx.runtime is None:
|
||||||
|
return ToolResult.error("Error: spawn requires an active model runtime")
|
||||||
|
origin_channel = request_ctx.channel
|
||||||
|
origin_chat_id = request_ctx.chat_id
|
||||||
|
session_key = request_ctx.session_key or f"{origin_channel}:{origin_chat_id}"
|
||||||
return await self._manager.spawn(
|
return await self._manager.spawn(
|
||||||
task=task,
|
task=task,
|
||||||
|
runtime=request_ctx.runtime,
|
||||||
label=label,
|
label=label,
|
||||||
origin_channel=self._origin_channel.get(),
|
origin_channel=origin_channel,
|
||||||
origin_chat_id=self._origin_chat_id.get(),
|
origin_chat_id=origin_chat_id,
|
||||||
session_key=self._session_key.get(),
|
session_key=session_key,
|
||||||
origin_message_id=self._origin_message_id.get(),
|
origin_message_id=request_ctx.message_id,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
workspace_scope=current_workspace_scope(),
|
workspace_scope=current_workspace_scope(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -111,6 +111,39 @@ def _validate_url_safe(url: str) -> tuple[bool, str]:
|
|||||||
return validate_url_target(url)
|
return validate_url_target(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url_safe(url: str) -> tuple[bool, str, tuple[str, ...]]:
|
||||||
|
"""Validate URL and return the resolved IPs to pin during the request."""
|
||||||
|
from nanobot.security.network import resolve_url_target
|
||||||
|
|
||||||
|
return resolve_url_target(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _pinned_dns_transport() -> httpx.AsyncBaseTransport:
|
||||||
|
from nanobot.security.network import PinnedDNSAsyncTransport
|
||||||
|
|
||||||
|
return PinnedDNSAsyncTransport()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_client_kwargs(proxy: str | None, timeout: float) -> dict[str, Any]:
|
||||||
|
from nanobot.security.network import httpx_env_proxy_mounts
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {"timeout": timeout}
|
||||||
|
if proxy:
|
||||||
|
kwargs["proxy"] = proxy
|
||||||
|
else:
|
||||||
|
kwargs["transport"] = _pinned_dns_transport()
|
||||||
|
mounts = httpx_env_proxy_mounts()
|
||||||
|
if mounts:
|
||||||
|
kwargs["mounts"] = mounts
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _unsafe_url_request_error(exc: BaseException) -> str | None:
|
||||||
|
from nanobot.security.network import UnsafeURLRequestError
|
||||||
|
|
||||||
|
return str(exc) if isinstance(exc, UnsafeURLRequestError) else None
|
||||||
|
|
||||||
|
|
||||||
async def _get_with_safe_redirects(
|
async def _get_with_safe_redirects(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -119,11 +152,17 @@ async def _get_with_safe_redirects(
|
|||||||
"""GET a URL while validating every redirect target before requesting it."""
|
"""GET a URL while validating every redirect target before requesting it."""
|
||||||
current_url = url
|
current_url = url
|
||||||
for _ in range(MAX_REDIRECTS + 1):
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
is_valid, error_msg = _validate_url_safe(current_url)
|
is_valid, error_msg, _ = _resolve_url_safe(current_url)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return None, f"Redirect blocked: {error_msg}"
|
return None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
try:
|
||||||
response = await client.get(current_url, headers=headers, follow_redirects=False)
|
response = await client.get(current_url, headers=headers, follow_redirects=False)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
unsafe_error = _unsafe_url_request_error(exc)
|
||||||
|
if unsafe_error is not None:
|
||||||
|
return None, f"Redirect blocked: {unsafe_error}"
|
||||||
|
raise
|
||||||
is_redirect = 300 <= response.status_code < 400
|
is_redirect = 300 <= response.status_code < 400
|
||||||
if not is_redirect:
|
if not is_redirect:
|
||||||
return response, None
|
return response, None
|
||||||
@@ -152,7 +191,7 @@ async def _stream_with_safe_redirects(
|
|||||||
"""Open a streamed response while validating every redirect target first."""
|
"""Open a streamed response while validating every redirect target first."""
|
||||||
current_url = url
|
current_url = url
|
||||||
for _ in range(MAX_REDIRECTS + 1):
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
is_valid, error_msg = _validate_url_safe(current_url)
|
is_valid, error_msg, _ = _resolve_url_safe(current_url)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return None, None, f"Redirect blocked: {error_msg}"
|
return None, None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
@@ -162,7 +201,13 @@ async def _stream_with_safe_redirects(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
response = await stream.__aenter__()
|
response = await stream.__aenter__()
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
unsafe_error = _unsafe_url_request_error(exc)
|
||||||
|
if unsafe_error is not None:
|
||||||
|
return None, None, f"Redirect blocked: {unsafe_error}"
|
||||||
|
raise
|
||||||
is_redirect = 300 <= response.status_code < 400
|
is_redirect = 300 <= response.status_code < 400
|
||||||
if not is_redirect:
|
if not is_redirect:
|
||||||
return response, stream, None
|
return response, stream, None
|
||||||
@@ -270,8 +315,9 @@ class WebSearchTool(Tool):
|
|||||||
config_loader = None
|
config_loader = None
|
||||||
if ctx.provider_snapshot_loader is not None:
|
if ctx.provider_snapshot_loader is not None:
|
||||||
def config_loader():
|
def config_loader():
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
return resolve_config_env_vars(load_config()).tools.web.search
|
|
||||||
|
return load_effective_config().tools.web.search
|
||||||
return cls(
|
return cls(
|
||||||
config=ctx.config.web.search,
|
config=ctx.config.web.search,
|
||||||
proxy=ctx.config.web.proxy,
|
proxy=ctx.config.web.proxy,
|
||||||
@@ -338,6 +384,9 @@ class WebSearchTool(Tool):
|
|||||||
return "volcengine" if api_key else "duckduckgo"
|
return "volcengine" if api_key else "duckduckgo"
|
||||||
if provider == "keenable":
|
if provider == "keenable":
|
||||||
return "keenable"
|
return "keenable"
|
||||||
|
if provider == "serper":
|
||||||
|
api_key = self.config.api_key or os.environ.get("SERPER_API_KEY", "")
|
||||||
|
return "serper" if api_key else "duckduckgo"
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -394,6 +443,8 @@ class WebSearchTool(Tool):
|
|||||||
)
|
)
|
||||||
elif provider == "keenable":
|
elif provider == "keenable":
|
||||||
return await self._search_keenable(query, n)
|
return await self._search_keenable(query, n)
|
||||||
|
elif provider == "serper":
|
||||||
|
return await self._search_serper(query, n)
|
||||||
else:
|
else:
|
||||||
return ToolResult.error(f"Error: unknown search provider '{provider}'")
|
return ToolResult.error(f"Error: unknown search provider '{provider}'")
|
||||||
|
|
||||||
@@ -668,6 +719,43 @@ class WebSearchTool(Tool):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: Exa search failed: {e}")
|
return ToolResult.error(f"Error: Exa search failed: {e}")
|
||||||
|
|
||||||
|
async def _search_serper(self, query: str, n: int) -> str:
|
||||||
|
"""Search via Serper.dev (Google Search API)."""
|
||||||
|
api_key = self.config.api_key or os.environ.get("SERPER_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("SERPER_API_KEY not set, falling back to DuckDuckGo")
|
||||||
|
return await self._search_duckduckgo(query, n)
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"X-API-KEY": api_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": self.user_agent,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
||||||
|
r = await client.post(
|
||||||
|
"https://google.serper.dev/search",
|
||||||
|
headers=headers,
|
||||||
|
json={"q": query, "num": n},
|
||||||
|
timeout=float(self.config.timeout),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"title": result.get("title", ""),
|
||||||
|
"url": result.get("link", ""),
|
||||||
|
"content": result.get("snippet", ""),
|
||||||
|
}
|
||||||
|
for result in r.json().get("organic", [])
|
||||||
|
if isinstance(result, dict)
|
||||||
|
]
|
||||||
|
return _format_results(query, items, n)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
return ToolResult.error("Error: Serper search rate limited. Try again later or reduce search frequency.")
|
||||||
|
return ToolResult.error(f"Error: Serper search failed ({e.response.status_code}): {e}")
|
||||||
|
except Exception as e:
|
||||||
|
return ToolResult.error(f"Error: Serper search failed: {e}")
|
||||||
|
|
||||||
async def _search_volcengine(
|
async def _search_volcengine(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -911,7 +999,9 @@ 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, timeout=15.0) as client:
|
async with httpx.AsyncClient(
|
||||||
|
**_fetch_client_kwargs(self.proxy, 15.0),
|
||||||
|
) as client:
|
||||||
r, stream, redirect_error = await _stream_with_safe_redirects(
|
r, stream, redirect_error = await _stream_with_safe_redirects(
|
||||||
client,
|
client,
|
||||||
url,
|
url,
|
||||||
@@ -932,6 +1022,9 @@ class WebFetchTool(Tool):
|
|||||||
if stream is not None:
|
if stream is not None:
|
||||||
await stream.__aexit__(None, None, None)
|
await stream.__aexit__(None, None, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
unsafe_error = _unsafe_url_request_error(e)
|
||||||
|
if unsafe_error is not None:
|
||||||
|
return json.dumps({"error": f"URL validation failed: {unsafe_error}", "url": url}, ensure_ascii=False)
|
||||||
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
@@ -981,8 +1074,7 @@ class WebFetchTool(Tool):
|
|||||||
"""Local fallback using readability-lxml."""
|
"""Local fallback using readability-lxml."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=30.0,
|
**_fetch_client_kwargs(self.proxy, 30.0),
|
||||||
proxy=self.proxy,
|
|
||||||
) as client:
|
) as client:
|
||||||
r, redirect_error = await _get_with_safe_redirects(
|
r, redirect_error = await _get_with_safe_redirects(
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Turn-scoped hook assembly for agent runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.hook import (
|
||||||
|
AgentHook,
|
||||||
|
AgentTurnHookContext,
|
||||||
|
AgentTurnHookFactory,
|
||||||
|
CompositeHook,
|
||||||
|
)
|
||||||
|
from nanobot.agent.progress_hook import AgentProgressHook
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AgentTurnHookSpec:
|
||||||
|
"""Inputs needed to build the hook chain for one agent turn."""
|
||||||
|
|
||||||
|
on_progress: Callable[..., Awaitable[None]] | None = None
|
||||||
|
on_stream: Callable[[str], Awaitable[None]] | None = None
|
||||||
|
on_stream_end: Callable[..., Awaitable[None]] | None = None
|
||||||
|
channel: str = "cli"
|
||||||
|
chat_id: str = "direct"
|
||||||
|
message_id: str | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
session_key: str | None = None
|
||||||
|
workspace: Path | None = None
|
||||||
|
tool_hint_max_length: int = 40
|
||||||
|
on_iteration: Callable[[int], None] | None = None
|
||||||
|
registered_hook_factories: list[AgentTurnHookFactory] = field(default_factory=list)
|
||||||
|
turn_hook_factories: list[AgentTurnHookFactory] = field(default_factory=list)
|
||||||
|
registered_hooks: list[AgentHook] = field(default_factory=list)
|
||||||
|
turn_hooks: list[AgentHook] = field(default_factory=list)
|
||||||
|
ephemeral: bool = False
|
||||||
|
run_extra_hooks_for_ephemeral: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent_turn_hook(spec: AgentTurnHookSpec) -> AgentHook:
|
||||||
|
"""Build the hook chain used by ``AgentRunner`` for one turn."""
|
||||||
|
progress_hook = AgentProgressHook(
|
||||||
|
on_progress=spec.on_progress,
|
||||||
|
on_stream=spec.on_stream,
|
||||||
|
on_stream_end=spec.on_stream_end,
|
||||||
|
session_key=spec.session_key,
|
||||||
|
tool_hint_max_length=spec.tool_hint_max_length,
|
||||||
|
on_iteration=spec.on_iteration,
|
||||||
|
)
|
||||||
|
if spec.ephemeral and not spec.run_extra_hooks_for_ephemeral:
|
||||||
|
return progress_hook
|
||||||
|
|
||||||
|
turn_context = AgentTurnHookContext(
|
||||||
|
on_progress=spec.on_progress,
|
||||||
|
workspace=spec.workspace,
|
||||||
|
channel=spec.channel,
|
||||||
|
chat_id=spec.chat_id,
|
||||||
|
message_id=spec.message_id,
|
||||||
|
session_key=spec.session_key,
|
||||||
|
metadata=dict(spec.metadata or {}),
|
||||||
|
ephemeral=spec.ephemeral,
|
||||||
|
)
|
||||||
|
hook_chain: list[AgentHook] = [progress_hook]
|
||||||
|
|
||||||
|
for factory in spec.registered_hook_factories:
|
||||||
|
try:
|
||||||
|
created_hook = factory(turn_context)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Agent turn hook factory failed: {}", factory)
|
||||||
|
continue
|
||||||
|
if created_hook is not None:
|
||||||
|
hook_chain.append(created_hook)
|
||||||
|
|
||||||
|
hook_chain.extend(spec.registered_hooks)
|
||||||
|
|
||||||
|
for factory in spec.turn_hook_factories:
|
||||||
|
try:
|
||||||
|
created_hook = factory(turn_context)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Agent turn hook factory failed: {}", factory)
|
||||||
|
continue
|
||||||
|
if created_hook is not None:
|
||||||
|
hook_chain.append(created_hook)
|
||||||
|
|
||||||
|
hook_chain.extend(spec.turn_hooks)
|
||||||
|
return CompositeHook(hook_chain) if len(hook_chain) > 1 else progress_hook
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Background process control for the WebUI-managed OpenAI-compatible API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.process_runtime import (
|
||||||
|
ManagedProcessRuntime,
|
||||||
|
ProcessRuntimePaths,
|
||||||
|
ProcessStartOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ApiStartOptions(ProcessStartOptions):
|
||||||
|
"""Options needed to start a managed ``nanobot serve`` process."""
|
||||||
|
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def api_runtime_paths(config_path: Path) -> ProcessRuntimePaths:
|
||||||
|
"""Return isolated state and log paths for one API process."""
|
||||||
|
resolved = config_path.expanduser().resolve(strict=False)
|
||||||
|
suffix = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[:16]
|
||||||
|
run_dir = resolved.parent / "run"
|
||||||
|
logs_dir = resolved.parent / "logs"
|
||||||
|
return ProcessRuntimePaths(
|
||||||
|
run_dir=run_dir,
|
||||||
|
logs_dir=logs_dir,
|
||||||
|
state_path=run_dir / f"api.{suffix}.json",
|
||||||
|
log_path=logs_dir / f"api.{suffix}.log",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiRuntime(ManagedProcessRuntime):
|
||||||
|
"""Manage a WebUI-controlled OpenAI-compatible API process."""
|
||||||
|
|
||||||
|
service_name = "api"
|
||||||
|
|
||||||
|
def _build_child_command(self, options: ApiStartOptions) -> list[str]:
|
||||||
|
command = [
|
||||||
|
self.python_executable or sys.executable,
|
||||||
|
"-m",
|
||||||
|
"nanobot",
|
||||||
|
"serve",
|
||||||
|
"--host",
|
||||||
|
options.host,
|
||||||
|
"--port",
|
||||||
|
str(options.port),
|
||||||
|
]
|
||||||
|
if options.verbose:
|
||||||
|
command.append("--verbose")
|
||||||
|
if options.workspace:
|
||||||
|
command.extend(["--workspace", options.workspace])
|
||||||
|
if options.config_path:
|
||||||
|
command.extend(["--config", options.config_path])
|
||||||
|
return command
|
||||||
@@ -404,7 +404,7 @@ def create_app(
|
|||||||
agent_loop: An initialized AgentLoop instance.
|
agent_loop: An initialized AgentLoop instance.
|
||||||
model_name: Model name reported in responses.
|
model_name: Model name reported in responses.
|
||||||
request_timeout: Per-request timeout in seconds.
|
request_timeout: Per-request timeout in seconds.
|
||||||
api_key: Optional API key for Bearer-token authentication.
|
api_key: Optional API key for Bearer-token authentication on API routes.
|
||||||
"""
|
"""
|
||||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||||
app["agent_loop"] = agent_loop
|
app["agent_loop"] = agent_loop
|
||||||
@@ -414,11 +414,11 @@ def create_app(
|
|||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
||||||
if not api_key:
|
|
||||||
return await handler(request)
|
|
||||||
# Allow unauthenticated health checks.
|
# Allow unauthenticated health checks.
|
||||||
if request.path == "/health":
|
if request.path == "/health":
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
if not api_key:
|
||||||
|
return await handler(request)
|
||||||
auth = request.headers.get("Authorization", "")
|
auth = request.headers.get("Authorization", "")
|
||||||
if not auth.startswith("Bearer "):
|
if not auth.startswith("Bearer "):
|
||||||
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")
|
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from typing import Any
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.paths import get_runtime_subdir
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
@@ -941,12 +942,19 @@ class CliAppManager:
|
|||||||
raise CliAppError("this CLI app uses an unsupported install strategy")
|
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||||
|
|
||||||
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||||
return subprocess.run(
|
command = subprocess.list2cmdline(argv)
|
||||||
|
logger.info("CLI Apps: running {}", command)
|
||||||
|
result = subprocess.run(
|
||||||
argv,
|
argv,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
|
||||||
|
output = (result.stderr or result.stdout or "").strip()
|
||||||
|
if output:
|
||||||
|
logger.info("CLI Apps command output:\n{}", _truncate(output, 4000))
|
||||||
|
return result
|
||||||
|
|
||||||
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
entry_point = str(app.get("entry_point") or "")
|
entry_point = str(app.get("entry_point") or "")
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[
|
|||||||
return []
|
return []
|
||||||
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
||||||
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||||
return _cli_app_runtime_lines(text, metadata, workspace)
|
return runtime_lines_for_request(text, metadata, workspace)
|
||||||
|
|
||||||
|
|
||||||
def _cli_app_runtime_lines(
|
def runtime_lines_for_request(
|
||||||
text: str,
|
text: str,
|
||||||
metadata: Mapping[str, Any] | None,
|
metadata: Mapping[str, Any] | None,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
mentions = [
|
mentions = [
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Helpers for channel instance configuration.
|
||||||
|
|
||||||
|
The first consumer is Feishu/Lark. Keep the helpers small and data-oriented so
|
||||||
|
ChannelManager can support Feishu assistant instances without turning every
|
||||||
|
channel into a multi-instance abstraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.config.loader import merge_missing_defaults
|
||||||
|
|
||||||
|
DEFAULT_INSTANCE_ID = "default"
|
||||||
|
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelInstanceSpec:
|
||||||
|
"""Runtime description for one channel instance."""
|
||||||
|
|
||||||
|
base_name: str
|
||||||
|
instance_id: str
|
||||||
|
runtime_name: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_instance_id(value: str) -> str:
|
||||||
|
"""Return a normalized instance id or raise ValueError."""
|
||||||
|
instance_id = value.strip()
|
||||||
|
if not instance_id or not _INSTANCE_ID_RE.fullmatch(instance_id):
|
||||||
|
raise ValueError("instance id must match [A-Za-z0-9_-]+")
|
||||||
|
return instance_id
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_channel_name(base_name: str, instance_id: str) -> str:
|
||||||
|
"""Return the channel key used for routing messages at runtime."""
|
||||||
|
return base_name if instance_id == DEFAULT_INSTANCE_ID else f"{base_name}.{instance_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _base_feishu_instance_config(defaults: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
config = dict(defaults)
|
||||||
|
config["instanceId"] = DEFAULT_INSTANCE_ID
|
||||||
|
config["name"] = "nanobot"
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_feishu_instance(
|
||||||
|
raw: dict[str, Any],
|
||||||
|
defaults: dict[str, Any],
|
||||||
|
*,
|
||||||
|
inherited: dict[str, Any] | None = None,
|
||||||
|
fallback_id: str = DEFAULT_INSTANCE_ID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = merge_missing_defaults(inherited or {}, defaults)
|
||||||
|
config = merge_missing_defaults(raw, config)
|
||||||
|
|
||||||
|
raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id
|
||||||
|
instance_id = validate_instance_id(str(raw_id))
|
||||||
|
config["id"] = instance_id
|
||||||
|
config["instanceId"] = instance_id
|
||||||
|
config.setdefault("name", "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def feishu_instance_specs(
|
||||||
|
section: Any,
|
||||||
|
defaults: dict[str, Any],
|
||||||
|
*,
|
||||||
|
enabled_only: bool = False,
|
||||||
|
) -> list[ChannelInstanceSpec]:
|
||||||
|
"""Expand legacy or canonical Feishu config into runtime instance specs."""
|
||||||
|
if hasattr(section, "model_dump"):
|
||||||
|
section = section.model_dump(mode="json", by_alias=True)
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
section = {}
|
||||||
|
|
||||||
|
instances = section.get("instances")
|
||||||
|
raw_specs: list[dict[str, Any]]
|
||||||
|
inherited: dict[str, Any] | None = None
|
||||||
|
if isinstance(instances, list):
|
||||||
|
inherited = {key: value for key, value in section.items() if key != "instances"}
|
||||||
|
raw_specs = [item for item in instances if isinstance(item, dict)]
|
||||||
|
else:
|
||||||
|
raw_specs = [section] if section else [_base_feishu_instance_config(defaults)]
|
||||||
|
|
||||||
|
specs: list[ChannelInstanceSpec] = []
|
||||||
|
for index, raw in enumerate(raw_specs):
|
||||||
|
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
||||||
|
try:
|
||||||
|
config = _normalize_feishu_instance(
|
||||||
|
raw,
|
||||||
|
defaults,
|
||||||
|
inherited=inherited,
|
||||||
|
fallback_id=fallback_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("Skipping invalid Feishu instance config: {}", exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
enabled = bool(config.get("enabled", defaults.get("enabled", False)))
|
||||||
|
if enabled_only and not enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
instance_id = str(config["instanceId"])
|
||||||
|
specs.append(
|
||||||
|
ChannelInstanceSpec(
|
||||||
|
base_name="feishu",
|
||||||
|
instance_id=instance_id,
|
||||||
|
runtime_name=runtime_channel_name("feishu", instance_id),
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return Feishu config in the canonical ``instances`` shape."""
|
||||||
|
specs = feishu_instance_specs(section, defaults)
|
||||||
|
return {"instances": [dict(spec.config) for spec in specs]}
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_feishu_instance(
|
||||||
|
section: Any,
|
||||||
|
defaults: dict[str, Any],
|
||||||
|
instance_id: str,
|
||||||
|
values: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return canonical Feishu section with one instance created or updated."""
|
||||||
|
instance_id = validate_instance_id(instance_id)
|
||||||
|
canonical = canonical_feishu_section(section, defaults)
|
||||||
|
instances = canonical.setdefault("instances", [])
|
||||||
|
|
||||||
|
for instance in instances:
|
||||||
|
if instance.get("id") == instance_id or instance.get("instanceId") == instance_id:
|
||||||
|
instance.update(values)
|
||||||
|
instance["id"] = instance_id
|
||||||
|
instance["instanceId"] = instance_id
|
||||||
|
instance.setdefault("name", "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}")
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
config = _normalize_feishu_instance(
|
||||||
|
{**values, "id": instance_id},
|
||||||
|
defaults,
|
||||||
|
fallback_id=instance_id,
|
||||||
|
)
|
||||||
|
instances.append(config)
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
|
def update_feishu_instance_preserving_shape(
|
||||||
|
section: Any,
|
||||||
|
defaults: dict[str, Any],
|
||||||
|
instance_id: str,
|
||||||
|
values: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update background metadata without migrating a legacy flat section."""
|
||||||
|
instance_id = validate_instance_id(instance_id)
|
||||||
|
if hasattr(section, "model_dump"):
|
||||||
|
section = section.model_dump(mode="json", by_alias=True)
|
||||||
|
|
||||||
|
if (
|
||||||
|
instance_id == DEFAULT_INSTANCE_ID
|
||||||
|
and isinstance(section, dict)
|
||||||
|
and not isinstance(section.get("instances"), list)
|
||||||
|
):
|
||||||
|
return {**section, **values}
|
||||||
|
|
||||||
|
return upsert_feishu_instance(section, defaults, instance_id, values)
|
||||||
|
|
||||||
|
|
||||||
|
def set_feishu_instance_enabled(
|
||||||
|
section: Any,
|
||||||
|
defaults: dict[str, Any],
|
||||||
|
instance_id: str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return canonical Feishu section with one instance's enabled flag updated."""
|
||||||
|
return upsert_feishu_instance(section, defaults, instance_id, {"enabled": enabled})
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Shared Feishu/Lark WebSocket runtime.
|
||||||
|
|
||||||
|
The official lark_oapi websocket client stores an asyncio loop in a module-level
|
||||||
|
variable. Running one blocking ``Client.start()`` per assistant would make
|
||||||
|
multiple Feishu instances fragile, so this module centralizes the loop patch and
|
||||||
|
starts each client through the SDK's async primitives on one dedicated loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ClientRuntime:
|
||||||
|
client: Any
|
||||||
|
stop_event: asyncio.Event
|
||||||
|
task: asyncio.Task
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuWsRunner:
|
||||||
|
"""Run multiple lark_oapi websocket clients on one dedicated event loop."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._ready = threading.Event()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._clients: dict[str, _ClientRuntime] = {}
|
||||||
|
|
||||||
|
async def start_client(self, key: str, client: Any) -> None:
|
||||||
|
"""Start or replace one client runtime."""
|
||||||
|
loop = self._ensure_loop()
|
||||||
|
await asyncio.wrap_future(
|
||||||
|
asyncio.run_coroutine_threadsafe(self._start_client(key, client), loop)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_client(self, key: str) -> None:
|
||||||
|
"""Stop one client runtime if it is active."""
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None or loop.is_closed():
|
||||||
|
return
|
||||||
|
await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self._stop_client(key), loop))
|
||||||
|
|
||||||
|
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
|
||||||
|
with self._lock:
|
||||||
|
if self._loop is not None and not self._loop.is_closed():
|
||||||
|
return self._loop
|
||||||
|
self._ready.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run_loop, name="feishu-ws", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
if not self._ready.wait(timeout=10) or self._loop is None:
|
||||||
|
raise RuntimeError("Feishu WebSocket runner did not start")
|
||||||
|
return self._loop
|
||||||
|
|
||||||
|
def _run_loop(self) -> None:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
import lark_oapi.ws.client as lark_ws_client
|
||||||
|
|
||||||
|
lark_ws_client.loop = loop
|
||||||
|
self._loop = loop
|
||||||
|
self._ready.set()
|
||||||
|
loop.run_forever()
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
async def _start_client(self, key: str, client: Any) -> None:
|
||||||
|
await self._stop_client(key)
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
task = asyncio.create_task(self._client_main(key, client, stop_event))
|
||||||
|
self._clients[key] = _ClientRuntime(client=client, stop_event=stop_event, task=task)
|
||||||
|
|
||||||
|
async def _stop_client(self, key: str) -> None:
|
||||||
|
runtime = self._clients.pop(key, None)
|
||||||
|
if runtime is None:
|
||||||
|
return
|
||||||
|
runtime.stop_event.set()
|
||||||
|
with suppress(Exception):
|
||||||
|
await runtime.client._disconnect()
|
||||||
|
runtime.task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await runtime.task
|
||||||
|
|
||||||
|
async def _client_main(self, key: str, client: Any, stop_event: asyncio.Event) -> None:
|
||||||
|
ping_task: asyncio.Task | None = None
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
await client._connect()
|
||||||
|
ping_task = asyncio.create_task(client._ping_loop())
|
||||||
|
await stop_event.wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Feishu WebSocket client '{}' failed: {}", key, exc)
|
||||||
|
with suppress(Exception):
|
||||||
|
await client._disconnect()
|
||||||
|
if not stop_event.is_set():
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
finally:
|
||||||
|
if ping_task is not None:
|
||||||
|
ping_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await ping_task
|
||||||
|
with suppress(Exception):
|
||||||
|
await client._disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
_RUNNER: FeishuWsRunner | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_feishu_ws_runner() -> FeishuWsRunner:
|
||||||
|
"""Return the process-wide Feishu WebSocket runner."""
|
||||||
|
global _RUNNER
|
||||||
|
if _RUNNER is None:
|
||||||
|
_RUNNER = FeishuWsRunner()
|
||||||
|
return _RUNNER
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Shared channel setup contract for configuration, display, and validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||||
|
RouteFieldType = str | tuple[str, set[str]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelFieldSpec:
|
||||||
|
"""One channel field exposed through the settings contract."""
|
||||||
|
|
||||||
|
kind: FieldKind = "string"
|
||||||
|
choices: frozenset[str] = frozenset()
|
||||||
|
writable: bool = True
|
||||||
|
snapshot: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def route_type(self) -> RouteFieldType:
|
||||||
|
if self.kind == "enum":
|
||||||
|
return ("enum", set(self.choices))
|
||||||
|
return self.kind
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SetupRequirement:
|
||||||
|
"""A requirement satisfied by any one complete field group."""
|
||||||
|
|
||||||
|
alternatives: tuple[tuple[str, ...], ...]
|
||||||
|
|
||||||
|
def is_satisfied(self, values: Any) -> bool:
|
||||||
|
return any(
|
||||||
|
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||||
|
for group in self.alternatives
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simple_field(self) -> str | None:
|
||||||
|
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||||
|
return self.alternatives[0][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelSetupSpec:
|
||||||
|
"""Save, display, and validation contract for one channel."""
|
||||||
|
|
||||||
|
fields: dict[str, ChannelFieldSpec]
|
||||||
|
required: tuple[SetupRequirement, ...] = ()
|
||||||
|
official_url: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secrets(self) -> frozenset[str]:
|
||||||
|
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snapshot_fields(self) -> tuple[str, ...]:
|
||||||
|
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||||
|
return {
|
||||||
|
name: field.route_type
|
||||||
|
for name, field in self.fields.items()
|
||||||
|
if field.writable
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simple_required_fields(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
field
|
||||||
|
for requirement in self.required
|
||||||
|
if (field := requirement.simple_field) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_configured(self, values: Any) -> bool:
|
||||||
|
return bool(self.required) and all(
|
||||||
|
requirement.is_satisfied(values) for requirement in self.required
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _field(
|
||||||
|
kind: FieldKind = "string",
|
||||||
|
*,
|
||||||
|
choices: set[str] | None = None,
|
||||||
|
writable: bool = True,
|
||||||
|
snapshot: bool = True,
|
||||||
|
) -> ChannelFieldSpec:
|
||||||
|
return ChannelFieldSpec(
|
||||||
|
kind=kind,
|
||||||
|
choices=frozenset(choices or ()),
|
||||||
|
writable=writable,
|
||||||
|
snapshot=snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required(field: str) -> SetupRequirement:
|
||||||
|
return SetupRequirement(((field,),))
|
||||||
|
|
||||||
|
|
||||||
|
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||||
|
return SetupRequirement(alternatives)
|
||||||
|
|
||||||
|
|
||||||
|
_GROUP_POLICIES = {"mention", "open", "allowlist"}
|
||||||
|
_DIRECT_GROUP_POLICIES = {"mention", "open"}
|
||||||
|
|
||||||
|
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
|
||||||
|
"websocket": ChannelSetupSpec(
|
||||||
|
fields={},
|
||||||
|
official_url="http://127.0.0.1:8765",
|
||||||
|
),
|
||||||
|
"telegram": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("token"),),
|
||||||
|
official_url="https://t.me/BotFather",
|
||||||
|
),
|
||||||
|
"slack": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appToken": _field("secret"),
|
||||||
|
"botToken": _field("secret"),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("appToken"), _required("botToken")),
|
||||||
|
official_url="https://api.slack.com/apps",
|
||||||
|
),
|
||||||
|
"discord": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": _field("secret"),
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"allowChannels": _field("list"),
|
||||||
|
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("token"),),
|
||||||
|
official_url="https://discord.com/developers/applications",
|
||||||
|
),
|
||||||
|
"email": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"consentGranted": _field("bool"),
|
||||||
|
"imapHost": _field(),
|
||||||
|
"imapPort": _field("int"),
|
||||||
|
"imapUsername": _field(),
|
||||||
|
"imapPassword": _field("secret"),
|
||||||
|
"smtpHost": _field(),
|
||||||
|
"smtpPort": _field("int"),
|
||||||
|
"smtpUsername": _field(),
|
||||||
|
"smtpPassword": _field("secret"),
|
||||||
|
"fromAddress": _field(),
|
||||||
|
"pollIntervalSeconds": _field("int"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"verifyDkim": _field("bool"),
|
||||||
|
"verifySpf": _field("bool"),
|
||||||
|
},
|
||||||
|
required=tuple(
|
||||||
|
_required(field)
|
||||||
|
for field in (
|
||||||
|
"consentGranted",
|
||||||
|
"imapHost",
|
||||||
|
"imapUsername",
|
||||||
|
"imapPassword",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpUsername",
|
||||||
|
"smtpPassword",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
official_url="https://support.google.com/accounts/answer/185833",
|
||||||
|
),
|
||||||
|
"matrix": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"homeserver": _field(),
|
||||||
|
"userId": _field(),
|
||||||
|
"password": _field("secret"),
|
||||||
|
"accessToken": _field("secret"),
|
||||||
|
"deviceId": _field(),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
"allowFrom": _field("list", writable=False),
|
||||||
|
},
|
||||||
|
required=(
|
||||||
|
_required("homeserver"),
|
||||||
|
_required("userId"),
|
||||||
|
_one_of(("password",), ("accessToken", "deviceId")),
|
||||||
|
),
|
||||||
|
official_url="https://matrix.org/ecosystem/clients/",
|
||||||
|
),
|
||||||
|
"mattermost": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"serverUrl": _field(),
|
||||||
|
"token": _field("secret"),
|
||||||
|
"teamId": _field(),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("serverUrl"), _required("token")),
|
||||||
|
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
|
||||||
|
),
|
||||||
|
"whatsapp": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
|
||||||
|
"databasePath": _field(writable=False, snapshot=False),
|
||||||
|
},
|
||||||
|
official_url="https://faq.whatsapp.com/",
|
||||||
|
),
|
||||||
|
"dingtalk": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"clientId": _field(),
|
||||||
|
"clientSecret": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("clientId"), _required("clientSecret")),
|
||||||
|
official_url="https://open.dingtalk.com/",
|
||||||
|
),
|
||||||
|
"wecom": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"botId": _field(),
|
||||||
|
"secret": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("botId"), _required("secret")),
|
||||||
|
official_url="https://developer.work.weixin.qq.com/",
|
||||||
|
),
|
||||||
|
"weixin": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("token"),),
|
||||||
|
official_url="https://weixin.qq.com/",
|
||||||
|
),
|
||||||
|
"qq": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appId": _field(),
|
||||||
|
"secret": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"msgFormat": _field("enum", choices={"plain", "markdown"}),
|
||||||
|
},
|
||||||
|
required=(_required("appId"), _required("secret")),
|
||||||
|
official_url="https://q.qq.com/",
|
||||||
|
),
|
||||||
|
"signal": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"phoneNumber": _field(),
|
||||||
|
"daemonHost": _field(),
|
||||||
|
"daemonPort": _field("int"),
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"dm.allowFrom": _field("list"),
|
||||||
|
"group.allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("phoneNumber"),),
|
||||||
|
official_url="https://github.com/bbernhard/signal-cli-rest-api",
|
||||||
|
),
|
||||||
|
"msteams": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appId": _field(),
|
||||||
|
"appPassword": _field("secret"),
|
||||||
|
"tenantId": _field(),
|
||||||
|
"path": _field(),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("appId"), _required("appPassword")),
|
||||||
|
official_url="https://dev.teams.microsoft.com/apps",
|
||||||
|
),
|
||||||
|
"napcat": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"wsUrl": _field(),
|
||||||
|
"accessToken": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("wsUrl"),),
|
||||||
|
official_url="https://napneko.github.io/",
|
||||||
|
),
|
||||||
|
"feishu": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appId": _field(snapshot=False),
|
||||||
|
"appSecret": _field("secret", snapshot=False),
|
||||||
|
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
|
||||||
|
"groupPolicy": _field(
|
||||||
|
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
|
||||||
|
),
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"topicIsolation": _field("bool", snapshot=False),
|
||||||
|
},
|
||||||
|
required=(_required("appId"), _required("appSecret")),
|
||||||
|
official_url="https://open.feishu.cn/app",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
|
||||||
|
return CHANNEL_SETUP_SPECS.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||||
|
current = values
|
||||||
|
for part in field_path.split("."):
|
||||||
|
candidates = (part, _camel_to_snake(part))
|
||||||
|
if isinstance(current, dict):
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in current:
|
||||||
|
current = current[candidate]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
for candidate in candidates:
|
||||||
|
if hasattr(current, candidate):
|
||||||
|
current = getattr(current, candidate)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def channel_value_present(value: Any) -> bool:
|
||||||
|
return value not in (None, "", [], {})
|
||||||
|
|
||||||
|
|
||||||
|
def stringify_channel_value(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ", ".join(str(item) for item in value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _camel_to_snake(value: str) -> str:
|
||||||
|
chars: list[str] = []
|
||||||
|
for char in value:
|
||||||
|
if char.isupper():
|
||||||
|
if chars:
|
||||||
|
chars.append("_")
|
||||||
|
chars.append(char.lower())
|
||||||
|
else:
|
||||||
|
chars.append(char)
|
||||||
|
return "".join(chars)
|
||||||
@@ -52,9 +52,9 @@ class BaseChannel(ABC):
|
|||||||
resolve_transcription_config,
|
resolve_transcription_config,
|
||||||
transcribe_audio_file,
|
transcribe_audio_file,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
return await transcribe_audio_file(file_path, resolve_transcription_config(load_config()))
|
return await transcribe_audio_file(file_path, resolve_transcription_config(load_raw_config()))
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Audio transcription failed")
|
self.logger.exception("Audio transcription failed")
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from contextlib import suppress
|
||||||
|
from inspect import isawaitable
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -204,6 +206,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
self.config: DingTalkConfig = config
|
self.config: DingTalkConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
self._start_task: asyncio.Task | None = None
|
||||||
|
|
||||||
# Access Token management for sending messages
|
# Access Token management for sending messages
|
||||||
self._access_token: str | None = None
|
self._access_token: str | None = None
|
||||||
@@ -214,10 +217,12 @@ class DingTalkChannel(BaseChannel):
|
|||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the DingTalk bot with Stream Mode."""
|
"""Start the DingTalk bot with Stream Mode."""
|
||||||
|
current_task = asyncio.current_task()
|
||||||
|
self._start_task = current_task
|
||||||
try:
|
try:
|
||||||
if not DINGTALK_AVAILABLE:
|
if not DINGTALK_AVAILABLE:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Stream SDK not installed. Run: pip install dingtalk-stream"
|
"Stream SDK not installed. Run: nanobot plugins enable dingtalk"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -255,10 +260,25 @@ class DingTalkChannel(BaseChannel):
|
|||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Failed to start channel")
|
self.logger.exception("Failed to start channel")
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
if self._start_task is current_task:
|
||||||
|
self._start_task = None
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the DingTalk bot."""
|
"""Stop the DingTalk bot."""
|
||||||
self._running = False
|
self._running = False
|
||||||
|
await self._close_stream_client()
|
||||||
|
start_task = self._start_task
|
||||||
|
if start_task and start_task is not asyncio.current_task() and not start_task.done():
|
||||||
|
start_task.cancel()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if not start_task.done():
|
||||||
|
start_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await start_task
|
||||||
|
self._client = None
|
||||||
|
|
||||||
# Close the shared HTTP client
|
# Close the shared HTTP client
|
||||||
if self._http:
|
if self._http:
|
||||||
await self._http.aclose()
|
await self._http.aclose()
|
||||||
@@ -268,6 +288,23 @@ class DingTalkChannel(BaseChannel):
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
self._background_tasks.clear()
|
self._background_tasks.clear()
|
||||||
|
|
||||||
|
async def _close_stream_client(self) -> None:
|
||||||
|
client = self._client
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
close = getattr(client, "close", None)
|
||||||
|
if close is None:
|
||||||
|
websocket = getattr(client, "websocket", None)
|
||||||
|
close = getattr(websocket, "close", None)
|
||||||
|
if close is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = close()
|
||||||
|
if isawaitable(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("DingTalk stream client close failed", exc_info=True)
|
||||||
|
|
||||||
async def _get_access_token(self) -> str | None:
|
async def _get_access_token(self) -> str | None:
|
||||||
"""Get or refresh Access Token."""
|
"""Get or refresh Access Token."""
|
||||||
if self._access_token and time.time() < self._token_expiry:
|
if self._access_token and time.time() < self._token_expiry:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user