mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f309982bb0 | ||
|
|
0e37024114 | ||
|
|
baffd6ef92 | ||
|
|
72fb642ef7 | ||
|
|
b886b4a566 | ||
|
|
a4bd4befd4 | ||
|
|
9ecd25bca1 | ||
|
|
503fc83ce2 | ||
|
|
806176f161 | ||
|
|
081482b20f | ||
|
|
ff80998423 | ||
|
|
b60e507010 | ||
|
|
76e857269d | ||
|
|
be2e0172d1 | ||
|
|
cba9ff1f57 | ||
|
|
33a13b701b | ||
|
|
34386fe676 | ||
|
|
31722120b7 | ||
|
|
15c6abc991 | ||
|
|
bdb3a2ded7 | ||
|
|
a3241c33ba | ||
|
|
15c2bd25b3 | ||
|
|
2671c8fe55 | ||
|
|
e3df310309 | ||
|
|
2b4c984e9a | ||
|
|
68712fc489 | ||
|
|
0cc58a80a4 | ||
|
|
e29c9c3906 | ||
|
|
3dcf511c84 | ||
|
|
b2e43955e3 | ||
|
|
98be0de919 | ||
|
|
13ab092cea | ||
|
|
5fe57f8afa | ||
|
|
288146315e | ||
|
|
13dec9d2c2 | ||
|
|
1d4000560d | ||
|
|
4dd89f4c46 | ||
|
|
7c86223643 | ||
|
|
8e421eb976 | ||
|
|
9ed5643d93 | ||
|
|
4a0035ef8f | ||
|
|
a71e6a0ae8 | ||
|
|
57563b671f | ||
|
|
d7bc1bcfb5 | ||
|
|
c1357e86de | ||
|
|
232df45126 | ||
|
|
5734c17ee0 | ||
|
|
9d3fe7c34b | ||
|
|
672fabe5be | ||
|
|
ec4f9e9857 | ||
|
|
404b68cdd4 | ||
|
|
3a420136bb | ||
|
|
84428136e6 | ||
|
|
0df60416ba | ||
|
|
1a4ae8994d | ||
|
|
fe2af64e04 | ||
|
|
7d09f1cd9e | ||
|
|
ac8bef76f6 | ||
|
|
1cfc3ef165 | ||
|
|
18567daaa0 | ||
|
|
9b9b48f1ea | ||
|
|
1eddc129a1 | ||
|
|
a4a2c55120 | ||
|
|
172ec4d4c4 | ||
|
|
4f14f980d9 | ||
|
|
7bbd9c7103 | ||
|
|
418cb23da2 | ||
|
|
179acfe104 | ||
|
|
cfabc29f74 | ||
|
|
92f2ff3a33 | ||
|
|
c433d60681 | ||
|
|
d472595417 | ||
|
|
92915ea424 | ||
|
|
3f0098839e | ||
|
|
c4e2fcaf0c | ||
|
|
8fedee276b | ||
|
|
547f81e4aa | ||
|
|
00a6e720dc | ||
|
|
6ea7a6a2ac | ||
|
|
704ac558f6 | ||
|
|
8be258212e | ||
|
|
c9ff64fc0f | ||
|
|
9efdce276f | ||
|
|
7a6cc657db | ||
|
|
ec99232208 | ||
|
|
43a1784c5f | ||
|
|
3d3ef586e7 | ||
|
|
ef2ef4f789 | ||
|
|
5b71f61f55 | ||
|
|
5937236f9d | ||
|
|
192d2af19d | ||
|
|
3e6f9907fe | ||
|
|
c0d4f012c8 | ||
|
|
e2d00ffc8f | ||
|
|
a5a956d9af | ||
|
|
8c5acea3b0 | ||
|
|
545294c62c | ||
|
|
25d00b1ea4 | ||
|
|
ff173045fe | ||
|
|
b1140f6aee | ||
|
|
782d761b81 | ||
|
|
c1073f2986 | ||
|
|
143224e25a | ||
|
|
055c9be359 | ||
|
|
ddfe5c3bdf | ||
|
|
f5534bcaa0 | ||
|
|
8c0b2c1a29 | ||
|
|
ffd85a8611 | ||
|
|
65dff4f3a5 | ||
|
|
3483141ed7 | ||
|
|
b0d3069621 | ||
|
|
3d9f50a0cc | ||
|
|
effc1efd92 | ||
|
|
9b2f452b6e | ||
|
|
d660573b18 | ||
|
|
cb7daa77db | ||
|
|
8281cd1946 | ||
|
|
e5476573f4 | ||
|
|
0d1d23b5fb | ||
|
|
835bab5f5a | ||
|
|
ccbc0bb6e3 | ||
|
|
722b760eae | ||
|
|
23d5148a57 | ||
|
|
d29fcaf5d1 | ||
|
|
84603f4cf2 | ||
|
|
581faa34f7 | ||
|
|
7e3af8c38b | ||
|
|
e645fbcb34 | ||
|
|
4f895e6307 | ||
|
|
0cd2f626c0 | ||
|
|
44ef697aac | ||
|
|
e2b51fa5dc | ||
|
|
7e122d6e49 | ||
|
|
de0a8f5e41 | ||
|
|
3d3ebf1110 | ||
|
|
77ec55bf8e | ||
|
|
8141df0d3f | ||
|
|
5f0ba05de5 | ||
|
|
886e7e43d5 | ||
|
|
b3d0d24a52 | ||
|
|
82dfe8c1f7 | ||
|
|
dc33247671 | ||
|
|
d376ec129d | ||
|
|
d653f23aba | ||
|
|
96767ca179 | ||
|
|
b300ea495f | ||
|
|
632f41e418 | ||
|
|
9c486b90d5 | ||
|
|
590ac99c8a | ||
|
|
7733a7840e | ||
|
|
83aed43682 | ||
|
|
ad7c1ac381 | ||
|
|
882d4139d7 | ||
|
|
ca72f6b6c9 | ||
|
|
96eb3b7194 | ||
|
|
8f6b7611a2 | ||
|
|
1a6fe093e7 | ||
|
|
8ec1025193 | ||
|
|
480ca28a2d | ||
|
|
3e154bb5cf | ||
|
|
6851fa57a6 | ||
|
|
09a692be6f | ||
|
|
3f789bd9f9 | ||
|
|
65cecc01fb | ||
|
|
a7b34422f3 | ||
|
|
72f999f8f7 | ||
|
|
e6587a8d8e | ||
|
|
eae51333ad | ||
|
|
6194a9b919 | ||
|
|
61ae869610 | ||
|
|
3eebe08dba | ||
|
|
38a5f09f02 | ||
|
|
af9f8d54b8 | ||
|
|
1391aa3d57 | ||
|
|
e00220bdb6 | ||
|
|
4dccee56a7 | ||
|
|
2d302a006e | ||
|
|
3f321179eb | ||
|
|
cda1de863e | ||
|
|
57d5276da1 | ||
|
|
30fc05c746 | ||
|
|
15dba8d080 | ||
|
|
a45884c0d3 | ||
|
|
6a8a17a380 | ||
|
|
705abff7a3 | ||
|
|
44b7bba9bd | ||
|
|
d7a73093a8 | ||
|
|
59548b0a04 | ||
|
|
fc1c8ea770 | ||
|
|
99e4d25d4c | ||
|
|
c588d56a77 | ||
|
|
7367741ac1 | ||
|
|
4e0d872588 | ||
|
|
0a5606b409 | ||
|
|
7411afa0e7 | ||
|
|
c4293a7835 | ||
|
|
40c1d83b32 | ||
|
|
0537cc1682 | ||
|
|
7e2dbdef7d | ||
|
|
c4794b82a9 | ||
|
|
d7122a13d3 | ||
|
|
d4ade8f680 | ||
|
|
28d0f8560e | ||
|
|
ba38f90832 | ||
|
|
eb3aed359f | ||
|
|
4445fcc8b9 | ||
|
|
b67205f5aa | ||
|
|
de8761f25a | ||
|
|
8708ccea86 | ||
|
|
eb0ff3ad1d | ||
|
|
c58a360b25 | ||
|
|
5bb94edc99 | ||
|
|
888d54790d | ||
|
|
48d35bd2d9 | ||
|
|
fce1550814 | ||
|
|
bf8a6e35fd | ||
|
|
f017e209da | ||
|
|
5a34504b76 | ||
|
|
af26ed0041 | ||
|
|
112f40ad67 | ||
|
|
2f323e24c1 | ||
|
|
361f31c0e4 | ||
|
|
945f208d38 | ||
|
|
c8bb04a8fe | ||
|
|
4b5de66c58 | ||
|
|
9340567f2d | ||
|
|
e5be4dac7a | ||
|
|
175b58e259 | ||
|
|
3bf8de047a | ||
|
|
400f822601 | ||
|
|
9fb9d7afcb | ||
|
|
c018c3fb6a |
@@ -31,10 +31,6 @@ Tool descriptions, skills, and replayed session history also shape model behavio
|
|||||||
|
|
||||||
Anything written into memory, session history, or prompt inputs can be replayed into future LLM calls. Metadata such as timestamps, local media paths, tool-call echoes, and raw fallback dumps must be bounded and sanitized before they become examples for the model to imitate.
|
Anything written into memory, session history, or prompt inputs can be replayed into future LLM calls. Metadata such as timestamps, local media paths, tool-call echoes, and raw fallback dumps must be bounded and sanitized before they become examples for the model to imitate.
|
||||||
|
|
||||||
## Heartbeat Virtual Tool Call
|
|
||||||
|
|
||||||
The heartbeat service (`heartbeat/service.py`) does not parse free-text LLM output. Instead, it injects a virtual `heartbeat` tool with `action: skip | run` into the conversation. Phase 1 is a structured decision; Phase 2 executes only on `run`. When adding new periodic background checks, follow this virtual-tool-call pattern rather than string matching.
|
|
||||||
|
|
||||||
## Skills as Extension Point
|
## Skills as Extension Point
|
||||||
|
|
||||||
Built-in skills live in `nanobot/skills/` (markdown + YAML frontmatter format). Agent capabilities that are "know-how" rather than code should be added as skills, not hardcoded into the agent loop. External skills can be published to and installed from ClawHub.
|
Built-in skills live in `nanobot/skills/` (markdown + YAML frontmatter format). Agent capabilities that are "know-how" rather than code should be added as skills, not hardcoded into the agent loop. External skills can be published to and installed from ClawHub.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ __pycache__
|
|||||||
*.egg-info
|
*.egg-info
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
nanobot/web/dist/
|
||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
.assets
|
.assets
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: nanobot Version
|
label: nanobot Version
|
||||||
description: Run `nanobot --version` or `pip show nanobot-ai`
|
description: Run `nanobot --version` or `pip show nanobot-ai`
|
||||||
placeholder: e.g., 0.1.5
|
placeholder: e.g., 0.2.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest","windows-latest"]') }}
|
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
|
||||||
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
||||||
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
.env
|
.env
|
||||||
.web
|
.web
|
||||||
.orion
|
.orion
|
||||||
|
nanobot-desktop/
|
||||||
|
desktop/
|
||||||
|
|
||||||
# Claude / AI assistant artifacts
|
# Claude / AI assistant artifacts
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
@@ -97,3 +99,5 @@ logs/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
exp/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
This file provides guidance to AI coding agents working with this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python: run single test / lint
|
||||||
|
pytest tests/test_openai_api.py::test_function -v
|
||||||
|
ruff check nanobot/
|
||||||
|
|
||||||
|
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
|
||||||
|
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
|
||||||
|
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
|
||||||
|
cd webui && bun run build
|
||||||
|
cd webui && bun run test
|
||||||
|
|
||||||
|
# Gateway
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## High-Level Architecture
|
||||||
|
|
||||||
|
### Core Data Flow
|
||||||
|
|
||||||
|
Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core:
|
||||||
|
|
||||||
|
1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus.
|
||||||
|
2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn.
|
||||||
|
3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses.
|
||||||
|
4. Responses are published as `OutboundMessage` events back to the appropriate channel.
|
||||||
|
|
||||||
|
### Key Subsystems
|
||||||
|
|
||||||
|
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
|
||||||
|
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
|
||||||
|
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||||
|
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||||
|
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
|
||||||
|
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
||||||
|
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
|
||||||
|
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
|
||||||
|
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
|
||||||
|
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
|
||||||
|
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
|
||||||
|
- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed).
|
||||||
|
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
|
||||||
|
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context.
|
||||||
|
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
|
||||||
|
- **CLI**: `nanobot/cli/commands.py`
|
||||||
|
- **Python SDK**: `nanobot/nanobot.py`
|
||||||
|
|
||||||
|
## Project-Specific Notes
|
||||||
|
|
||||||
|
- Architecture constraints: [`.agent/design.md`](.agent/design.md)
|
||||||
|
- Security boundaries: [`.agent/security.md`](.agent/security.md)
|
||||||
|
- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md)
|
||||||
|
|
||||||
|
## Branching Strategy
|
||||||
|
|
||||||
|
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) and PR guidelines.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Python 3.11+, asyncio throughout.
|
||||||
|
- Line length: 100.
|
||||||
|
- Linting: `ruff` with rules E, F, I, N, W (E501 ignored).
|
||||||
|
- pytest with `asyncio_mode = "auto"`.
|
||||||
|
|
||||||
|
## Common File Locations
|
||||||
|
|
||||||
|
- Config schema: `nanobot/config/schema.py`
|
||||||
|
- Provider base / new provider template: `nanobot/providers/base.py`
|
||||||
|
- Channel base / new channel template: `nanobot/channels/base.py`
|
||||||
|
- Tool registry: `nanobot/agent/tools/registry.py`
|
||||||
|
- WebUI dev proxy config: `webui/vite.config.ts`
|
||||||
|
- Tests mirror the `nanobot/` package structure.
|
||||||
@@ -1,84 +1 @@
|
|||||||
# CLAUDE.md
|
@AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
nanobot is a lightweight, open-source AI agent framework written in Python with a React/TypeScript WebUI. It centers around a small agent loop that receives messages from chat channels, invokes an LLM provider, executes tools, and manages session memory.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Python: run single test / lint
|
|
||||||
pytest tests/test_openai_api.py::test_function -v
|
|
||||||
ruff check nanobot/
|
|
||||||
|
|
||||||
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
|
|
||||||
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
|
|
||||||
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
|
|
||||||
cd webui && bun run build
|
|
||||||
cd webui && bun run test
|
|
||||||
|
|
||||||
# Gateway
|
|
||||||
nanobot gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
## High-Level Architecture
|
|
||||||
|
|
||||||
### Core Data Flow
|
|
||||||
|
|
||||||
Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decouples chat channels from the agent core:
|
|
||||||
|
|
||||||
1. **Channels** (`nanobot/channels/`) receive messages from external platforms and publish `InboundMessage` events to the bus.
|
|
||||||
2. **`AgentLoop`** (`nanobot/agent/loop.py`) consumes inbound messages, builds context, and coordinates the turn.
|
|
||||||
3. **`AgentRunner`** (`nanobot/agent/runner.py`) handles the actual LLM conversation loop: send messages to the provider, receive tool calls, execute tools, and stream responses.
|
|
||||||
4. Responses are published as `OutboundMessage` events back to the appropriate channel.
|
|
||||||
|
|
||||||
### Key Subsystems
|
|
||||||
|
|
||||||
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
|
|
||||||
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
|
|
||||||
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
|
|
||||||
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
|
|
||||||
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
|
|
||||||
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
|
||||||
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
|
|
||||||
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
|
|
||||||
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
|
|
||||||
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
|
|
||||||
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
|
|
||||||
- **Heartbeat** (`nanobot/heartbeat/`): Periodic agent wake-up service for scheduled task checking.
|
|
||||||
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
|
|
||||||
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context.
|
|
||||||
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
|
|
||||||
|
|
||||||
### Entry Points
|
|
||||||
|
|
||||||
- **CLI**: `nanobot/cli/commands.py`
|
|
||||||
- **Python SDK**: `nanobot/nanobot.py`
|
|
||||||
|
|
||||||
## Project-Specific Notes
|
|
||||||
|
|
||||||
- Architecture constraints: [`.agent/design.md`](.agent/design.md)
|
|
||||||
- Security boundaries: [`.agent/security.md`](.agent/security.md)
|
|
||||||
- Common gotchas: [`.agent/gotchas.md`](.agent/gotchas.md)
|
|
||||||
|
|
||||||
## Branching Strategy
|
|
||||||
|
|
||||||
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full two-branch model (`main` vs `nightly`) and PR guidelines.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- Python 3.11+, asyncio throughout.
|
|
||||||
- Line length: 100.
|
|
||||||
- Linting: `ruff` with rules E, F, I, N, W (E501 ignored).
|
|
||||||
- pytest with `asyncio_mode = "auto"`.
|
|
||||||
|
|
||||||
## Common File Locations
|
|
||||||
|
|
||||||
- Config schema: `nanobot/config/schema.py`
|
|
||||||
- Provider base / new provider template: `nanobot/providers/base.py`
|
|
||||||
- Channel base / new channel template: `nanobot/channels/base.py`
|
|
||||||
- Tool registry: `nanobot/agent/tools/registry.py`
|
|
||||||
- WebUI dev proxy config: `webui/vite.config.ts`
|
|
||||||
- Tests mirror the `nanobot/` package structure.
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ software together: with care, clarity, and respect for the next person reading t
|
|||||||
|
|
||||||
## Maintainers
|
## Maintainers
|
||||||
|
|
||||||
|
Maintainers are community stewards who help review, organize, and maintain the project. The list below describes each maintainer's current open-source project responsibilities.
|
||||||
|
|
||||||
| Maintainer | Focus |
|
| Maintainer | Focus |
|
||||||
|------------|-------|
|
|------------|-------|
|
||||||
| [@re-bin](https://github.com/re-bin) | Project lead, `main` branch |
|
| [@re-bin](https://github.com/re-bin) | Project lead, `main` branch |
|
||||||
|
|||||||
+7
-5
@@ -14,8 +14,9 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python dependencies first (cached layer)
|
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
||||||
COPY pyproject.toml README.md LICENSE ./
|
# hook from hatch_build.py even for this metadata-only install.
|
||||||
|
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
||||||
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||||
uv pip install --system --no-cache . && \
|
uv pip install --system --no-cache . && \
|
||||||
rm -rf nanobot bridge
|
rm -rf nanobot bridge
|
||||||
@@ -23,7 +24,8 @@ RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
|||||||
# Copy the full source and install
|
# Copy the full source and install
|
||||||
COPY nanobot/ nanobot/
|
COPY nanobot/ nanobot/
|
||||||
COPY bridge/ bridge/
|
COPY bridge/ bridge/
|
||||||
RUN uv pip install --system --no-cache .
|
COPY webui/ webui/
|
||||||
|
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache .
|
||||||
|
|
||||||
# Build the WhatsApp bridge
|
# Build the WhatsApp bridge
|
||||||
WORKDIR /app/bridge
|
WORKDIR /app/bridge
|
||||||
@@ -43,8 +45,8 @@ RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/ent
|
|||||||
USER nanobot
|
USER nanobot
|
||||||
ENV HOME=/home/nanobot
|
ENV HOME=/home/nanobot
|
||||||
|
|
||||||
# Gateway default port
|
# Gateway health endpoint and optional WebUI/WebSocket channel ports
|
||||||
EXPOSE 18790
|
EXPOSE 18790 8765
|
||||||
|
|
||||||
ENTRYPOINT ["entrypoint.sh"]
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
CMD ["status"]
|
CMD ["status"]
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
<p>
|
||||||
|
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview">English</a> |
|
||||||
|
<a href="https://nanobot.wiki/cn/docs/latest/getting-started/nanobot-overview">简体中文</a> |
|
||||||
|
<a href="https://nanobot.wiki/zh-Hant/docs/latest/getting-started/nanobot-overview">繁體中文</a> |
|
||||||
|
<a href="https://nanobot.wiki/es/docs/latest/getting-started/nanobot-overview">Español</a> |
|
||||||
|
<a href="https://nanobot.wiki/fr/docs/latest/getting-started/nanobot-overview">Français</a> |
|
||||||
|
<a href="https://nanobot.wiki/id/docs/latest/getting-started/nanobot-overview">Bahasa Indonesia</a> |
|
||||||
|
<a href="https://nanobot.wiki/ja/docs/latest/getting-started/nanobot-overview">日本語</a> |
|
||||||
|
<a href="https://nanobot.wiki/ko/docs/latest/getting-started/nanobot-overview">한국어</a> |
|
||||||
|
<a href="https://nanobot.wiki/ru/docs/latest/getting-started/nanobot-overview">Русский</a> |
|
||||||
|
<a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a>
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
|
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
|
||||||
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
|
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
|
||||||
@@ -19,10 +31,30 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
🐈 **nanobot** is an open-source and ultra-lightweight AI agent in the spirit of [OpenClaw](https://github.com/openclaw/openclaw), [Claude Code](https://www.anthropic.com/claude-code), and [Codex](https://www.openai.com/codex/). It keeps the core agent loop small and readable while still supporting chat channels, memory, MCP and practical deployment paths, so you can go from local setup to a long-running personal agent with minimal overhead.
|
🐈 **nanobot** is an open-source, ultra-lightweight agent runtime for people who want to own their AI agent stack. It gives you a small, readable core plus the practical pieces for real long-running agents: WebUI, chat channels, tools, memory, MCP, model routing, and deployment.
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
|
||||||
|
- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
|
||||||
|
- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
|
||||||
|
- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
|
||||||
|
- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
|
||||||
|
- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
|
||||||
|
- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
|
||||||
|
- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
|
||||||
|
- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
|
||||||
|
- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Earlier news</summary>
|
||||||
|
|
||||||
|
- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
|
||||||
|
- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
|
||||||
|
- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
|
||||||
|
- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
|
||||||
|
- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
|
||||||
|
- **2026-05-15** 🚀 Released **v0.2.0** — **`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
|
||||||
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
|
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
|
||||||
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
|
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
|
||||||
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
|
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
|
||||||
@@ -32,10 +64,6 @@
|
|||||||
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
|
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
|
||||||
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
|
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
|
||||||
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
|
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Earlier news</summary>
|
|
||||||
|
|
||||||
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
|
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
|
||||||
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
|
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
|
||||||
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
|
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
|
||||||
@@ -60,7 +88,7 @@
|
|||||||
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
||||||
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
||||||
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
||||||
- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji.
|
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
|
||||||
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
|
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
|
||||||
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
||||||
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
||||||
@@ -132,12 +160,13 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
## 💡 Key Features of nanobot
|
## 💡 Why nanobot
|
||||||
|
|
||||||
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
|
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
|
||||||
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
|
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email.
|
||||||
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
|
- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
|
||||||
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
|
- **Small core**: readable internals with MCP, memory, deployment, and automation built in.
|
||||||
|
- **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
|
||||||
|
|
||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|
||||||
@@ -211,13 +240,13 @@ nanobot agent
|
|||||||
|
|
||||||
|
|
||||||
- Want different LLM providers, web search, MCP, security settings, or more config options? See [Configuration](./docs/configuration.md)
|
- Want different LLM providers, web search, MCP, security settings, or more config options? See [Configuration](./docs/configuration.md)
|
||||||
|
- Want to run locally? Use [Atomic Chat](./docs/configuration.md#atomic-chat-local), [vLLM](./docs/configuration.md#vllm-local-openai-compatible), [Ollama](./docs/configuration.md#ollama-local), and [others](./docs/configuration.md#local-providers).
|
||||||
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
|
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
|
||||||
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
|
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
|
||||||
|
|
||||||
## 🧪 WebUI (Development)
|
## 🌐 WebUI
|
||||||
|
|
||||||
> [!NOTE]
|
The WebUI ships **inside the published wheel** — no extra build step. Just enable the WebSocket channel and open it in your browser.
|
||||||
> The WebUI development workflow currently requires a source checkout and is not yet shipped together with the official packaged release. See [WebUI Document](./webui/README.md) for full WebUI development docs and build steps.
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||||
@@ -235,13 +264,12 @@ nanobot agent
|
|||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Start the webui dev server**
|
**3. Open the WebUI**
|
||||||
|
|
||||||
```bash
|
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs → LAN access](./webui/README.md#access-from-another-device-lan).
|
||||||
cd webui
|
|
||||||
bun install
|
> [!TIP]
|
||||||
bun run dev
|
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the Vite dev server (HMR) workflow.
|
||||||
```
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
@@ -330,4 +358,4 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<em> Thanks for visiting ✨ nanobot!</em><br><br>
|
<em> Thanks for visiting ✨ nanobot!</em><br><br>
|
||||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views">
|
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views">
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+1
-3
@@ -46,17 +46,15 @@ core_agent=$(count_top_level_py_lines "nanobot/agent")
|
|||||||
core_bus=$(count_top_level_py_lines "nanobot/bus")
|
core_bus=$(count_top_level_py_lines "nanobot/bus")
|
||||||
core_config=$(count_top_level_py_lines "nanobot/config")
|
core_config=$(count_top_level_py_lines "nanobot/config")
|
||||||
core_cron=$(count_top_level_py_lines "nanobot/cron")
|
core_cron=$(count_top_level_py_lines "nanobot/cron")
|
||||||
core_heartbeat=$(count_top_level_py_lines "nanobot/heartbeat")
|
|
||||||
core_session=$(count_top_level_py_lines "nanobot/session")
|
core_session=$(count_top_level_py_lines "nanobot/session")
|
||||||
|
|
||||||
print_row "agent/" "$core_agent"
|
print_row "agent/" "$core_agent"
|
||||||
print_row "bus/" "$core_bus"
|
print_row "bus/" "$core_bus"
|
||||||
print_row "config/" "$core_config"
|
print_row "config/" "$core_config"
|
||||||
print_row "cron/" "$core_cron"
|
print_row "cron/" "$core_cron"
|
||||||
print_row "heartbeat/" "$core_heartbeat"
|
|
||||||
print_row "session/" "$core_session"
|
print_row "session/" "$core_session"
|
||||||
|
|
||||||
core_total=$((core_agent + core_bus + core_config + core_cron + core_heartbeat + core_session))
|
core_total=$((core_agent + core_bus + core_config + core_cron + core_session))
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Separate buckets"
|
echo "Separate buckets"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 18790:18790
|
- 18790:18790
|
||||||
|
- 8765:8765
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Start here for setup, everyday usage, and deployment.
|
|||||||
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
|
| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
|
||||||
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
|
| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
|
||||||
| Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts |
|
| Image generation | [`image-generation.md`](./image-generation.md) | Configure image providers, WebUI image mode, and generated artifacts |
|
||||||
|
| WebUI | [`../webui/README.md`](../webui/README.md) | Open the bundled browser UI; LAN access; Vite dev server for contributors |
|
||||||
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
|
| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
|
||||||
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
|
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
|
||||||
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
|
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
|
|||||||
| **Wecom** | Bot ID + Bot Secret |
|
| **Wecom** | Bot ID + Bot Secret |
|
||||||
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
||||||
| **Mochat** | Claw token (auto-setup available) |
|
| **Mochat** | Claw token (auto-setup available) |
|
||||||
|
| **Signal** | signal-cli daemon + phone number |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
@@ -50,6 +51,43 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
|
|||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Webhook mode (optional)**
|
||||||
|
|
||||||
|
Telegram uses long polling by default. To receive updates through a webhook, expose
|
||||||
|
a public HTTPS URL that forwards to nanobot's local listener and set `mode` to
|
||||||
|
`webhook`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"mode": "webhook",
|
||||||
|
"webhookUrl": "https://example.com/telegram",
|
||||||
|
"webhookListenHost": "127.0.0.1",
|
||||||
|
"webhookListenPort": 8081,
|
||||||
|
"webhookPath": "/telegram",
|
||||||
|
"webhookSecretToken": "CHANGE_ME_RANDOM_SECRET",
|
||||||
|
"webhookMaxConnections": 4,
|
||||||
|
"allowFrom": ["YOUR_USER_ID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `webhookSecretToken` is required in webhook mode. Do not expose the local
|
||||||
|
> webhook listener directly to the public internet without a reverse proxy or
|
||||||
|
> tunnel in front of it. TLS/Host policy is handled by your proxy; nanobot only
|
||||||
|
> listens on `webhookListenHost:webhookListenPort` and validates Telegram's
|
||||||
|
> webhook secret token. `webhookMaxConnections` defaults to `4`; nanobot
|
||||||
|
> still serializes Telegram updates per conversation before forwarding them to
|
||||||
|
> the agent.
|
||||||
|
>
|
||||||
|
> `webhookUrl` is the public HTTPS URL registered with Telegram.
|
||||||
|
> `webhookPath` is the local path nanobot listens on. They often use the same
|
||||||
|
> path, but may differ when a reverse proxy or tunnel rewrites the request path.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -206,6 +244,7 @@ for reliable encryption, password login is recommended instead. If the
|
|||||||
"userId": "@nanobot:matrix.org",
|
"userId": "@nanobot:matrix.org",
|
||||||
"password": "mypasswordhere",
|
"password": "mypasswordhere",
|
||||||
"e2eeEnabled": true,
|
"e2eeEnabled": true,
|
||||||
|
"sasVerification": true,
|
||||||
"allowFrom": ["@your_user:matrix.org"],
|
"allowFrom": ["@your_user:matrix.org"],
|
||||||
"groupPolicy": "open",
|
"groupPolicy": "open",
|
||||||
"groupAllowFrom": [],
|
"groupAllowFrom": [],
|
||||||
@@ -225,6 +264,7 @@ for reliable encryption, password login is recommended instead. If the
|
|||||||
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
|
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
|
||||||
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
|
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
|
||||||
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
|
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
|
||||||
|
| `sasVerification` | Auto-complete SAS device verification requests from allowed users (default `false`). Useful for Element X, which does not expose manual trust for third-party devices. |
|
||||||
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
|
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
|
||||||
|
|
||||||
|
|
||||||
@@ -669,3 +709,69 @@ nanobot gateway
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Signal</b></summary>
|
||||||
|
|
||||||
|
Uses **signal-cli** daemon in HTTP mode — receive messages via SSE, send via JSON-RPC.
|
||||||
|
|
||||||
|
**1. Install signal-cli**
|
||||||
|
|
||||||
|
Install [signal-cli](https://github.com/AsamK/signal-cli) and register a phone number:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signal-cli -u +1234567890 register
|
||||||
|
signal-cli -u +1234567890 verify <CODE>
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signal-cli -a +1234567890 daemon --http localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"signal": {
|
||||||
|
"enabled": true,
|
||||||
|
"phoneNumber": "+1234567890",
|
||||||
|
"daemonHost": "localhost",
|
||||||
|
"daemonPort": 8080,
|
||||||
|
"dm": {
|
||||||
|
"enabled": true,
|
||||||
|
"policy": "open"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"enabled": true,
|
||||||
|
"policy": "open",
|
||||||
|
"requireMention": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> - `phoneNumber`: Your registered Signal phone number.
|
||||||
|
> - `daemonHost` / `daemonPort`: Where signal-cli daemon is listening (default `localhost:8080`).
|
||||||
|
> - `dm.policy`: `"open"` (anyone can DM) or `"allowlist"` (only listed numbers/UUIDs). When `"allowlist"`, unlisted DM senders receive a pairing code.
|
||||||
|
> - `dm.allowFrom`: List of allowed phone numbers or UUIDs (used when policy is `"allowlist"`).
|
||||||
|
> - `group.policy`: `"open"` (all groups) or `"allowlist"` (only listed group IDs).
|
||||||
|
> - `group.requireMention`: When `true` (default), the bot only responds in groups when @mentioned.
|
||||||
|
> - `group.allowFrom`: List of allowed group IDs (used when group policy is `"allowlist"`).
|
||||||
|
> - `attachmentsDir`: Override the directory where signal-cli stores inbound attachments. Defaults to `~/.local/share/signal-cli/attachments` (the Linux default). Set this if signal-cli runs with a custom `XDG_DATA_HOME` or on macOS/Windows.
|
||||||
|
> - `groupMessageBufferSize`: Number of recent group messages kept for context (default `20`, must be > 0).
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> The channel automatically reconnects to the signal-cli daemon with exponential backoff if the connection drops.
|
||||||
|
> Markdown in bot replies is automatically converted to Signal text styles (bold, italic, code, etc.).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -56,17 +56,17 @@ Preset names come from the top-level `modelPresets` config. Switching is runtime
|
|||||||
|
|
||||||
## Periodic Tasks
|
## Periodic Tasks
|
||||||
|
|
||||||
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
|
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks under `## Active Tasks`, the agent executes them and delivers results to your most recently active chat channel. If there are no active tasks, the heartbeat is skipped silently.
|
||||||
|
|
||||||
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
|
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Periodic Tasks
|
## Active Tasks
|
||||||
|
|
||||||
- [ ] Check weather forecast and send a summary
|
- [ ] Check weather forecast and send a summary
|
||||||
- [ ] Scan inbox for urgent emails
|
- [ ] Scan inbox for urgent emails
|
||||||
```
|
```
|
||||||
|
|
||||||
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
|
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
|
||||||
|
|
||||||
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
|
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
|
||||||
|
|||||||
+264
-13
@@ -26,7 +26,52 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
|
Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
|
||||||
|
|
||||||
|
If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`.
|
||||||
|
|
||||||
|
### More examples
|
||||||
|
|
||||||
|
**MCP servers** — both stdio `env` and HTTP `headers`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcpServers": {
|
||||||
|
"github": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||||
|
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
|
||||||
|
},
|
||||||
|
"remote": {
|
||||||
|
"url": "https://example.com/mcp/",
|
||||||
|
"headers": { "Authorization": "Bearer ${REMOTE_MCP_TOKEN}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web search providers:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"provider": "brave",
|
||||||
|
"apiKey": "${BRAVE_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading variables at startup
|
||||||
|
|
||||||
|
Pick whatever fits your deployment — nanobot only reads `os.environ` at startup, so any mechanism that populates the process environment works.
|
||||||
|
|
||||||
|
**systemd** — use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# /etc/systemd/system/nanobot.service (excerpt)
|
# /etc/systemd/system/nanobot.service (excerpt)
|
||||||
@@ -42,6 +87,35 @@ TELEGRAM_TOKEN=your-token-here
|
|||||||
IMAP_PASSWORD=your-password-here
|
IMAP_PASSWORD=your-password-here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Docker** — pass an env file to the locally built image (one `KEY=VALUE` per line), or use `-e KEY=value`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --env-file=./nanobot.env \
|
||||||
|
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||||
|
nanobot agent -m "Hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
**direnv** — drop a `.envrc` in your working directory and run `direnv allow`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .envrc (auto-loaded by direnv)
|
||||||
|
export TELEGRAM_TOKEN=your-token-here
|
||||||
|
export ANTHROPIC_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secret managers (1Password, Bitwarden, pass)** — wrap the process so secrets only exist as env vars for the lifetime of the run, never on disk:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1Password — references in .env.tpl look like `op://Vault/Item/field`
|
||||||
|
op run --env-file=.env.tpl -- nanobot agent
|
||||||
|
|
||||||
|
# pass (passwordstore.org)
|
||||||
|
ANTHROPIC_API_KEY="$(pass show api/anthropic)" nanobot agent
|
||||||
|
|
||||||
|
# Bitwarden
|
||||||
|
ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
||||||
|
```
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -52,14 +126,17 @@ IMAP_PASSWORD=your-password-here
|
|||||||
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
|
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
|
||||||
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
||||||
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
||||||
|
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.com/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
||||||
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
|
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
|
||||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||||
|
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `custom` | Any OpenAI-compatible endpoint | — |
|
| `custom` | Any OpenAI-compatible endpoint | — |
|
||||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
|
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
|
||||||
|
| `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) |
|
||||||
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
|
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
|
||||||
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
|
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
|
||||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
@@ -73,11 +150,13 @@ IMAP_PASSWORD=your-password-here
|
|||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
||||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||||
|
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||||
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||||
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
|
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
|
||||||
|
| `ant_ling` | LLM (Ant Ling / 蚂蚁百灵) | [developer.ant-ling.com](https://developer.ant-ling.com/en/docs/api-reference/openai/) |
|
||||||
| `ollama` | LLM (local, Ollama) | — |
|
| `ollama` | LLM (local, Ollama) | — |
|
||||||
| `lm_studio` | LLM (local, LM Studio) | — |
|
| `lm_studio` | LLM (local, LM Studio) | — |
|
||||||
| `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — |
|
| `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — |
|
||||||
@@ -89,6 +168,73 @@ IMAP_PASSWORD=your-password-here
|
|||||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||||
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>OpenAI</b></summary>
|
||||||
|
|
||||||
|
By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normally and routes GPT-5/o-series or explicit `reasoningEffort` requests through the Responses API when useful. You can force a specific API surface:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"openai": {
|
||||||
|
"apiKey": "${OPENAI_API_KEY}",
|
||||||
|
"apiType": "chat_completions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
|
||||||
|
|
||||||
|
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"openai": {
|
||||||
|
"apiKey": "${OPENAI_API_KEY}",
|
||||||
|
"apiType": "responses",
|
||||||
|
"extraBody": {
|
||||||
|
"tools": [{ "type": "web_search" }],
|
||||||
|
"include": ["web_search_call.action.sources"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Skywork / APIFree</b></summary>
|
||||||
|
|
||||||
|
Skywork uses APIFree's OpenAI-compatible Agent API endpoint. Configure the provider
|
||||||
|
once, then use Skywork model IDs such as `skywork-ai/skyclaw-v1`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"skywork": {
|
||||||
|
"apiKey": "${SKYWORK_API_KEY}",
|
||||||
|
"apiBase": "https://api.apifree.ai/agent/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "skywork",
|
||||||
|
"model": "skywork-ai/skyclaw-v1",
|
||||||
|
"maxTokens": 32768,
|
||||||
|
"contextWindowTokens": 131072
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also reference `${APIFREE_API_KEY}` in `apiKey` if that is how your
|
||||||
|
environment names the credential.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>AWS Bedrock (Converse API)</b></summary>
|
<summary><b>AWS Bedrock (Converse API)</b></summary>
|
||||||
|
|
||||||
@@ -370,6 +516,96 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Xiaomi MiMo</b></summary>
|
||||||
|
|
||||||
|
Xiaomi MiMo models are automatically detected by the `xiaomi_mimo` provider when
|
||||||
|
the model name contains `mimo`. The default API base is
|
||||||
|
`https://api.xiaomimimo.com/v1`.
|
||||||
|
|
||||||
|
> **Token Plan**: If you're using MiMo's token plan, override `apiBase` with the
|
||||||
|
> dedicated endpoint:
|
||||||
|
>
|
||||||
|
> ```json
|
||||||
|
> {
|
||||||
|
> "providers": {
|
||||||
|
> "xiaomi_mimo": {
|
||||||
|
> "apiKey": "${XIAOMIMIMO_API_KEY}",
|
||||||
|
> "apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"
|
||||||
|
> }
|
||||||
|
> },
|
||||||
|
> "agents": {
|
||||||
|
> "defaults": {
|
||||||
|
> "model": "xiaomi/mimo-v2.5-pro"
|
||||||
|
> }
|
||||||
|
> }
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> No need to set `provider` explicitly — the model name contains `mimo`, which
|
||||||
|
> auto-matches to the `xiaomi_mimo` provider spec. Use an API key from the MiMo
|
||||||
|
> token plan console and check the MiMo platform for the latest supported model
|
||||||
|
> names.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>StepFun Step Plan (subscription)</b></summary>
|
||||||
|
|
||||||
|
Step Plan is StepFun's subscription-based service for high-frequency AI developers.
|
||||||
|
If you're on a Step Plan subscription, override `apiBase` in the existing `stepfun`
|
||||||
|
provider config to point to the dedicated Step Plan endpoint.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"stepfun": {
|
||||||
|
"apiKey": "${STEPFUN_API_KEY}",
|
||||||
|
"apiBase": "https://api.stepfun.com/step_plan/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "stepfun",
|
||||||
|
"model": "step-3.5-flash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and
|
||||||
|
`step-router-v1`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Ant Ling (OpenAI-compatible)</b></summary>
|
||||||
|
|
||||||
|
Ant Ling is available through nanobot's built-in OpenAI-compatible provider flow.
|
||||||
|
The default API base points to `https://api.ant-ling.com/v1`, so you usually
|
||||||
|
only need to set `apiKey`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"antLing": {
|
||||||
|
"apiKey": "${ANT_LING_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "ant_ling",
|
||||||
|
"model": "Ling-2.6-flash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Official OpenAI-compatible model names include `Ling-2.6-1T`,
|
||||||
|
`Ling-2.6-flash`, `Ling-2.5-1T`, `Ling-1T`, `Ring-2.5-1T`, and `Ring-1T`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
||||||
|
|
||||||
@@ -438,6 +674,8 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<a id="local-providers"></a>
|
||||||
|
<a id="ollama-local"></a>
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Ollama (local)</b></summary>
|
<summary><b>Ollama (local)</b></summary>
|
||||||
|
|
||||||
@@ -503,12 +741,19 @@ ollama run llama3.2
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<a id="atomic-chat-local"></a>
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Atomic Chat (local)</b></summary>
|
<summary><b>Atomic Chat (local)</b></summary>
|
||||||
|
|
||||||
[Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Start Atomic Chat and enable the local API server, then point nanobot at it.
|
[Atomic Chat](https://atomic.chat/) is a local-first desktop app that exposes an **OpenAI-compatible** HTTP API (default `http://localhost:1337/v1`). Use it when you want to run nanobot against a model on your own machine instead of a hosted API provider.
|
||||||
|
|
||||||
**1. Add to config** (partial — merge into `~/.nanobot/config.json`):
|
**1. Start Atomic Chat**
|
||||||
|
|
||||||
|
- Install [Atomic Chat](https://atomic.chat/) on your machine.
|
||||||
|
- Open Atomic Chat, download a model, and keep the app running. The local API is enabled by default.
|
||||||
|
- Copy the model ID exposed by the local API. For example, the model ID for `Qwen 3 32B` might be `qwen3-32b`.
|
||||||
|
|
||||||
|
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -521,13 +766,13 @@ ollama run llama3.2
|
|||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"provider": "atomic_chat",
|
"provider": "atomic_chat",
|
||||||
"model": "your-model-id-from-atomic-chat"
|
"model": "qwen3-32b"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects. The `model` string must match the model id Atomic Chat exposes on its OpenAI-compatible endpoint.
|
> **Note:** Replace `qwen3-32b` with the model ID from Atomic Chat. Set `apiKey` to `null` if your Atomic Chat server does not require a key. If it does, set `apiKey` (or the `ATOMIC_CHAT_API_KEY` environment variable) to the value Atomic Chat expects.
|
||||||
|
|
||||||
> `provider: "auto"` also works when `providers.atomic_chat.apiBase` is configured, but setting `"provider": "atomic_chat"` is the clearest option.
|
> `provider: "auto"` also works when `providers.atomic_chat.apiBase` is configured, but setting `"provider": "atomic_chat"` is the clearest option.
|
||||||
|
|
||||||
@@ -608,6 +853,7 @@ docker run -d \
|
|||||||
> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
|
> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<a id="vllm-local-openai-compatible"></a>
|
||||||
<details>
|
<details>
|
||||||
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
|
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
|
||||||
|
|
||||||
@@ -797,6 +1043,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
|||||||
"channels": {
|
"channels": {
|
||||||
"sendProgress": true,
|
"sendProgress": true,
|
||||||
"sendToolHints": false,
|
"sendToolHints": false,
|
||||||
|
"extractDocumentText": true,
|
||||||
"sendMaxRetries": 3,
|
"sendMaxRetries": 3,
|
||||||
"transcriptionProvider": "groq",
|
"transcriptionProvider": "groq",
|
||||||
"transcriptionLanguage": null,
|
"transcriptionLanguage": null,
|
||||||
@@ -810,8 +1057,9 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
|||||||
| `sendProgress` | `true` | Stream agent's text progress to the channel |
|
| `sendProgress` | `true` | Stream agent's text progress to the channel |
|
||||||
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
||||||
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
|
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
|
||||||
|
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
|
||||||
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
|
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
|
||||||
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
|
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key and optional `apiBase` are auto-resolved from the matching provider config. Chat-style bases such as `https://api.groq.com/openai/v1` are normalized to the audio transcription endpoint. |
|
||||||
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
|
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
|
||||||
|
|
||||||
`sendProgress` and `sendToolHints` can also be overridden per channel. The
|
`sendProgress` and `sendToolHints` can also be overridden per channel. The
|
||||||
@@ -917,7 +1165,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "brave",
|
"provider": "brave",
|
||||||
"apiKey": "BSA..."
|
"apiKey": "${BRAVE_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -931,7 +1179,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "tavily",
|
"provider": "tavily",
|
||||||
"apiKey": "tvly-..."
|
"apiKey": "${TAVILY_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,7 +1193,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "jina",
|
"provider": "jina",
|
||||||
"apiKey": "jina_..."
|
"apiKey": "${JINA_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -959,7 +1207,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "kagi",
|
"provider": "kagi",
|
||||||
"apiKey": "your-kagi-api-key"
|
"apiKey": "${KAGI_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -973,7 +1221,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
"provider": "olostep",
|
"provider": "olostep",
|
||||||
"apiKey": "YOUR_OLOSTEP_API_KEY"
|
"apiKey": "${OLOSTEP_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1050,7 +1298,7 @@ If you want to always use the local conversion, you can force it using:
|
|||||||
|
|
||||||
## Image Generation
|
## Image Generation
|
||||||
|
|
||||||
Image generation is configured under `tools.imageGeneration` and uses provider credentials from `providers.openrouter` or `providers.aihubmix`.
|
Image generation is configured under `tools.imageGeneration` and uses credentials from the selected provider's `providers.<name>` block.
|
||||||
|
|
||||||
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
|
See [Image Generation](./image-generation.md) for WebUI usage, provider examples, artifact storage, and troubleshooting.
|
||||||
|
|
||||||
@@ -1136,11 +1384,14 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
|
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
|
||||||
|
|
||||||
|
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
||||||
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
|
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
|
||||||
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
|
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
|
||||||
|
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
|
||||||
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
||||||
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
||||||
|
|
||||||
@@ -1283,7 +1534,7 @@ By default, nanobot uses `UTC` for runtime time context. If you want the agent t
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.
|
This affects runtime time strings shown to the model, such as runtime context. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.
|
||||||
|
|
||||||
Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.
|
Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.
|
||||||
|
|
||||||
|
|||||||
+33
-2
@@ -10,6 +10,25 @@
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
|
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret:
|
||||||
|
>
|
||||||
|
> ```json
|
||||||
|
> {
|
||||||
|
> "gateway": { "host": "0.0.0.0" },
|
||||||
|
> "channels": {
|
||||||
|
> "websocket": {
|
||||||
|
> "enabled": true,
|
||||||
|
> "host": "0.0.0.0",
|
||||||
|
> "port": 8765,
|
||||||
|
> "tokenIssueSecret": "your-secret-here"
|
||||||
|
> }
|
||||||
|
> }
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured — see [`webui/README.md`](../webui/README.md) for details.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,8 +55,20 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
|||||||
# Edit config on host to add API keys
|
# Edit config on host to add API keys
|
||||||
vim ~/.nanobot/config.json
|
vim ~/.nanobot/config.json
|
||||||
|
|
||||||
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
|
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
|
||||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
|
# Mirrors the security caps and port mappings declared in docker-compose.yml:
|
||||||
|
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
|
||||||
|
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
|
||||||
|
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
|
||||||
|
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
|
||||||
|
# endpoint on 18790.
|
||||||
|
docker run \
|
||||||
|
--cap-drop ALL --cap-add SYS_ADMIN \
|
||||||
|
--security-opt apparmor=unconfined \
|
||||||
|
--security-opt seccomp=unconfined \
|
||||||
|
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||||
|
-p 18790:18790 -p 8765:8765 \
|
||||||
|
nanobot gateway
|
||||||
|
|
||||||
# Or run a single command
|
# Or run a single command
|
||||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
|
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
|
||||||
|
|||||||
+158
-28
@@ -6,8 +6,6 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
|
|||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
OpenRouter example:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
@@ -19,34 +17,13 @@ OpenRouter example:
|
|||||||
"imageGeneration": {
|
"imageGeneration": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "openrouter",
|
"provider": "openrouter",
|
||||||
"model": "openai/gpt-5.4-image-2",
|
"model": "openai/gpt-5.4-image-2"
|
||||||
"defaultAspectRatio": "1:1",
|
|
||||||
"defaultImageSize": "1K"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
AIHubMix example:
|
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"aihubmix": {
|
|
||||||
"apiKey": "${AIHUBMIX_API_KEY}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"imageGeneration": {
|
|
||||||
"enabled": true,
|
|
||||||
"provider": "aihubmix",
|
|
||||||
"model": "gpt-image-2-free",
|
|
||||||
"defaultAspectRatio": "1:1",
|
|
||||||
"defaultImageSize": "1K"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
||||||
@@ -69,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
|
|||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
||||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Currently `openrouter` and `aihubmix` are supported |
|
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
|
||||||
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
||||||
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
||||||
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
||||||
@@ -139,6 +116,160 @@ Configure:
|
|||||||
|
|
||||||
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness.
|
`quality: low` is optional. It can make free image models faster and less likely to time out, but it is not required for correctness.
|
||||||
|
|
||||||
|
### MiniMax
|
||||||
|
|
||||||
|
MiniMax `image-01` supports text-to-image and reference-image (subject reference) edits. Supported aspect ratios are `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, and `21:9`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"minimax": {
|
||||||
|
"apiKey": "${MINIMAX_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "minimax",
|
||||||
|
"model": "image-01",
|
||||||
|
"defaultAspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
nanobot supports two Gemini image generation model families via Google's Generative Language API:
|
||||||
|
|
||||||
|
| Model | Endpoint | Reference images |
|
||||||
|
|-------|----------|-----------------|
|
||||||
|
| `imagen-4.0-generate-001` | `:predict` | Not supported by this integration |
|
||||||
|
| `gemini-2.5-flash-image` | `:generateContent` | Supported |
|
||||||
|
|
||||||
|
For reference-image edits, use a Gemini Flash image model:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"gemini": {
|
||||||
|
"apiKey": "${GEMINI_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "gemini",
|
||||||
|
"model": "gemini-2.5-flash-image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
|
||||||
|
|
||||||
|
### Ollama
|
||||||
|
|
||||||
|
Ollama's experimental native image generation API works with local servers and hosted ollama.com models. Local access at `http://localhost:11434/api` does not require an API key; set `providers.ollama.apiKey` only when targeting `https://ollama.com/api`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"ollama": {
|
||||||
|
"apiBase": "http://localhost:11434/api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "x/z-image-turbo",
|
||||||
|
"defaultAspectRatio": "16:9",
|
||||||
|
"defaultImageSize": "2K"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ollama maps `defaultAspectRatio` and `defaultImageSize` to native `width` and `height` values. Reference images are not supported by this integration.
|
||||||
|
|
||||||
|
### StepFun
|
||||||
|
|
||||||
|
StepFun (阶跃星辰) `step-image-edit-2` supports text-to-image generation. The `step-1x-medium` variant additionally supports **style-reference** image edits, where a reference image guides the visual style of the output.
|
||||||
|
|
||||||
|
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes are specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1280x800`, `800x1280`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"stepfun": {
|
||||||
|
"apiKey": "${STEPFUN_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "stepfun",
|
||||||
|
"model": "step-image-edit-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The StepFun provider reuses the existing `providers.stepfun` config block (the same one used for StepFun's LLM API). Set `providers.stepfun.apiKey` once and it is shared between text and image generation.
|
||||||
|
>
|
||||||
|
> When `step-image-edit-2` is used, `reference_images` are ignored (the model does not support style reference). Switch to `step-1x-medium` to use reference-image-guided generation.
|
||||||
|
|
||||||
|
#### StepPlan (Subscription)
|
||||||
|
|
||||||
|
StepPlan is StepFun's subscription tier and uses a different API base URL. The image generation endpoint path is the same — just override `apiBase`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"stepfun": {
|
||||||
|
"apiKey": "${STEPFUN_API_KEY}",
|
||||||
|
"apiBase": "https://api.stepfun.com/step_plan/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "stepfun",
|
||||||
|
"model": "step-image-edit-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.com/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
|
||||||
|
|
||||||
|
### Zhipu
|
||||||
|
|
||||||
|
Zhipu (智谱) `glm-image` model supports text-to-image generation. The API returns temporary image URLs (valid for 30 days); nanobot downloads and re-encodes them as base64 data URLs.
|
||||||
|
|
||||||
|
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1280x1280`, `1728x960`) or using aspect ratio presets.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"zhipu": {
|
||||||
|
"apiKey": "${ZAI_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "zhipu",
|
||||||
|
"model": "glm-image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
|
||||||
|
|
||||||
## Artifacts
|
## Artifacts
|
||||||
|
|
||||||
Generated images are stored under the active nanobot instance's media directory:
|
Generated images are stored under the active nanobot instance's media directory:
|
||||||
@@ -193,8 +324,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|
|||||||
|---------|-------|
|
|---------|-------|
|
||||||
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
||||||
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
||||||
| `unsupported image generation provider` | Use `openrouter` or `aihubmix` |
|
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
|
||||||
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
||||||
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
||||||
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
||||||
|
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
"""Hatch build hook that bundles the webui (Vite) into nanobot/web/dist.
|
||||||
|
|
||||||
|
Triggered automatically by `python -m build` (and any other hatch-driven build)
|
||||||
|
so published wheels and sdists ship a fresh webui without requiring developers
|
||||||
|
to remember `cd webui && bun run build` beforehand.
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
|
||||||
|
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
|
||||||
|
do not need a packaged `dist/`.
|
||||||
|
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
|
||||||
|
already contains a prebuilt `nanobot/web/dist/`).
|
||||||
|
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
|
||||||
|
- Skips when `nanobot/web/dist/index.html` already exists, unless
|
||||||
|
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
|
||||||
|
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
|
||||||
|
performs `install` followed by `run build`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
|
||||||
|
class WebUIBuildHook(BuildHookInterface):
|
||||||
|
PLUGIN_NAME = "webui-build"
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None: # noqa: D401
|
||||||
|
root = Path(self.root)
|
||||||
|
webui_dir = root / "webui"
|
||||||
|
package_json = webui_dir / "package.json"
|
||||||
|
dist_dir = root / "nanobot" / "web" / "dist"
|
||||||
|
index_html = dist_dir / "index.html"
|
||||||
|
|
||||||
|
# `pip install -e .` builds an editable wheel; skip the (slow) webui
|
||||||
|
# bundle since editable installs target Python development and webui
|
||||||
|
# work uses `bun run dev` instead.
|
||||||
|
if self.target_name == "wheel" and version == "editable":
|
||||||
|
self.app.display_info(
|
||||||
|
"[webui-build] skipped for editable install "
|
||||||
|
"(use `cd webui && bun run build` to bundle webui manually)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.environ.get("NANOBOT_SKIP_WEBUI_BUILD") == "1":
|
||||||
|
self.app.display_info("[webui-build] skipped via NANOBOT_SKIP_WEBUI_BUILD=1")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not package_json.is_file():
|
||||||
|
self.app.display_info(
|
||||||
|
"[webui-build] no webui/ source tree, assuming prebuilt nanobot/web/dist/"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
|
||||||
|
if index_html.is_file() and not force:
|
||||||
|
self.app.display_info(
|
||||||
|
f"[webui-build] reusing existing build at {dist_dir} "
|
||||||
|
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
runner = self._pick_runner()
|
||||||
|
if runner is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"[webui-build] neither `bun` nor `npm` is available on PATH; "
|
||||||
|
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.app.display_info(f"[webui-build] using {runner} to build webui")
|
||||||
|
self._run([runner, "install"], cwd=webui_dir)
|
||||||
|
self._run([runner, "run", "build"], cwd=webui_dir)
|
||||||
|
|
||||||
|
if not index_html.is_file():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"[webui-build] build finished but {index_html} is missing; "
|
||||||
|
"check webui/vite.config.ts outDir."
|
||||||
|
)
|
||||||
|
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pick_runner() -> str | None:
|
||||||
|
for candidate in ("bun", "npm"):
|
||||||
|
if shutil.which(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _run(self, cmd: list[str], *, cwd: Path) -> None:
|
||||||
|
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, cwd=cwd, check=True)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
|
||||||
|
) from exc
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 287 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
+20
-4
@@ -2,9 +2,10 @@
|
|||||||
nanobot - A lightweight AI agent framework
|
nanobot - A lightweight AI agent framework
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
||||||
from pathlib import Path
|
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from importlib.metadata import PackageNotFoundError
|
||||||
|
from importlib.metadata import version as _pkg_version
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def _read_pyproject_version() -> str | None:
|
def _read_pyproject_version() -> str | None:
|
||||||
@@ -21,12 +22,27 @@ def _resolve_version() -> str:
|
|||||||
return _pkg_version("nanobot-ai")
|
return _pkg_version("nanobot-ai")
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
# Source checkouts often import nanobot without installed dist-info.
|
# Source checkouts often import nanobot without installed dist-info.
|
||||||
return _read_pyproject_version() or "0.1.5.post3"
|
return _read_pyproject_version() or "0.2.1"
|
||||||
|
|
||||||
|
|
||||||
__version__ = _resolve_version()
|
__version__ = _resolve_version()
|
||||||
__logo__ = "🐈"
|
__logo__ = "🐈"
|
||||||
|
|
||||||
from nanobot.nanobot import Nanobot, RunResult
|
_LAZY_EXPORTS = {
|
||||||
|
"Nanobot": ".nanobot",
|
||||||
|
"RunResult": ".nanobot",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
module_path = _LAZY_EXPORTS.get(name)
|
||||||
|
if module_path is None:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
from importlib import import_module
|
||||||
|
mod = import_module(module_path, __name__)
|
||||||
|
val = getattr(mod, name)
|
||||||
|
globals()[name] = val
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Nanobot", "RunResult"]
|
__all__ = ["Nanobot", "RunResult"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Collection
|
from collections.abc import Collection
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
from typing import TYPE_CHECKING, Callable, Coroutine
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -37,27 +37,6 @@ class AutoCompact:
|
|||||||
def _format_summary(text: str, last_active: datetime) -> str:
|
def _format_summary(text: str, last_active: datetime) -> str:
|
||||||
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
||||||
|
|
||||||
def _split_unconsolidated(
|
|
||||||
self, session: Session,
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Split live session tail into archiveable prefix and retained recent suffix."""
|
|
||||||
tail = list(session.messages[session.last_consolidated:])
|
|
||||||
if not tail:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
probe = Session(
|
|
||||||
key=session.key,
|
|
||||||
messages=tail.copy(),
|
|
||||||
created_at=session.created_at,
|
|
||||||
updated_at=session.updated_at,
|
|
||||||
metadata={},
|
|
||||||
last_consolidated=0,
|
|
||||||
)
|
|
||||||
probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES)
|
|
||||||
kept = probe.messages
|
|
||||||
cut = len(tail) - len(kept)
|
|
||||||
return tail[:cut], kept
|
|
||||||
|
|
||||||
def check_expired(self, schedule_background: Callable[[Coroutine], None],
|
def check_expired(self, schedule_background: Callable[[Coroutine], None],
|
||||||
active_session_keys: Collection[str] = ()) -> None:
|
active_session_keys: Collection[str] = ()) -> None:
|
||||||
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
||||||
@@ -74,33 +53,17 @@ class AutoCompact:
|
|||||||
|
|
||||||
async def _archive(self, key: str) -> None:
|
async def _archive(self, key: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.sessions.invalidate(key)
|
summary = await self.consolidator.compact_idle_session(
|
||||||
session = self.sessions.get_or_create(key)
|
key, self._RECENT_SUFFIX_MESSAGES,
|
||||||
archive_msgs, kept_msgs = self._split_unconsolidated(session)
|
)
|
||||||
if not archive_msgs and not kept_msgs:
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
self.sessions.save(session)
|
|
||||||
return
|
|
||||||
|
|
||||||
last_active = session.updated_at
|
|
||||||
summary = ""
|
|
||||||
if archive_msgs:
|
|
||||||
summary = await self.consolidator.archive(archive_msgs) or ""
|
|
||||||
if summary and summary != "(nothing)":
|
if summary and summary != "(nothing)":
|
||||||
self._summaries[key] = (summary, last_active)
|
session = self.sessions.get_or_create(key)
|
||||||
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
|
meta = session.metadata.get("_last_summary")
|
||||||
session.messages = kept_msgs
|
if isinstance(meta, dict):
|
||||||
session.last_consolidated = 0
|
self._summaries[key] = (
|
||||||
session.updated_at = datetime.now()
|
meta["text"],
|
||||||
self.sessions.save(session)
|
datetime.fromisoformat(meta["last_active"]),
|
||||||
if archive_msgs:
|
)
|
||||||
logger.info(
|
|
||||||
"Auto-compact: archived {} (archived={}, kept={}, summary={})",
|
|
||||||
key,
|
|
||||||
len(archive_msgs),
|
|
||||||
len(kept_msgs),
|
|
||||||
bool(summary),
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Auto-compact: failed for {}", key)
|
logger.exception("Auto-compact: failed for {}", key)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+69
-39
@@ -3,26 +3,55 @@
|
|||||||
import base64
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import platform
|
import platform
|
||||||
from contextlib import suppress
|
|
||||||
from importlib.resources import files as pkg_files
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.agent.tools import mcp as mcp_tools
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.apps.cli import utils as cli_app_utils
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.session.goal_state import goal_state_runtime_lines
|
from nanobot.session.goal_state import goal_state_runtime_lines
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
current_time_str,
|
current_time_str,
|
||||||
detect_image_mime,
|
detect_image_mime,
|
||||||
|
load_bundled_template,
|
||||||
truncate_text,
|
truncate_text,
|
||||||
)
|
)
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
|
|
||||||
|
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Return persisted kwargs for turn-attached capabilities."""
|
||||||
|
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||||
|
"""Return model-visible runtime annotations for turn-attached capabilities."""
|
||||||
|
return [
|
||||||
|
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
|
||||||
|
*mcp_tools.runtime_lines(
|
||||||
|
msg,
|
||||||
|
configured_server_names=set(state._mcp_servers),
|
||||||
|
connected_server_names=set(state._mcp_stacks),
|
||||||
|
skip=skip,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||||
|
await mcp_tools.connect_missing_servers(state, tools)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||||
|
return await mcp_tools.handle_runtime_control(state, msg, tools)
|
||||||
|
|
||||||
|
|
||||||
class ContextBuilder:
|
class ContextBuilder:
|
||||||
"""Builds the context (system prompt + messages) for the agent."""
|
"""Builds the context (system prompt + messages) for the agent."""
|
||||||
|
|
||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
||||||
@@ -39,15 +68,18 @@ class ContextBuilder:
|
|||||||
skill_names: list[str] | None = None,
|
skill_names: list[str] | None = None,
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
session_key: str | None = None,
|
workspace: Path | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||||
parts = [self._get_identity(channel=channel)]
|
root = workspace or self.workspace
|
||||||
|
parts = [self._get_identity(channel=channel, workspace=root)]
|
||||||
|
|
||||||
bootstrap = self._load_bootstrap_files()
|
bootstrap = self._load_bootstrap_files(root)
|
||||||
if bootstrap:
|
if bootstrap:
|
||||||
parts.append(bootstrap)
|
parts.append(bootstrap)
|
||||||
|
|
||||||
|
parts.append(render_template("agent/tool_contract.md"))
|
||||||
|
|
||||||
memory = self.memory.get_memory_context()
|
memory = self.memory.get_memory_context()
|
||||||
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
||||||
parts.append(f"# Memory\n\n{memory}")
|
parts.append(f"# Memory\n\n{memory}")
|
||||||
@@ -74,32 +106,12 @@ class ContextBuilder:
|
|||||||
if session_summary:
|
if session_summary:
|
||||||
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
|
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
|
||||||
|
|
||||||
# Inject P2P collaboration hint for task-scoped sessions
|
|
||||||
if session_key and session_key.startswith("task:"):
|
|
||||||
parts.append(self._p2p_collaboration_hint())
|
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
@staticmethod
|
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
|
||||||
def _p2p_collaboration_hint() -> str:
|
|
||||||
return (
|
|
||||||
"# Multi-Agent Collaboration\n\n"
|
|
||||||
"You are part of a decentralized agent network. You can:\n"
|
|
||||||
"- Use `broadcast_task` to announce subtasks and collect BIDs\n"
|
|
||||||
"- Use `dispatch_task` to assign tasks to specific agents\n"
|
|
||||||
"- Use `poll_task_result` to check task status\n"
|
|
||||||
"- Use `report_user` to deliver final results to the user\n"
|
|
||||||
"- Use `finalize_task` to terminate tasks\n\n"
|
|
||||||
"Rules:\n"
|
|
||||||
"- Never block waiting for results. Dispatch and continue.\n"
|
|
||||||
"- If a task times out, decide whether to retry, failover, or report partial.\n"
|
|
||||||
"- Respect the user's INTERRUPT messages — they have highest priority.\n"
|
|
||||||
"- You are currently in a task-scoped session; focus on the delegated task."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_identity(self, channel: str | None = None) -> str:
|
|
||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
workspace_path = str(self.workspace.expanduser().resolve())
|
root = workspace or self.workspace
|
||||||
|
workspace_path = str(root.expanduser().resolve())
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
|
|
||||||
@@ -143,12 +155,13 @@ class ContextBuilder:
|
|||||||
|
|
||||||
return _to_blocks(left) + _to_blocks(right)
|
return _to_blocks(left) + _to_blocks(right)
|
||||||
|
|
||||||
def _load_bootstrap_files(self) -> str:
|
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
|
||||||
"""Load all bootstrap files from workspace."""
|
"""Load all bootstrap files from workspace."""
|
||||||
parts = []
|
parts = []
|
||||||
|
root = workspace or self.workspace
|
||||||
|
|
||||||
for filename in self.BOOTSTRAP_FILES:
|
for filename in self.BOOTSTRAP_FILES:
|
||||||
file_path = self.workspace / filename
|
file_path = root / filename
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
content = file_path.read_text(encoding="utf-8")
|
content = file_path.read_text(encoding="utf-8")
|
||||||
parts.append(f"## {filename}\n\n{content}")
|
parts.append(f"## {filename}\n\n{content}")
|
||||||
@@ -158,10 +171,9 @@ class ContextBuilder:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_template_content(content: str, template_path: str) -> bool:
|
def _is_template_content(content: str, template_path: str) -> bool:
|
||||||
"""Check if *content* is identical to the bundled template (user hasn't customized it)."""
|
"""Check if *content* is identical to the bundled template (user hasn't customized it)."""
|
||||||
with suppress(Exception):
|
tpl = load_bundled_template(template_path)
|
||||||
tpl = pkg_files("nanobot") / "templates" / template_path
|
if tpl is not None:
|
||||||
if tpl.is_file():
|
return content.strip() == tpl.strip()
|
||||||
return content.strip() == tpl.read_text(encoding="utf-8").strip()
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def build_messages(
|
def build_messages(
|
||||||
@@ -176,10 +188,21 @@ class ContextBuilder:
|
|||||||
sender_id: str | None = None,
|
sender_id: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
session_metadata: Mapping[str, Any] | None = None,
|
session_metadata: Mapping[str, Any] | None = None,
|
||||||
session_key: str | None = None,
|
current_runtime_lines: Sequence[str] | None = None,
|
||||||
|
workspace: Path | None = None,
|
||||||
|
runtime_state: Any | None = None,
|
||||||
|
inbound_message: Any | None = None,
|
||||||
|
skip_runtime_lines: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
extra = goal_state_runtime_lines(session_metadata)
|
root = workspace or self.workspace
|
||||||
|
extra = [
|
||||||
|
*goal_state_runtime_lines(session_metadata),
|
||||||
|
]
|
||||||
|
if runtime_state is not None and inbound_message is not None:
|
||||||
|
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
|
||||||
|
if current_runtime_lines:
|
||||||
|
extra.extend(line for line in current_runtime_lines if line)
|
||||||
runtime_ctx = self._build_runtime_context(
|
runtime_ctx = self._build_runtime_context(
|
||||||
channel,
|
channel,
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -198,7 +221,15 @@ class ContextBuilder:
|
|||||||
else:
|
else:
|
||||||
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary, session_key=session_key)},
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": self.build_system_prompt(
|
||||||
|
skill_names,
|
||||||
|
channel=channel,
|
||||||
|
session_summary=session_summary,
|
||||||
|
workspace=root,
|
||||||
|
),
|
||||||
|
},
|
||||||
*history,
|
*history,
|
||||||
]
|
]
|
||||||
if messages[-1].get("role") == current_role:
|
if messages[-1].get("role") == current_role:
|
||||||
@@ -233,4 +264,3 @@ class ContextBuilder:
|
|||||||
if not images:
|
if not images:
|
||||||
return text
|
return text
|
||||||
return images + [{"type": "text", "text": text}]
|
return images + [{"type": "text", "text": text}]
|
||||||
|
|
||||||
|
|||||||
+211
-171
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent import context as agent_context
|
||||||
from nanobot.agent import model_presets as preset_helpers
|
from nanobot.agent import model_presets as preset_helpers
|
||||||
from nanobot.agent.autocompact import AutoCompact
|
from nanobot.agent.autocompact import AutoCompact
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
@@ -22,16 +23,9 @@ from nanobot.agent.memory import Consolidator, Dream
|
|||||||
from nanobot.agent.progress_hook import AgentProgressHook
|
from nanobot.agent.progress_hook import AgentProgressHook
|
||||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.p2p import (
|
|
||||||
BroadcastTaskTool,
|
|
||||||
CheckAggregationTool,
|
|
||||||
DispatchTaskTool,
|
|
||||||
FinalizeTaskTool,
|
|
||||||
PollTaskResultTool,
|
|
||||||
ReportUserTool,
|
|
||||||
)
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
@@ -40,20 +34,32 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm
|
|||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
|
from nanobot.security.workspace_access import (
|
||||||
|
WorkspaceScopeResolver,
|
||||||
|
bind_workspace_scope,
|
||||||
|
reset_workspace_scope,
|
||||||
|
)
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
goal_state_ws_blob,
|
goal_state_runtime_lines,
|
||||||
runner_wall_llm_timeout_s,
|
runner_wall_llm_timeout_s,
|
||||||
|
sustained_goal_active,
|
||||||
)
|
)
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.artifacts import generated_image_paths_from_messages
|
from nanobot.session import turn_continuation
|
||||||
from nanobot.utils.document import extract_documents
|
from nanobot.session.webui_turns import (
|
||||||
|
WebuiTurnCoordinator,
|
||||||
|
build_bus_progress_callback,
|
||||||
|
mark_webui_session,
|
||||||
|
)
|
||||||
|
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||||
from nanobot.utils.helpers import image_placeholder_text
|
from nanobot.utils.helpers import image_placeholder_text
|
||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
from nanobot.utils.image_generation_intent import image_generation_prompt
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
|
from nanobot.utils.runtime import (
|
||||||
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
from nanobot.utils.webui_turn_helpers import publish_turn_run_status
|
SUSTAINED_GOAL_CONTINUE_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
@@ -66,7 +72,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
UNIFIED_SESSION_KEY = "unified:default"
|
UNIFIED_SESSION_KEY = "unified:default"
|
||||||
|
|
||||||
|
|
||||||
class TurnState(Enum):
|
class TurnState(Enum):
|
||||||
RESTORE = auto()
|
RESTORE = auto()
|
||||||
COMPACT = auto()
|
COMPACT = auto()
|
||||||
@@ -108,7 +113,7 @@ class TurnContext:
|
|||||||
save_skip: int = 0
|
save_skip: int = 0
|
||||||
|
|
||||||
outbound: OutboundMessage | None = None
|
outbound: OutboundMessage | None = None
|
||||||
generated_media: list[str] = field(default_factory=list)
|
suppress_response: bool = False
|
||||||
|
|
||||||
on_progress: Callable[..., Awaitable[None]] | None = None
|
on_progress: Callable[..., Awaitable[None]] | None = None
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None = None
|
on_stream: Callable[[str], Awaitable[None]] | None = None
|
||||||
@@ -117,8 +122,8 @@ class TurnContext:
|
|||||||
|
|
||||||
pending_queue: asyncio.Queue | None = None
|
pending_queue: asyncio.Queue | None = None
|
||||||
pending_summary: str | None = None
|
pending_summary: str | None = None
|
||||||
|
|
||||||
turn_wall_started_at: float = field(default_factory=time.time)
|
turn_wall_started_at: float = field(default_factory=time.time)
|
||||||
|
visible_run_started_at: float | None = None
|
||||||
turn_latency_ms: int | None = None
|
turn_latency_ms: int | None = None
|
||||||
|
|
||||||
trace: list[StateTraceEntry] = field(default_factory=list)
|
trace: list[StateTraceEntry] = field(default_factory=list)
|
||||||
@@ -144,6 +149,11 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
return self.tools.tool_names
|
||||||
|
|
||||||
|
def llm_runtime(self) -> LLMRuntime:
|
||||||
|
"""Return the current provider/model pair owned by this loop."""
|
||||||
|
self._refresh_provider_snapshot()
|
||||||
|
return LLMRuntime(self.provider, self.model)
|
||||||
|
|
||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
|
||||||
@@ -167,6 +177,7 @@ class AgentLoop:
|
|||||||
workspace: Path,
|
workspace: Path,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_iterations: int | None = None,
|
max_iterations: int | None = None,
|
||||||
|
max_concurrent_subagents: int | None = None,
|
||||||
context_window_tokens: int | None = None,
|
context_window_tokens: int | None = None,
|
||||||
context_block_limit: int | None = None,
|
context_block_limit: int | None = None,
|
||||||
max_tool_result_chars: int | None = None,
|
max_tool_result_chars: int | None = None,
|
||||||
@@ -193,7 +204,6 @@ class AgentLoop:
|
|||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||||
p2p_shell: Any | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
@@ -201,7 +211,6 @@ class AgentLoop:
|
|||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.channels_config = channels_config
|
self.channels_config = channels_config
|
||||||
self.p2p_shell = p2p_shell
|
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self._provider_snapshot_loader = provider_snapshot_loader
|
self._provider_snapshot_loader = provider_snapshot_loader
|
||||||
self._preset_snapshot_loader = preset_snapshot_loader
|
self._preset_snapshot_loader = preset_snapshot_loader
|
||||||
@@ -240,6 +249,10 @@ class AgentLoop:
|
|||||||
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
|
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
|
self.workspace_scopes = WorkspaceScopeResolver(
|
||||||
|
default_workspace=workspace,
|
||||||
|
default_restrict_to_workspace=restrict_to_workspace,
|
||||||
|
)
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
self._last_usage: dict[str, int] = {}
|
self._last_usage: dict[str, int] = {}
|
||||||
self._pending_turn_latency_ms: dict[str, int] = {}
|
self._pending_turn_latency_ms: dict[str, int] = {}
|
||||||
@@ -247,6 +260,11 @@ class AgentLoop:
|
|||||||
|
|
||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
self.sessions = session_manager or SessionManager(workspace)
|
||||||
|
self._webui_turns = WebuiTurnCoordinator(
|
||||||
|
bus=self.bus,
|
||||||
|
sessions=self.sessions,
|
||||||
|
schedule_background=lambda coro: self._schedule_background(coro),
|
||||||
|
)
|
||||||
self.tools = ToolRegistry()
|
self.tools = ToolRegistry()
|
||||||
# One file-read/write tracker per logical session. The tool registry is
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
# shared by this loop, so tools resolve the active state via contextvars.
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
@@ -262,6 +280,7 @@ class AgentLoop:
|
|||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
max_iterations=self.max_iterations,
|
max_iterations=self.max_iterations,
|
||||||
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
self._unified_session = unified_session
|
||||||
@@ -347,6 +366,7 @@ class AgentLoop:
|
|||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
model=model,
|
model=model,
|
||||||
max_iterations=defaults.max_tool_iterations,
|
max_iterations=defaults.max_tool_iterations,
|
||||||
|
max_concurrent_subagents=defaults.max_concurrent_subagents,
|
||||||
context_window_tokens=context_window_tokens,
|
context_window_tokens=context_window_tokens,
|
||||||
context_block_limit=defaults.context_block_limit,
|
context_block_limit=defaults.context_block_limit,
|
||||||
max_tool_result_chars=defaults.max_tool_result_chars,
|
max_tool_result_chars=defaults.max_tool_result_chars,
|
||||||
@@ -462,6 +482,7 @@ class AgentLoop:
|
|||||||
provider_snapshot_loader=self._provider_snapshot_loader,
|
provider_snapshot_loader=self._provider_snapshot_loader,
|
||||||
image_generation_provider_configs=self._image_generation_provider_configs,
|
image_generation_provider_configs=self._image_generation_provider_configs,
|
||||||
timezone=self.context.timezone or "UTC",
|
timezone=self.context.timezone or "UTC",
|
||||||
|
workspace_sandbox=self.workspace_scopes.sandbox_status,
|
||||||
)
|
)
|
||||||
loader = ToolLoader()
|
loader = ToolLoader()
|
||||||
registered = loader.load(ctx, self.tools)
|
registered = loader.load(ctx, self.tools)
|
||||||
@@ -473,45 +494,11 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
registered.append("my")
|
registered.append("my")
|
||||||
|
|
||||||
# Register P2P tools if enabled
|
|
||||||
if self.p2p_shell:
|
|
||||||
self.tools.register(DispatchTaskTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(PollTaskResultTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(BroadcastTaskTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(CheckAggregationTool(shell=self.p2p_shell))
|
|
||||||
self.tools.register(
|
|
||||||
ReportUserTool(
|
|
||||||
send_callback=self.bus.publish_outbound,
|
|
||||||
default_channel=getattr(self.channels_config, "default_channel", ""),
|
|
||||||
default_chat_id=getattr(self.channels_config, "default_chat_id", ""),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.tools.register(FinalizeTaskTool(shell=self.p2p_shell, session_manager=self.sessions))
|
|
||||||
registered.append("p2p")
|
|
||||||
|
|
||||||
logger.info("Registered {} tools: {}", len(registered), registered)
|
logger.info("Registered {} tools: {}", len(registered), registered)
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
"""Connect to configured MCP servers (one-time, lazy)."""
|
"""Connect configured MCP servers."""
|
||||||
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
|
await agent_context.connect_mcp(self, self.tools)
|
||||||
return
|
|
||||||
self._mcp_connecting = True
|
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools)
|
|
||||||
if self._mcp_stacks:
|
|
||||||
self._mcp_connected = True
|
|
||||||
else:
|
|
||||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.warning("MCP connection cancelled (will retry next message)")
|
|
||||||
self._mcp_stacks.clear()
|
|
||||||
except BaseException as e:
|
|
||||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
|
||||||
self._mcp_stacks.clear()
|
|
||||||
finally:
|
|
||||||
self._mcp_connecting = False
|
|
||||||
|
|
||||||
def _set_tool_context(
|
def _set_tool_context(
|
||||||
self, channel: str, chat_id: str,
|
self, channel: str, chat_id: str,
|
||||||
@@ -519,7 +506,7 @@ class AgentLoop:
|
|||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update context for all tools that need routing info."""
|
"""Update context for all tools that need routing info."""
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware
|
||||||
|
|
||||||
if session_key is not None:
|
if session_key is not None:
|
||||||
effective_key = session_key
|
effective_key = session_key
|
||||||
@@ -550,34 +537,7 @@ class AgentLoop:
|
|||||||
self, msg: InboundMessage
|
self, msg: InboundMessage
|
||||||
) -> Callable[..., Awaitable[None]]:
|
) -> Callable[..., Awaitable[None]]:
|
||||||
"""Build a progress callback that publishes to the message bus."""
|
"""Build a progress callback that publishes to the message bus."""
|
||||||
|
return build_bus_progress_callback(self.bus, msg)
|
||||||
async def _bus_progress(
|
|
||||||
content: str,
|
|
||||||
*,
|
|
||||||
tool_hint: bool = False,
|
|
||||||
tool_events: list[dict[str, Any]] | None = None,
|
|
||||||
reasoning: bool = False,
|
|
||||||
reasoning_end: bool = False,
|
|
||||||
) -> None:
|
|
||||||
meta = dict(msg.metadata or {})
|
|
||||||
meta["_progress"] = True
|
|
||||||
meta["_tool_hint"] = tool_hint
|
|
||||||
if reasoning:
|
|
||||||
meta["_reasoning_delta"] = True
|
|
||||||
if reasoning_end:
|
|
||||||
meta["_reasoning_end"] = True
|
|
||||||
if tool_events:
|
|
||||||
meta["_tool_events"] = tool_events
|
|
||||||
await self.bus.publish_outbound(
|
|
||||||
OutboundMessage(
|
|
||||||
channel=msg.channel,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
content=content,
|
|
||||||
metadata=meta,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return _bus_progress
|
|
||||||
|
|
||||||
async def _build_retry_wait_callback(
|
async def _build_retry_wait_callback(
|
||||||
self, msg: InboundMessage
|
self, msg: InboundMessage
|
||||||
@@ -608,10 +568,12 @@ class AgentLoop:
|
|||||||
|
|
||||||
Returns True if the message was persisted.
|
Returns True if the message was persisted.
|
||||||
"""
|
"""
|
||||||
|
if not turn_continuation.should_persist_user_message(msg.metadata):
|
||||||
|
return False
|
||||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
||||||
has_text = isinstance(msg.content, str) and msg.content.strip()
|
has_text = isinstance(msg.content, str) and msg.content.strip()
|
||||||
if has_text or media_paths:
|
if has_text or media_paths:
|
||||||
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
|
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
extra.update(kwargs)
|
||||||
text = msg.content if isinstance(msg.content, str) else ""
|
text = msg.content if isinstance(msg.content, str) else ""
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
@@ -628,6 +590,7 @@ class AgentLoop:
|
|||||||
pending_summary: str | None,
|
pending_summary: str | None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the initial message list for the LLM turn."""
|
"""Build the initial message list for the LLM turn."""
|
||||||
|
scope = self.workspace_scopes.for_message(msg, session.metadata)
|
||||||
return self.context.build_messages(
|
return self.context.build_messages(
|
||||||
history=history,
|
history=history,
|
||||||
current_message=image_generation_prompt(msg.content, msg.metadata),
|
current_message=image_generation_prompt(msg.content, msg.metadata),
|
||||||
@@ -637,6 +600,9 @@ class AgentLoop:
|
|||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending_summary,
|
session_summary=pending_summary,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata,
|
||||||
|
workspace=scope.project_path,
|
||||||
|
runtime_state=self,
|
||||||
|
inbound_message=msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
@@ -750,7 +716,7 @@ class AgentLoop:
|
|||||||
content = pending_msg.content
|
content = pending_msg.content
|
||||||
media = pending_msg.media if pending_msg.media else None
|
media = pending_msg.media if pending_msg.media else None
|
||||||
if media:
|
if media:
|
||||||
content, media = extract_documents(content, media)
|
content, media = self._prepare_message_media(content, media)
|
||||||
media = media or None
|
media = media or None
|
||||||
user_content = self.context._build_user_content(content, media)
|
user_content = self.context._build_user_content(content, media)
|
||||||
return {"role": "user", "content": user_content}
|
return {"role": "user", "content": user_content}
|
||||||
@@ -786,7 +752,31 @@ class AgentLoop:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
active_session_key = session.key if session else session_key
|
active_session_key = session.key if session else session_key
|
||||||
|
effective_scope = self.workspace_scopes.for_turn(
|
||||||
|
channel=channel,
|
||||||
|
message_metadata=metadata,
|
||||||
|
session_metadata=session.metadata if session is not None else None,
|
||||||
|
)
|
||||||
|
request_ctx = RequestContext(
|
||||||
|
channel=channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
session_key=active_session_key,
|
||||||
|
metadata=dict(metadata or {}),
|
||||||
|
)
|
||||||
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||||
|
request_token = bind_request_context(request_ctx)
|
||||||
|
workspace_token = bind_workspace_scope(effective_scope)
|
||||||
|
# Build continuation message that embeds the active goal objective so
|
||||||
|
# the LLM can see it even if earlier Runtime Context was truncated.
|
||||||
|
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
|
||||||
|
_goal_continue = (
|
||||||
|
"You have an active sustained goal:\n\n"
|
||||||
|
+ "\n".join(_goal_lines)
|
||||||
|
+ "\n\nPlease continue working toward the objective using your tools, "
|
||||||
|
"or call complete_goal if the work is truly finished."
|
||||||
|
) if _goal_lines else SUSTAINED_GOAL_CONTINUE_PROMPT
|
||||||
|
session_metadata = session.metadata if session is not None else None
|
||||||
try:
|
try:
|
||||||
result = await self.runner.run(AgentRunSpec(
|
result = await self.runner.run(AgentRunSpec(
|
||||||
initial_messages=initial_messages,
|
initial_messages=initial_messages,
|
||||||
@@ -797,7 +787,7 @@ class AgentLoop:
|
|||||||
hook=hook,
|
hook=hook,
|
||||||
error_message="Sorry, I encountered an error calling the AI model.",
|
error_message="Sorry, I encountered an error calling the AI model.",
|
||||||
concurrent_tools=True,
|
concurrent_tools=True,
|
||||||
workspace=self.workspace,
|
workspace=effective_scope.project_path,
|
||||||
session_key=session.key if session else None,
|
session_key=session.key if session else None,
|
||||||
context_window_tokens=self.context_window_tokens,
|
context_window_tokens=self.context_window_tokens,
|
||||||
context_block_limit=self.context_block_limit,
|
context_block_limit=self.context_block_limit,
|
||||||
@@ -812,17 +802,28 @@ class AgentLoop:
|
|||||||
llm_timeout_s=runner_wall_llm_timeout_s(
|
llm_timeout_s=runner_wall_llm_timeout_s(
|
||||||
self.sessions,
|
self.sessions,
|
||||||
session.key if session is not None else session_key,
|
session.key if session is not None else session_key,
|
||||||
metadata=(session.metadata if session is not None else None),
|
metadata=session_metadata,
|
||||||
|
message_metadata=metadata,
|
||||||
),
|
),
|
||||||
|
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
|
||||||
|
goal_continue_message=_goal_continue,
|
||||||
))
|
))
|
||||||
finally:
|
finally:
|
||||||
|
reset_workspace_scope(workspace_token)
|
||||||
|
reset_request_context(request_token)
|
||||||
reset_file_states(file_state_token)
|
reset_file_states(file_state_token)
|
||||||
self._last_usage = result.usage
|
self._last_usage = result.usage
|
||||||
if result.stop_reason == "max_iterations":
|
if result.stop_reason == "max_iterations":
|
||||||
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
||||||
|
should_stream = turn_continuation.should_stream_budget_response(
|
||||||
|
stop_reason=result.stop_reason,
|
||||||
|
pending_queue_available=pending_queue is not None and session is not None,
|
||||||
|
session_metadata=session_metadata,
|
||||||
|
message_metadata=metadata,
|
||||||
|
)
|
||||||
# Push final content through stream so streaming channels (e.g. Feishu)
|
# Push final content through stream so streaming channels (e.g. Feishu)
|
||||||
# update the card instead of leaving it empty.
|
# update the card instead of leaving it empty.
|
||||||
if on_stream and on_stream_end:
|
if on_stream and on_stream_end and should_stream:
|
||||||
await on_stream(result.final_content or "")
|
await on_stream(result.final_content or "")
|
||||||
await on_stream_end(resuming=False)
|
await on_stream_end(resuming=False)
|
||||||
elif result.stop_reason == "error":
|
elif result.stop_reason == "error":
|
||||||
@@ -855,13 +856,15 @@ class AgentLoop:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
raw = msg.content.strip()
|
raw = msg.content.strip()
|
||||||
|
effective_key = self._effective_session_key(msg)
|
||||||
|
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||||
|
continue
|
||||||
if self.commands.is_priority(raw):
|
if self.commands.is_priority(raw):
|
||||||
await self._dispatch_command_inline(
|
await self._dispatch_command_inline(
|
||||||
msg, msg.session_key, raw,
|
msg, effective_key, raw,
|
||||||
self.commands.dispatch_priority,
|
self.commands.dispatch_priority,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
effective_key = self._effective_session_key(msg)
|
|
||||||
# If this session already has an active pending queue (i.e. a task
|
# If this session already has an active pending queue (i.e. a task
|
||||||
# is processing this session), route the message there for mid-turn
|
# is processing this session), route the message there for mid-turn
|
||||||
# injection instead of creating a competing task.
|
# injection instead of creating a competing task.
|
||||||
@@ -912,13 +915,13 @@ class AgentLoop:
|
|||||||
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
gate = self._concurrency_gate or nullcontext()
|
gate = self._concurrency_gate or nullcontext()
|
||||||
|
|
||||||
# Register a pending queue so follow-up messages for this session are
|
pending: asyncio.Queue | None = None
|
||||||
# routed here (mid-turn injection) instead of spawning a new task.
|
|
||||||
pending = asyncio.Queue(maxsize=20)
|
|
||||||
self._pending_queues[session_key] = pending
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with lock, gate:
|
async with lock, gate:
|
||||||
|
# Only the task that owns the session lock may publish the
|
||||||
|
# active mid-turn injection queue for this session.
|
||||||
|
pending = asyncio.Queue(maxsize=20)
|
||||||
|
self._pending_queues[session_key] = pending
|
||||||
try:
|
try:
|
||||||
on_stream = on_stream_end = None
|
on_stream = on_stream_end = None
|
||||||
if msg.metadata.get("_wants_stream"):
|
if msg.metadata.get("_wants_stream"):
|
||||||
@@ -963,39 +966,14 @@ class AgentLoop:
|
|||||||
channel=msg.channel, chat_id=msg.chat_id,
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="", metadata=msg.metadata or {},
|
content="", metadata=msg.metadata or {},
|
||||||
))
|
))
|
||||||
if msg.channel == "websocket":
|
continuing = turn_continuation.internal_continuation_pending(msg.metadata)
|
||||||
# Signal that the turn is fully complete (all tools executed,
|
if msg.channel == "websocket" and not continuing:
|
||||||
# final text streamed). This lets WS clients know when to
|
|
||||||
# definitively stop the loading indicator.
|
|
||||||
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
|
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
|
||||||
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
await self._webui_turns.handle_turn_end(
|
||||||
if turn_lat is not None:
|
msg,
|
||||||
turn_metadata["latency_ms"] = int(turn_lat)
|
session_key=session_key,
|
||||||
sess_turn = self.sessions.get_or_create(session_key)
|
latency_ms=turn_lat,
|
||||||
turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata)
|
)
|
||||||
await self.bus.publish_outbound(OutboundMessage(
|
|
||||||
channel=msg.channel, chat_id=msg.chat_id,
|
|
||||||
content="", metadata=turn_metadata,
|
|
||||||
))
|
|
||||||
if msg.metadata.get("webui") is True:
|
|
||||||
async def _generate_title_and_notify() -> None:
|
|
||||||
generated = await maybe_generate_webui_title_after_turn(
|
|
||||||
channel=msg.channel,
|
|
||||||
metadata=msg.metadata,
|
|
||||||
sessions=self.sessions,
|
|
||||||
session_key=session_key,
|
|
||||||
provider=self.provider,
|
|
||||||
model=self.model,
|
|
||||||
)
|
|
||||||
if generated:
|
|
||||||
await self.bus.publish_outbound(OutboundMessage(
|
|
||||||
channel=msg.channel,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
content="",
|
|
||||||
metadata={**msg.metadata, "_session_updated": True},
|
|
||||||
))
|
|
||||||
|
|
||||||
self._schedule_background(_generate_title_and_notify())
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Task cancelled for session {}", session_key)
|
logger.info("Task cancelled for session {}", session_key)
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
@@ -1028,27 +1006,40 @@ class AgentLoop:
|
|||||||
channel=msg.channel, chat_id=msg.chat_id,
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="Sorry, I encountered an error.",
|
content="Sorry, I encountered an error.",
|
||||||
))
|
))
|
||||||
|
finally:
|
||||||
|
# Drain any messages still in the pending queue and re-publish
|
||||||
|
# them to the bus so they are processed as fresh inbound messages
|
||||||
|
# rather than silently lost. Only remove our own queue; a
|
||||||
|
# later task waiting on the lock must not be able to steal
|
||||||
|
# cleanup ownership.
|
||||||
|
queue = None
|
||||||
|
if self._pending_queues.get(session_key) is pending:
|
||||||
|
queue = self._pending_queues.pop(session_key, None)
|
||||||
|
else:
|
||||||
|
queue = pending
|
||||||
|
if queue is not None:
|
||||||
|
leftover = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
await self.bus.publish_inbound(item)
|
||||||
|
leftover += 1
|
||||||
|
if leftover:
|
||||||
|
logger.info(
|
||||||
|
"Re-published {} leftover message(s) to bus for session {}",
|
||||||
|
leftover, session_key,
|
||||||
|
)
|
||||||
|
if not turn_continuation.internal_continuation_pending(msg.metadata):
|
||||||
|
await self._webui_turns.publish_run_status(msg, "idle")
|
||||||
|
self._pending_turn_latency_ms.pop(session_key, None)
|
||||||
|
self._webui_turns.discard(session_key)
|
||||||
finally:
|
finally:
|
||||||
# Drain any messages still in the pending queue and re-publish
|
if pending is None:
|
||||||
# them to the bus so they are processed as fresh inbound messages
|
await self._webui_turns.publish_run_status(msg, "idle")
|
||||||
# rather than silently lost.
|
self._pending_turn_latency_ms.pop(session_key, None)
|
||||||
queue = self._pending_queues.pop(session_key, None)
|
self._webui_turns.discard(session_key)
|
||||||
if queue is not None:
|
|
||||||
leftover = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
item = queue.get_nowait()
|
|
||||||
except asyncio.QueueEmpty:
|
|
||||||
break
|
|
||||||
await self.bus.publish_inbound(item)
|
|
||||||
leftover += 1
|
|
||||||
if leftover:
|
|
||||||
logger.info(
|
|
||||||
"Re-published {} leftover message(s) to bus for session {}",
|
|
||||||
leftover, session_key,
|
|
||||||
)
|
|
||||||
await publish_turn_run_status(self.bus, msg, "idle")
|
|
||||||
self._pending_turn_latency_ms.pop(session_key, None)
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Drain pending background archives, then close MCP connections."""
|
"""Drain pending background archives, then close MCP connections."""
|
||||||
@@ -1117,6 +1108,7 @@ class AgentLoop:
|
|||||||
}
|
}
|
||||||
history = session.get_history(**_hist_kwargs)
|
history = session.get_history(**_hist_kwargs)
|
||||||
current_role = "assistant" if is_subagent else "user"
|
current_role = "assistant" if is_subagent else "user"
|
||||||
|
workspace_scope = self.workspace_scopes.for_message(msg, session.metadata)
|
||||||
|
|
||||||
messages = self.context.build_messages(
|
messages = self.context.build_messages(
|
||||||
history=history,
|
history=history,
|
||||||
@@ -1127,6 +1119,10 @@ class AgentLoop:
|
|||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata,
|
||||||
|
workspace=workspace_scope.project_path,
|
||||||
|
runtime_state=self,
|
||||||
|
inbound_message=msg,
|
||||||
|
skip_runtime_lines=is_subagent,
|
||||||
)
|
)
|
||||||
t_wall = time.time()
|
t_wall = time.time()
|
||||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||||
@@ -1186,12 +1182,17 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
|
|
||||||
key = session_key or msg.session_key
|
key = session_key or msg.session_key
|
||||||
|
t0 = time.time()
|
||||||
ctx = TurnContext(
|
ctx = TurnContext(
|
||||||
msg=msg,
|
msg=msg,
|
||||||
session=None,
|
session=None,
|
||||||
session_key=key,
|
session_key=key,
|
||||||
state=TurnState.RESTORE,
|
state=TurnState.RESTORE,
|
||||||
turn_id=f"{key}:{time.time_ns()}",
|
turn_id=f"{key}:{time.time_ns()}",
|
||||||
|
turn_wall_started_at=t0,
|
||||||
|
visible_run_started_at=turn_continuation.internal_continuation_run_started_at(
|
||||||
|
msg.metadata,
|
||||||
|
),
|
||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
on_stream=on_stream,
|
on_stream=on_stream,
|
||||||
on_stream_end=on_stream_end,
|
on_stream_end=on_stream_end,
|
||||||
@@ -1259,7 +1260,6 @@ class AgentLoop:
|
|||||||
all_msgs: list[dict[str, Any]],
|
all_msgs: list[dict[str, Any]],
|
||||||
stop_reason: str,
|
stop_reason: str,
|
||||||
had_injections: bool,
|
had_injections: bool,
|
||||||
generated_media: list[str],
|
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None,
|
on_stream: Callable[[str], Awaitable[None]] | None,
|
||||||
*,
|
*,
|
||||||
turn_latency_ms: int | None = None,
|
turn_latency_ms: int | None = None,
|
||||||
@@ -1283,7 +1283,6 @@ class AgentLoop:
|
|||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
content=final_content,
|
content=final_content,
|
||||||
media=generated_media,
|
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1292,7 +1291,7 @@ class AgentLoop:
|
|||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
if msg.media:
|
if msg.media:
|
||||||
new_content, image_only = extract_documents(msg.content, msg.media)
|
new_content, image_only = self._prepare_message_media(msg.content, msg.media)
|
||||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only)
|
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only)
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
@@ -1304,6 +1303,7 @@ class AgentLoop:
|
|||||||
if ctx.session is None:
|
if ctx.session is None:
|
||||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||||
mark_webui_session(ctx.session, msg.metadata)
|
mark_webui_session(ctx.session, msg.metadata)
|
||||||
|
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
||||||
|
|
||||||
if self._restore_runtime_checkpoint(ctx.session):
|
if self._restore_runtime_checkpoint(ctx.session):
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
@@ -1312,6 +1312,16 @@ class AgentLoop:
|
|||||||
|
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
|
||||||
|
if self._should_extract_document_text():
|
||||||
|
return extract_documents(content, media)
|
||||||
|
return reference_non_image_attachments(content, media)
|
||||||
|
|
||||||
|
def _should_extract_document_text(self) -> bool:
|
||||||
|
if self.channels_config is None:
|
||||||
|
return True
|
||||||
|
return self.channels_config.extract_document_text
|
||||||
|
|
||||||
async def _state_compact(self, ctx: TurnContext) -> str:
|
async def _state_compact(self, ctx: TurnContext) -> str:
|
||||||
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
||||||
ctx.pending_summary = pending
|
ctx.pending_summary = pending
|
||||||
@@ -1364,9 +1374,17 @@ class AgentLoop:
|
|||||||
"include_timestamps": True,
|
"include_timestamps": True,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||||
|
self._webui_turns.capture_title_context(
|
||||||
|
ctx.session_key,
|
||||||
|
ctx.msg,
|
||||||
|
self.llm_runtime(),
|
||||||
|
)
|
||||||
|
|
||||||
ctx.initial_messages = self._build_initial_messages(
|
ctx.initial_messages = self._build_initial_messages(
|
||||||
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
|
ctx.msg,
|
||||||
|
ctx.session,
|
||||||
|
ctx.history,
|
||||||
|
ctx.pending_summary,
|
||||||
)
|
)
|
||||||
ctx.user_persisted_early = self._persist_user_message_early(
|
ctx.user_persisted_early = self._persist_user_message_early(
|
||||||
ctx.msg, ctx.session
|
ctx.msg, ctx.session
|
||||||
@@ -1380,7 +1398,13 @@ class AgentLoop:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_run(self, ctx: TurnContext) -> str:
|
async def _state_run(self, ctx: TurnContext) -> str:
|
||||||
await publish_turn_run_status(self.bus, ctx.msg, "running")
|
if ctx.visible_run_started_at is None:
|
||||||
|
ctx.visible_run_started_at = time.time()
|
||||||
|
await self._webui_turns.publish_run_status(
|
||||||
|
ctx.msg,
|
||||||
|
"running",
|
||||||
|
started_at=ctx.visible_run_started_at,
|
||||||
|
)
|
||||||
result = await self._run_agent_loop(
|
result = await self._run_agent_loop(
|
||||||
ctx.initial_messages,
|
ctx.initial_messages,
|
||||||
on_progress=ctx.on_progress,
|
on_progress=ctx.on_progress,
|
||||||
@@ -1401,20 +1425,25 @@ class AgentLoop:
|
|||||||
ctx.all_messages = all_msgs
|
ctx.all_messages = all_msgs
|
||||||
ctx.stop_reason = stop_reason
|
ctx.stop_reason = stop_reason
|
||||||
ctx.had_injections = had_injections
|
ctx.had_injections = had_injections
|
||||||
|
await turn_continuation.maybe_continue_turn(ctx)
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_save(self, ctx: TurnContext) -> str:
|
async def _state_save(self, ctx: TurnContext) -> str:
|
||||||
if ctx.final_content is None or not ctx.final_content.strip():
|
turn_continuation.prepare_save_boundary(ctx)
|
||||||
|
|
||||||
|
if (
|
||||||
|
(ctx.final_content is None or not ctx.final_content.strip())
|
||||||
|
and not ctx.suppress_response
|
||||||
|
):
|
||||||
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
|
latency_started_at = (
|
||||||
skip_msgs = ctx.all_messages[ctx.save_skip:]
|
ctx.visible_run_started_at
|
||||||
ctx.generated_media = generated_image_paths_from_messages(skip_msgs)
|
if turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
|
||||||
mt = self.tools.get("message")
|
and ctx.visible_run_started_at is not None
|
||||||
extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else []
|
else ctx.turn_wall_started_at
|
||||||
merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra)
|
)
|
||||||
|
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
|
||||||
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
|
|
||||||
self._save_turn(
|
self._save_turn(
|
||||||
ctx.session, ctx.all_messages, ctx.save_skip,
|
ctx.session, ctx.all_messages, ctx.save_skip,
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
@@ -1434,13 +1463,15 @@ class AgentLoop:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_respond(self, ctx: TurnContext) -> str:
|
async def _state_respond(self, ctx: TurnContext) -> str:
|
||||||
|
if ctx.suppress_response:
|
||||||
|
ctx.outbound = None
|
||||||
|
return "ok"
|
||||||
ctx.outbound = self._assemble_outbound(
|
ctx.outbound = self._assemble_outbound(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.final_content,
|
ctx.final_content,
|
||||||
ctx.all_messages,
|
ctx.all_messages,
|
||||||
ctx.stop_reason,
|
ctx.stop_reason,
|
||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.generated_media,
|
|
||||||
ctx.on_stream,
|
ctx.on_stream,
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
@@ -1675,10 +1706,19 @@ class AgentLoop:
|
|||||||
channel=channel, sender_id="user", chat_id=chat_id,
|
channel=channel, sender_id="user", chat_id=chat_id,
|
||||||
content=content, media=media or [],
|
content=content, media=media or [],
|
||||||
)
|
)
|
||||||
return await self._process_message(
|
# Share the dispatch lock so direct calls serialize with bus turns.
|
||||||
msg,
|
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
session_key=session_key,
|
try:
|
||||||
on_progress=on_progress,
|
async with lock:
|
||||||
on_stream=on_stream,
|
return await self._process_message(
|
||||||
on_stream_end=on_stream_end,
|
msg,
|
||||||
)
|
session_key=session_key,
|
||||||
|
on_progress=on_progress,
|
||||||
|
on_stream=on_stream,
|
||||||
|
on_stream_end=on_stream_end,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if channel == "websocket":
|
||||||
|
await self._webui_turns.publish_run_status(msg, "idle")
|
||||||
|
self._pending_turn_latency_ms.pop(session_key, None)
|
||||||
|
self._webui_turns.discard(session_key)
|
||||||
|
|||||||
+75
-1
@@ -678,11 +678,18 @@ class Consolidator:
|
|||||||
The budget reserves space for completion tokens and a safety buffer
|
The budget reserves space for completion tokens and a safety buffer
|
||||||
so the LLM request never exceeds the context window.
|
so the LLM request never exceeds the context window.
|
||||||
"""
|
"""
|
||||||
if not session.messages or self.context_window_tokens <= 0:
|
if self.context_window_tokens <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
lock = self.get_lock(session.key)
|
lock = self.get_lock(session.key)
|
||||||
async with lock:
|
async with lock:
|
||||||
|
# Refresh session reference: AutoCompact may have replaced it.
|
||||||
|
fresh = self.sessions.get_or_create(session.key)
|
||||||
|
if fresh is not session:
|
||||||
|
session = fresh
|
||||||
|
if not session.messages:
|
||||||
|
return
|
||||||
|
|
||||||
budget = self._input_token_budget
|
budget = self._input_token_budget
|
||||||
target = int(budget * self.consolidation_ratio)
|
target = int(budget * self.consolidation_ratio)
|
||||||
last_summary = await self._consolidate_replay_overflow(
|
last_summary = await self._consolidate_replay_overflow(
|
||||||
@@ -769,6 +776,73 @@ class Consolidator:
|
|||||||
# the summary injection strategy with AutoCompact._archive().
|
# the summary injection strategy with AutoCompact._archive().
|
||||||
self._persist_last_summary(session, last_summary)
|
self._persist_last_summary(session, last_summary)
|
||||||
|
|
||||||
|
async def compact_idle_session(
|
||||||
|
self,
|
||||||
|
session_key: str,
|
||||||
|
max_suffix: int = 8,
|
||||||
|
) -> str | None:
|
||||||
|
"""Hard-truncate an idle session under the consolidation lock.
|
||||||
|
|
||||||
|
Used by AutoCompact so all session mutation goes through a single
|
||||||
|
lock-protected path. Returns the summary text on success, ``None``
|
||||||
|
if the LLM failed (raw_archive fallback), or ``""`` if there was
|
||||||
|
nothing to archive.
|
||||||
|
"""
|
||||||
|
lock = self.get_lock(session_key)
|
||||||
|
async with lock:
|
||||||
|
self.sessions.invalidate(session_key)
|
||||||
|
session = self.sessions.get_or_create(session_key)
|
||||||
|
|
||||||
|
tail = list(session.messages[session.last_consolidated:])
|
||||||
|
if not tail:
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
self.sessions.save(session)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
probe = Session(
|
||||||
|
key=session.key,
|
||||||
|
messages=tail.copy(),
|
||||||
|
created_at=session.created_at,
|
||||||
|
updated_at=session.updated_at,
|
||||||
|
metadata={},
|
||||||
|
last_consolidated=0,
|
||||||
|
)
|
||||||
|
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix)
|
||||||
|
kept = probe.messages
|
||||||
|
archive_msgs = dropped[already_consolidated:]
|
||||||
|
|
||||||
|
if not archive_msgs and not kept:
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
self.sessions.save(session)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
last_active = session.updated_at
|
||||||
|
summary: str | None = ""
|
||||||
|
if archive_msgs:
|
||||||
|
summary = await self.archive(archive_msgs)
|
||||||
|
|
||||||
|
if summary and summary != "(nothing)":
|
||||||
|
session.metadata["_last_summary"] = {
|
||||||
|
"text": summary,
|
||||||
|
"last_active": last_active.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
session.messages = kept
|
||||||
|
session.last_consolidated = 0
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
self.sessions.save(session)
|
||||||
|
|
||||||
|
if archive_msgs:
|
||||||
|
logger.info(
|
||||||
|
"Idle-session compact for {}: archived={}, kept={}, summary={}",
|
||||||
|
session_key,
|
||||||
|
len(archive_msgs),
|
||||||
|
len(kept),
|
||||||
|
bool(summary),
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dream — heavyweight cron-scheduled memory consolidation
|
# Dream — heavyweight cron-scheduled memory consolidation
|
||||||
|
|||||||
+135
-13
@@ -8,13 +8,23 @@ import os
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
from nanobot.utils.file_edit_events import (
|
||||||
|
StreamingFileEditTracker,
|
||||||
|
build_file_edit_end_event,
|
||||||
|
build_file_edit_error_event,
|
||||||
|
build_file_edit_start_event,
|
||||||
|
prepare_file_edit_trackers,
|
||||||
|
)
|
||||||
|
from nanobot.utils.file_edit_events import (
|
||||||
|
prepare_file_edit_tracker as _prepare_file_edit_tracker,
|
||||||
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
IncrementalThinkExtractor,
|
IncrementalThinkExtractor,
|
||||||
build_assistant_message,
|
build_assistant_message,
|
||||||
@@ -26,10 +36,15 @@ from nanobot.utils.helpers import (
|
|||||||
strip_think,
|
strip_think,
|
||||||
truncate_text,
|
truncate_text,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.progress_events import (
|
||||||
|
invoke_file_edit_progress,
|
||||||
|
on_progress_accepts_file_edit_events,
|
||||||
|
)
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
from nanobot.utils.runtime import (
|
from nanobot.utils.runtime import (
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
build_finalization_retry_message,
|
build_finalization_retry_message,
|
||||||
|
build_goal_continue_message,
|
||||||
build_length_recovery_message,
|
build_length_recovery_message,
|
||||||
ensure_nonempty_tool_result,
|
ensure_nonempty_tool_result,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
@@ -38,6 +53,10 @@ from nanobot.utils.runtime import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
||||||
|
_ARREARAGE_ERROR_MESSAGE = (
|
||||||
|
"The AI provider rejected the request because the API key is out of quota or the "
|
||||||
|
"account is in arrears. Please top up / check the billing status of your API key and try again."
|
||||||
|
)
|
||||||
_PERSISTED_MODEL_ERROR_PLACEHOLDER = "[Assistant reply unavailable due to model error.]"
|
_PERSISTED_MODEL_ERROR_PLACEHOLDER = "[Assistant reply unavailable due to model error.]"
|
||||||
_MAX_EMPTY_RETRIES = 2
|
_MAX_EMPTY_RETRIES = 2
|
||||||
_MAX_LENGTH_RECOVERIES = 3
|
_MAX_LENGTH_RECOVERIES = 3
|
||||||
@@ -47,11 +66,14 @@ _SNIP_SAFETY_BUFFER = 1024
|
|||||||
_MICROCOMPACT_KEEP_RECENT = 10
|
_MICROCOMPACT_KEEP_RECENT = 10
|
||||||
_MICROCOMPACT_MIN_CHARS = 500
|
_MICROCOMPACT_MIN_CHARS = 500
|
||||||
_COMPACTABLE_TOOLS = frozenset({
|
_COMPACTABLE_TOOLS = frozenset({
|
||||||
"read_file", "exec", "grep",
|
"read_file", "exec", "grep", "find_files",
|
||||||
"web_search", "web_fetch", "list_dir",
|
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||||
})
|
})
|
||||||
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||||
|
|
||||||
|
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
||||||
|
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
||||||
|
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -82,6 +104,8 @@ class AgentRunSpec:
|
|||||||
checkpoint_callback: Any | None = None
|
checkpoint_callback: Any | None = None
|
||||||
injection_callback: Any | None = None
|
injection_callback: Any | None = None
|
||||||
llm_timeout_s: float | None = None
|
llm_timeout_s: float | None = None
|
||||||
|
goal_active_predicate: Callable[[], bool] | None = None
|
||||||
|
goal_continue_message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -152,6 +176,7 @@ class AgentRunner:
|
|||||||
*,
|
*,
|
||||||
phase: str = "after error",
|
phase: str = "after error",
|
||||||
iteration: int | None = None,
|
iteration: int | None = None,
|
||||||
|
allow_goal_continue: bool = False,
|
||||||
) -> tuple[bool, int]:
|
) -> tuple[bool, int]:
|
||||||
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
||||||
|
|
||||||
@@ -160,12 +185,19 @@ class AgentRunner:
|
|||||||
and *iteration* are both provided) and return (True, cycles+1) so the
|
and *iteration* are both provided) and return (True, cycles+1) so the
|
||||||
caller continues the iteration loop. Otherwise return (False, cycles).
|
caller continues the iteration loop. Otherwise return (False, cycles).
|
||||||
"""
|
"""
|
||||||
if injection_cycles >= _MAX_INJECTION_CYCLES:
|
injections: list[dict[str, Any]] = []
|
||||||
return False, injection_cycles
|
real_injection = False
|
||||||
injections = await self._drain_injections(spec)
|
if injection_cycles < _MAX_INJECTION_CYCLES:
|
||||||
|
injections = await self._drain_injections(spec)
|
||||||
|
real_injection = bool(injections)
|
||||||
|
if not injections and allow_goal_continue and assistant_message is not None:
|
||||||
|
predicate = spec.goal_active_predicate
|
||||||
|
if predicate is not None and predicate():
|
||||||
|
injections = [build_goal_continue_message(spec.goal_continue_message)]
|
||||||
if not injections:
|
if not injections:
|
||||||
return False, injection_cycles
|
return False, injection_cycles
|
||||||
injection_cycles += 1
|
if real_injection:
|
||||||
|
injection_cycles += 1
|
||||||
if assistant_message is not None:
|
if assistant_message is not None:
|
||||||
messages.append(assistant_message)
|
messages.append(assistant_message)
|
||||||
if iteration is not None:
|
if iteration is not None:
|
||||||
@@ -181,10 +213,13 @@ class AgentRunner:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._append_injected_messages(messages, injections)
|
self._append_injected_messages(messages, injections)
|
||||||
logger.info(
|
if real_injection:
|
||||||
"Injected {} follow-up message(s) {} ({}/{})",
|
logger.info(
|
||||||
len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
|
"Injected {} follow-up message(s) {} ({}/{})",
|
||||||
)
|
len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Injected sustained-goal continuation {}", phase)
|
||||||
return True, injection_cycles
|
return True, injection_cycles
|
||||||
|
|
||||||
async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]:
|
async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]:
|
||||||
@@ -460,6 +495,7 @@ class AgentRunner:
|
|||||||
spec, messages, assistant_message, injection_cycles,
|
spec, messages, assistant_message, injection_cycles,
|
||||||
phase="after final response",
|
phase="after final response",
|
||||||
iteration=iteration,
|
iteration=iteration,
|
||||||
|
allow_goal_continue=True,
|
||||||
)
|
)
|
||||||
if should_continue:
|
if should_continue:
|
||||||
had_injections = True
|
had_injections = True
|
||||||
@@ -472,7 +508,10 @@ class AgentRunner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if response.finish_reason == "error":
|
if response.finish_reason == "error":
|
||||||
final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE
|
if LLMProvider.is_arrearage_response(response):
|
||||||
|
final_content = _ARREARAGE_ERROR_MESSAGE
|
||||||
|
else:
|
||||||
|
final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE
|
||||||
stop_reason = "error"
|
stop_reason = "error"
|
||||||
error = final_content
|
error = final_content
|
||||||
self._append_model_error_placeholder(messages)
|
self._append_model_error_placeholder(messages)
|
||||||
@@ -619,6 +658,24 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
progress_state: dict[str, bool] | None = None
|
progress_state: dict[str, bool] | None = None
|
||||||
|
live_file_edits: StreamingFileEditTracker | None = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
spec.progress_callback is not None
|
||||||
|
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||||
|
):
|
||||||
|
async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None:
|
||||||
|
await invoke_file_edit_progress(spec.progress_callback, events)
|
||||||
|
|
||||||
|
live_file_edits = StreamingFileEditTracker(
|
||||||
|
workspace=spec.workspace,
|
||||||
|
tools=spec.tools,
|
||||||
|
emit=_emit_live_file_edits,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _tool_call_delta(delta: dict[str, Any]) -> None:
|
||||||
|
if live_file_edits is not None:
|
||||||
|
await live_file_edits.update(delta)
|
||||||
|
|
||||||
if wants_streaming:
|
if wants_streaming:
|
||||||
async def _stream(delta: str) -> None:
|
async def _stream(delta: str) -> None:
|
||||||
@@ -636,6 +693,7 @@ class AgentRunner:
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
on_content_delta=_stream,
|
on_content_delta=_stream,
|
||||||
on_thinking_delta=_thinking,
|
on_thinking_delta=_thinking,
|
||||||
|
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
|
||||||
)
|
)
|
||||||
elif wants_progress_streaming:
|
elif wants_progress_streaming:
|
||||||
stream_buf = ""
|
stream_buf = ""
|
||||||
@@ -665,6 +723,7 @@ class AgentRunner:
|
|||||||
coro = self.provider.chat_stream_with_retry(
|
coro = self.provider.chat_stream_with_retry(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
on_content_delta=_stream_progress,
|
on_content_delta=_stream_progress,
|
||||||
|
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
coro = self.provider.chat_with_retry(**kwargs)
|
coro = self.provider.chat_with_retry(**kwargs)
|
||||||
@@ -679,6 +738,14 @@ class AgentRunner:
|
|||||||
await coro if outer_timeout_s is None
|
await coro if outer_timeout_s is None
|
||||||
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
|
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
|
||||||
)
|
)
|
||||||
|
if live_file_edits is not None:
|
||||||
|
await live_file_edits.flush()
|
||||||
|
if response.should_execute_tools:
|
||||||
|
live_file_edits.apply_final_call_ids(response.tool_calls)
|
||||||
|
await live_file_edits.error_unmatched(
|
||||||
|
response.tool_calls if response.should_execute_tools else [],
|
||||||
|
"Tool call did not complete.",
|
||||||
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
if outer_timeout_s is None:
|
if outer_timeout_s is None:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
@@ -813,6 +880,30 @@ class AgentRunner:
|
|||||||
return prep_error + hint, event, (
|
return prep_error + hint, event, (
|
||||||
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||||
)
|
)
|
||||||
|
emit_file_edit_events = (
|
||||||
|
spec.progress_callback is not None
|
||||||
|
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||||
|
)
|
||||||
|
progress_callback = spec.progress_callback if emit_file_edit_events else None
|
||||||
|
file_edit_trackers = (
|
||||||
|
prepare_file_edit_trackers(
|
||||||
|
call_id=tool_call.id,
|
||||||
|
tool_name=tool_call.name,
|
||||||
|
tool=tool,
|
||||||
|
workspace=spec.workspace,
|
||||||
|
params=params if isinstance(params, dict) else None,
|
||||||
|
)
|
||||||
|
if progress_callback is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if file_edit_trackers and progress_callback is not None:
|
||||||
|
await invoke_file_edit_progress(
|
||||||
|
progress_callback,
|
||||||
|
[build_file_edit_start_event(
|
||||||
|
file_edit_tracker,
|
||||||
|
params if isinstance(params, dict) else None,
|
||||||
|
) for file_edit_tracker in file_edit_trackers],
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if tool is not None:
|
if tool is not None:
|
||||||
result = await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
@@ -821,6 +912,14 @@ class AgentRunner:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
|
if file_edit_trackers and progress_callback is not None:
|
||||||
|
await invoke_file_edit_progress(
|
||||||
|
progress_callback,
|
||||||
|
[
|
||||||
|
build_file_edit_error_event(file_edit_tracker, str(exc))
|
||||||
|
for file_edit_tracker in file_edit_trackers
|
||||||
|
],
|
||||||
|
)
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -842,6 +941,14 @@ class AgentRunner:
|
|||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if isinstance(result, str) and result.startswith("Error"):
|
if isinstance(result, str) and result.startswith("Error"):
|
||||||
|
if file_edit_trackers and progress_callback is not None:
|
||||||
|
await invoke_file_edit_progress(
|
||||||
|
progress_callback,
|
||||||
|
[
|
||||||
|
build_file_edit_error_event(file_edit_tracker, result)
|
||||||
|
for file_edit_tracker in file_edit_trackers
|
||||||
|
],
|
||||||
|
)
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -860,6 +967,15 @@ class AgentRunner:
|
|||||||
return result + hint, event, RuntimeError(result)
|
return result + hint, event, RuntimeError(result)
|
||||||
return result + hint, event, None
|
return result + hint, event, None
|
||||||
|
|
||||||
|
if file_edit_trackers and progress_callback is not None:
|
||||||
|
await invoke_file_edit_progress(
|
||||||
|
progress_callback,
|
||||||
|
[build_file_edit_end_event(
|
||||||
|
file_edit_tracker,
|
||||||
|
params if isinstance(params, dict) else None,
|
||||||
|
) for file_edit_tracker in file_edit_trackers],
|
||||||
|
)
|
||||||
|
|
||||||
detail = "" if result is None else str(result)
|
detail = "" if result is None else str(result)
|
||||||
detail = detail.replace("\n", " ").strip()
|
detail = detail.replace("\n", " ").strip()
|
||||||
if not detail:
|
if not detail:
|
||||||
@@ -1164,7 +1280,13 @@ class AgentRunner:
|
|||||||
return messages
|
return messages
|
||||||
|
|
||||||
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
||||||
remaining_budget = max(128, budget - system_tokens)
|
fixed_tokens, _ = estimate_prompt_tokens_chain(
|
||||||
|
self.provider,
|
||||||
|
spec.model,
|
||||||
|
system_messages,
|
||||||
|
spec.tools.get_definitions(),
|
||||||
|
)
|
||||||
|
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||||
kept: list[dict[str, Any]] = []
|
kept: list[dict[str, Any]] = []
|
||||||
kept_tokens = 0
|
kept_tokens = 0
|
||||||
for message in reversed(non_system):
|
for message in reversed(non_system):
|
||||||
|
|||||||
+62
-21
@@ -16,6 +16,12 @@ from nanobot.agent.tools.context import ToolContext
|
|||||||
from nanobot.agent.tools.file_state import FileStates
|
from nanobot.agent.tools.file_state import FileStates
|
||||||
from nanobot.agent.tools.loader import ToolLoader
|
from nanobot.agent.tools.loader import ToolLoader
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.security.workspace_access import (
|
||||||
|
WorkspaceScope,
|
||||||
|
bind_workspace_scope,
|
||||||
|
reset_workspace_scope,
|
||||||
|
workspace_sandbox_status,
|
||||||
|
)
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
||||||
@@ -79,6 +85,7 @@ class SubagentManager:
|
|||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
disabled_skills: list[str] | None = None,
|
disabled_skills: list[str] | None = None,
|
||||||
max_iterations: int | None = None,
|
max_iterations: int | None = None,
|
||||||
|
max_concurrent_subagents: int | None = None,
|
||||||
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
||||||
):
|
):
|
||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
@@ -95,7 +102,11 @@ class SubagentManager:
|
|||||||
if max_iterations is not None
|
if max_iterations is not None
|
||||||
else defaults.max_tool_iterations
|
else defaults.max_tool_iterations
|
||||||
)
|
)
|
||||||
self.max_concurrent_subagents = defaults.max_concurrent_subagents
|
self.max_concurrent_subagents = (
|
||||||
|
max_concurrent_subagents
|
||||||
|
if max_concurrent_subagents is not None
|
||||||
|
else defaults.max_concurrent_subagents
|
||||||
|
)
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner(provider)
|
||||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
@@ -123,6 +134,10 @@ class SubagentManager:
|
|||||||
config=cfg,
|
config=cfg,
|
||||||
workspace=str(root.resolve()),
|
workspace=str(root.resolve()),
|
||||||
file_state_store=FileStates(),
|
file_state_store=FileStates(),
|
||||||
|
workspace_sandbox=workspace_sandbox_status(
|
||||||
|
restrict_to_workspace=cfg.restrict_to_workspace,
|
||||||
|
workspace=root,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
ToolLoader().load(ctx, registry, scope="subagent")
|
ToolLoader().load(ctx, registry, scope="subagent")
|
||||||
return registry
|
return registry
|
||||||
@@ -140,6 +155,8 @@ class SubagentManager:
|
|||||||
origin_chat_id: str = "direct",
|
origin_chat_id: str = "direct",
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
workspace_scope: WorkspaceScope | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Spawn a subagent to execute a task in the background."""
|
"""Spawn a subagent to execute a task in the background."""
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
@@ -155,7 +172,16 @@ class SubagentManager:
|
|||||||
self._task_statuses[task_id] = status
|
self._task_statuses[task_id] = status
|
||||||
|
|
||||||
bg_task = asyncio.create_task(
|
bg_task = asyncio.create_task(
|
||||||
self._run_subagent(task_id, task, display_label, origin, status, origin_message_id)
|
self._run_subagent(
|
||||||
|
task_id,
|
||||||
|
task,
|
||||||
|
display_label,
|
||||||
|
origin,
|
||||||
|
status,
|
||||||
|
origin_message_id,
|
||||||
|
temperature,
|
||||||
|
workspace_scope,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self._running_tasks[task_id] = bg_task
|
self._running_tasks[task_id] = bg_task
|
||||||
if session_key:
|
if session_key:
|
||||||
@@ -182,6 +208,8 @@ class SubagentManager:
|
|||||||
origin: dict[str, str],
|
origin: dict[str, str],
|
||||||
status: SubagentStatus,
|
status: SubagentStatus,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
workspace_scope: WorkspaceScope | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute the subagent task and announce the result."""
|
"""Execute the subagent task and announce the result."""
|
||||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||||
@@ -191,8 +219,13 @@ class SubagentManager:
|
|||||||
status.iteration = payload.get("iteration", status.iteration)
|
status.iteration = payload.get("iteration", status.iteration)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tools = self._build_tools()
|
root = workspace_scope.project_path if workspace_scope is not None else self.workspace
|
||||||
system_prompt = self._build_subagent_prompt()
|
cfg = None
|
||||||
|
if workspace_scope is not None:
|
||||||
|
cfg = self._subagent_tools_config()
|
||||||
|
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
|
||||||
|
tools = self._build_tools(workspace=root, tools_config=cfg)
|
||||||
|
system_prompt = self._build_subagent_prompt(workspace=root)
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": task},
|
{"role": "user", "content": task},
|
||||||
@@ -204,20 +237,27 @@ class SubagentManager:
|
|||||||
if self._llm_wall_timeout_for_session
|
if self._llm_wall_timeout_for_session
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
result = await self.runner.run(AgentRunSpec(
|
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
|
||||||
initial_messages=messages,
|
try:
|
||||||
tools=tools,
|
result = await self.runner.run(AgentRunSpec(
|
||||||
model=self.model,
|
initial_messages=messages,
|
||||||
max_iterations=self.max_iterations,
|
tools=tools,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
model=self.model,
|
||||||
hook=_SubagentHook(task_id, status),
|
temperature=temperature,
|
||||||
max_iterations_message="Task completed but no final response was generated.",
|
max_iterations=self.max_iterations,
|
||||||
error_message=None,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
fail_on_tool_error=True,
|
hook=_SubagentHook(task_id, status),
|
||||||
checkpoint_callback=_on_checkpoint,
|
max_iterations_message="Task completed but no final response was generated.",
|
||||||
session_key=sess_key,
|
error_message=None,
|
||||||
llm_timeout_s=llm_timeout,
|
fail_on_tool_error=True,
|
||||||
))
|
checkpoint_callback=_on_checkpoint,
|
||||||
|
session_key=sess_key,
|
||||||
|
workspace=root,
|
||||||
|
llm_timeout_s=llm_timeout,
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
if token is not None:
|
||||||
|
reset_workspace_scope(token)
|
||||||
status.phase = "done"
|
status.phase = "done"
|
||||||
status.stop_reason = result.stop_reason
|
status.stop_reason = result.stop_reason
|
||||||
|
|
||||||
@@ -311,20 +351,21 @@ class SubagentManager:
|
|||||||
lines.append(f"- {result.error}")
|
lines.append(f"- {result.error}")
|
||||||
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
|
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
|
||||||
|
|
||||||
def _build_subagent_prompt(self) -> str:
|
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
|
||||||
"""Build a focused system prompt for the subagent."""
|
"""Build a focused system prompt for the subagent."""
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
time_ctx = ContextBuilder._build_runtime_context(None, None)
|
time_ctx = ContextBuilder._build_runtime_context(None, None)
|
||||||
|
root = workspace or self.workspace
|
||||||
skills_summary = SkillsLoader(
|
skills_summary = SkillsLoader(
|
||||||
self.workspace,
|
root,
|
||||||
disabled_skills=self.disabled_skills,
|
disabled_skills=self.disabled_skills,
|
||||||
).build_skills_summary()
|
).build_skills_summary()
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/subagent_system.md",
|
"agent/subagent_system.md",
|
||||||
time_ctx=time_ctx,
|
time_ctx=time_ctx,
|
||||||
workspace=str(self.workspace),
|
workspace=str(root),
|
||||||
skills_summary=skills_summary or "",
|
skills_summary=skills_summary or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Apply file edits by providing structured edit instructions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import tool_parameters
|
||||||
|
from nanobot.agent.tools.filesystem import _FsTool
|
||||||
|
from nanobot.agent.tools.schema import (
|
||||||
|
ArraySchema,
|
||||||
|
BooleanSchema,
|
||||||
|
ObjectSchema,
|
||||||
|
StringSchema,
|
||||||
|
tool_parameters_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _PatchSummary:
|
||||||
|
action: str
|
||||||
|
path: str
|
||||||
|
added: int = 0
|
||||||
|
deleted: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class _PatchError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_relative_path(path: str) -> str:
|
||||||
|
normalized = path.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise _PatchError("patch path cannot be empty")
|
||||||
|
if "\0" in normalized:
|
||||||
|
raise _PatchError(f"patch path contains a null byte: {path!r}")
|
||||||
|
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
|
||||||
|
raise _PatchError(f"patch path must be relative: {path}")
|
||||||
|
if any(part == ".." for part in re.split(r"[\\/]+", normalized)):
|
||||||
|
raise _PatchError(f"patch path must not contain '..': {path}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _lines_to_text(lines: list[str]) -> str:
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _text_line_count(text: str) -> int:
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
return len(text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def _line_diff_stats(before: str, after: str) -> tuple[int, int]:
|
||||||
|
before_lines = before.replace("\r\n", "\n").splitlines()
|
||||||
|
after_lines = after.replace("\r\n", "\n").splitlines()
|
||||||
|
added = 0
|
||||||
|
deleted = 0
|
||||||
|
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == "equal":
|
||||||
|
continue
|
||||||
|
if tag in ("replace", "delete"):
|
||||||
|
deleted += i2 - i1
|
||||||
|
if tag in ("replace", "insert"):
|
||||||
|
added += j2 - j1
|
||||||
|
return added, deleted
|
||||||
|
|
||||||
|
|
||||||
|
def _format_summary(summary: _PatchSummary) -> str:
|
||||||
|
stats = ""
|
||||||
|
if summary.added or summary.deleted:
|
||||||
|
stats = f" (+{summary.added}/-{summary.deleted})"
|
||||||
|
return f"- {summary.action} {summary.path}{stats}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
edits=ArraySchema(
|
||||||
|
items=ObjectSchema(
|
||||||
|
path=StringSchema("Relative path to the file to edit."),
|
||||||
|
action=StringSchema(
|
||||||
|
"Operation type: replace or add.",
|
||||||
|
enum=["replace", "add"],
|
||||||
|
),
|
||||||
|
old_text=StringSchema(
|
||||||
|
"Exact text to search for in the file. Required for replace.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
new_text=StringSchema(
|
||||||
|
"Text to replace with or append. Required for replace and add.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
required=["path", "action"],
|
||||||
|
),
|
||||||
|
description="List of edits to apply. Each edit specifies a file and the change to make.",
|
||||||
|
min_items=1,
|
||||||
|
max_items=20,
|
||||||
|
),
|
||||||
|
dry_run=BooleanSchema(
|
||||||
|
description="Validate and summarize the patch without writing files.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
|
required=["edits"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class ApplyPatchTool(_FsTool):
|
||||||
|
"""Apply file edits by providing structured edit instructions."""
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "apply_patch"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Default tool for code edits. Supports multi-file changes in a single call. "
|
||||||
|
"Provide a list of structured edits, each specifying a file path, action "
|
||||||
|
"(replace/add), and the exact text to change. "
|
||||||
|
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
|
||||||
|
"Use edit_file only for small exact replacements on a single file."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
edits: list[dict] | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
if not edits:
|
||||||
|
raise _PatchError("must provide edits")
|
||||||
|
|
||||||
|
writes: dict[Path, str] = {}
|
||||||
|
summaries: list[_PatchSummary] = []
|
||||||
|
|
||||||
|
for edit in edits:
|
||||||
|
if not isinstance(edit, dict):
|
||||||
|
raise _PatchError("each edit must be an object")
|
||||||
|
raw_path = edit.get("path")
|
||||||
|
if not isinstance(raw_path, str):
|
||||||
|
raise _PatchError("path required for edit")
|
||||||
|
path = _validate_relative_path(raw_path)
|
||||||
|
action = edit.get("action")
|
||||||
|
if not isinstance(action, str):
|
||||||
|
raise _PatchError(f"action required for edit: {path}")
|
||||||
|
source = self._resolve(path)
|
||||||
|
|
||||||
|
if action == "add":
|
||||||
|
new_text = edit.get("new_text")
|
||||||
|
if new_text is None:
|
||||||
|
raise _PatchError(f"new_text required for add: {path}")
|
||||||
|
|
||||||
|
pending = writes.get(source)
|
||||||
|
if pending is not None:
|
||||||
|
content = pending
|
||||||
|
exists = True
|
||||||
|
elif source.exists():
|
||||||
|
raw = source.read_bytes()
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise _PatchError(f"file is not UTF-8 text: {path}")
|
||||||
|
exists = True
|
||||||
|
else:
|
||||||
|
content = ""
|
||||||
|
exists = False
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
uses_crlf = "\r\n" in content
|
||||||
|
new_norm = content.replace("\r\n", "\n") + new_text.replace("\r\n", "\n")
|
||||||
|
if new_norm and not new_norm.endswith("\n"):
|
||||||
|
new_norm += "\n"
|
||||||
|
if uses_crlf:
|
||||||
|
new_norm = new_norm.replace("\n", "\r\n")
|
||||||
|
writes[source] = new_norm
|
||||||
|
added, deleted = _line_diff_stats(content, new_norm)
|
||||||
|
action_name = "update"
|
||||||
|
else:
|
||||||
|
new_norm = new_text.replace("\r\n", "\n")
|
||||||
|
if new_norm and not new_norm.endswith("\n"):
|
||||||
|
new_norm += "\n"
|
||||||
|
writes[source] = new_norm
|
||||||
|
added = _text_line_count(new_norm)
|
||||||
|
deleted = 0
|
||||||
|
action_name = "add"
|
||||||
|
|
||||||
|
summaries.append(
|
||||||
|
_PatchSummary(
|
||||||
|
action=action_name, path=path, added=added, deleted=deleted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "replace":
|
||||||
|
old_text = edit.get("old_text") or ""
|
||||||
|
if not old_text:
|
||||||
|
raise _PatchError(f"old_text required for replace: {path}")
|
||||||
|
new_text = edit.get("new_text")
|
||||||
|
if new_text is None:
|
||||||
|
raise _PatchError(f"new_text required for replace: {path}")
|
||||||
|
|
||||||
|
pending = writes.get(source)
|
||||||
|
if pending is not None:
|
||||||
|
content = pending
|
||||||
|
elif source.exists():
|
||||||
|
raw = source.read_bytes()
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise _PatchError(f"file is not UTF-8 text: {path}")
|
||||||
|
else:
|
||||||
|
raise _PatchError(f"file to update does not exist: {path}")
|
||||||
|
|
||||||
|
if pending is None and not source.is_file():
|
||||||
|
raise _PatchError(f"path to update is not a file: {path}")
|
||||||
|
|
||||||
|
uses_crlf = "\r\n" in content
|
||||||
|
norm_content = content.replace("\r\n", "\n")
|
||||||
|
norm_old = old_text.replace("\r\n", "\n")
|
||||||
|
|
||||||
|
pos = norm_content.find(norm_old)
|
||||||
|
if pos < 0:
|
||||||
|
raise _PatchError(f"old_text not found in {path}")
|
||||||
|
if norm_content.find(norm_old, pos + 1) >= 0:
|
||||||
|
raise _PatchError(f"old_text appears multiple times in {path}")
|
||||||
|
|
||||||
|
new_norm = (
|
||||||
|
norm_content[:pos]
|
||||||
|
+ new_text.replace("\r\n", "\n")
|
||||||
|
+ norm_content[pos + len(norm_old) :]
|
||||||
|
)
|
||||||
|
if new_norm and not new_norm.endswith("\n"):
|
||||||
|
new_norm += "\n"
|
||||||
|
if uses_crlf:
|
||||||
|
new_norm = new_norm.replace("\n", "\r\n")
|
||||||
|
|
||||||
|
writes[source] = new_norm
|
||||||
|
added, deleted = _line_diff_stats(content, new_norm)
|
||||||
|
summaries.append(
|
||||||
|
_PatchSummary(
|
||||||
|
action="update", path=path, added=added, deleted=deleted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise _PatchError(f"unknown action: {action}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return "Patch dry-run succeeded:\n" + "\n".join(
|
||||||
|
_format_summary(summary) for summary in summaries
|
||||||
|
)
|
||||||
|
|
||||||
|
backups: dict[Path, bytes | None] = {}
|
||||||
|
for path in writes:
|
||||||
|
backups[path] = path.read_bytes() if path.exists() else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for path, content in writes.items():
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8", newline="")
|
||||||
|
except Exception:
|
||||||
|
for path, data in backups.items():
|
||||||
|
if data is None:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(data)
|
||||||
|
raise
|
||||||
|
|
||||||
|
for path in writes:
|
||||||
|
self._file_states.record_write(path)
|
||||||
|
return "Patch applied:\n" + "\n".join(
|
||||||
|
_format_summary(summary) for summary in summaries
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
except _PatchError as exc:
|
||||||
|
return f"Error applying patch: {exc}"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error applying patch: {exc}"
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Controlled runner for installed CLI Apps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
|
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
|
from nanobot.security.workspace_access import current_tool_workspace
|
||||||
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CliAppsToolConfig(Base):
|
||||||
|
"""CLI Apps tool configuration."""
|
||||||
|
|
||||||
|
enable: bool = True
|
||||||
|
install_timeout: int = Field(default=300, ge=1, le=3600)
|
||||||
|
run_timeout: int = Field(default=60, ge=1, le=600)
|
||||||
|
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
required=["name"],
|
||||||
|
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
|
||||||
|
args=ArraySchema(
|
||||||
|
StringSchema("One command-line argument."),
|
||||||
|
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
json=BooleanSchema(
|
||||||
|
description="Whether to prepend --json when supported by the CLI.",
|
||||||
|
default=False,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
|
||||||
|
timeout=IntegerSchema(
|
||||||
|
description="Timeout in seconds for this CLI call.",
|
||||||
|
minimum=1,
|
||||||
|
maximum=600,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class CliAppsTool(Tool):
|
||||||
|
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
|
||||||
|
|
||||||
|
config_key = "cli_apps"
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls):
|
||||||
|
return CliAppsToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: Any) -> bool:
|
||||||
|
return ctx.config.cli_apps.enable
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: Any) -> Tool:
|
||||||
|
cfg = ctx.config.cli_apps
|
||||||
|
return cls(
|
||||||
|
workspace=Path(ctx.workspace),
|
||||||
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
|
runtime=CliAppsRuntimeConfig(
|
||||||
|
install_timeout=cfg.install_timeout,
|
||||||
|
run_timeout=cfg.run_timeout,
|
||||||
|
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace: Path,
|
||||||
|
restrict_to_workspace: bool = False,
|
||||||
|
runtime: CliAppsRuntimeConfig | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.workspace = workspace
|
||||||
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
|
self.runtime = runtime or CliAppsRuntimeConfig()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "run_cli_app"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
try:
|
||||||
|
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
|
||||||
|
except Exception:
|
||||||
|
installed = []
|
||||||
|
installed_note = (
|
||||||
|
f" Installed Settings CLI Apps: {', '.join(installed)}."
|
||||||
|
if installed
|
||||||
|
else " No Settings CLI Apps are currently installed."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
|
||||||
|
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
|
||||||
|
"unknown names are rejected. Execution uses argv, not shell."
|
||||||
|
+ installed_note
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
json: bool | None = False,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
access = current_tool_workspace(
|
||||||
|
self.workspace,
|
||||||
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
|
)
|
||||||
|
workspace = access.project_path or self.workspace
|
||||||
|
manager = CliAppManager(workspace=workspace, runtime=self.runtime)
|
||||||
|
try:
|
||||||
|
return manager.run(
|
||||||
|
name,
|
||||||
|
args=args or [],
|
||||||
|
json_output=bool(json),
|
||||||
|
working_dir=working_dir,
|
||||||
|
timeout=timeout,
|
||||||
|
restrict_to_workspace=access.restrict_to_workspace,
|
||||||
|
)
|
||||||
|
except CliAppError as exc:
|
||||||
|
return f"Error: {exc.message}"
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
"""Runtime context for tool construction."""
|
"""Runtime context for tool construction."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Protocol, runtime_checkable
|
from typing import Any, Callable, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
|
||||||
|
"nanobot_tool_request_context",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RequestContext:
|
class RequestContext:
|
||||||
@@ -21,6 +27,23 @@ class ContextAware(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def bind_request_context(ctx: RequestContext) -> Token[RequestContext | None]:
|
||||||
|
return _CURRENT_REQUEST_CONTEXT.set(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_request_context(token: Token[RequestContext | None]) -> None:
|
||||||
|
_CURRENT_REQUEST_CONTEXT.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def current_request_context() -> RequestContext | None:
|
||||||
|
return _CURRENT_REQUEST_CONTEXT.get()
|
||||||
|
|
||||||
|
|
||||||
|
def current_request_session_key() -> str | None:
|
||||||
|
ctx = current_request_context()
|
||||||
|
return ctx.session_key if ctx else None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolContext:
|
class ToolContext:
|
||||||
config: Any
|
config: Any
|
||||||
@@ -33,3 +56,4 @@ class ToolContext:
|
|||||||
provider_snapshot_loader: Callable[[], Any] | None = None
|
provider_snapshot_loader: Callable[[], Any] | None = None
|
||||||
image_generation_provider_configs: dict[str, Any] | None = None
|
image_generation_provider_configs: dict[str, Any] | None = None
|
||||||
timezone: str = "UTC"
|
timezone: str = "UTC"
|
||||||
|
workspace_sandbox: Any | None = None
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
"""Session support for long-running exec workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
|
from nanobot.agent.tools.context import current_request_session_key
|
||||||
|
from nanobot.agent.tools.schema import (
|
||||||
|
BooleanSchema,
|
||||||
|
IntegerSchema,
|
||||||
|
StringSchema,
|
||||||
|
tool_parameters_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_YIELD_MS = 1000
|
||||||
|
MAX_YIELD_MS = 30_000
|
||||||
|
DEFAULT_WAIT_FOR_MS = 10_000
|
||||||
|
MAX_WAIT_FOR_MS = 120_000
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS = 10_000
|
||||||
|
MAX_OUTPUT_CHARS = 50_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _SessionPoll:
|
||||||
|
output: str
|
||||||
|
done: bool
|
||||||
|
exit_code: int | None
|
||||||
|
elapsed_s: float = 0.0
|
||||||
|
timed_out: bool = False
|
||||||
|
terminated: bool = False
|
||||||
|
stdin_closed: bool = False
|
||||||
|
truncated_chars: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ExecSessionInfo:
|
||||||
|
session_id: str
|
||||||
|
command: str
|
||||||
|
cwd: str
|
||||||
|
elapsed_s: float
|
||||||
|
idle_s: float
|
||||||
|
remaining_s: float
|
||||||
|
returncode: int | None
|
||||||
|
owner_session_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecSession:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
process: asyncio.subprocess.Process,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
timeout: int | None,
|
||||||
|
owner_session_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session_id = session_id
|
||||||
|
self.process = process
|
||||||
|
self.command = command
|
||||||
|
self.cwd = cwd
|
||||||
|
self.owner_session_key = owner_session_key
|
||||||
|
self.started_at = time.monotonic()
|
||||||
|
# timeout None/0 means no limit; an infinite deadline is never reached.
|
||||||
|
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
||||||
|
self.last_access = time.monotonic()
|
||||||
|
self._chunks: list[str] = []
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._timed_out = False
|
||||||
|
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, ""))
|
||||||
|
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n"))
|
||||||
|
|
||||||
|
async def _read_stream(
|
||||||
|
self,
|
||||||
|
stream: asyncio.StreamReader | None,
|
||||||
|
prefix: str,
|
||||||
|
) -> None:
|
||||||
|
if stream is None:
|
||||||
|
return
|
||||||
|
first = True
|
||||||
|
while True:
|
||||||
|
chunk = await stream.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
text = chunk.decode("utf-8", errors="replace")
|
||||||
|
if prefix and first:
|
||||||
|
text = prefix + text
|
||||||
|
first = False
|
||||||
|
async with self._lock:
|
||||||
|
self._chunks.append(text)
|
||||||
|
|
||||||
|
async def write(self, chars: str) -> str | None:
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
return "session has already exited"
|
||||||
|
if self.process.stdin is None:
|
||||||
|
return "session stdin is not available"
|
||||||
|
try:
|
||||||
|
self.process.stdin.write(chars.encode("utf-8"))
|
||||||
|
await self.process.stdin.drain()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
return "session stdin is closed"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close_stdin(self) -> str | None:
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
return "session has already exited"
|
||||||
|
if self.process.stdin is None:
|
||||||
|
return "session stdin is not available"
|
||||||
|
self.process.stdin.close()
|
||||||
|
with suppress(BrokenPipeError, ConnectionResetError):
|
||||||
|
await self.process.stdin.wait_closed()
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def poll(
|
||||||
|
self,
|
||||||
|
yield_time_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
*,
|
||||||
|
terminated: bool = False,
|
||||||
|
stdin_closed: bool = False,
|
||||||
|
) -> _SessionPoll:
|
||||||
|
self.last_access = time.monotonic()
|
||||||
|
if yield_time_ms > 0 and self.process.returncode is None:
|
||||||
|
await asyncio.sleep(min(yield_time_ms, MAX_YIELD_MS) / 1000)
|
||||||
|
|
||||||
|
if self.process.returncode is None and time.monotonic() >= self.deadline:
|
||||||
|
self._timed_out = True
|
||||||
|
await self.kill()
|
||||||
|
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
with suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(self._stdout_task, self._stderr_task),
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
output = "".join(self._chunks)
|
||||||
|
self._chunks.clear()
|
||||||
|
|
||||||
|
output, truncated = _truncate_output(output, max_output_chars)
|
||||||
|
return _SessionPoll(
|
||||||
|
output=output,
|
||||||
|
done=self.process.returncode is not None,
|
||||||
|
exit_code=self.process.returncode,
|
||||||
|
elapsed_s=max(0.0, time.monotonic() - self.started_at),
|
||||||
|
timed_out=self._timed_out,
|
||||||
|
terminated=terminated,
|
||||||
|
stdin_closed=stdin_closed,
|
||||||
|
truncated_chars=truncated,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def kill(self) -> None:
|
||||||
|
if self.process.returncode is not None:
|
||||||
|
return
|
||||||
|
self.process.kill()
|
||||||
|
with suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecSessionManager:
|
||||||
|
def __init__(self, *, max_sessions: int = 8, idle_timeout: int = 1800) -> None:
|
||||||
|
self.max_sessions = max_sessions
|
||||||
|
self.idle_timeout = idle_timeout
|
||||||
|
self._sessions: dict[str, _ExecSession] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
timeout: int | None,
|
||||||
|
shell_program: str | None,
|
||||||
|
login: bool,
|
||||||
|
yield_time_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
owner_session_key: str | None = None,
|
||||||
|
) -> tuple[str, _SessionPoll]:
|
||||||
|
async with self._lock:
|
||||||
|
await self._cleanup_locked()
|
||||||
|
if len(self._sessions) >= self.max_sessions:
|
||||||
|
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
|
||||||
|
process = await self._spawn(command, cwd, env, shell_program, login)
|
||||||
|
session_id = uuid.uuid4().hex[:12]
|
||||||
|
session = _ExecSession(
|
||||||
|
session_id=session_id,
|
||||||
|
process=process,
|
||||||
|
command=command,
|
||||||
|
cwd=cwd,
|
||||||
|
timeout=timeout,
|
||||||
|
owner_session_key=owner_session_key,
|
||||||
|
)
|
||||||
|
self._sessions[session_id] = session
|
||||||
|
|
||||||
|
poll = await session.poll(yield_time_ms, max_output_chars)
|
||||||
|
if poll.done:
|
||||||
|
async with self._lock:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
return session_id, poll
|
||||||
|
|
||||||
|
async def write(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
chars: str | None,
|
||||||
|
close_stdin: bool,
|
||||||
|
terminate: bool,
|
||||||
|
yield_time_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
owner_session_key: str | None = None,
|
||||||
|
) -> _SessionPoll:
|
||||||
|
async with self._lock:
|
||||||
|
await self._cleanup_locked()
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if session is None:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
if (
|
||||||
|
owner_session_key
|
||||||
|
and session.owner_session_key
|
||||||
|
and session.owner_session_key != owner_session_key
|
||||||
|
):
|
||||||
|
raise KeyError(session_id)
|
||||||
|
|
||||||
|
if chars:
|
||||||
|
error = await session.write(chars)
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
stdin_closed = False
|
||||||
|
if close_stdin:
|
||||||
|
error = await session.close_stdin()
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(error)
|
||||||
|
stdin_closed = True
|
||||||
|
if terminate:
|
||||||
|
await session.kill()
|
||||||
|
poll = await session.poll(
|
||||||
|
yield_time_ms,
|
||||||
|
max_output_chars,
|
||||||
|
terminated=terminate,
|
||||||
|
stdin_closed=stdin_closed,
|
||||||
|
)
|
||||||
|
if poll.done:
|
||||||
|
async with self._lock:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
return poll
|
||||||
|
|
||||||
|
async def list(self, *, owner_session_key: str | None = None) -> list[ExecSessionInfo]:
|
||||||
|
async with self._lock:
|
||||||
|
await self._cleanup_locked()
|
||||||
|
now = time.monotonic()
|
||||||
|
return [
|
||||||
|
ExecSessionInfo(
|
||||||
|
session_id=session_id,
|
||||||
|
command=session.command,
|
||||||
|
cwd=session.cwd,
|
||||||
|
elapsed_s=max(0.0, now - session.started_at),
|
||||||
|
idle_s=max(0.0, now - session.last_access),
|
||||||
|
remaining_s=max(0.0, session.deadline - now),
|
||||||
|
returncode=session.process.returncode,
|
||||||
|
owner_session_key=session.owner_session_key,
|
||||||
|
)
|
||||||
|
for session_id, session in sorted(self._sessions.items())
|
||||||
|
if not owner_session_key
|
||||||
|
or not session.owner_session_key
|
||||||
|
or session.owner_session_key == owner_session_key
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _cleanup_locked(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
stale = [
|
||||||
|
session_id
|
||||||
|
for session_id, session in self._sessions.items()
|
||||||
|
if now - session.last_access > self.idle_timeout
|
||||||
|
]
|
||||||
|
for session_id in stale:
|
||||||
|
session = self._sessions.pop(session_id)
|
||||||
|
await session.kill()
|
||||||
|
|
||||||
|
async def _spawn(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
shell_program: str | None,
|
||||||
|
login: bool,
|
||||||
|
) -> asyncio.subprocess.Process:
|
||||||
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
|
|
||||||
|
return await ExecTool._spawn(
|
||||||
|
command, cwd, env, shell_program, login,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_EXEC_SESSION_MANAGER = ExecSessionManager()
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_session_int(value: int | None, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return min(max(value, minimum), maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
|
||||||
|
if len(output) <= max_output_chars:
|
||||||
|
return output, 0
|
||||||
|
half = max_output_chars // 2
|
||||||
|
omitted = len(output) - max_output_chars
|
||||||
|
return (
|
||||||
|
output[:half]
|
||||||
|
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
|
||||||
|
+ output[-half:],
|
||||||
|
omitted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
||||||
|
parts = [poll.output] if poll.output else []
|
||||||
|
if poll.truncated_chars:
|
||||||
|
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)")
|
||||||
|
if poll.timed_out:
|
||||||
|
parts.append("Error: Command timed out; session was terminated.")
|
||||||
|
if poll.terminated and not poll.timed_out:
|
||||||
|
parts.append("Session terminated.")
|
||||||
|
if poll.stdin_closed:
|
||||||
|
parts.append("Stdin closed.")
|
||||||
|
if poll.done:
|
||||||
|
parts.append(f"Exit code: {poll.exit_code}")
|
||||||
|
else:
|
||||||
|
parts.append(f"Process running. session_id: {session_id}")
|
||||||
|
parts.append(f"Elapsed: {poll.elapsed_s:.1f}s")
|
||||||
|
return "\n".join(parts) if parts else "(no output yet)"
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
|
||||||
|
chars=StringSchema(
|
||||||
|
"Bytes/text to write to stdin. Omit or pass an empty string to only poll recent output.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
close_stdin=BooleanSchema(
|
||||||
|
description="Close stdin after writing chars. Useful for commands waiting for EOF.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
|
terminate=BooleanSchema(
|
||||||
|
description="Terminate the running exec session.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
|
yield_time_ms=IntegerSchema(
|
||||||
|
DEFAULT_YIELD_MS,
|
||||||
|
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
|
||||||
|
minimum=0,
|
||||||
|
maximum=MAX_YIELD_MS,
|
||||||
|
),
|
||||||
|
wait_for=StringSchema(
|
||||||
|
"Optional text to wait for in output before returning. "
|
||||||
|
"Useful for interactive commands and dev servers.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
wait_timeout_ms=IntegerSchema(
|
||||||
|
DEFAULT_WAIT_FOR_MS,
|
||||||
|
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
|
||||||
|
minimum=0,
|
||||||
|
maximum=MAX_WAIT_FOR_MS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
max_output_chars=IntegerSchema(
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
description="Maximum output characters to return from this poll (default 10000, max 50000).",
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
),
|
||||||
|
max_output_tokens=IntegerSchema(
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
required=["session_id"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class WriteStdinTool(Tool):
|
||||||
|
"""Write to or poll a running exec session."""
|
||||||
|
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
config_key = "exec"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls):
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
|
||||||
|
return ExecToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: Any) -> bool:
|
||||||
|
return ctx.config.exec.enable
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
manager: ExecSessionManager | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: Any) -> Tool:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exclusive(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "write_stdin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Interact with a running exec session created by exec with "
|
||||||
|
"yield_time_ms. Use chars='' to poll without writing, chars to send "
|
||||||
|
"stdin, close_stdin=true to send EOF, or terminate=true to stop the "
|
||||||
|
"process. Use wait_for with wait_timeout_ms for dev servers, test "
|
||||||
|
"watchers, and prompts where you need to wait for expected output. "
|
||||||
|
"Do not use this to start new commands; start them with exec."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
chars: str | None = None,
|
||||||
|
close_stdin: bool = False,
|
||||||
|
terminate: bool = False,
|
||||||
|
yield_time_ms: int | None = None,
|
||||||
|
wait_for: str | None = None,
|
||||||
|
wait_timeout_ms: int | None = None,
|
||||||
|
max_output_chars: int | None = None,
|
||||||
|
max_output_tokens: int | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
if max_output_chars is None:
|
||||||
|
max_output_chars = max_output_tokens
|
||||||
|
output_limit = clamp_session_int(
|
||||||
|
max_output_chars,
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
1000,
|
||||||
|
MAX_OUTPUT_CHARS,
|
||||||
|
)
|
||||||
|
if wait_for:
|
||||||
|
return await self._wait_for_output(
|
||||||
|
session_id=session_id,
|
||||||
|
chars=chars,
|
||||||
|
close_stdin=close_stdin,
|
||||||
|
terminate=terminate,
|
||||||
|
wait_for=wait_for,
|
||||||
|
wait_timeout_ms=clamp_session_int(
|
||||||
|
wait_timeout_ms,
|
||||||
|
DEFAULT_WAIT_FOR_MS,
|
||||||
|
0,
|
||||||
|
MAX_WAIT_FOR_MS,
|
||||||
|
),
|
||||||
|
max_output_chars=output_limit,
|
||||||
|
)
|
||||||
|
poll = await self._manager.write(
|
||||||
|
session_id=session_id,
|
||||||
|
chars=chars,
|
||||||
|
close_stdin=close_stdin,
|
||||||
|
terminate=terminate,
|
||||||
|
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
|
||||||
|
max_output_chars=output_limit,
|
||||||
|
owner_session_key=current_request_session_key(),
|
||||||
|
)
|
||||||
|
return format_session_poll(session_id, poll)
|
||||||
|
except KeyError:
|
||||||
|
return f"Error: exec session not found: {session_id}"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error writing to exec session: {exc}"
|
||||||
|
|
||||||
|
async def _wait_for_output(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
chars: str | None,
|
||||||
|
close_stdin: bool,
|
||||||
|
terminate: bool,
|
||||||
|
wait_for: str,
|
||||||
|
wait_timeout_ms: int,
|
||||||
|
max_output_chars: int,
|
||||||
|
) -> str:
|
||||||
|
deadline = time.monotonic() + (wait_timeout_ms / 1000)
|
||||||
|
aggregate: list[str] = []
|
||||||
|
first = True
|
||||||
|
poll: _SessionPoll | None = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
|
||||||
|
step_ms = min(500, remaining_ms)
|
||||||
|
poll = await self._manager.write(
|
||||||
|
session_id=session_id,
|
||||||
|
chars=chars if first else None,
|
||||||
|
close_stdin=close_stdin if first else False,
|
||||||
|
terminate=terminate if first else False,
|
||||||
|
yield_time_ms=step_ms,
|
||||||
|
max_output_chars=max_output_chars,
|
||||||
|
owner_session_key=current_request_session_key(),
|
||||||
|
)
|
||||||
|
first = False
|
||||||
|
if poll.output:
|
||||||
|
aggregate.append(poll.output)
|
||||||
|
joined = "".join(aggregate)
|
||||||
|
if wait_for in joined:
|
||||||
|
poll.output = joined
|
||||||
|
return format_session_poll(session_id, poll)
|
||||||
|
if poll.done or remaining_ms <= 0:
|
||||||
|
poll.output = "".join(aggregate)
|
||||||
|
result = format_session_poll(session_id, poll)
|
||||||
|
if wait_for not in poll.output:
|
||||||
|
result += f"\nWait target not observed: {wait_for!r}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(tool_parameters_schema())
|
||||||
|
class ListExecSessionsTool(Tool):
|
||||||
|
"""List active exec sessions."""
|
||||||
|
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
config_key = "exec"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls):
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
|
||||||
|
return ExecToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: Any) -> bool:
|
||||||
|
return ctx.config.exec.enable
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
manager: ExecSessionManager | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: Any) -> Tool:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "list_exec_sessions"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"List active long-running exec sessions, including session_id, cwd, "
|
||||||
|
"elapsed time, idle time, remaining timeout, and command preview. "
|
||||||
|
"Use this to recover a session_id after context shifts before "
|
||||||
|
"polling, writing stdin, or terminating with write_stdin."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def execute(self, **kwargs: Any) -> str:
|
||||||
|
try:
|
||||||
|
sessions = await self._manager.list(
|
||||||
|
owner_session_key=current_request_session_key(),
|
||||||
|
)
|
||||||
|
if not sessions:
|
||||||
|
return "No active exec sessions."
|
||||||
|
lines = []
|
||||||
|
for info in sessions:
|
||||||
|
command = " ".join(info.command.split())
|
||||||
|
if len(command) > 120:
|
||||||
|
command = command[:119] + "..."
|
||||||
|
status = "exited" if info.returncode is not None else "running"
|
||||||
|
lines.append(
|
||||||
|
f"{info.session_id} | {status} | elapsed={info.elapsed_s:.1f}s "
|
||||||
|
f"| idle={info.idle_s:.1f}s | remaining={info.remaining_s:.1f}s "
|
||||||
|
f"| cwd={info.cwd} | {command}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error listing exec sessions: {exc}"
|
||||||
@@ -10,6 +10,7 @@ from typing import Any
|
|||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
||||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||||
|
from nanobot.security.workspace_access import current_tool_workspace
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
@@ -28,10 +29,18 @@ class _FsTool(Tool):
|
|||||||
allowed_dir: Path | None = None,
|
allowed_dir: Path | None = None,
|
||||||
extra_allowed_dirs: list[Path] | None = None,
|
extra_allowed_dirs: list[Path] | None = None,
|
||||||
file_states: FileStates | None = None,
|
file_states: FileStates | None = None,
|
||||||
|
restrict_to_workspace: bool | None = None,
|
||||||
|
sandbox_restricts_workspace: bool = False,
|
||||||
):
|
):
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
self._allowed_dir = allowed_dir
|
self._allowed_dir = allowed_dir
|
||||||
self._extra_allowed_dirs = extra_allowed_dirs
|
self._extra_allowed_dirs = extra_allowed_dirs
|
||||||
|
self._restrict_to_workspace = (
|
||||||
|
bool(restrict_to_workspace)
|
||||||
|
if restrict_to_workspace is not None
|
||||||
|
else allowed_dir is not None
|
||||||
|
)
|
||||||
|
self._sandbox_restricts_workspace = sandbox_restricts_workspace
|
||||||
# Explicit state is used by isolated runners like Dream/subagents.
|
# Explicit state is used by isolated runners like Dream/subagents.
|
||||||
# Main AgentLoop tools leave this unset and resolve state from the
|
# Main AgentLoop tools leave this unset and resolve state from the
|
||||||
# current async task, which keeps shared tool instances session-safe.
|
# current async task, which keeps shared tool instances session-safe.
|
||||||
@@ -46,13 +55,16 @@ class _FsTool(Tool):
|
|||||||
ctx.config.restrict_to_workspace
|
ctx.config.restrict_to_workspace
|
||||||
or ctx.config.exec.sandbox
|
or ctx.config.exec.sandbox
|
||||||
)
|
)
|
||||||
|
sandbox_restricts = bool(ctx.config.exec.sandbox)
|
||||||
allowed_dir = Path(ctx.workspace) if restrict else None
|
allowed_dir = Path(ctx.workspace) if restrict else None
|
||||||
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
extra_read = [BUILTIN_SKILLS_DIR]
|
||||||
return cls(
|
return cls(
|
||||||
workspace=Path(ctx.workspace),
|
workspace=Path(ctx.workspace),
|
||||||
allowed_dir=allowed_dir,
|
allowed_dir=allowed_dir,
|
||||||
extra_allowed_dirs=extra_read,
|
extra_allowed_dirs=extra_read,
|
||||||
file_states=ctx.file_state_store,
|
file_states=ctx.file_state_store,
|
||||||
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
|
sandbox_restricts_workspace=sandbox_restricts,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -62,13 +74,21 @@ class _FsTool(Tool):
|
|||||||
return current_file_states(self._fallback_file_states)
|
return current_file_states(self._fallback_file_states)
|
||||||
|
|
||||||
def _resolve(self, path: str) -> Path:
|
def _resolve(self, path: str) -> Path:
|
||||||
|
access = current_tool_workspace(
|
||||||
|
self._workspace,
|
||||||
|
restrict_to_workspace=self._restrict_to_workspace,
|
||||||
|
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
|
||||||
|
)
|
||||||
return resolve_workspace_path(
|
return resolve_workspace_path(
|
||||||
path,
|
path,
|
||||||
self._workspace,
|
access.project_path,
|
||||||
self._allowed_dir,
|
access.allowed_root,
|
||||||
self._extra_allowed_dirs,
|
self._extra_allowed_dirs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _display_workspace(self) -> Path | None:
|
||||||
|
return current_tool_workspace(self._workspace).project_path
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# read_file
|
# read_file
|
||||||
@@ -132,6 +152,10 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
|
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
|
||||||
|
force=BooleanSchema(
|
||||||
|
description="Bypass same-file read deduplication and return content again.",
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
required=["path"],
|
required=["path"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -154,7 +178,11 @@ class ReadFileTool(_FsTool):
|
|||||||
"Text output format: LINE_NUM|CONTENT. "
|
"Text output format: LINE_NUM|CONTENT. "
|
||||||
"Images return visual content for analysis. "
|
"Images return visual content for analysis. "
|
||||||
"Supports PDF, DOCX, XLSX, PPTX documents. "
|
"Supports PDF, DOCX, XLSX, PPTX documents. "
|
||||||
|
"Use find_files/list_dir first when the path is uncertain. "
|
||||||
|
"Read the relevant range before editing so replacements or patches "
|
||||||
|
"are based on current content. "
|
||||||
"Use offset and limit for large text files. "
|
"Use offset and limit for large text files. "
|
||||||
|
"Use force=true to re-read content even if unchanged. "
|
||||||
"Reads exceeding ~128K chars are truncated."
|
"Reads exceeding ~128K chars are truncated."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,7 +190,15 @@ class ReadFileTool(_FsTool):
|
|||||||
def read_only(self) -> bool:
|
def read_only(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any:
|
async def execute(
|
||||||
|
self,
|
||||||
|
path: str | None = None,
|
||||||
|
offset: int = 1,
|
||||||
|
limit: int | None = None,
|
||||||
|
pages: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
try:
|
try:
|
||||||
if not path:
|
if not path:
|
||||||
return "Error reading file: Unknown path"
|
return "Error reading file: Unknown path"
|
||||||
@@ -202,7 +238,13 @@ class ReadFileTool(_FsTool):
|
|||||||
current_mtime = os.path.getmtime(fp)
|
current_mtime = os.path.getmtime(fp)
|
||||||
except OSError:
|
except OSError:
|
||||||
current_mtime = 0.0
|
current_mtime = 0.0
|
||||||
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
|
if (
|
||||||
|
not force
|
||||||
|
and entry
|
||||||
|
and entry.can_dedup
|
||||||
|
and entry.offset == offset
|
||||||
|
and entry.limit == limit
|
||||||
|
):
|
||||||
if current_mtime != entry.mtime:
|
if current_mtime != entry.mtime:
|
||||||
# File was modified externally - force full read and mark as not dedupable
|
# File was modified externally - force full read and mark as not dedupable
|
||||||
entry.can_dedup = False
|
entry.can_dedup = False
|
||||||
@@ -365,9 +407,10 @@ class WriteFileTool(_FsTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Write content to a file. Overwrites if the file already exists; "
|
"Create a new file or intentionally replace an entire file with "
|
||||||
"creates parent directories as needed. "
|
"the provided content. Overwrites existing files and creates parent "
|
||||||
"For partial edits, prefer edit_file instead."
|
"directories as needed. For code changes or partial edits, prefer "
|
||||||
|
"apply_patch; use edit_file only for small exact replacements."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
|
||||||
@@ -657,6 +700,24 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
old_text=StringSchema("The text to find and replace"),
|
old_text=StringSchema("The text to find and replace"),
|
||||||
new_text=StringSchema("The text to replace with"),
|
new_text=StringSchema("The text to replace with"),
|
||||||
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
||||||
|
occurrence=IntegerSchema(
|
||||||
|
1,
|
||||||
|
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
|
||||||
|
minimum=1,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
line_hint=IntegerSchema(
|
||||||
|
1,
|
||||||
|
description="Optional 1-based line hint used to choose the nearest match.",
|
||||||
|
minimum=1,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
expected_replacements=IntegerSchema(
|
||||||
|
1,
|
||||||
|
description="Optional guard for the number of replacements that must be made.",
|
||||||
|
minimum=1,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
required=["path", "old_text", "new_text"],
|
required=["path", "old_text", "new_text"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -674,10 +735,13 @@ class EditFileTool(_FsTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Edit a file by replacing old_text with new_text. "
|
"Perform a small, exact replacement in one file by replacing "
|
||||||
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
|
"old_text with new_text. Use this for narrow text substitutions "
|
||||||
"If old_text matches multiple times, you must provide more context "
|
"with old_text copied from read_file. For multi-file, structural, "
|
||||||
"or set replace_all=true. Shows a diff of the closest match on failure."
|
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||||
|
"multiple times, provide more context or set occurrence, line_hint, "
|
||||||
|
"replace_all, and expected_replacements. Shows closest-match "
|
||||||
|
"diagnostics on failure."
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -688,7 +752,8 @@ class EditFileTool(_FsTool):
|
|||||||
async def execute(
|
async def execute(
|
||||||
self, path: str | None = None, old_text: str | None = None,
|
self, path: str | None = None, old_text: str | None = None,
|
||||||
new_text: str | None = None,
|
new_text: str | None = None,
|
||||||
replace_all: bool = False, **kwargs: Any,
|
replace_all: bool = False, occurrence: int | None = None,
|
||||||
|
line_hint: int | None = None, expected_replacements: int | None = None, **kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
try:
|
try:
|
||||||
if not path:
|
if not path:
|
||||||
@@ -697,10 +762,12 @@ class EditFileTool(_FsTool):
|
|||||||
raise ValueError("Unknown old_text")
|
raise ValueError("Unknown old_text")
|
||||||
if new_text is None:
|
if new_text is None:
|
||||||
raise ValueError("Unknown new_text")
|
raise ValueError("Unknown new_text")
|
||||||
|
if occurrence is not None and occurrence < 1:
|
||||||
# .ipynb detection
|
return "Error: occurrence must be >= 1."
|
||||||
if path.endswith(".ipynb"):
|
if line_hint is not None and line_hint < 1:
|
||||||
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
|
return "Error: line_hint must be >= 1."
|
||||||
|
if expected_replacements is not None and expected_replacements < 1:
|
||||||
|
return "Error: expected_replacements must be >= 1."
|
||||||
|
|
||||||
fp = self._resolve(path)
|
fp = self._resolve(path)
|
||||||
|
|
||||||
@@ -743,15 +810,42 @@ class EditFileTool(_FsTool):
|
|||||||
if not matches:
|
if not matches:
|
||||||
return self._not_found_msg(old_text, content, path)
|
return self._not_found_msg(old_text, content, path)
|
||||||
count = len(matches)
|
count = len(matches)
|
||||||
|
if replace_all and occurrence is not None:
|
||||||
|
return "Error: occurrence cannot be used with replace_all=true."
|
||||||
|
if replace_all and line_hint is not None:
|
||||||
|
return "Error: line_hint cannot be used with replace_all=true."
|
||||||
|
if occurrence is not None and line_hint is not None:
|
||||||
|
return "Error: line_hint cannot be used with occurrence."
|
||||||
if count > 1 and not replace_all:
|
if count > 1 and not replace_all:
|
||||||
line_numbers = [match.line for match in matches]
|
if occurrence is not None:
|
||||||
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
if occurrence > count:
|
||||||
if len(line_numbers) > 3:
|
return (
|
||||||
preview += ", ..."
|
f"Error: occurrence {occurrence} is out of range; "
|
||||||
location_hint = f" at {preview}" if preview else ""
|
f"old_text appears {count} times."
|
||||||
|
)
|
||||||
|
elif line_hint is not None:
|
||||||
|
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
|
||||||
|
distance = abs(nearest.line - line_hint)
|
||||||
|
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
|
||||||
|
return (
|
||||||
|
f"Error: line_hint {line_hint} is ambiguous; "
|
||||||
|
f"old_text appears {count} times."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
line_numbers = [match.line for match in matches]
|
||||||
|
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
||||||
|
if len(line_numbers) > 3:
|
||||||
|
preview += ", ..."
|
||||||
|
location_hint = f" at {preview}" if preview else ""
|
||||||
|
return (
|
||||||
|
f"Warning: old_text appears {count} times{location_hint}. "
|
||||||
|
"Provide more context, set occurrence to choose one match, "
|
||||||
|
"or set replace_all=true."
|
||||||
|
)
|
||||||
|
elif occurrence is not None and occurrence > count:
|
||||||
return (
|
return (
|
||||||
f"Warning: old_text appears {count} times{location_hint}. "
|
f"Error: occurrence {occurrence} is out of range; "
|
||||||
"Provide more context to make it unique, or set replace_all=true."
|
f"old_text appears {count} time."
|
||||||
)
|
)
|
||||||
|
|
||||||
norm_new = new_text.replace("\r\n", "\n")
|
norm_new = new_text.replace("\r\n", "\n")
|
||||||
@@ -760,7 +854,17 @@ class EditFileTool(_FsTool):
|
|||||||
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
|
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
|
||||||
norm_new = self._strip_trailing_ws(norm_new)
|
norm_new = self._strip_trailing_ws(norm_new)
|
||||||
|
|
||||||
selected = matches if replace_all else matches[:1]
|
if replace_all:
|
||||||
|
selected = matches
|
||||||
|
elif line_hint is not None:
|
||||||
|
selected = [min(matches, key=lambda match: abs(match.line - line_hint))]
|
||||||
|
else:
|
||||||
|
selected = [matches[occurrence - 1 if occurrence else 0]]
|
||||||
|
if expected_replacements is not None and len(selected) != expected_replacements:
|
||||||
|
return (
|
||||||
|
f"Error: expected {expected_replacements} replacements but "
|
||||||
|
f"would make {len(selected)}."
|
||||||
|
)
|
||||||
new_content = content
|
new_content = content
|
||||||
for match in reversed(selected):
|
for match in reversed(selected):
|
||||||
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
|
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ from nanobot.agent.tools.schema import (
|
|||||||
StringSchema,
|
StringSchema,
|
||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
|
from nanobot.security.workspace_access import current_tool_workspace
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
AIHubMixImageGenerationClient,
|
|
||||||
ImageGenerationError,
|
ImageGenerationError,
|
||||||
OpenRouterImageGenerationClient,
|
ImageGenerationProvider,
|
||||||
|
get_image_gen_provider,
|
||||||
)
|
)
|
||||||
|
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
|
||||||
from nanobot.utils.artifacts import (
|
from nanobot.utils.artifacts import (
|
||||||
ArtifactError,
|
ArtifactError,
|
||||||
generated_image_tool_result,
|
generated_image_tool_result,
|
||||||
@@ -117,41 +119,36 @@ class ImageGenerationTool(Tool):
|
|||||||
def _provider_config(self) -> ProviderConfig | None:
|
def _provider_config(self) -> ProviderConfig | None:
|
||||||
return self.provider_configs.get(self.config.provider)
|
return self.provider_configs.get(self.config.provider)
|
||||||
|
|
||||||
def _provider_client(self) -> OpenRouterImageGenerationClient | AIHubMixImageGenerationClient | None:
|
def _provider_client(self) -> ImageGenerationProvider | None:
|
||||||
provider = self._provider_config()
|
provider = self._provider_config()
|
||||||
|
cls = get_image_gen_provider(self.config.provider)
|
||||||
|
if cls is None:
|
||||||
|
return None
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"api_key": provider.api_key if provider else None,
|
"api_key": provider.api_key if provider else None,
|
||||||
"api_base": provider.api_base if provider else None,
|
"api_base": provider.api_base if provider else None,
|
||||||
"extra_headers": provider.extra_headers if provider else None,
|
"extra_headers": provider.extra_headers if provider else None,
|
||||||
"extra_body": provider.extra_body if provider else None,
|
"extra_body": provider.extra_body if provider else None,
|
||||||
}
|
}
|
||||||
if self.config.provider == "openrouter":
|
return cls(**kwargs)
|
||||||
return OpenRouterImageGenerationClient(**kwargs)
|
|
||||||
if self.config.provider == "aihubmix":
|
|
||||||
return AIHubMixImageGenerationClient(**kwargs)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _missing_api_key_error(self) -> str:
|
|
||||||
provider = self.config.provider
|
|
||||||
if provider == "openrouter":
|
|
||||||
return "Error: OpenRouter API key is not configured. Set providers.openrouter.apiKey."
|
|
||||||
if provider == "aihubmix":
|
|
||||||
return "Error: AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
|
|
||||||
return f"Error: {provider} API key is not configured."
|
|
||||||
|
|
||||||
def _resolve_reference_image(self, value: str) -> str:
|
def _resolve_reference_image(self, value: str) -> str:
|
||||||
raw_path = Path(value).expanduser()
|
access = current_tool_workspace(self.workspace, restrict_to_workspace=True)
|
||||||
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
|
workspace = access.project_path or self.workspace
|
||||||
try:
|
try:
|
||||||
resolved = path.resolve(strict=True)
|
resolved = resolve_allowed_path(
|
||||||
except OSError as exc:
|
value,
|
||||||
raise ImageGenerationError(f"reference image not found: {value}") from exc
|
workspace=workspace,
|
||||||
|
allowed_root=access.allowed_root,
|
||||||
allowed_roots = [self.workspace.resolve(), get_media_dir().resolve()]
|
extra_allowed_roots=[get_media_dir()] if access.allowed_root is not None else None,
|
||||||
if not any(_is_relative_to(resolved, root) for root in allowed_roots):
|
strict=True,
|
||||||
|
)
|
||||||
|
except WorkspaceBoundaryError as exc:
|
||||||
raise ImageGenerationError(
|
raise ImageGenerationError(
|
||||||
"reference_images must be inside the workspace or nanobot media directory"
|
"reference_images must be inside the workspace or nanobot media directory"
|
||||||
)
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise ImageGenerationError(f"reference image not found: {value}") from exc
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
raise ImageGenerationError(f"reference image is not a file: {value}")
|
raise ImageGenerationError(f"reference image is not a file: {value}")
|
||||||
raw = resolved.read_bytes()
|
raw = resolved.read_bytes()
|
||||||
@@ -176,9 +173,6 @@ class ImageGenerationTool(Tool):
|
|||||||
client = self._provider_client()
|
client = self._provider_client()
|
||||||
if client is None:
|
if client is None:
|
||||||
return f"Error: unsupported image generation provider '{self.config.provider}'"
|
return f"Error: unsupported image generation provider '{self.config.provider}'"
|
||||||
provider = self._provider_config()
|
|
||||||
if not provider or not provider.api_key:
|
|
||||||
return self._missing_api_key_error()
|
|
||||||
|
|
||||||
requested = count or 1
|
requested = count or 1
|
||||||
if requested > self.config.max_images_per_turn:
|
if requested > self.config.max_images_per_turn:
|
||||||
@@ -213,11 +207,3 @@ class ImageGenerationTool(Tool):
|
|||||||
return generated_image_tool_result(artifacts)
|
return generated_image_tool_result(artifacts)
|
||||||
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
||||||
return f"Error: {exc}"
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
def _is_relative_to(path: Path, root: Path) -> bool:
|
|
||||||
try:
|
|
||||||
path.relative_to(root)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui``
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -45,15 +46,22 @@ class _GoalToolsMixin(ContextAware):
|
|||||||
def __init__(self, sessions: SessionManager, bus: Any | None = None) -> None:
|
def __init__(self, sessions: SessionManager, bus: Any | None = None) -> None:
|
||||||
self._sessions = sessions
|
self._sessions = sessions
|
||||||
self._bus = bus
|
self._bus = bus
|
||||||
self._request_ctx: RequestContext | None = None
|
# Each subclass gets its own ContextVar so concurrent tasks across
|
||||||
|
# different tool types (LongTaskTool vs CompleteGoalTool) do not
|
||||||
|
# interfere with each other.
|
||||||
|
self._request_ctx: ContextVar[RequestContext | None] = ContextVar(
|
||||||
|
f"{self.__class__.__name__}_request_ctx",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
def set_context(self, ctx: RequestContext) -> None:
|
def set_context(self, ctx: RequestContext) -> None:
|
||||||
self._request_ctx = ctx
|
self._request_ctx.set(ctx)
|
||||||
|
|
||||||
def _session(self):
|
def _session(self):
|
||||||
if self._request_ctx is None:
|
request_ctx = self._request_ctx.get()
|
||||||
|
if request_ctx is None:
|
||||||
return None
|
return None
|
||||||
key = self._request_ctx.session_key
|
key = request_ctx.session_key
|
||||||
if not key:
|
if not key:
|
||||||
return None
|
return None
|
||||||
return self._sessions.get_or_create(key)
|
return self._sessions.get_or_create(key)
|
||||||
@@ -61,7 +69,7 @@ class _GoalToolsMixin(ContextAware):
|
|||||||
async def _publish_goal_state_ws(self, metadata: dict[str, Any]) -> None:
|
async def _publish_goal_state_ws(self, metadata: dict[str, Any]) -> None:
|
||||||
"""Fan-out authoritative goal snapshot for this WebSocket chat only."""
|
"""Fan-out authoritative goal snapshot for this WebSocket chat only."""
|
||||||
bus = self._bus
|
bus = self._bus
|
||||||
rc = self._request_ctx
|
rc = self._request_ctx.get()
|
||||||
if bus is None or rc is None or rc.channel != "websocket":
|
if bus is None or rc is None or rc.channel != "websocket":
|
||||||
return
|
return
|
||||||
cid = (rc.chat_id or "").strip()
|
cid = (rc.chat_id or "").strip()
|
||||||
@@ -224,4 +232,3 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
|
|||||||
if tail:
|
if tail:
|
||||||
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
||||||
return f"Goal marked complete ({ended})."
|
return f"Goal marked complete ({ended})."
|
||||||
|
|
||||||
|
|||||||
+279
-1
@@ -6,13 +6,20 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from contextlib import AsyncExitStack, suppress
|
from contextlib import AsyncExitStack, suppress
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.bus.events import (
|
||||||
|
INBOUND_META_RUNTIME_CONTROL,
|
||||||
|
RUNTIME_CONTROL_ACK,
|
||||||
|
RUNTIME_CONTROL_MCP_RELOAD,
|
||||||
|
InboundMessage,
|
||||||
|
)
|
||||||
|
|
||||||
# Transient connection errors that warrant a single retry.
|
# Transient connection errors that warrant a single retry.
|
||||||
# These typically happen when an MCP server restarts or a network
|
# These typically happen when an MCP server restarts or a network
|
||||||
@@ -33,6 +40,7 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
|
|||||||
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
||||||
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
||||||
_SANITIZE_RE = re.compile(r"_+")
|
_SANITIZE_RE = re.compile(r"_+")
|
||||||
|
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
def _sanitize_name(name: str) -> str:
|
||||||
@@ -503,6 +511,7 @@ async def connect_mcp_servers(
|
|||||||
command=command,
|
command=command,
|
||||||
args=args,
|
args=args,
|
||||||
env=env,
|
env=env,
|
||||||
|
cwd=cfg.cwd or None,
|
||||||
)
|
)
|
||||||
read, write = await server_stack.enter_async_context(stdio_client(params))
|
read, write = await server_stack.enter_async_context(stdio_client(params))
|
||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
@@ -662,3 +671,272 @@ async def connect_mcp_servers(
|
|||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
|
|
||||||
return server_stacks
|
return server_stacks
|
||||||
|
|
||||||
|
|
||||||
|
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Return persisted session kwargs for MCP preset attachments."""
|
||||||
|
mcp_presets = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
|
||||||
|
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_lines(
|
||||||
|
message: Any,
|
||||||
|
*,
|
||||||
|
available_server_names: set[str] | None = None,
|
||||||
|
configured_server_names: set[str] | None = None,
|
||||||
|
connected_server_names: set[str] | None = None,
|
||||||
|
skip: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return model-visible MCP preset annotations for the current turn."""
|
||||||
|
if skip:
|
||||||
|
return []
|
||||||
|
if configured_server_names is None:
|
||||||
|
configured_server_names = available_server_names
|
||||||
|
if connected_server_names is None:
|
||||||
|
connected_server_names = available_server_names
|
||||||
|
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||||
|
structured = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
|
||||||
|
if not isinstance(structured, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
for item in structured[:8]:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
continue
|
||||||
|
raw_name = str(item.get("name") or "").strip().lower()
|
||||||
|
if not raw_name:
|
||||||
|
continue
|
||||||
|
display = str(item.get("display_name") or raw_name).strip() or raw_name
|
||||||
|
transport = str(item.get("transport") or "mcp").strip() or "mcp"
|
||||||
|
prefix = f"mcp_{raw_name}_"
|
||||||
|
if configured_server_names is not None and raw_name not in configured_server_names:
|
||||||
|
lines.append(
|
||||||
|
"MCP Preset Attachment: "
|
||||||
|
f"@{raw_name} ({display}; transport={transport}) is configured in WebUI Settings, "
|
||||||
|
"but this gateway has not loaded the latest MCP settings yet. "
|
||||||
|
f"Tools with prefix `{prefix}` may not be available yet; if they are missing, "
|
||||||
|
"tell the user to restart nanobot."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if connected_server_names is not None and raw_name not in connected_server_names:
|
||||||
|
lines.append(
|
||||||
|
"MCP Preset Attachment: "
|
||||||
|
f"@{raw_name} ({display}; transport={transport}) is configured, "
|
||||||
|
"but its MCP connection is not currently live. "
|
||||||
|
f"Tools with prefix `{prefix}` may be unavailable; tell the user to open Settings, "
|
||||||
|
"run the preset test, and restart nanobot only if hot reload is unavailable."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
"MCP Preset Attachment: "
|
||||||
|
f"@{raw_name} ({display}; transport={transport}; tool_prefix={prefix}). "
|
||||||
|
f"Prefer available tools whose names start with `{prefix}` for this request; "
|
||||||
|
"do not substitute shell commands for this MCP integration unless the user asks."
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||||
|
"""Connect configured MCP servers that are not currently live."""
|
||||||
|
missing_servers = {
|
||||||
|
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
||||||
|
}
|
||||||
|
if state._mcp_connecting or not missing_servers:
|
||||||
|
return
|
||||||
|
state._mcp_connecting = True
|
||||||
|
try:
|
||||||
|
connected = await connect_mcp_servers(missing_servers, registry)
|
||||||
|
state._mcp_stacks.update(connected)
|
||||||
|
state._mcp_connected = bool(state._mcp_stacks)
|
||||||
|
if connected:
|
||||||
|
logger.info("MCP connected servers: {}", sorted(connected))
|
||||||
|
else:
|
||||||
|
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.warning("MCP connection cancelled (will retry next message)")
|
||||||
|
state._mcp_connected = bool(state._mcp_stacks)
|
||||||
|
except BaseException as e:
|
||||||
|
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
|
state._mcp_connected = bool(state._mcp_stacks)
|
||||||
|
finally:
|
||||||
|
state._mcp_connecting = False
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||||
|
"""Reconcile live MCP connections with the current config file."""
|
||||||
|
async with _reload_lock(state):
|
||||||
|
try:
|
||||||
|
from nanobot.config.loader import (load_config,
|
||||||
|
resolve_config_env_vars)
|
||||||
|
|
||||||
|
config = resolve_config_env_vars(load_config())
|
||||||
|
next_servers = dict(config.tools.mcp_servers)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
|
||||||
|
"requires_restart": True,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
current_servers = dict(state._mcp_servers)
|
||||||
|
current_names = set(current_servers)
|
||||||
|
next_names = set(next_servers)
|
||||||
|
removed = sorted(current_names - next_names)
|
||||||
|
added = sorted(next_names - current_names)
|
||||||
|
changed = sorted(
|
||||||
|
name
|
||||||
|
for name in current_names & next_names
|
||||||
|
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
|
||||||
|
)
|
||||||
|
|
||||||
|
tools_removed = 0
|
||||||
|
for name in [*removed, *changed]:
|
||||||
|
tools_removed += _unregister_server_tools(state, registry, name)
|
||||||
|
await _close_server(state, name)
|
||||||
|
|
||||||
|
state._mcp_servers = next_servers
|
||||||
|
retry_missing = sorted(
|
||||||
|
name
|
||||||
|
for name in next_names
|
||||||
|
if name not in state._mcp_stacks and name not in set(added) | set(changed)
|
||||||
|
)
|
||||||
|
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
||||||
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
|
connected: dict[str, AsyncExitStack] = {}
|
||||||
|
if to_connect:
|
||||||
|
connected = await connect_mcp_servers(to_connect, registry)
|
||||||
|
state._mcp_stacks.update(connected)
|
||||||
|
|
||||||
|
state._mcp_connected = bool(state._mcp_stacks)
|
||||||
|
failed = sorted(set(to_connect) - set(connected))
|
||||||
|
unchanged = not removed and not added and not changed and not retry_missing
|
||||||
|
ok = not failed
|
||||||
|
if failed:
|
||||||
|
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
|
||||||
|
elif unchanged:
|
||||||
|
message = "MCP config is already live."
|
||||||
|
elif retry_missing and not added and not changed and not removed:
|
||||||
|
message = "MCP connections refreshed without restarting nanobot."
|
||||||
|
else:
|
||||||
|
message = "MCP config reloaded without restarting nanobot."
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
|
||||||
|
added,
|
||||||
|
changed,
|
||||||
|
removed,
|
||||||
|
retry_missing,
|
||||||
|
sorted(connected),
|
||||||
|
failed,
|
||||||
|
tools_removed,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"message": message,
|
||||||
|
"added": added,
|
||||||
|
"changed": changed,
|
||||||
|
"removed": removed,
|
||||||
|
"retried": retry_missing,
|
||||||
|
"connected": sorted(state._mcp_stacks),
|
||||||
|
"configured": sorted(state._mcp_servers),
|
||||||
|
"failed": failed,
|
||||||
|
"tools_removed": tools_removed,
|
||||||
|
"requires_restart": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
|
||||||
|
"""Ask the running agent loop to reconcile live MCP connections."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||||
|
await bus.publish_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel="system",
|
||||||
|
sender_id="webui-settings",
|
||||||
|
chat_id="runtime",
|
||||||
|
content=RUNTIME_CONTROL_MCP_RELOAD,
|
||||||
|
metadata={
|
||||||
|
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
|
||||||
|
RUNTIME_CONTROL_ACK: ack,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(ack, timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
return result if isinstance(result, dict) else {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP hot reload returned an unexpected response.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
|
||||||
|
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
|
||||||
|
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
|
||||||
|
if control != RUNTIME_CONTROL_MCP_RELOAD:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ack = metadata.get(RUNTIME_CONTROL_ACK)
|
||||||
|
try:
|
||||||
|
result = await reload_servers(state, registry)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("MCP hot reload failed")
|
||||||
|
result = {
|
||||||
|
"ok": False,
|
||||||
|
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
|
||||||
|
"requires_restart": True,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
if isinstance(ack, asyncio.Future) and not ack.done():
|
||||||
|
ack.set_result(result)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_lock(state: Any) -> asyncio.Lock:
|
||||||
|
try:
|
||||||
|
return _RELOAD_LOCKS[state]
|
||||||
|
except KeyError:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_RELOAD_LOCKS[state] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _server_signature(cfg: Any) -> Any:
|
||||||
|
if hasattr(cfg, "model_dump"):
|
||||||
|
return cfg.model_dump(mode="json")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_prefix(server_name: str) -> str:
|
||||||
|
safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in server_name)
|
||||||
|
while "__" in safe_name:
|
||||||
|
safe_name = safe_name.replace("__", "_")
|
||||||
|
return f"mcp_{safe_name}_"
|
||||||
|
|
||||||
|
|
||||||
|
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
|
||||||
|
prefix = _tool_prefix(server_name)
|
||||||
|
removed = 0
|
||||||
|
for tool_name in list(registry.tool_names):
|
||||||
|
if tool_name.startswith(prefix):
|
||||||
|
registry.unregister(tool_name)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_server(state: Any, server_name: str) -> None:
|
||||||
|
stack = state._mcp_stacks.pop(server_name, None)
|
||||||
|
if stack is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await stack.aclose()
|
||||||
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ from contextvars import ContextVar
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||||
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
||||||
|
from nanobot.security.workspace_access import current_tool_workspace
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.config.paths import get_workspace_path
|
from nanobot.config.paths import get_workspace_path
|
||||||
|
|
||||||
@@ -31,8 +34,8 @@ from nanobot.config.paths import get_workspace_path
|
|||||||
media=ArraySchema(
|
media=ArraySchema(
|
||||||
StringSchema(""),
|
StringSchema(""),
|
||||||
description=(
|
description=(
|
||||||
"Optional list of existing file paths to attach for proactive or cross-channel delivery. "
|
"Optional list of existing file paths to attach. "
|
||||||
"Do not use this to resend generate_image outputs in the current chat."
|
"Use artifact paths returned by generate_image here when delivering generated images."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
buttons=ArraySchema(
|
buttons=ArraySchema(
|
||||||
@@ -82,6 +85,10 @@ class MessageTool(Tool, ContextAware):
|
|||||||
"message_record_channel_delivery",
|
"message_record_channel_delivery",
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
|
||||||
|
"message_suppress_delivery",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
@@ -120,6 +127,14 @@ class MessageTool(Tool, ContextAware):
|
|||||||
"""Restore previous proactive delivery recording state."""
|
"""Restore previous proactive delivery recording state."""
|
||||||
self._record_channel_delivery_var.reset(token)
|
self._record_channel_delivery_var.reset(token)
|
||||||
|
|
||||||
|
def set_suppress_delivery(self, active: bool):
|
||||||
|
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
|
||||||
|
return self._suppress_delivery_var.set(active)
|
||||||
|
|
||||||
|
def reset_suppress_delivery(self, token) -> None:
|
||||||
|
"""Restore previous delivery-suppression state."""
|
||||||
|
self._suppress_delivery_var.reset(token)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _sent_in_turn(self) -> bool:
|
def _sent_in_turn(self) -> bool:
|
||||||
return self._sent_in_turn_var.get()
|
return self._sent_in_turn_var.get()
|
||||||
@@ -140,8 +155,8 @@ class MessageTool(Tool, ContextAware):
|
|||||||
"Do not use this for the normal reply in the current chat: answer naturally instead. "
|
"Do not use this for the normal reply in the current chat: answer naturally instead. "
|
||||||
"If channel/chat_id would target the current runtime conversation, do not call this tool "
|
"If channel/chat_id would target the current runtime conversation, do not call this tool "
|
||||||
"unless the user explicitly asked you to proactively send an existing file attachment. "
|
"unless the user explicitly asked you to proactively send an existing file attachment. "
|
||||||
"When generate_image creates images in the current chat, the final assistant reply "
|
"When generate_image creates images in the current chat, use the message tool "
|
||||||
"automatically attaches them; do not call message just to announce or resend them. "
|
"with the artifact paths in the media parameter to deliver the images to the user. "
|
||||||
"For proactive attachment delivery, use the 'media' parameter with file paths. "
|
"For proactive attachment delivery, use the 'media' parameter with file paths. "
|
||||||
"Do NOT use read_file to send files — that only reads content for your own analysis."
|
"Do NOT use read_file to send files — that only reads content for your own analysis."
|
||||||
)
|
)
|
||||||
@@ -149,15 +164,19 @@ class MessageTool(Tool, ContextAware):
|
|||||||
def _resolve_media(self, media: list[str]) -> list[str]:
|
def _resolve_media(self, media: list[str]) -> list[str]:
|
||||||
"""Resolve local media attachments and enforce workspace restriction when enabled."""
|
"""Resolve local media attachments and enforce workspace restriction when enabled."""
|
||||||
resolved: list[str] = []
|
resolved: list[str] = []
|
||||||
allowed_dir = self._workspace if self._restrict_to_workspace else None
|
access = current_tool_workspace(
|
||||||
|
self._workspace,
|
||||||
|
restrict_to_workspace=self._restrict_to_workspace,
|
||||||
|
)
|
||||||
|
workspace = access.project_path or self._workspace
|
||||||
for p in media:
|
for p in media:
|
||||||
if p.startswith(("http://", "https://")):
|
if p.startswith(("http://", "https://")):
|
||||||
resolved.append(p)
|
resolved.append(p)
|
||||||
elif not self._restrict_to_workspace:
|
elif not access.restrict_to_workspace:
|
||||||
path = Path(p).expanduser()
|
path = Path(p).expanduser()
|
||||||
resolved.append(p if path.is_absolute() else str(self._workspace / path))
|
resolved.append(p if path.is_absolute() else str(workspace / path))
|
||||||
else:
|
else:
|
||||||
resolved.append(str(resolve_workspace_path(p, self._workspace, allowed_dir)))
|
resolved.append(str(resolve_workspace_path(p, workspace, access.allowed_root)))
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -236,6 +255,10 @@ class MessageTool(Tool, ContextAware):
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self._suppress_delivery_var.get():
|
||||||
|
logger.debug("MessageTool: delivery suppressed during internal check")
|
||||||
|
return f"Message acknowledged for {channel}:{chat_id} (not delivered)"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._send_callback(msg)
|
await self._send_callback(msg)
|
||||||
if channel == default_channel and chat_id == default_chat_id:
|
if channel == default_channel and chat_id == default_chat_id:
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
"""NotebookEditTool — edit Jupyter .ipynb notebooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import tool_parameters
|
|
||||||
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
|
|
||||||
from nanobot.agent.tools.filesystem import _FsTool
|
|
||||||
|
|
||||||
|
|
||||||
def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
|
|
||||||
cell: dict[str, Any] = {
|
|
||||||
"cell_type": cell_type,
|
|
||||||
"source": source,
|
|
||||||
"metadata": {},
|
|
||||||
}
|
|
||||||
if cell_type == "code":
|
|
||||||
cell["outputs"] = []
|
|
||||||
cell["execution_count"] = None
|
|
||||||
if generate_id:
|
|
||||||
cell["id"] = uuid.uuid4().hex[:8]
|
|
||||||
return cell
|
|
||||||
|
|
||||||
|
|
||||||
def _make_empty_notebook() -> dict:
|
|
||||||
return {
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5,
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
|
||||||
"language_info": {"name": "python"},
|
|
||||||
},
|
|
||||||
"cells": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
|
||||||
tool_parameters_schema(
|
|
||||||
path=StringSchema("Path to the .ipynb notebook file"),
|
|
||||||
cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
|
|
||||||
new_source=StringSchema("New source content for the cell"),
|
|
||||||
cell_type=StringSchema(
|
|
||||||
"Cell type: 'code' or 'markdown' (default: code)",
|
|
||||||
enum=["code", "markdown"],
|
|
||||||
),
|
|
||||||
edit_mode=StringSchema(
|
|
||||||
"Mode: 'replace' (default), 'insert' (after target), or 'delete'",
|
|
||||||
enum=["replace", "insert", "delete"],
|
|
||||||
),
|
|
||||||
required=["path", "cell_index"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
class NotebookEditTool(_FsTool):
|
|
||||||
"""Edit Jupyter notebook cells: replace, insert, or delete."""
|
|
||||||
_scopes = {"core"}
|
|
||||||
|
|
||||||
_VALID_CELL_TYPES = frozenset({"code", "markdown"})
|
|
||||||
_VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "notebook_edit"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Edit a Jupyter notebook (.ipynb) cell. "
|
|
||||||
"Modes: replace (default) replaces cell content, "
|
|
||||||
"insert adds a new cell after the target index, "
|
|
||||||
"delete removes the cell at the index. "
|
|
||||||
"cell_index is 0-based."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
path: str | None = None,
|
|
||||||
cell_index: int = 0,
|
|
||||||
new_source: str = "",
|
|
||||||
cell_type: str = "code",
|
|
||||||
edit_mode: str = "replace",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
if not path:
|
|
||||||
return "Error: path is required"
|
|
||||||
|
|
||||||
if not path.endswith(".ipynb"):
|
|
||||||
return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
|
|
||||||
|
|
||||||
if edit_mode not in self._VALID_EDIT_MODES:
|
|
||||||
return (
|
|
||||||
f"Error: Invalid edit_mode '{edit_mode}'. "
|
|
||||||
"Use one of: replace, insert, delete."
|
|
||||||
)
|
|
||||||
|
|
||||||
if cell_type not in self._VALID_CELL_TYPES:
|
|
||||||
return (
|
|
||||||
f"Error: Invalid cell_type '{cell_type}'. "
|
|
||||||
"Use one of: code, markdown."
|
|
||||||
)
|
|
||||||
|
|
||||||
fp = self._resolve(path)
|
|
||||||
|
|
||||||
# Create new notebook if file doesn't exist and mode is insert
|
|
||||||
if not fp.exists():
|
|
||||||
if edit_mode != "insert":
|
|
||||||
return f"Error: File not found: {path}"
|
|
||||||
nb = _make_empty_notebook()
|
|
||||||
cell = _new_cell(new_source, cell_type, generate_id=True)
|
|
||||||
nb["cells"].append(cell)
|
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully created {fp} with 1 cell"
|
|
||||||
|
|
||||||
try:
|
|
||||||
nb = json.loads(fp.read_text(encoding="utf-8"))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
||||||
return f"Error: Failed to parse notebook: {e}"
|
|
||||||
|
|
||||||
cells = nb.get("cells", [])
|
|
||||||
nbformat_minor = nb.get("nbformat_minor", 0)
|
|
||||||
generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
|
|
||||||
|
|
||||||
if edit_mode == "delete":
|
|
||||||
if cell_index < 0 or cell_index >= len(cells):
|
|
||||||
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
|
|
||||||
cells.pop(cell_index)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully deleted cell {cell_index} from {fp}"
|
|
||||||
|
|
||||||
if edit_mode == "insert":
|
|
||||||
insert_at = min(cell_index + 1, len(cells))
|
|
||||||
cell = _new_cell(new_source, cell_type, generate_id=generate_id)
|
|
||||||
cells.insert(insert_at, cell)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully inserted cell at index {insert_at} in {fp}"
|
|
||||||
|
|
||||||
# Default: replace
|
|
||||||
if cell_index < 0 or cell_index >= len(cells):
|
|
||||||
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
|
|
||||||
cells[cell_index]["source"] = new_source
|
|
||||||
if cell_type and cells[cell_index].get("cell_type") != cell_type:
|
|
||||||
cells[cell_index]["cell_type"] = cell_type
|
|
||||||
if cell_type == "code":
|
|
||||||
cells[cell_index].setdefault("outputs", [])
|
|
||||||
cells[cell_index].setdefault("execution_count", None)
|
|
||||||
elif "outputs" in cells[cell_index]:
|
|
||||||
del cells[cell_index]["outputs"]
|
|
||||||
cells[cell_index].pop("execution_count", None)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully edited cell {cell_index} in {fp}"
|
|
||||||
|
|
||||||
except PermissionError as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error editing notebook: {e}"
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
"""P2P tools for inter-agent task dispatch and coordination."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Awaitable, Callable
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchTaskTool(Tool):
|
|
||||||
"""Asynchronously dispatch a task to another agent. Non-blocking."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "dispatch_task"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Dispatch a task to a specific target agent. Returns immediately with a receipt. "
|
|
||||||
"The target agent will process the task independently. Use poll_task_result later to check completion. "
|
|
||||||
"Do NOT block waiting for results."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"to": {"type": "string", "description": "Target agent ID"},
|
|
||||||
"task_description": {"type": "string", "description": "Clear description of the task"},
|
|
||||||
"parent_task_id": {"type": "string", "description": "Parent task ID for ancestry tracking"},
|
|
||||||
"deadline_seconds": {"type": "integer", "default": 300, "description": "Task deadline in seconds"},
|
|
||||||
"allow_redelegation": {"type": "boolean", "default": True, "description": "Whether the target may re-delegate"},
|
|
||||||
},
|
|
||||||
"required": ["to", "task_description"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
to: str,
|
|
||||||
task_description: str,
|
|
||||||
parent_task_id: str | None = None,
|
|
||||||
deadline_seconds: int = 300,
|
|
||||||
allow_redelegation: bool = True,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
result = self._shell.dispatch(
|
|
||||||
to=to,
|
|
||||||
parent_task_id=parent_task_id,
|
|
||||||
description=task_description,
|
|
||||||
deadline_seconds=deadline_seconds,
|
|
||||||
allow_redelegation=allow_redelegation,
|
|
||||||
)
|
|
||||||
if result.get("status") == "rejected":
|
|
||||||
return f"Error: dispatch rejected — {result.get('reason', 'unknown')}"
|
|
||||||
if result.get("status") == "circuit_open":
|
|
||||||
failover = result.get("failover_to")
|
|
||||||
return f"Error: circuit open for {to}. Failover candidate: {failover or 'none'}"
|
|
||||||
return (
|
|
||||||
f"Dispatched to {to}. Task ID: {result.get('task_id')}. "
|
|
||||||
f"Depth: {result.get('depth', 0)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PollTaskResultTool(Tool):
|
|
||||||
"""Poll the status of a previously dispatched task."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "poll_task_result"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Check the current status of a task you previously dispatched. "
|
|
||||||
"Returns completed, pending, timeout, failed, or not_found. "
|
|
||||||
"Call this proactively — do not wait for automatic notifications."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string", "description": "Task ID returned by dispatch_task"},
|
|
||||||
},
|
|
||||||
"required": ["task_id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(self, task_id: str, **kwargs: Any) -> str:
|
|
||||||
result = self._shell.poll(task_id)
|
|
||||||
status = result.get("status")
|
|
||||||
if status == "not_found":
|
|
||||||
return f"Task {task_id} not found."
|
|
||||||
if status == "pending":
|
|
||||||
return f"Task {task_id} is pending (elapsed {result.get('elapsed', '?')}s)."
|
|
||||||
if status == "timeout":
|
|
||||||
return f"Task {task_id} timed out after {result.get('elapsed', '?')}s."
|
|
||||||
if status in ("completed", "failed", "aborted"):
|
|
||||||
from_agent = result.get("from", "unknown")
|
|
||||||
content = result.get("result", "")
|
|
||||||
preview = content[:500] + "..." if len(content) > 500 else content
|
|
||||||
return f"Task {task_id} is {status} (from {from_agent}).\n\n{preview}"
|
|
||||||
return f"Task {task_id} status: {status}"
|
|
||||||
|
|
||||||
|
|
||||||
class BroadcastTaskTool(Tool):
|
|
||||||
"""Broadcast subtasks to discover capable agents."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "broadcast_task"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Announce subtasks to the agent network to collect BIDs. "
|
|
||||||
"Returns immediately. Use check_aggregation later to see which agents responded. "
|
|
||||||
"Each subtask should include a capability hint for matching."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string", "description": "Your task identifier"},
|
|
||||||
"subtasks": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"subtask_id": {"type": "string"},
|
|
||||||
"description": {"type": "string"},
|
|
||||||
"capability": {"type": "string", "description": "Required capability, e.g. 'web_search'"},
|
|
||||||
"budget_seconds": {"type": "integer", "default": 300},
|
|
||||||
},
|
|
||||||
"required": ["subtask_id", "description", "capability"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"aggregation_timeout": {"type": "integer", "default": 30, "description": "Seconds to wait for BIDs"},
|
|
||||||
},
|
|
||||||
"required": ["task_id", "subtasks"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
subtasks: list[dict[str, Any]],
|
|
||||||
aggregation_timeout: int = 30,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
result = self._shell.broadcast(task_id, subtasks, aggregation_timeout)
|
|
||||||
invited = result.get("invited", 0)
|
|
||||||
return f"Broadcast opened for {task_id}. Invited {invited} agent(s). Use check_aggregation to collect BIDs."
|
|
||||||
|
|
||||||
|
|
||||||
class CheckAggregationTool(Tool):
|
|
||||||
"""Check the status of a broadcast aggregation window."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell"):
|
|
||||||
self._shell = shell
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "check_aggregation"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Check whether a previously broadcast task has collected enough BIDs or timed out. "
|
|
||||||
"Returns the list of responding agents and their bids, or a pending status with counts."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string", "description": "Task ID used in broadcast_task"},
|
|
||||||
},
|
|
||||||
"required": ["task_id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(self, task_id: str, **kwargs: Any) -> str:
|
|
||||||
result = self._shell.check_aggregation(task_id)
|
|
||||||
status = result.get("status")
|
|
||||||
if status == "no_window":
|
|
||||||
return f"No broadcast window found for {task_id}."
|
|
||||||
if status == "pending":
|
|
||||||
received = result.get("received", 0)
|
|
||||||
expected = result.get("expected", "?")
|
|
||||||
remaining = result.get("seconds_remaining", 0)
|
|
||||||
return (
|
|
||||||
f"Aggregation pending for {task_id}: "
|
|
||||||
f"{received}/{expected} received, {remaining}s remaining."
|
|
||||||
)
|
|
||||||
if status == "closed":
|
|
||||||
entries = result.get("entries", [])
|
|
||||||
lines = [f"Aggregation closed for {task_id} ({result.get('reason', '')}):", ""]
|
|
||||||
for e in entries:
|
|
||||||
agent = e.get("from", "unknown")
|
|
||||||
sub = e.get("subtask_id", "")
|
|
||||||
lines.append(f"- {agent} bid for {sub}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
return f"Unknown aggregation status for {task_id}: {status}"
|
|
||||||
|
|
||||||
|
|
||||||
class ReportUserTool(Tool):
|
|
||||||
"""Deliver a final answer to the user."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
|
||||||
default_channel: str = "",
|
|
||||||
default_chat_id: str = "",
|
|
||||||
):
|
|
||||||
self._send_callback = send_callback
|
|
||||||
self._default_channel = default_channel
|
|
||||||
self._default_chat_id = default_chat_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "report_user"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Report the final answer to the user. Use this when you have gathered enough results. "
|
|
||||||
"Status 'partial' means some subtasks are incomplete — list them in pending_items."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"final_answer": {"type": "string", "description": "Complete answer for the user"},
|
|
||||||
"status": {"type": "string", "enum": ["success", "partial", "failed"]},
|
|
||||||
"pending_items": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"type": "string"},
|
|
||||||
"description": "Incomplete items when status is partial",
|
|
||||||
},
|
|
||||||
"task_summary": {"type": "string", "description": "Optional brief summary"},
|
|
||||||
},
|
|
||||||
"required": ["final_answer", "status"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
final_answer: str,
|
|
||||||
status: str,
|
|
||||||
pending_items: list[str] | None = None,
|
|
||||||
task_summary: str = "",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
if not self._send_callback:
|
|
||||||
return "Error: report_user not configured (no send callback)"
|
|
||||||
|
|
||||||
parts = [final_answer]
|
|
||||||
if pending_items:
|
|
||||||
parts.append(f"\n\nPending items:\n" + "\n".join(f"- {i}" for i in pending_items))
|
|
||||||
if task_summary:
|
|
||||||
parts.append(f"\n\nSummary: {task_summary}")
|
|
||||||
|
|
||||||
content = "\n".join(parts)
|
|
||||||
msg = OutboundMessage(
|
|
||||||
channel=self._default_channel,
|
|
||||||
chat_id=self._default_chat_id,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
await self._send_callback(msg)
|
|
||||||
return f"Reported to user (status={status})."
|
|
||||||
|
|
||||||
|
|
||||||
class FinalizeTaskTool(Tool):
|
|
||||||
"""Force-finalize a task and close its sessions."""
|
|
||||||
|
|
||||||
def __init__(self, shell: "P2PShell", session_manager: "SessionManager | None" = None):
|
|
||||||
self._shell = shell
|
|
||||||
self._session_manager = session_manager
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "finalize_task"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Terminate a task and all its subtasks. Use when the user says 'stop', "
|
|
||||||
"or when a task is fundamentally blocked. outcome can be completed, failed, or aborted."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {"type": "string"},
|
|
||||||
"outcome": {"type": "string", "enum": ["completed", "failed", "aborted"]},
|
|
||||||
"reason": {"type": "string", "description": "Why the task was finalized"},
|
|
||||||
},
|
|
||||||
"required": ["task_id", "outcome"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
outcome: str,
|
|
||||||
reason: str = "",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
self._shell.finalize(task_id, outcome, reason)
|
|
||||||
if self._session_manager:
|
|
||||||
self._session_manager.finalize_task_session(task_id)
|
|
||||||
return f"Task {task_id} finalized with outcome={outcome}."
|
|
||||||
@@ -3,21 +3,15 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
|
from nanobot.security.workspace_policy import (
|
||||||
WORKSPACE_BOUNDARY_NOTE = (
|
is_path_within,
|
||||||
" (this is a hard policy boundary, not a transient failure; "
|
resolve_allowed_path,
|
||||||
"do not retry with shell tricks or alternative tools, and ask "
|
|
||||||
"the user how to proceed if the resource is genuinely required)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_under(path: Path, directory: Path) -> bool:
|
def is_under(path: Path, directory: Path) -> bool:
|
||||||
"""Return True when path resolves under directory."""
|
"""Return True when path resolves under directory."""
|
||||||
try:
|
return is_path_within(path, directory)
|
||||||
path.relative_to(directory.resolve())
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_workspace_path(
|
def resolve_workspace_path(
|
||||||
@@ -27,16 +21,10 @@ def resolve_workspace_path(
|
|||||||
extra_allowed_dirs: list[Path] | None = None,
|
extra_allowed_dirs: list[Path] | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Resolve path against workspace and enforce allowed directory containment."""
|
"""Resolve path against workspace and enforce allowed directory containment."""
|
||||||
p = Path(path).expanduser()
|
extra_roots = [get_media_dir(), *(extra_allowed_dirs or [])] if allowed_dir else None
|
||||||
if not p.is_absolute() and workspace:
|
return resolve_allowed_path(
|
||||||
p = workspace / p
|
path,
|
||||||
resolved = p.resolve()
|
workspace=workspace,
|
||||||
if allowed_dir:
|
allowed_root=allowed_dir,
|
||||||
media_path = get_media_dir().resolve()
|
extra_allowed_roots=extra_roots,
|
||||||
all_dirs = [allowed_dir, media_path, *(extra_allowed_dirs or [])]
|
)
|
||||||
if not any(is_under(resolved, d) for d in all_dirs):
|
|
||||||
raise PermissionError(
|
|
||||||
f"Path {path} is outside allowed directory {allowed_dir}"
|
|
||||||
+ WORKSPACE_BOUNDARY_NOTE
|
|
||||||
)
|
|
||||||
return resolved
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class RuntimeState(Protocol):
|
|||||||
@property
|
@property
|
||||||
def exec_config(self) -> Any: ...
|
def exec_config(self) -> Any: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workspace_sandbox(self) -> Any: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subagents(self) -> Any: ...
|
def subagents(self) -> Any: ...
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Search tools: grep."""
|
"""Search tools: file discovery and grep."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from typing import Any, Iterable, TypeVar
|
|||||||
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
||||||
|
|
||||||
_DEFAULT_HEAD_LIMIT = 250
|
_DEFAULT_HEAD_LIMIT = 250
|
||||||
|
_DEFAULT_FILE_HEAD_LIMIT = 200
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
_TYPE_GLOB_MAP = {
|
_TYPE_GLOB_MAP = {
|
||||||
"py": ("*.py", "*.pyi"),
|
"py": ("*.py", "*.pyi"),
|
||||||
@@ -88,13 +89,22 @@ def _matches_type(name: str, file_type: str | None) -> bool:
|
|||||||
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
|
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_query(rel_path: str, query: str | None) -> bool:
|
||||||
|
if not query:
|
||||||
|
return True
|
||||||
|
haystack = rel_path.lower()
|
||||||
|
terms = [part for part in query.lower().split() if part]
|
||||||
|
return all(term in haystack for term in terms)
|
||||||
|
|
||||||
|
|
||||||
class _SearchTool(_FsTool):
|
class _SearchTool(_FsTool):
|
||||||
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
|
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
|
||||||
|
|
||||||
def _display_path(self, target: Path, root: Path) -> str:
|
def _display_path(self, target: Path, root: Path) -> str:
|
||||||
if self._workspace:
|
workspace = self._display_workspace()
|
||||||
|
if workspace:
|
||||||
with suppress(ValueError):
|
with suppress(ValueError):
|
||||||
return target.relative_to(self._workspace).as_posix()
|
return target.relative_to(workspace).as_posix()
|
||||||
return target.relative_to(root).as_posix()
|
return target.relative_to(root).as_posix()
|
||||||
|
|
||||||
def _iter_files(self, root: Path) -> Iterable[Path]:
|
def _iter_files(self, root: Path) -> Iterable[Path]:
|
||||||
@@ -109,6 +119,163 @@ class _SearchTool(_FsTool):
|
|||||||
yield current / filename
|
yield current / filename
|
||||||
|
|
||||||
|
|
||||||
|
class FindFilesTool(_SearchTool):
|
||||||
|
"""Find files by path fragment, glob, or type."""
|
||||||
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "find_files"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Find files by path fragment, glob, or file type. "
|
||||||
|
"Use this before read_file when you need to locate files, and "
|
||||||
|
"prefer it over shell find/ls for ordinary workspace discovery. "
|
||||||
|
"Returns workspace-relative paths and skips common dependency/build "
|
||||||
|
"directories."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory or file to search in (default '.')",
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Optional case-insensitive path fragment search. "
|
||||||
|
"Whitespace-separated terms must all be present."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"glob": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
|
||||||
|
},
|
||||||
|
"include_dirs": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Include matching directories as well as files (default false)",
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["path", "modified"],
|
||||||
|
"description": "Sort by path or most recently modified first (default path)",
|
||||||
|
},
|
||||||
|
"head_limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1000,
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Skip the first N results before applying head_limit",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _iter_paths(self, root: Path, *, include_dirs: bool) -> Iterable[Path]:
|
||||||
|
if root.is_file():
|
||||||
|
yield root
|
||||||
|
return
|
||||||
|
if include_dirs:
|
||||||
|
yield root
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
|
||||||
|
current = Path(dirpath)
|
||||||
|
if include_dirs and current != root:
|
||||||
|
yield current
|
||||||
|
for filename in sorted(filenames):
|
||||||
|
yield current / filename
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
path: str = ".",
|
||||||
|
query: str | None = None,
|
||||||
|
glob: str | None = None,
|
||||||
|
type: str | None = None,
|
||||||
|
include_dirs: bool = False,
|
||||||
|
sort: str = "path",
|
||||||
|
head_limit: int | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
target = self._resolve(path or ".")
|
||||||
|
if not target.exists():
|
||||||
|
return f"Error: Path not found: {path}"
|
||||||
|
if not (target.is_dir() or target.is_file()):
|
||||||
|
return f"Error: Unsupported path: {path}"
|
||||||
|
|
||||||
|
if sort not in {"path", "modified"}:
|
||||||
|
return "Error: sort must be 'path' or 'modified'"
|
||||||
|
|
||||||
|
limit = (
|
||||||
|
_DEFAULT_FILE_HEAD_LIMIT
|
||||||
|
if head_limit is None
|
||||||
|
else None if head_limit == 0 else head_limit
|
||||||
|
)
|
||||||
|
root = target if target.is_dir() else target.parent
|
||||||
|
matches: list[tuple[str, float]] = []
|
||||||
|
|
||||||
|
for candidate in self._iter_paths(target, include_dirs=include_dirs):
|
||||||
|
if candidate.is_dir() and not include_dirs:
|
||||||
|
continue
|
||||||
|
rel_path = candidate.relative_to(root).as_posix()
|
||||||
|
display_path = self._display_path(candidate, root)
|
||||||
|
name = candidate.name
|
||||||
|
|
||||||
|
if glob and not _match_glob(rel_path, name, glob):
|
||||||
|
continue
|
||||||
|
if candidate.is_file() and not _matches_type(name, type):
|
||||||
|
continue
|
||||||
|
if candidate.is_dir() and type:
|
||||||
|
continue
|
||||||
|
if not _matches_query(display_path, query):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mtime = candidate.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
mtime = 0.0
|
||||||
|
suffix = "/" if candidate.is_dir() else ""
|
||||||
|
matches.append((display_path + suffix, mtime))
|
||||||
|
|
||||||
|
if sort == "modified":
|
||||||
|
matches.sort(key=lambda item: (-item[1], item[0]))
|
||||||
|
else:
|
||||||
|
matches.sort(key=lambda item: item[0])
|
||||||
|
|
||||||
|
paths = [item[0] for item in matches]
|
||||||
|
paged, truncated = _paginate(paths, limit, offset)
|
||||||
|
if not paged:
|
||||||
|
return "No files found"
|
||||||
|
|
||||||
|
result = "\n".join(paged)
|
||||||
|
note = _pagination_note(limit, offset, truncated)
|
||||||
|
if note:
|
||||||
|
result += "\n\n" + note
|
||||||
|
return result
|
||||||
|
except PermissionError as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error finding files: {e}"
|
||||||
|
|
||||||
|
|
||||||
class GrepTool(_SearchTool):
|
class GrepTool(_SearchTool):
|
||||||
"""Search file contents using a regex-like pattern."""
|
"""Search file contents using a regex-like pattern."""
|
||||||
_scopes = {"core", "subagent"}
|
_scopes = {"core", "subagent"}
|
||||||
@@ -125,7 +292,8 @@ class GrepTool(_SearchTool):
|
|||||||
return (
|
return (
|
||||||
"Search file contents with a regex pattern. "
|
"Search file contents with a regex pattern. "
|
||||||
"Default output_mode is files_with_matches (file paths only); "
|
"Default output_mode is files_with_matches (file paths only); "
|
||||||
"use content mode for matching lines with context. "
|
"use content mode for matching lines with context. Prefer this "
|
||||||
|
"over shell grep for ordinary workspace searches. "
|
||||||
"Skips binary and files >2 MB. Supports glob/type filtering."
|
"Skips binary and files >2 MB. Supports glob/type filtering."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
|
||||||
|
|
||||||
class MyToolConfig(Base):
|
class MyToolConfig(Base):
|
||||||
"""Self-inspection tool configuration."""
|
"""Self-inspection tool configuration."""
|
||||||
@@ -33,6 +35,12 @@ def _has_real_attr(obj: Any, key: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subagent_status(value: Any) -> bool:
|
||||||
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
|
||||||
|
return isinstance(value, SubagentStatus)
|
||||||
|
|
||||||
|
|
||||||
class MyTool(Tool, ContextAware):
|
class MyTool(Tool, ContextAware):
|
||||||
"""Check and set the agent loop's runtime configuration."""
|
"""Check and set the agent loop's runtime configuration."""
|
||||||
|
|
||||||
@@ -68,6 +76,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
"_current_iteration", # updated by runner only
|
"_current_iteration", # updated by runner only
|
||||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||||
|
"workspace_sandbox", # read-only view of workspace enforcement level
|
||||||
})
|
})
|
||||||
|
|
||||||
_DENIED_ATTRS = frozenset({
|
_DENIED_ATTRS = frozenset({
|
||||||
@@ -214,7 +223,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_status(st: SubagentStatus, indent: str = " ") -> str:
|
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
||||||
elapsed = time.monotonic() - st.started_at
|
elapsed = time.monotonic() - st.started_at
|
||||||
tool_summary = ", ".join(
|
tool_summary = ", ".join(
|
||||||
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||||
@@ -232,14 +241,14 @@ class MyTool(Tool, ContextAware):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_value(val: Any, key: str = "") -> str:
|
def _format_value(val: Any, key: str = "") -> str:
|
||||||
if isinstance(val, SubagentStatus):
|
if _is_subagent_status(val):
|
||||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||||
detail = MyTool._format_status(val, " ")
|
detail = MyTool._format_status(val, " ")
|
||||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||||
# SubagentManager: delegate to its _task_statuses dict
|
# SubagentManager: delegate to its _task_statuses dict
|
||||||
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
|
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
|
||||||
return MyTool._format_value(val._task_statuses, key)
|
return MyTool._format_value(val._task_statuses, key)
|
||||||
if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus):
|
if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
|
||||||
prefix = f"{key}: " if key else ""
|
prefix = f"{key}: " if key else ""
|
||||||
lines = [f"{prefix}{len(val)} subagent(s):"]
|
lines = [f"{prefix}{len(val)} subagent(s):"]
|
||||||
for tid, st in val.items():
|
for tid, st in val.items():
|
||||||
@@ -349,7 +358,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
parts.append(self._format_value(getattr(state, k, None), k))
|
parts.append(self._format_value(getattr(state, k, None), k))
|
||||||
parts.append(self._format_value(state.model_preset, "model_preset"))
|
parts.append(self._format_value(state.model_preset, "model_preset"))
|
||||||
# Other useful top-level keys shown in description
|
# Other useful top-level keys shown in description
|
||||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
|
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
||||||
if _has_real_attr(state, k):
|
if _has_real_attr(state, k):
|
||||||
parts.append(self._format_value(getattr(state, k, None), k))
|
parts.append(self._format_value(getattr(state, k, None), k))
|
||||||
# Token usage
|
# Token usage
|
||||||
|
|||||||
+299
-71
@@ -8,6 +8,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,10 +16,27 @@ from loguru import logger
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
|
from nanobot.agent.tools.context import current_request_session_key
|
||||||
|
from nanobot.agent.tools.exec_session import (
|
||||||
|
DEFAULT_EXEC_SESSION_MANAGER,
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
DEFAULT_YIELD_MS,
|
||||||
|
MAX_OUTPUT_CHARS,
|
||||||
|
MAX_YIELD_MS,
|
||||||
|
clamp_session_int,
|
||||||
|
format_session_poll,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.sandbox import wrap_command
|
from nanobot.agent.tools.sandbox import wrap_command
|
||||||
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import (
|
||||||
|
BooleanSchema,
|
||||||
|
IntegerSchema,
|
||||||
|
StringSchema,
|
||||||
|
tool_parameters_schema,
|
||||||
|
)
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
|
||||||
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
|
|
||||||
_IS_WINDOWS = sys.platform == "win32"
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
@@ -36,7 +54,7 @@ _WORKSPACE_BOUNDARY_NOTE = (
|
|||||||
class ExecToolConfig(Base):
|
class ExecToolConfig(Base):
|
||||||
"""Shell exec tool configuration."""
|
"""Shell exec tool configuration."""
|
||||||
enable: bool = True
|
enable: bool = True
|
||||||
timeout: int = 60
|
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
sandbox: str = ""
|
sandbox: str = ""
|
||||||
allowed_env_keys: list[str] = Field(default_factory=list)
|
allowed_env_keys: list[str] = Field(default_factory=list)
|
||||||
@@ -44,10 +62,22 @@ class ExecToolConfig(Base):
|
|||||||
deny_patterns: list[str] = Field(default_factory=list)
|
deny_patterns: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _PreparedCommand:
|
||||||
|
command: str
|
||||||
|
cwd: str
|
||||||
|
env: dict[str, str]
|
||||||
|
timeout: int | None
|
||||||
|
shell_program: str | None
|
||||||
|
login: bool
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
command=StringSchema("The shell command to execute"),
|
command=StringSchema("The shell command to execute"),
|
||||||
|
cmd=StringSchema("Compatibility alias for command"),
|
||||||
working_dir=StringSchema("Optional working directory for the command"),
|
working_dir=StringSchema("Optional working directory for the command"),
|
||||||
|
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||||
timeout=IntegerSchema(
|
timeout=IntegerSchema(
|
||||||
60,
|
60,
|
||||||
description=(
|
description=(
|
||||||
@@ -57,7 +87,44 @@ class ExecToolConfig(Base):
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=600,
|
maximum=600,
|
||||||
),
|
),
|
||||||
required=["command"],
|
shell=StringSchema(
|
||||||
|
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
login=BooleanSchema(
|
||||||
|
description="Whether to run bash/zsh with login shell semantics (default true).",
|
||||||
|
default=True,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
yield_time_ms=IntegerSchema(
|
||||||
|
description=(
|
||||||
|
"Optional milliseconds to wait before returning output. "
|
||||||
|
"When set, a still-running command returns a session_id that "
|
||||||
|
"can be polled or written to with write_stdin. Omit this field "
|
||||||
|
"to keep one-shot exec behavior."
|
||||||
|
),
|
||||||
|
minimum=0,
|
||||||
|
maximum=MAX_YIELD_MS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
max_output_chars=IntegerSchema(
|
||||||
|
description=(
|
||||||
|
"Maximum output characters to return when yield_time_ms is used "
|
||||||
|
"(default 10000, max 50000)."
|
||||||
|
),
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
max_output_tokens=IntegerSchema(
|
||||||
|
description=(
|
||||||
|
"Compatibility alias for max_output_chars. The current runtime "
|
||||||
|
"uses a character budget."
|
||||||
|
),
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
class ExecTool(Tool):
|
class ExecTool(Tool):
|
||||||
@@ -81,6 +148,7 @@ class ExecTool(Tool):
|
|||||||
working_dir=ctx.workspace,
|
working_dir=ctx.workspace,
|
||||||
timeout=cfg.timeout,
|
timeout=cfg.timeout,
|
||||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
|
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
|
||||||
sandbox=cfg.sandbox,
|
sandbox=cfg.sandbox,
|
||||||
path_append=cfg.path_append,
|
path_append=cfg.path_append,
|
||||||
allowed_env_keys=cfg.allowed_env_keys,
|
allowed_env_keys=cfg.allowed_env_keys,
|
||||||
@@ -95,9 +163,12 @@ class ExecTool(Tool):
|
|||||||
deny_patterns: list[str] | None = None,
|
deny_patterns: list[str] | None = None,
|
||||||
allow_patterns: list[str] | None = None,
|
allow_patterns: list[str] | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
|
webui_allow_local_service_access: bool = True,
|
||||||
|
allow_local_preview_access: bool | None = None,
|
||||||
sandbox: str = "",
|
sandbox: str = "",
|
||||||
path_append: str = "",
|
path_append: str = "",
|
||||||
allowed_env_keys: list[str] | None = None,
|
allowed_env_keys: list[str] | None = None,
|
||||||
|
session_manager: Any | None = None,
|
||||||
):
|
):
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
@@ -123,8 +194,12 @@ class ExecTool(Tool):
|
|||||||
]
|
]
|
||||||
self.allow_patterns = allow_patterns or []
|
self.allow_patterns = allow_patterns or []
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
|
if allow_local_preview_access is not None:
|
||||||
|
webui_allow_local_service_access = allow_local_preview_access
|
||||||
|
self.webui_allow_local_service_access = webui_allow_local_service_access
|
||||||
self.path_append = path_append
|
self.path_append = path_append
|
||||||
self.allowed_env_keys = allowed_env_keys or []
|
self.allowed_env_keys = allowed_env_keys or []
|
||||||
|
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -150,10 +225,15 @@ class ExecTool(Tool):
|
|||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Execute a shell command and return its output. "
|
"Execute a shell command and return its output. "
|
||||||
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
|
"Use this for tests, builds, package commands, git commands, and "
|
||||||
"and grep/glob over shell find/grep. "
|
"other process execution. Prefer read_file/find_files/grep for "
|
||||||
|
"inspection and apply_patch/write_file/edit_file for file changes "
|
||||||
|
"instead of cat, shell find/grep, echo, or sed. "
|
||||||
"Use -y or --yes flags to avoid interactive prompts. "
|
"Use -y or --yes flags to avoid interactive prompts. "
|
||||||
"Output is truncated at 10 000 chars; timeout defaults to 60s."
|
"For long-running or interactive commands, pass yield_time_ms; "
|
||||||
|
"if the command keeps running, exec returns a session_id that can "
|
||||||
|
"be polled or written to with write_stdin. Output is truncated at "
|
||||||
|
"10 000 chars; timeout defaults to 60s."
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -161,67 +241,45 @@ class ExecTool(Tool):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self, command: str, working_dir: str | None = None,
|
self, command: str | None = None, cmd: str | None = None,
|
||||||
timeout: int | None = None, **kwargs: Any,
|
working_dir: str | None = None, workdir: str | None = None,
|
||||||
|
timeout: int | None = None, shell: str | None = None,
|
||||||
|
login: bool | None = None, yield_time_ms: int | None = None,
|
||||||
|
max_output_chars: int | None = None,
|
||||||
|
max_output_tokens: int | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
cwd = working_dir or self.working_dir or os.getcwd()
|
command = command or cmd
|
||||||
|
working_dir = working_dir or workdir
|
||||||
|
if not command:
|
||||||
|
return "Error: Missing command. Provide command or cmd."
|
||||||
|
if max_output_chars is None:
|
||||||
|
max_output_chars = max_output_tokens
|
||||||
|
|
||||||
# Prevent an LLM-supplied working_dir from escaping the configured
|
prepared = self._prepare_command(command, working_dir, timeout, shell, login)
|
||||||
# workspace when restrict_to_workspace is enabled (#2826). Without
|
if isinstance(prepared, str):
|
||||||
# this, a caller can pass working_dir="/etc" and then all absolute
|
return prepared
|
||||||
# paths under /etc would pass the _guard_command check that anchors
|
|
||||||
# on cwd.
|
|
||||||
if self.restrict_to_workspace and self.working_dir:
|
|
||||||
try:
|
|
||||||
requested = Path(cwd).expanduser().resolve()
|
|
||||||
workspace_root = Path(self.working_dir).expanduser().resolve()
|
|
||||||
except Exception:
|
|
||||||
return (
|
|
||||||
"Error: working_dir could not be resolved"
|
|
||||||
+ _WORKSPACE_BOUNDARY_NOTE
|
|
||||||
)
|
|
||||||
if requested != workspace_root and workspace_root not in requested.parents:
|
|
||||||
return (
|
|
||||||
"Error: working_dir is outside the configured workspace"
|
|
||||||
+ _WORKSPACE_BOUNDARY_NOTE
|
|
||||||
)
|
|
||||||
|
|
||||||
guard_error = self._guard_command(command, cwd)
|
if yield_time_ms is not None:
|
||||||
if guard_error:
|
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
||||||
return guard_error
|
|
||||||
|
|
||||||
if self.sandbox:
|
|
||||||
if _IS_WINDOWS:
|
|
||||||
logger.warning(
|
|
||||||
"Sandbox '{}' is not supported on Windows; running unsandboxed",
|
|
||||||
self.sandbox,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
workspace = self.working_dir or cwd
|
|
||||||
command = wrap_command(self.sandbox, command, workspace, cwd)
|
|
||||||
cwd = str(Path(workspace).resolve())
|
|
||||||
|
|
||||||
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
|
||||||
env = self._build_env()
|
|
||||||
|
|
||||||
if self.path_append:
|
|
||||||
if _IS_WINDOWS:
|
|
||||||
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
|
|
||||||
else:
|
|
||||||
env["NANOBOT_PATH_APPEND"] = self.path_append
|
|
||||||
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = await self._spawn(command, cwd, env)
|
process = await self._spawn(
|
||||||
|
prepared.command,
|
||||||
|
prepared.cwd,
|
||||||
|
prepared.env,
|
||||||
|
prepared.shell_program,
|
||||||
|
prepared.login,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(
|
stdout, stderr = await asyncio.wait_for(
|
||||||
process.communicate(),
|
process.communicate(),
|
||||||
timeout=effective_timeout,
|
timeout=prepared.timeout,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
return f"Error: Command timed out after {effective_timeout} seconds"
|
return f"Error: Command timed out after {prepared.timeout} seconds"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
raise
|
raise
|
||||||
@@ -240,7 +298,7 @@ class ExecTool(Tool):
|
|||||||
|
|
||||||
result = "\n".join(output_parts) if output_parts else "(no output)"
|
result = "\n".join(output_parts) if output_parts else "(no output)"
|
||||||
|
|
||||||
max_len = self._MAX_OUTPUT
|
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
||||||
if len(result) > max_len:
|
if len(result) > max_len:
|
||||||
half = max_len // 2
|
half = max_len // 2
|
||||||
result = (
|
result = (
|
||||||
@@ -254,32 +312,192 @@ class ExecTool(Tool):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error executing command: {str(e)}"
|
return f"Error executing command: {str(e)}"
|
||||||
|
|
||||||
|
async def _execute_session(
|
||||||
|
self,
|
||||||
|
prepared: _PreparedCommand,
|
||||||
|
yield_time_ms: int | None,
|
||||||
|
max_output_chars: int | None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
session_id, poll = await self._session_manager.start(
|
||||||
|
command=prepared.command,
|
||||||
|
cwd=prepared.cwd,
|
||||||
|
env=prepared.env,
|
||||||
|
timeout=prepared.timeout,
|
||||||
|
shell_program=prepared.shell_program,
|
||||||
|
login=prepared.login,
|
||||||
|
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
|
||||||
|
owner_session_key=current_request_session_key(),
|
||||||
|
max_output_chars=clamp_session_int(
|
||||||
|
max_output_chars,
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
|
1000,
|
||||||
|
MAX_OUTPUT_CHARS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return format_session_poll(session_id, poll)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error executing command: {exc}"
|
||||||
|
|
||||||
|
def _resolve_timeout(self, timeout: int | None) -> int | None:
|
||||||
|
"""Resolve the effective hard timeout in seconds (None = no limit).
|
||||||
|
|
||||||
|
A per-call timeout supplied by the model stays capped at _MAX_TIMEOUT so
|
||||||
|
the LLM cannot request unbounded execution. The config-level default
|
||||||
|
(self.timeout) may exceed that cap, and 0 disables the limit entirely
|
||||||
|
for trusted long-running tasks (#3595).
|
||||||
|
"""
|
||||||
|
if timeout:
|
||||||
|
return min(timeout, self._MAX_TIMEOUT)
|
||||||
|
if self.timeout and self.timeout > 0:
|
||||||
|
return self.timeout
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _prepare_command(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
shell: str | None = None,
|
||||||
|
login: bool | None = None,
|
||||||
|
) -> _PreparedCommand | str:
|
||||||
|
access = current_tool_workspace(
|
||||||
|
self.working_dir,
|
||||||
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
|
sandbox_restricts_workspace=bool(self.sandbox),
|
||||||
|
)
|
||||||
|
workspace_root = str(access.project_path) if access.project_path is not None else self.working_dir
|
||||||
|
cwd = working_dir or workspace_root or os.getcwd()
|
||||||
|
|
||||||
|
# Prevent an LLM-supplied working_dir from escaping the configured
|
||||||
|
# workspace when restrict_to_workspace is enabled (#2826). Without
|
||||||
|
# this, a caller can pass working_dir="/etc" and then all absolute
|
||||||
|
# paths under /etc would pass the _guard_command check that anchors
|
||||||
|
# on cwd.
|
||||||
|
if access.restrict_to_workspace and workspace_root:
|
||||||
|
try:
|
||||||
|
requested = Path(cwd).expanduser().resolve()
|
||||||
|
resolved_root = Path(workspace_root).expanduser().resolve()
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
"Error: working_dir could not be resolved"
|
||||||
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
|
if not is_path_within(requested, resolved_root):
|
||||||
|
return (
|
||||||
|
"Error: working_dir is outside the configured workspace"
|
||||||
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
|
|
||||||
|
guard_error = self._guard_command(
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
restrict_to_workspace=access.restrict_to_workspace,
|
||||||
|
)
|
||||||
|
if guard_error:
|
||||||
|
return guard_error
|
||||||
|
|
||||||
|
if self.sandbox:
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
logger.warning(
|
||||||
|
"Sandbox '{}' is not supported on Windows; running unsandboxed",
|
||||||
|
self.sandbox,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
workspace = workspace_root or cwd
|
||||||
|
command = wrap_command(self.sandbox, command, workspace, cwd)
|
||||||
|
cwd = str(Path(workspace).resolve())
|
||||||
|
|
||||||
|
effective_timeout = self._resolve_timeout(timeout)
|
||||||
|
env = self._build_env()
|
||||||
|
|
||||||
|
if self.path_append:
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
|
||||||
|
else:
|
||||||
|
env["NANOBOT_PATH_APPEND"] = self.path_append
|
||||||
|
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
|
||||||
|
|
||||||
|
shell_program, shell_error = self._resolve_shell(shell)
|
||||||
|
if shell_error:
|
||||||
|
return shell_error
|
||||||
|
|
||||||
|
return _PreparedCommand(
|
||||||
|
command=command,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
timeout=effective_timeout,
|
||||||
|
shell_program=shell_program,
|
||||||
|
login=True if login is None else login,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _spawn(
|
async def _spawn(
|
||||||
command: str, cwd: str, env: dict[str, str],
|
command: str, cwd: str, env: dict[str, str],
|
||||||
|
shell_program: str | None = None,
|
||||||
|
login: bool = True,
|
||||||
|
*,
|
||||||
|
stdin: int = asyncio.subprocess.DEVNULL,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Launch *command* in a platform-appropriate shell."""
|
"""Launch *command* in a platform-appropriate shell."""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
# create_subprocess_exec re-quotes args via list2cmdline, which
|
if "\n" in command:
|
||||||
# breaks commands containing paths with spaces (e.g. "D:\Program
|
return await asyncio.create_subprocess_exec(
|
||||||
# Files\python.exe" "script.py"). create_subprocess_shell passes
|
"powershell", "-NoProfile", "-Command", command,
|
||||||
# the raw command string to COMSPEC without re-quoting.
|
stdin=stdin,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
return await asyncio.create_subprocess_shell(
|
return await asyncio.create_subprocess_shell(
|
||||||
command,
|
command,
|
||||||
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
bash = shutil.which("bash") or "/bin/bash"
|
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||||
|
args = [shell_program]
|
||||||
|
shell_name = Path(shell_program).name.lower()
|
||||||
|
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
|
||||||
|
args.append("-l")
|
||||||
|
args.extend(["-c", command])
|
||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
bash, "-l", "-c", command,
|
*args,
|
||||||
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
|
||||||
|
if not shell:
|
||||||
|
return None, None
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
return None, "Error: shell parameter is not supported on Windows"
|
||||||
|
if "\0" in shell or "\n" in shell or "\r" in shell:
|
||||||
|
return None, "Error: shell contains invalid characters"
|
||||||
|
allowed = {"sh", "bash", "zsh"}
|
||||||
|
path = Path(shell).expanduser()
|
||||||
|
if path.is_absolute():
|
||||||
|
if path.name not in allowed:
|
||||||
|
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||||
|
if not path.is_file() or not os.access(path, os.X_OK):
|
||||||
|
return None, f"Error: shell is not executable: {shell}"
|
||||||
|
return str(path), None
|
||||||
|
if "/" in shell or "\\" in shell:
|
||||||
|
return None, "Error: shell must be a shell name or absolute path"
|
||||||
|
if shell not in allowed:
|
||||||
|
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||||
|
resolved = shutil.which(shell)
|
||||||
|
if not resolved:
|
||||||
|
return None, f"Error: shell not found: {shell}"
|
||||||
|
return resolved, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
||||||
"""Kill a subprocess and reap it to prevent zombies."""
|
"""Kill a subprocess and reap it to prevent zombies."""
|
||||||
@@ -342,7 +560,13 @@ class ExecTool(Tool):
|
|||||||
env[key] = val
|
env[key] = val
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def _guard_command(self, command: str, cwd: str) -> str | None:
|
def _guard_command(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
cwd: str,
|
||||||
|
*,
|
||||||
|
restrict_to_workspace: bool | None = None,
|
||||||
|
) -> str | None:
|
||||||
"""Best-effort safety guard for potentially destructive commands."""
|
"""Best-effort safety guard for potentially destructive commands."""
|
||||||
cmd = command.strip()
|
cmd = command.strip()
|
||||||
lower = cmd.lower()
|
lower = cmd.lower()
|
||||||
@@ -362,11 +586,17 @@ class ExecTool(Tool):
|
|||||||
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
||||||
|
|
||||||
from nanobot.security.network import contains_internal_url
|
from nanobot.security.network import contains_internal_url
|
||||||
if contains_internal_url(cmd):
|
if contains_internal_url(
|
||||||
|
cmd,
|
||||||
|
allow_loopback=current_scope_allows_loopback(
|
||||||
|
enabled=self.webui_allow_local_service_access,
|
||||||
|
),
|
||||||
|
):
|
||||||
# The runner turns this marker into a non-retryable security hint.
|
# The runner turns this marker into a non-retryable security hint.
|
||||||
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
|
|
||||||
if self.restrict_to_workspace:
|
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
|
||||||
|
if should_restrict:
|
||||||
if "..\\" in cmd or "../" in cmd:
|
if "..\\" in cmd or "../" in cmd:
|
||||||
return (
|
return (
|
||||||
"Error: Command blocked by safety guard (path traversal detected)"
|
"Error: Command blocked by safety guard (path traversal detected)"
|
||||||
@@ -391,11 +621,9 @@ class ExecTool(Tool):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
media_path = get_media_dir().resolve()
|
media_path = get_media_dir().resolve()
|
||||||
if (p.is_absolute()
|
if p.is_absolute() and not (
|
||||||
and cwd_path not in p.parents
|
is_path_within(p, cwd_path)
|
||||||
and p != cwd_path
|
or is_path_within(p, media_path)
|
||||||
and media_path not in p.parents
|
|
||||||
and p != media_path
|
|
||||||
):
|
):
|
||||||
return (
|
return (
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
"Error: Command blocked by safety guard (path outside working dir)"
|
||||||
@@ -416,7 +644,7 @@ class ExecTool(Tool):
|
|||||||
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
|
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
|
||||||
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
||||||
win_paths = re.findall(
|
win_paths = re.findall(
|
||||||
r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||||
command
|
command
|
||||||
)
|
)
|
||||||
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
||||||
|
from nanobot.security.workspace_access import current_workspace_scope
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
@@ -17,6 +18,15 @@ if TYPE_CHECKING:
|
|||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
task=StringSchema("The task for the subagent to complete"),
|
task=StringSchema("The task for the subagent to complete"),
|
||||||
label=StringSchema("Optional short label for the task (for display)"),
|
label=StringSchema("Optional short label for the task (for display)"),
|
||||||
|
temperature=NumberSchema(
|
||||||
|
description=(
|
||||||
|
"Optional sampling temperature for the subagent "
|
||||||
|
"(0.0 = deterministic, higher = more creative). "
|
||||||
|
"Defaults to the provider's configured temperature."
|
||||||
|
),
|
||||||
|
minimum=0.0,
|
||||||
|
maximum=2.0,
|
||||||
|
),
|
||||||
required=["task"],
|
required=["task"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -58,7 +68,13 @@ class SpawnTool(Tool, ContextAware):
|
|||||||
"and use a dedicated subdirectory when helpful."
|
"and use a dedicated subdirectory when helpful."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
async def execute(
|
||||||
|
self,
|
||||||
|
task: str,
|
||||||
|
label: str | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
"""Spawn a subagent to execute the given task."""
|
"""Spawn a subagent to execute the given task."""
|
||||||
running = self._manager.get_running_count()
|
running = self._manager.get_running_count()
|
||||||
limit = self._manager.max_concurrent_subagents
|
limit = self._manager.max_concurrent_subagents
|
||||||
@@ -75,4 +91,6 @@ class SpawnTool(Tool, ContextAware):
|
|||||||
origin_chat_id=self._origin_chat_id.get(),
|
origin_chat_id=self._origin_chat_id.get(),
|
||||||
session_key=self._session_key.get(),
|
session_key=self._session_key.get(),
|
||||||
origin_message_id=self._origin_message_id.get(),
|
origin_message_id=self._origin_message_id.get(),
|
||||||
|
temperature=temperature,
|
||||||
|
workspace_scope=current_workspace_scope(),
|
||||||
)
|
)
|
||||||
|
|||||||
+104
-24
@@ -8,7 +8,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -78,9 +78,82 @@ def _validate_url(url: str) -> tuple[bool, str]:
|
|||||||
def _validate_url_safe(url: str) -> tuple[bool, str]:
|
def _validate_url_safe(url: str) -> tuple[bool, str]:
|
||||||
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
|
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
|
||||||
from nanobot.security.network import validate_url_target
|
from nanobot.security.network import validate_url_target
|
||||||
|
|
||||||
return validate_url_target(url)
|
return validate_url_target(url)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_with_safe_redirects(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> tuple[httpx.Response | None, str | None]:
|
||||||
|
"""GET a URL while validating every redirect target before requesting it."""
|
||||||
|
current_url = url
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
is_valid, error_msg = _validate_url_safe(current_url)
|
||||||
|
if not is_valid:
|
||||||
|
return None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
response = await client.get(current_url, headers=headers, follow_redirects=False)
|
||||||
|
is_redirect = 300 <= response.status_code < 400
|
||||||
|
if not is_redirect:
|
||||||
|
return response, None
|
||||||
|
|
||||||
|
location = response.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return response, None
|
||||||
|
|
||||||
|
next_url = urljoin(str(response.url), location)
|
||||||
|
is_valid, error_msg = _validate_url_safe(next_url)
|
||||||
|
if not is_valid:
|
||||||
|
await response.aclose()
|
||||||
|
return None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
await response.aclose()
|
||||||
|
current_url = next_url
|
||||||
|
|
||||||
|
return None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_with_safe_redirects(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> tuple[httpx.Response | None, Any | None, str | None]:
|
||||||
|
"""Open a streamed response while validating every redirect target first."""
|
||||||
|
current_url = url
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
is_valid, error_msg = _validate_url_safe(current_url)
|
||||||
|
if not is_valid:
|
||||||
|
return None, None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
stream = client.stream(
|
||||||
|
"GET",
|
||||||
|
current_url,
|
||||||
|
headers=headers,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
response = await stream.__aenter__()
|
||||||
|
is_redirect = 300 <= response.status_code < 400
|
||||||
|
if not is_redirect:
|
||||||
|
return response, stream, None
|
||||||
|
|
||||||
|
location = response.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return response, stream, None
|
||||||
|
|
||||||
|
next_url = urljoin(str(response.url), location)
|
||||||
|
is_valid, error_msg = _validate_url_safe(next_url)
|
||||||
|
if not is_valid:
|
||||||
|
await stream.__aexit__(None, None, None)
|
||||||
|
return None, None, f"Redirect blocked: {error_msg}"
|
||||||
|
|
||||||
|
await stream.__aexit__(None, None, None)
|
||||||
|
current_url = next_url
|
||||||
|
|
||||||
|
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
|
||||||
|
|
||||||
|
|
||||||
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
|
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
|
||||||
"""Format provider results into shared plaintext output."""
|
"""Format provider results into shared plaintext output."""
|
||||||
if not items:
|
if not items:
|
||||||
@@ -382,17 +455,16 @@ class WebSearchTool(Tool):
|
|||||||
return await self._search_duckduckgo(query, n)
|
return await self._search_duckduckgo(query, n)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
||||||
r = await client.get(
|
r = await client.post(
|
||||||
"https://kagi.com/api/v0/search",
|
"https://kagi.com/api/v1/search",
|
||||||
params={"q": query, "limit": n},
|
json={"query": query, "limit": n},
|
||||||
headers={"Authorization": f"Bot {api_key}", "User-Agent": self.user_agent},
|
headers={"Authorization": f"Bearer {api_key}", "User-Agent": self.user_agent},
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
# t=0 items are search results; other values are related searches, etc.
|
|
||||||
items = [
|
items = [
|
||||||
{"title": d.get("title", ""), "url": d.get("url", ""), "content": d.get("snippet", "")}
|
{"title": d.get("title", ""), "url": d.get("url", ""), "content": d.get("snippet", "")}
|
||||||
for d in r.json().get("data", []) if d.get("t") == 0
|
for d in r.json().get("data", {}).get("search", [])
|
||||||
]
|
]
|
||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -488,19 +560,26 @@ class WebFetchTool(Tool):
|
|||||||
|
|
||||||
# Detect and fetch images directly to avoid Jina's textual image captioning
|
# Detect and fetch images directly to avoid Jina's textual image captioning
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(proxy=self.proxy, follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=15.0) as client:
|
async with httpx.AsyncClient(proxy=self.proxy, timeout=15.0) as client:
|
||||||
async with client.stream("GET", url, headers={"User-Agent": self.user_agent}) as r:
|
r, stream, redirect_error = await _stream_with_safe_redirects(
|
||||||
from nanobot.security.network import validate_resolved_url
|
client,
|
||||||
|
url,
|
||||||
redir_ok, redir_err = validate_resolved_url(str(r.url))
|
headers={"User-Agent": self.user_agent},
|
||||||
if not redir_ok:
|
)
|
||||||
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
|
if redirect_error:
|
||||||
|
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
|
||||||
|
if r is None:
|
||||||
|
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
ctype = r.headers.get("content-type", "")
|
ctype = r.headers.get("content-type", "")
|
||||||
if ctype.startswith("image/"):
|
if ctype.startswith("image/"):
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
raw = await r.aread()
|
raw = await r.aread()
|
||||||
return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})")
|
return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})")
|
||||||
|
finally:
|
||||||
|
if stream is not None:
|
||||||
|
await stream.__aexit__(None, None, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
||||||
|
|
||||||
@@ -549,23 +628,22 @@ class WebFetchTool(Tool):
|
|||||||
|
|
||||||
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
|
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
|
||||||
"""Local fallback using readability-lxml."""
|
"""Local fallback using readability-lxml."""
|
||||||
from readability import Document
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
follow_redirects=True,
|
|
||||||
max_redirects=MAX_REDIRECTS,
|
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
proxy=self.proxy,
|
proxy=self.proxy,
|
||||||
) as client:
|
) as client:
|
||||||
r = await client.get(url, headers={"User-Agent": self.user_agent})
|
r, redirect_error = await _get_with_safe_redirects(
|
||||||
|
client,
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": self.user_agent},
|
||||||
|
)
|
||||||
|
if redirect_error:
|
||||||
|
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
|
||||||
|
if r is None:
|
||||||
|
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
from nanobot.security.network import validate_resolved_url
|
|
||||||
redir_ok, redir_err = validate_resolved_url(str(r.url))
|
|
||||||
if not redir_ok:
|
|
||||||
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
|
|
||||||
|
|
||||||
ctype = r.headers.get("content-type", "")
|
ctype = r.headers.get("content-type", "")
|
||||||
if ctype.startswith("image/"):
|
if ctype.startswith("image/"):
|
||||||
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
|
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
|
||||||
@@ -573,6 +651,8 @@ class WebFetchTool(Tool):
|
|||||||
if "application/json" in ctype:
|
if "application/json" in ctype:
|
||||||
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
|
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
|
||||||
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
|
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
|
||||||
|
from readability import Document
|
||||||
|
|
||||||
doc = Document(r.text)
|
doc = Document(r.text)
|
||||||
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
|
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
|
||||||
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
|
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Shared app protocol helpers."""
|
||||||
|
|
||||||
|
from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest
|
||||||
|
|
||||||
|
__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""CLI app adapter for the unified Apps domain."""
|
||||||
|
|
||||||
|
from nanobot.apps.cli.service import (
|
||||||
|
CliAppError,
|
||||||
|
CliAppManager,
|
||||||
|
CliAppsRuntimeConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CliAppError",
|
||||||
|
"CliAppManager",
|
||||||
|
"CliAppsRuntimeConfig",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
"""CLI Apps helpers shared by the agent loop and settings surfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Return persisted session kwargs for CLI app attachments."""
|
||||||
|
cli_apps = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
|
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||||
|
"""Return model-visible CLI app annotations for the current turn."""
|
||||||
|
if skip:
|
||||||
|
return []
|
||||||
|
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
||||||
|
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||||
|
return _cli_app_runtime_lines(text, metadata, workspace)
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_app_runtime_lines(
|
||||||
|
text: str,
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
workspace: Path,
|
||||||
|
) -> list[str]:
|
||||||
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
|
if isinstance(structured, list):
|
||||||
|
mentions = [
|
||||||
|
item for item in structured
|
||||||
|
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
|
||||||
|
]
|
||||||
|
if mentions:
|
||||||
|
return [
|
||||||
|
"CLI App Attachment: "
|
||||||
|
f"@{str(item['name']).strip().lower()} "
|
||||||
|
f"(installed; tool=run_cli_app; "
|
||||||
|
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||||
|
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
||||||
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
|
for item in mentions
|
||||||
|
if str(item.get("name") or "").strip()
|
||||||
|
]
|
||||||
|
if "@" not in text:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
from nanobot.apps.cli import CliAppManager
|
||||||
|
|
||||||
|
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
"CLI App Mention: "
|
||||||
|
f"@{item['name']} "
|
||||||
|
f"(installed; tool={item['tool']}; "
|
||||||
|
f"entry_point={item['entry_point'] or 'unknown'}; "
|
||||||
|
f"skill={item['skill']}). "
|
||||||
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
|
for item in mentions
|
||||||
|
]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Neutral manifest shape for settings-managed agent apps.
|
||||||
|
|
||||||
|
The manifest is intentionally descriptive. Installers still live in their
|
||||||
|
own adapters, while this protocol gives the WebUI and future registries one
|
||||||
|
small vocabulary for capabilities, trust, and verified install/remove plans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
APP_PROTOCOL_SCHEMA = "agent-app.v1"
|
||||||
|
|
||||||
|
|
||||||
|
def compact_dict(values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Drop empty optional values while preserving explicit booleans and zeros."""
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in values.items()
|
||||||
|
if value is not None and value != "" and value != [] and value != {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def app_manifest(
|
||||||
|
*,
|
||||||
|
app_id: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str,
|
||||||
|
category: str,
|
||||||
|
source: str,
|
||||||
|
capabilities: list[dict[str, Any]],
|
||||||
|
install: dict[str, Any],
|
||||||
|
remove: dict[str, Any],
|
||||||
|
trust: dict[str, Any],
|
||||||
|
version: str | None = None,
|
||||||
|
logo_url: str | None = None,
|
||||||
|
brand_color: str | None = None,
|
||||||
|
docs_url: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a stable app manifest dictionary."""
|
||||||
|
return compact_dict({
|
||||||
|
"schema": APP_PROTOCOL_SCHEMA,
|
||||||
|
"id": app_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"version": version,
|
||||||
|
"description": description,
|
||||||
|
"category": category,
|
||||||
|
"source": source,
|
||||||
|
"logo_url": logo_url,
|
||||||
|
"brand_color": brand_color,
|
||||||
|
"docs_url": docs_url,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"install": install,
|
||||||
|
"remove": remove,
|
||||||
|
"trust": trust,
|
||||||
|
})
|
||||||
@@ -9,6 +9,12 @@ from typing import Any
|
|||||||
# render it and other channels may ignore unknown keys.
|
# render it and other channels may ignore unknown keys.
|
||||||
OUTBOUND_META_AGENT_UI = "_agent_ui"
|
OUTBOUND_META_AGENT_UI = "_agent_ui"
|
||||||
|
|
||||||
|
# Internal-only inbound metadata used by in-process channels to ask the agent
|
||||||
|
# loop to update runtime state without going through a user session.
|
||||||
|
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||||
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
|
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
@@ -45,4 +51,3 @@ class OutboundMessage:
|
|||||||
media: list[str] = field(default_factory=list)
|
media: list[str] = field(default_factory=list)
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
buttons: list[list[str]] = field(default_factory=list)
|
buttons: list[list[str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,16 @@ if DISCORD_AVAILABLE:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await self._forward_slash_command(interaction, _command_text)
|
await self._forward_slash_command(interaction, _command_text)
|
||||||
|
|
||||||
|
@self.tree.command(name="model", description="Show or switch runtime model preset")
|
||||||
|
@app_commands.describe(preset="Optional model preset name, such as default")
|
||||||
|
async def model_command(
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
preset: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
preset = (preset or "").strip()
|
||||||
|
command_text = f"/model {preset}" if preset else "/model"
|
||||||
|
await self._forward_slash_command(interaction, command_text)
|
||||||
|
|
||||||
@self.tree.command(name="help", description="Show available commands")
|
@self.tree.command(name="help", description="Show available commands")
|
||||||
async def help_command(interaction: discord.Interaction) -> None:
|
async def help_command(interaction: discord.Interaction) -> None:
|
||||||
sender_id = str(interaction.user.id)
|
sender_id = str(interaction.user.id)
|
||||||
|
|||||||
@@ -57,11 +57,17 @@ class ChannelManager:
|
|||||||
*,
|
*,
|
||||||
session_manager: "SessionManager | None" = None,
|
session_manager: "SessionManager | None" = None,
|
||||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
|
webui_static_dist: bool = True,
|
||||||
|
webui_runtime_surface: str = "browser",
|
||||||
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
):
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self._webui_runtime_model_name = webui_runtime_model_name
|
self._webui_runtime_model_name = webui_runtime_model_name
|
||||||
|
self._webui_static_dist = webui_static_dist
|
||||||
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
|
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._dispatch_task: asyncio.Task | None = None
|
self._dispatch_task: asyncio.Task | None = None
|
||||||
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
||||||
@@ -70,36 +76,52 @@ class ChannelManager:
|
|||||||
|
|
||||||
def _init_channels(self) -> None:
|
def _init_channels(self) -> None:
|
||||||
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
|
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
|
||||||
from nanobot.channels.registry import discover_all
|
from nanobot.channels.registry import discover_channel_names, discover_enabled
|
||||||
|
|
||||||
transcription_provider = self.config.channels.transcription_provider
|
transcription_provider = self.config.channels.transcription_provider
|
||||||
transcription_key = self._resolve_transcription_key(transcription_provider)
|
transcription_key = self._resolve_transcription_key(transcription_provider)
|
||||||
transcription_base = self._resolve_transcription_base(transcription_provider)
|
transcription_base = self._resolve_transcription_base(transcription_provider)
|
||||||
transcription_language = self.config.channels.transcription_language
|
transcription_language = self.config.channels.transcription_language
|
||||||
|
|
||||||
for name, cls in discover_all().items():
|
# Collect enabled module names first, then only import those.
|
||||||
|
# Channel configs live in ChannelsConfig's extra fields (via
|
||||||
|
# extra="allow"), so we enumerate candidates from pkgutil scan
|
||||||
|
# (cheap, no imports) and any plugin keys in __pydantic_extra__.
|
||||||
|
names = discover_channel_names()
|
||||||
|
candidate_names = set(names)
|
||||||
|
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
|
||||||
|
candidate_names.update(extra.keys())
|
||||||
|
|
||||||
|
enabled_names: set[str] = set()
|
||||||
|
for name in candidate_names:
|
||||||
section = getattr(self.config.channels, name, None)
|
section = getattr(self.config.channels, name, None)
|
||||||
if section is None:
|
if section is None:
|
||||||
continue
|
continue
|
||||||
enabled = (
|
if (
|
||||||
section.get("enabled", False)
|
section.get("enabled", False)
|
||||||
if isinstance(section, dict)
|
if isinstance(section, dict)
|
||||||
else getattr(section, "enabled", False)
|
else getattr(section, "enabled", False)
|
||||||
)
|
):
|
||||||
if not enabled:
|
enabled_names.add(name)
|
||||||
|
|
||||||
|
for name, cls in discover_enabled(enabled_names, _names=names).items():
|
||||||
|
section = getattr(self.config.channels, name, None)
|
||||||
|
if section is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
kwargs: dict[str, Any] = {}
|
kwargs: dict[str, Any] = {}
|
||||||
# Only the WebSocket channel currently hosts the embedded webui
|
|
||||||
# surface; other channels stay oblivious to these knobs.
|
|
||||||
if cls.name == "websocket":
|
if cls.name == "websocket":
|
||||||
if self._session_manager is not None:
|
if self._session_manager is not None:
|
||||||
kwargs["session_manager"] = self._session_manager
|
kwargs["session_manager"] = self._session_manager
|
||||||
static_path = _default_webui_dist()
|
static_path = _default_webui_dist() if self._webui_static_dist else None
|
||||||
if static_path is not None:
|
if static_path is not None:
|
||||||
kwargs["static_dist_path"] = static_path
|
kwargs["static_dist_path"] = static_path
|
||||||
|
kwargs["workspace_path"] = self.config.workspace_path
|
||||||
|
kwargs["restrict_to_workspace"] = self.config.tools.restrict_to_workspace
|
||||||
if self._webui_runtime_model_name is not None:
|
if self._webui_runtime_model_name is not None:
|
||||||
kwargs["runtime_model_name"] = self._webui_runtime_model_name
|
kwargs["runtime_model_name"] = self._webui_runtime_model_name
|
||||||
|
kwargs["runtime_surface"] = self._webui_runtime_surface
|
||||||
|
kwargs["runtime_capabilities_overrides"] = self._webui_runtime_capabilities
|
||||||
channel = cls(section, self.bus, **kwargs)
|
channel = cls(section, self.bus, **kwargs)
|
||||||
channel.transcription_provider = transcription_provider
|
channel.transcription_provider = transcription_provider
|
||||||
channel.transcription_api_key = transcription_key
|
channel.transcription_api_key = transcription_key
|
||||||
|
|||||||
+134
-28
@@ -8,21 +8,28 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, TypeAlias
|
from typing import Any, Literal, TypeAlias
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
import aiohttp
|
||||||
import nh3
|
import nh3
|
||||||
from mistune import create_markdown
|
from mistune import create_markdown
|
||||||
from nio import (
|
from nio import (
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
DownloadError,
|
|
||||||
InviteEvent,
|
InviteEvent,
|
||||||
JoinError,
|
JoinError,
|
||||||
|
KeyVerificationCancel,
|
||||||
|
KeyVerificationEvent,
|
||||||
|
KeyVerificationKey,
|
||||||
|
KeyVerificationMac,
|
||||||
|
KeyVerificationStart,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
MatrixRoom,
|
MatrixRoom,
|
||||||
MemoryDownloadResponse,
|
|
||||||
RoomEncryptedMedia,
|
RoomEncryptedMedia,
|
||||||
RoomMessage,
|
RoomMessage,
|
||||||
RoomMessageMedia,
|
RoomMessageMedia,
|
||||||
@@ -31,6 +38,7 @@ try:
|
|||||||
RoomSendResponse,
|
RoomSendResponse,
|
||||||
RoomTypingError,
|
RoomTypingError,
|
||||||
SyncError,
|
SyncError,
|
||||||
|
ToDeviceError,
|
||||||
UploadError,
|
UploadError,
|
||||||
)
|
)
|
||||||
from nio.crypto.attachments import decrypt_attachment
|
from nio.crypto.attachments import decrypt_attachment
|
||||||
@@ -62,6 +70,10 @@ _MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.f
|
|||||||
MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
|
MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
|
||||||
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
|
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
|
||||||
|
|
||||||
|
|
||||||
|
class _MediaTooLargeError(Exception):
|
||||||
|
"""Raised when an inbound Matrix media download exceeds the configured cap."""
|
||||||
|
|
||||||
MATRIX_MARKDOWN = create_markdown(
|
MATRIX_MARKDOWN = create_markdown(
|
||||||
escape=True,
|
escape=True,
|
||||||
plugins=["table", "strikethrough", "url", "superscript", "subscript"],
|
plugins=["table", "strikethrough", "url", "superscript", "subscript"],
|
||||||
@@ -188,8 +200,10 @@ class MatrixConfig(Base):
|
|||||||
access_token: str = ""
|
access_token: str = ""
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled")
|
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled")
|
||||||
|
sas_verification: bool = Field(default=False, alias="sasVerification")
|
||||||
sync_stop_grace_seconds: int = 2
|
sync_stop_grace_seconds: int = 2
|
||||||
max_media_bytes: int = 20 * 1024 * 1024
|
max_media_bytes: int = 20 * 1024 * 1024
|
||||||
|
max_concurrent_media_downloads: int = 2
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||||
group_allow_from: list[str] = Field(default_factory=list)
|
group_allow_from: list[str] = Field(default_factory=list)
|
||||||
@@ -231,6 +245,9 @@ class MatrixChannel(BaseChannel):
|
|||||||
self._server_upload_limit_checked = False
|
self._server_upload_limit_checked = False
|
||||||
self._stream_bufs: dict[str, _StreamBuf] = {}
|
self._stream_bufs: dict[str, _StreamBuf] = {}
|
||||||
self._started_at_ms: int = 0
|
self._started_at_ms: int = 0
|
||||||
|
self._media_download_semaphore = asyncio.Semaphore(
|
||||||
|
max(1, int(self.config.max_concurrent_media_downloads))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -258,6 +275,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._register_event_callbacks()
|
self._register_event_callbacks()
|
||||||
|
self._register_to_device_callbacks()
|
||||||
self._register_response_callbacks()
|
self._register_response_callbacks()
|
||||||
|
|
||||||
if not self.config.e2ee_enabled:
|
if not self.config.e2ee_enabled:
|
||||||
@@ -344,11 +362,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
"""Check path is inside workspace (when restriction enabled)."""
|
"""Check path is inside workspace (when restriction enabled)."""
|
||||||
if not self._restrict_to_workspace or not self._workspace:
|
if not self._restrict_to_workspace or not self._workspace:
|
||||||
return True
|
return True
|
||||||
try:
|
return is_path_within(path, self._workspace)
|
||||||
path.resolve(strict=False).relative_to(self._workspace)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
|
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
|
||||||
"""Deduplicate and resolve outbound attachment paths."""
|
"""Deduplicate and resolve outbound attachment paths."""
|
||||||
@@ -566,11 +580,77 @@ class MatrixChannel(BaseChannel):
|
|||||||
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
|
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
|
||||||
self.client.add_event_callback(self._on_room_invite, InviteEvent)
|
self.client.add_event_callback(self._on_room_invite, InviteEvent)
|
||||||
|
|
||||||
|
def _register_to_device_callbacks(self) -> None:
|
||||||
|
if self.config.e2ee_enabled and self.config.sas_verification:
|
||||||
|
self.client.add_to_device_callback(
|
||||||
|
self._on_key_verification_event,
|
||||||
|
(KeyVerificationEvent,),
|
||||||
|
)
|
||||||
|
|
||||||
def _register_response_callbacks(self) -> None:
|
def _register_response_callbacks(self) -> None:
|
||||||
self.client.add_response_callback(self._on_sync_error, SyncError)
|
self.client.add_response_callback(self._on_sync_error, SyncError)
|
||||||
self.client.add_response_callback(self._on_join_error, JoinError)
|
self.client.add_response_callback(self._on_join_error, JoinError)
|
||||||
self.client.add_response_callback(self._on_send_error, RoomSendError)
|
self.client.add_response_callback(self._on_send_error, RoomSendError)
|
||||||
|
|
||||||
|
def _is_sas_sender_allowed(self, sender: str) -> bool:
|
||||||
|
return bool(sender and self.is_allowed(sender))
|
||||||
|
|
||||||
|
async def _on_key_verification_event(self, event: KeyVerificationEvent) -> None:
|
||||||
|
try:
|
||||||
|
await self._handle_key_verification_event(event)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Matrix SAS verification handling failed")
|
||||||
|
|
||||||
|
async def _handle_key_verification_event(self, event: KeyVerificationEvent) -> None:
|
||||||
|
if not (self.config.e2ee_enabled and self.config.sas_verification):
|
||||||
|
return
|
||||||
|
if not self.client:
|
||||||
|
return
|
||||||
|
|
||||||
|
sender = str(getattr(event, "sender", "") or "")
|
||||||
|
transaction_id = str(getattr(event, "transaction_id", "") or "")
|
||||||
|
if not transaction_id or not self._is_sas_sender_allowed(sender):
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, KeyVerificationStart):
|
||||||
|
if "emoji" not in (getattr(event, "short_authentication_string", None) or []):
|
||||||
|
self.logger.info(
|
||||||
|
"Ignoring Matrix SAS verification from {} without emoji support",
|
||||||
|
sender,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = await self.client.accept_key_verification(transaction_id)
|
||||||
|
if isinstance(response, ToDeviceError):
|
||||||
|
self.logger.warning("Matrix SAS accept failed for {}: {}", sender, response)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, KeyVerificationKey):
|
||||||
|
responses = await self.client.send_to_device_messages()
|
||||||
|
if any(isinstance(response, ToDeviceError) for response in responses):
|
||||||
|
self.logger.warning("Matrix SAS key share failed for {}", sender)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = await self.client.confirm_short_auth_string(transaction_id)
|
||||||
|
if isinstance(response, ToDeviceError):
|
||||||
|
self.logger.warning("Matrix SAS confirm failed for {}: {}", sender, response)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, KeyVerificationMac):
|
||||||
|
sas = getattr(self.client, "key_verifications", {}).get(transaction_id)
|
||||||
|
if sas is not None and getattr(sas, "verified", False):
|
||||||
|
self.logger.info("Matrix SAS verification completed for {}", sender)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, KeyVerificationCancel):
|
||||||
|
self.logger.info(
|
||||||
|
"Matrix SAS verification cancelled by {}: {}",
|
||||||
|
sender,
|
||||||
|
getattr(event, "reason", ""),
|
||||||
|
)
|
||||||
|
|
||||||
def _is_fatal_auth_response(self, response: Any) -> bool:
|
def _is_fatal_auth_response(self, response: Any) -> bool:
|
||||||
code = getattr(response, "status_code", None)
|
code = getattr(response, "status_code", None)
|
||||||
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
|
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
|
||||||
@@ -743,7 +823,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
|
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
|
||||||
info = self._event_source_content(event).get("info")
|
info = self._event_source_content(event).get("info")
|
||||||
size = info.get("size") if isinstance(info, dict) else None
|
size = info.get("size") if isinstance(info, dict) else None
|
||||||
return size if isinstance(size, int) and size >= 0 else None
|
return size if type(size) is int and size >= 0 else None
|
||||||
|
|
||||||
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
|
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
|
||||||
info = self._event_source_content(event).get("info")
|
info = self._event_source_content(event).get("info")
|
||||||
@@ -772,26 +852,48 @@ class MatrixChannel(BaseChannel):
|
|||||||
event_prefix = (event_id[:24] or "evt").strip("_")
|
event_prefix = (event_id[:24] or "evt").strip("_")
|
||||||
return self._media_dir() / f"{event_prefix}_{stem}{suffix}"
|
return self._media_dir() / f"{event_prefix}_{stem}{suffix}"
|
||||||
|
|
||||||
async def _download_media_bytes(self, mxc_url: str) -> bytes | None:
|
async def _download_media_bytes(self, mxc_url: str, limit_bytes: int) -> bytes | None:
|
||||||
if not self.client:
|
if not self.client or limit_bytes <= 0:
|
||||||
|
raise _MediaTooLargeError
|
||||||
|
|
||||||
|
parsed = urlparse(mxc_url)
|
||||||
|
if parsed.scheme != "mxc" or not parsed.netloc or not parsed.path.strip("/"):
|
||||||
return None
|
return None
|
||||||
response = await self.client.download(mxc=mxc_url)
|
|
||||||
if isinstance(response, DownloadError):
|
homeserver = str(getattr(self.client, "homeserver", "") or self.config.homeserver).rstrip("/")
|
||||||
self.logger.warning("download failed for {}: {}", mxc_url, response)
|
media_url = (
|
||||||
|
f"{homeserver}/_matrix/client/v1/media/download/"
|
||||||
|
f"{quote(parsed.netloc, safe='')}/{quote(parsed.path.strip('/'), safe='')}"
|
||||||
|
)
|
||||||
|
token = getattr(self.client, "access_token", None) or self.config.access_token
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||||
|
timeout = aiohttp.ClientTimeout(total=None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
|
||||||
|
async with session.get(media_url, params={"allow_remote": "true"}) as response:
|
||||||
|
if response.status >= 400:
|
||||||
|
self.logger.warning("download failed for {}: HTTP {}", mxc_url, response.status)
|
||||||
|
return None
|
||||||
|
content_length = response.headers.get("Content-Length")
|
||||||
|
if content_length is not None:
|
||||||
|
try:
|
||||||
|
if int(content_length) > limit_bytes:
|
||||||
|
raise _MediaTooLargeError
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
chunks = bytearray()
|
||||||
|
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||||
|
chunks.extend(chunk)
|
||||||
|
if len(chunks) > limit_bytes:
|
||||||
|
raise _MediaTooLargeError
|
||||||
|
return bytes(chunks)
|
||||||
|
except _MediaTooLargeError:
|
||||||
|
raise
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
|
||||||
|
self.logger.warning("download failed for {}", mxc_url, exc_info=True)
|
||||||
return None
|
return None
|
||||||
body = getattr(response, "body", None)
|
|
||||||
if isinstance(body, (bytes, bytearray)):
|
|
||||||
return bytes(body)
|
|
||||||
if isinstance(response, MemoryDownloadResponse):
|
|
||||||
return bytes(response.body)
|
|
||||||
if isinstance(body, (str, Path)):
|
|
||||||
path = Path(body)
|
|
||||||
if path.is_file():
|
|
||||||
try:
|
|
||||||
return path.read_bytes()
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
|
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
|
||||||
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
|
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
|
||||||
@@ -820,10 +922,14 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
limit_bytes = await self._effective_media_limit_bytes()
|
limit_bytes = await self._effective_media_limit_bytes()
|
||||||
declared = self._event_declared_size_bytes(event)
|
declared = self._event_declared_size_bytes(event)
|
||||||
if declared is not None and declared > limit_bytes:
|
if declared is None or declared > limit_bytes:
|
||||||
return None, _ATTACH_TOO_LARGE.format(filename)
|
return None, _ATTACH_TOO_LARGE.format(filename)
|
||||||
|
|
||||||
downloaded = await self._download_media_bytes(mxc_url)
|
try:
|
||||||
|
async with self._media_download_semaphore:
|
||||||
|
downloaded = await self._download_media_bytes(mxc_url, limit_bytes)
|
||||||
|
except _MediaTooLargeError:
|
||||||
|
return None, _ATTACH_TOO_LARGE.format(filename)
|
||||||
if downloaded is None:
|
if downloaded is None:
|
||||||
return None, fail
|
return None, fail
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ if MSTEAMS_AVAILABLE:
|
|||||||
|
|
||||||
MSTEAMS_REF_TTL_DAYS = 30
|
MSTEAMS_REF_TTL_DAYS = 30
|
||||||
MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com"
|
MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com"
|
||||||
|
MSTEAMS_DEFAULT_TRUSTED_SERVICE_URL_HOSTS = [
|
||||||
|
"smba.trafficmanager.net",
|
||||||
|
"smba.infra.gcc.teams.microsoft.com",
|
||||||
|
"smba.infra.gov.teams.microsoft.us",
|
||||||
|
"smba.infra.dod.teams.microsoft.us",
|
||||||
|
"*.botframework.com",
|
||||||
|
]
|
||||||
MSTEAMS_REF_META_FILENAME = "msteams_conversations_meta.json"
|
MSTEAMS_REF_META_FILENAME = "msteams_conversations_meta.json"
|
||||||
MSTEAMS_REF_LOCK_FILENAME = "msteams_conversations.lock"
|
MSTEAMS_REF_LOCK_FILENAME = "msteams_conversations.lock"
|
||||||
MSTEAMS_REF_TOUCH_INTERVAL_S = 300
|
MSTEAMS_REF_TOUCH_INTERVAL_S = 300
|
||||||
@@ -76,6 +83,9 @@ class MSTeamsConfig(Base):
|
|||||||
prune_web_chat_refs: bool = True
|
prune_web_chat_refs: bool = True
|
||||||
prune_non_personal_refs: bool = True
|
prune_non_personal_refs: bool = True
|
||||||
ref_touch_interval_s: int = Field(default=MSTEAMS_REF_TOUCH_INTERVAL_S, ge=0)
|
ref_touch_interval_s: int = Field(default=MSTEAMS_REF_TOUCH_INTERVAL_S, ge=0)
|
||||||
|
trusted_service_url_hosts: list[str] = Field(
|
||||||
|
default_factory=lambda: MSTEAMS_DEFAULT_TRUSTED_SERVICE_URL_HOSTS.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -242,6 +252,11 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
if not ref:
|
if not ref:
|
||||||
raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}")
|
raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}")
|
||||||
|
|
||||||
|
if not self._is_trusted_service_url(ref.service_url):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"MSTeams conversation ref has untrusted service_url for chat_id={msg.chat_id}"
|
||||||
|
)
|
||||||
|
|
||||||
token = await self._get_access_token()
|
token = await self._get_access_token()
|
||||||
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
|
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
|
||||||
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
|
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
|
||||||
@@ -284,6 +299,13 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
if not sender_id or not conversation_id or not service_url:
|
if not sender_id or not conversation_id or not service_url:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self._is_trusted_service_url(service_url):
|
||||||
|
self.logger.warning(
|
||||||
|
"Ignoring MSTeams activity with untrusted serviceUrl host: {}",
|
||||||
|
service_url,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if recipient.get("id") and from_user.get("id") == recipient.get("id"):
|
if recipient.get("id") and from_user.get("id") == recipient.get("id"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -626,6 +648,29 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
return host == MSTEAMS_WEBCHAT_HOST or host.endswith(f".{MSTEAMS_WEBCHAT_HOST}")
|
return host == MSTEAMS_WEBCHAT_HOST or host.endswith(f".{MSTEAMS_WEBCHAT_HOST}")
|
||||||
return MSTEAMS_WEBCHAT_HOST in normalized.lower()
|
return MSTEAMS_WEBCHAT_HOST in normalized.lower()
|
||||||
|
|
||||||
|
def _is_trusted_service_url(self, service_url: str) -> bool:
|
||||||
|
"""Return True for HTTPS Bot Framework service URLs trusted for bearer replies."""
|
||||||
|
parsed = urlparse(service_url.strip())
|
||||||
|
if parsed.scheme.lower() != "https":
|
||||||
|
return False
|
||||||
|
|
||||||
|
host = (parsed.hostname or "").strip().lower().rstrip(".")
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for pattern in self.config.trusted_service_url_hosts:
|
||||||
|
trusted_host = str(pattern or "").strip().lower().rstrip(".")
|
||||||
|
if not trusted_host:
|
||||||
|
continue
|
||||||
|
if trusted_host.startswith("*."):
|
||||||
|
suffix = trusted_host[1:]
|
||||||
|
if host.endswith(suffix) and host != suffix.lstrip("."):
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
if host == trusted_host:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _prune_conversation_refs(self, *, now: float | None = None) -> bool:
|
def _prune_conversation_refs(self, *, now: float | None = None) -> bool:
|
||||||
"""Remove stale and unsupported conversation refs from memory."""
|
"""Remove stale and unsupported conversation refs from memory."""
|
||||||
if not self._conversation_refs:
|
if not self._conversation_refs:
|
||||||
@@ -637,6 +682,10 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
keys_to_drop: list[str] = []
|
keys_to_drop: list[str] = []
|
||||||
|
|
||||||
for key, ref in self._conversation_refs.items():
|
for key, ref in self._conversation_refs.items():
|
||||||
|
if not self._is_trusted_service_url(ref.service_url):
|
||||||
|
keys_to_drop.append(key)
|
||||||
|
continue
|
||||||
|
|
||||||
if self.config.prune_web_chat_refs and self._is_webchat_service_url(ref.service_url):
|
if self.config.prune_web_chat_refs and self._is_webchat_service_url(ref.service_url):
|
||||||
keys_to_drop.append(key)
|
keys_to_drop.append(key)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Auto-discovery for built-in channel modules and external plugins."""
|
"""Auto-discovery for built-in channel modules and external plugins."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
@@ -37,12 +36,14 @@ def load_channel_class(module_name: str) -> type[BaseChannel]:
|
|||||||
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
|
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
|
||||||
|
|
||||||
|
|
||||||
def discover_plugins() -> dict[str, type[BaseChannel]]:
|
def discover_plugins(enabled_names: set[str] | None = None) -> dict[str, type[BaseChannel]]:
|
||||||
"""Discover external channel plugins registered via entry_points."""
|
"""Discover external channel plugins registered via entry_points."""
|
||||||
from importlib.metadata import entry_points
|
from importlib.metadata import entry_points
|
||||||
|
|
||||||
plugins: dict[str, type[BaseChannel]] = {}
|
plugins: dict[str, type[BaseChannel]] = {}
|
||||||
for ep in entry_points(group="nanobot.channels"):
|
for ep in entry_points(group="nanobot.channels"):
|
||||||
|
if enabled_names is not None and ep.name not in enabled_names:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
cls = ep.load()
|
cls = ep.load()
|
||||||
plugins[ep.name] = cls
|
plugins[ep.name] = cls
|
||||||
@@ -51,21 +52,44 @@ def discover_plugins() -> dict[str, type[BaseChannel]]:
|
|||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
|
def discover_enabled(
|
||||||
|
enabled_names: set[str],
|
||||||
|
*,
|
||||||
|
_names: list[str] | None = None,
|
||||||
|
_include_all_external: bool = False,
|
||||||
|
) -> dict[str, type[BaseChannel]]:
|
||||||
|
"""Return channels whose module names are in *enabled_names*.
|
||||||
|
|
||||||
|
Uses cheap ``pkgutil.iter_modules`` to list names, then imports only
|
||||||
|
those that match — skipping the heavy third-party SDK imports of
|
||||||
|
unneeded channels.
|
||||||
|
"""
|
||||||
|
names = _names if _names is not None else discover_channel_names()
|
||||||
|
result: dict[str, type[BaseChannel]] = {}
|
||||||
|
for modname in names:
|
||||||
|
if modname not in enabled_names:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result[modname] = load_channel_class(modname)
|
||||||
|
except ImportError as e:
|
||||||
|
logger.debug("Skipping built-in channel '{}': {}", modname, e)
|
||||||
|
|
||||||
|
external = discover_plugins(None if _include_all_external else enabled_names)
|
||||||
|
shadowed = set(external) & set(result)
|
||||||
|
if shadowed:
|
||||||
|
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
|
||||||
|
if _include_all_external:
|
||||||
|
result.update({k: v for k, v in external.items() if k not in shadowed})
|
||||||
|
else:
|
||||||
|
result.update({k: v for k, v in external.items() if k not in shadowed and k in enabled_names})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def discover_all() -> dict[str, type[BaseChannel]]:
|
def discover_all() -> dict[str, type[BaseChannel]]:
|
||||||
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
|
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
|
||||||
|
|
||||||
Built-in channels take priority — an external plugin cannot shadow a built-in name.
|
Built-in channels take priority — an external plugin cannot shadow a built-in name.
|
||||||
"""
|
"""
|
||||||
builtin: dict[str, type[BaseChannel]] = {}
|
names = discover_channel_names()
|
||||||
for modname in discover_channel_names():
|
return discover_enabled(set(names), _names=names, _include_all_external=True)
|
||||||
try:
|
|
||||||
builtin[modname] = load_channel_class(modname)
|
|
||||||
except ImportError as e:
|
|
||||||
logger.debug("Skipping built-in channel '{}': {}", modname, e)
|
|
||||||
|
|
||||||
external = discover_plugins()
|
|
||||||
shadowed = set(external) & set(builtin)
|
|
||||||
if shadowed:
|
|
||||||
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
|
|
||||||
|
|
||||||
return {**external, **builtin}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+165
-12
@@ -10,8 +10,9 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator, model_validator
|
||||||
from telegram import (
|
from telegram import (
|
||||||
BotCommand,
|
BotCommand,
|
||||||
InlineKeyboardButton,
|
InlineKeyboardButton,
|
||||||
@@ -225,11 +226,22 @@ class _StreamBuf:
|
|||||||
stream_id: str | None = None
|
stream_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _QueuedTelegramUpdate:
|
||||||
|
"""Telegram update staged for per-session ordered processing."""
|
||||||
|
|
||||||
|
kind: Literal["command", "message"]
|
||||||
|
update: Update
|
||||||
|
context: Any
|
||||||
|
sort_key: tuple[int, int]
|
||||||
|
|
||||||
|
|
||||||
class TelegramConfig(Base):
|
class TelegramConfig(Base):
|
||||||
"""Telegram channel configuration."""
|
"""Telegram channel configuration."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
token: str = ""
|
token: str = ""
|
||||||
|
mode: Literal["polling", "webhook"] = "polling"
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
proxy: str | None = None
|
proxy: str | None = None
|
||||||
reply_to_message: bool = False
|
reply_to_message: bool = False
|
||||||
@@ -241,13 +253,48 @@ class TelegramConfig(Base):
|
|||||||
# Enable inline keyboard buttons in Telegram messages.
|
# Enable inline keyboard buttons in Telegram messages.
|
||||||
inline_keyboards: bool = False
|
inline_keyboards: bool = False
|
||||||
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
|
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
|
||||||
|
webhook_url: str = ""
|
||||||
|
webhook_listen_host: str = "127.0.0.1"
|
||||||
|
webhook_listen_port: int = Field(default=8081, ge=1, le=65535)
|
||||||
|
webhook_path: str = "/telegram"
|
||||||
|
webhook_secret_token: str = ""
|
||||||
|
webhook_max_connections: int = Field(default=4, ge=1, le=100)
|
||||||
|
|
||||||
|
@field_validator("webhook_path")
|
||||||
|
@classmethod
|
||||||
|
def webhook_path_must_start_with_slash(cls, value: str) -> str:
|
||||||
|
value = value.strip() or "/telegram"
|
||||||
|
if not value.startswith("/"):
|
||||||
|
raise ValueError('webhook_path must start with "/"')
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_webhook_config(self) -> "TelegramConfig":
|
||||||
|
if self.mode != "webhook":
|
||||||
|
return self
|
||||||
|
|
||||||
|
url = self.webhook_url.strip()
|
||||||
|
if not url:
|
||||||
|
raise ValueError("webhook_url is required when Telegram mode is webhook")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme != "https" or not parsed.netloc:
|
||||||
|
raise ValueError("webhook_url must be a public HTTPS URL")
|
||||||
|
secret = self.webhook_secret_token.strip()
|
||||||
|
if not secret:
|
||||||
|
raise ValueError("webhook_secret_token is required when Telegram mode is webhook")
|
||||||
|
if len(secret) > 256 or re.match(r"^[A-Za-z0-9_-]+$", secret) is None:
|
||||||
|
raise ValueError(
|
||||||
|
"webhook_secret_token must be 1-256 characters using only A-Z, a-z, 0-9, _ and -"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TelegramChannel(BaseChannel):
|
class TelegramChannel(BaseChannel):
|
||||||
"""
|
"""
|
||||||
Telegram channel using long polling.
|
Telegram channel using long polling or webhook mode.
|
||||||
|
|
||||||
Simple and reliable - no webhook/public IP needed.
|
Long polling is the default. Webhook mode requires a public HTTPS URL and a
|
||||||
|
Telegram secret token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "telegram"
|
name = "telegram"
|
||||||
@@ -294,6 +341,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._bot_user_id: int | None = None
|
self._bot_user_id: int | None = None
|
||||||
self._bot_username: str | None = None
|
self._bot_username: str | None = None
|
||||||
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
|
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
|
||||||
|
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
|
||||||
|
self._inbound_workers: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
def is_allowed(self, sender_id: str) -> bool:
|
def is_allowed(self, sender_id: str) -> bool:
|
||||||
"""Preserve Telegram's legacy id|username allowlist matching."""
|
"""Preserve Telegram's legacy id|username allowlist matching."""
|
||||||
@@ -326,7 +375,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Telegram bot with long polling."""
|
"""Start the Telegram bot."""
|
||||||
if not self.config.token:
|
if not self.config.token:
|
||||||
self.logger.error("bot token not configured")
|
self.logger.error("bot token not configured")
|
||||||
return
|
return
|
||||||
@@ -394,9 +443,12 @@ class TelegramChannel(BaseChannel):
|
|||||||
else:
|
else:
|
||||||
allowed_updates = ["message"]
|
allowed_updates = ["message"]
|
||||||
|
|
||||||
self.logger.info("Starting bot (polling mode)...")
|
if self.config.mode == "webhook":
|
||||||
|
self.logger.info("Starting bot (webhook mode)...")
|
||||||
|
else:
|
||||||
|
self.logger.info("Starting bot (polling mode)...")
|
||||||
|
|
||||||
# Initialize and start polling
|
# Initialize and start receiving updates
|
||||||
await self._app.initialize()
|
await self._app.initialize()
|
||||||
await self._app.start()
|
await self._app.start()
|
||||||
|
|
||||||
@@ -412,12 +464,26 @@ class TelegramChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to register bot commands: {}", e)
|
self.logger.warning("Failed to register bot commands: {}", e)
|
||||||
|
|
||||||
# Start polling (this runs until stopped)
|
if self.config.mode == "webhook":
|
||||||
await self._app.updater.start_polling(
|
# ``url_path`` is the local HTTP route. ``webhook_url`` is the
|
||||||
allowed_updates=allowed_updates,
|
# public HTTPS URL Telegram calls; reverse proxies may rewrite it.
|
||||||
drop_pending_updates=False, # Process pending messages on startup
|
await self._app.updater.start_webhook(
|
||||||
error_callback=self._on_polling_error,
|
listen=self.config.webhook_listen_host,
|
||||||
)
|
port=self.config.webhook_listen_port,
|
||||||
|
url_path=self.config.webhook_path.lstrip("/"),
|
||||||
|
webhook_url=self.config.webhook_url.strip(),
|
||||||
|
allowed_updates=allowed_updates,
|
||||||
|
drop_pending_updates=False,
|
||||||
|
secret_token=self.config.webhook_secret_token.strip(),
|
||||||
|
max_connections=self.config.webhook_max_connections,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Start polling (this runs until stopped)
|
||||||
|
await self._app.updater.start_polling(
|
||||||
|
allowed_updates=allowed_updates,
|
||||||
|
drop_pending_updates=False, # Process pending messages on startup
|
||||||
|
error_callback=self._on_polling_error,
|
||||||
|
)
|
||||||
|
|
||||||
# Keep running until stopped
|
# Keep running until stopped
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -436,6 +502,11 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._media_group_tasks.clear()
|
self._media_group_tasks.clear()
|
||||||
self._media_group_buffers.clear()
|
self._media_group_buffers.clear()
|
||||||
|
|
||||||
|
for task in self._inbound_workers.values():
|
||||||
|
task.cancel()
|
||||||
|
self._inbound_workers.clear()
|
||||||
|
self._inbound_buffers.clear()
|
||||||
|
|
||||||
if self._app:
|
if self._app:
|
||||||
self.logger.info("Stopping bot...")
|
self.logger.info("Stopping bot...")
|
||||||
await self._app.updater.stop()
|
await self._app.updater.stop()
|
||||||
@@ -995,10 +1066,85 @@ class TelegramChannel(BaseChannel):
|
|||||||
if len(self._message_threads) > 1000:
|
if len(self._message_threads) > 1000:
|
||||||
self._message_threads.pop(next(iter(self._message_threads)))
|
self._message_threads.pop(next(iter(self._message_threads)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _queue_key_for_message(message) -> str:
|
||||||
|
"""Return the final nanobot session key used for ordered Telegram ingress."""
|
||||||
|
return TelegramChannel._derive_topic_session_key(message) or f"telegram:{message.chat_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sort_key_for_update(update: Update) -> tuple[int, int]:
|
||||||
|
"""Sort by chat message id first, then Telegram update id."""
|
||||||
|
message = getattr(update, "message", None)
|
||||||
|
message_id = int(getattr(message, "message_id", 0) or 0)
|
||||||
|
update_id = int(getattr(update, "update_id", 0) or 0)
|
||||||
|
return (message_id, update_id)
|
||||||
|
|
||||||
|
def _enqueue_ordered_update(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: Literal["command", "message"],
|
||||||
|
update: Update,
|
||||||
|
context: ContextTypes.DEFAULT_TYPE,
|
||||||
|
) -> None:
|
||||||
|
"""Stage a Telegram update behind a short per-session reorder window."""
|
||||||
|
message = update.message
|
||||||
|
key = self._queue_key_for_message(message)
|
||||||
|
self._inbound_buffers.setdefault(key, []).append(
|
||||||
|
_QueuedTelegramUpdate(
|
||||||
|
kind=kind,
|
||||||
|
update=update,
|
||||||
|
context=context,
|
||||||
|
sort_key=self._sort_key_for_update(update),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if key not in self._inbound_workers:
|
||||||
|
self._inbound_workers[key] = asyncio.create_task(
|
||||||
|
self._drain_ordered_updates(key)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _drain_ordered_updates(self, key: str) -> None:
|
||||||
|
"""Drain one Telegram session buffer in stable message order."""
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
batch = self._inbound_buffers.get(key, [])
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
self._inbound_buffers[key] = []
|
||||||
|
batch.sort(key=lambda item: item.sort_key)
|
||||||
|
for item in batch:
|
||||||
|
try:
|
||||||
|
if item.kind == "command":
|
||||||
|
await self._process_forward_command(item.update, item.context)
|
||||||
|
else:
|
||||||
|
await self._process_message_update(item.update, item.context)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"Telegram queued update handling failed for {}: {}",
|
||||||
|
key,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
if not self._inbound_buffers.get(key):
|
||||||
|
self._inbound_buffers.pop(key, None)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning("Telegram ordered update worker failed for {}: {}", key, e)
|
||||||
|
finally:
|
||||||
|
if not self._inbound_buffers.get(key):
|
||||||
|
self._inbound_workers.pop(key, None)
|
||||||
|
|
||||||
async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Forward slash commands to the bus for unified handling in AgentLoop."""
|
"""Forward slash commands to the bus for unified handling in AgentLoop."""
|
||||||
if not update.message or not update.effective_user:
|
if not update.message or not update.effective_user:
|
||||||
return
|
return
|
||||||
|
if not self._running:
|
||||||
|
await self._process_forward_command(update, context)
|
||||||
|
return
|
||||||
|
self._enqueue_ordered_update(kind="command", update=update, context=context)
|
||||||
|
|
||||||
|
async def _process_forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""Process a queued slash command."""
|
||||||
message = update.message
|
message = update.message
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
sender_id = self._sender_id(user)
|
sender_id = self._sender_id(user)
|
||||||
@@ -1027,6 +1173,13 @@ class TelegramChannel(BaseChannel):
|
|||||||
"""Handle incoming messages (text, photos, voice, documents)."""
|
"""Handle incoming messages (text, photos, voice, documents)."""
|
||||||
if not update.message or not update.effective_user:
|
if not update.message or not update.effective_user:
|
||||||
return
|
return
|
||||||
|
if not self._running:
|
||||||
|
await self._process_message_update(update, context)
|
||||||
|
return
|
||||||
|
self._enqueue_ordered_update(kind="message", update=update, context=context)
|
||||||
|
|
||||||
|
async def _process_message_update(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""Process a queued Telegram message update."""
|
||||||
|
|
||||||
message = update.message
|
message = update.message
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
|||||||
+477
-381
File diff suppressed because it is too large
Load Diff
+163
-6
@@ -79,6 +79,12 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
|
|||||||
ERRCODE_SESSION_EXPIRED = -14
|
ERRCODE_SESSION_EXPIRED = -14
|
||||||
SESSION_PAUSE_DURATION_S = 60 * 60
|
SESSION_PAUSE_DURATION_S = 60 * 60
|
||||||
|
|
||||||
|
# iLink context_token is observed to expire server-side after ~90-160s of
|
||||||
|
# agent inactivity (openclaw/openclaw#61174). Proactively refresh before
|
||||||
|
# sending if the cached token is older than this threshold.
|
||||||
|
CONTEXT_TOKEN_MAX_AGE_S = 60
|
||||||
|
|
||||||
|
|
||||||
# Retry constants (matching the reference plugin's monitor.ts)
|
# Retry constants (matching the reference plugin's monitor.ts)
|
||||||
MAX_CONSECUTIVE_FAILURES = 3
|
MAX_CONSECUTIVE_FAILURES = 3
|
||||||
BACKOFF_DELAY_S = 30
|
BACKOFF_DELAY_S = 30
|
||||||
@@ -159,6 +165,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._session_pause_until: float = 0.0
|
self._session_pause_until: float = 0.0
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||||
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
||||||
|
self._context_token_at: dict[str, float] = {}
|
||||||
|
self._pending_tool_hints: dict[str, list[str]] = {}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# State persistence
|
# State persistence
|
||||||
@@ -486,6 +494,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
break
|
break
|
||||||
|
self.logger.exception("WeChat poll loop error")
|
||||||
consecutive_failures += 1
|
consecutive_failures += 1
|
||||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
@@ -495,6 +504,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._pending_tool_hints.clear()
|
||||||
if self._poll_task and not self._poll_task.done():
|
if self._poll_task and not self._poll_task.done():
|
||||||
self._poll_task.cancel()
|
self._poll_task.cancel()
|
||||||
for chat_id in list(self._typing_tasks):
|
for chat_id in list(self._typing_tasks):
|
||||||
@@ -545,6 +555,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
# Check for API-level errors (monitor.ts checks both ret and errcode)
|
# Check for API-level errors (monitor.ts checks both ret and errcode)
|
||||||
ret = data.get("ret", 0)
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
|
|
||||||
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
|
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
|
||||||
|
|
||||||
if is_error:
|
if is_error:
|
||||||
@@ -575,8 +586,10 @@ class WeixinChannel(BaseChannel):
|
|||||||
# Process messages (WeixinMessage[] from types.ts)
|
# Process messages (WeixinMessage[] from types.ts)
|
||||||
msgs: list[dict] = data.get("msgs", []) or []
|
msgs: list[dict] = data.get("msgs", []) or []
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
with suppress(Exception):
|
try:
|
||||||
await self._process_message(msg)
|
await self._process_message(msg)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Failed to process WeChat message")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||||
@@ -610,6 +623,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
ctx_token = msg.get("context_token", "")
|
ctx_token = msg.get("context_token", "")
|
||||||
if ctx_token:
|
if ctx_token:
|
||||||
self._context_tokens[from_user_id] = ctx_token
|
self._context_tokens[from_user_id] = ctx_token
|
||||||
|
self._context_token_at[from_user_id] = time.time()
|
||||||
self._save_state()
|
self._save_state()
|
||||||
|
|
||||||
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
||||||
@@ -915,6 +929,99 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def _refresh_context_token_if_stale(
|
||||||
|
self, chat_id: str, context_token: str
|
||||||
|
) -> str:
|
||||||
|
"""Return a fresh context_token if the cached one is too old.
|
||||||
|
|
||||||
|
iLink context_token expires server-side after a short idle period
|
||||||
|
(empirically ~90s). Proactively refreshing before sending prevents
|
||||||
|
silent message loss on long agent turns or cron pushes.
|
||||||
|
"""
|
||||||
|
if not context_token:
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
cached_at = self._context_token_at.get(chat_id, 0)
|
||||||
|
age = now - cached_at
|
||||||
|
|
||||||
|
if age < CONTEXT_TOKEN_MAX_AGE_S:
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"WeChat context_token for {} is {:.0f}s old; refreshing via getconfig",
|
||||||
|
chat_id,
|
||||||
|
age,
|
||||||
|
)
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"ilink_user_id": chat_id,
|
||||||
|
"context_token": context_token,
|
||||||
|
"base_info": BASE_INFO,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await self._api_post("ilink/bot/getconfig", body)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning("WeChat getconfig failed for {}: {}", chat_id, e)
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
if data.get("ret", 0) != 0:
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat getconfig returned ret={} for {}: {}",
|
||||||
|
data.get("ret"),
|
||||||
|
chat_id,
|
||||||
|
data.get("errmsg", ""),
|
||||||
|
)
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
new_token = str(data.get("context_token", "") or "")
|
||||||
|
if new_token and new_token != context_token:
|
||||||
|
self.logger.info(
|
||||||
|
"WeChat context_token refreshed for {} (age {:.0f}s -> fresh)",
|
||||||
|
chat_id,
|
||||||
|
age,
|
||||||
|
)
|
||||||
|
self._context_tokens[chat_id] = new_token
|
||||||
|
self._context_token_at[chat_id] = now
|
||||||
|
self._save_state()
|
||||||
|
return new_token
|
||||||
|
|
||||||
|
return context_token
|
||||||
|
|
||||||
|
async def _flush_tool_hints(self, chat_id: str) -> None:
|
||||||
|
"""Send any buffered tool hints for *chat_id* as a single message.
|
||||||
|
|
||||||
|
Tool hints are coalesced to reduce message count and avoid hitting the
|
||||||
|
WeChat iLink rate limit (~7 msgs / 5 min). Failures are logged but
|
||||||
|
not raised so that the main message send is never blocked.
|
||||||
|
"""
|
||||||
|
hints = self._pending_tool_hints.pop(chat_id, None)
|
||||||
|
if not hints:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Flushing {} buffered tool hint(s) for {}",
|
||||||
|
len(hints),
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx_token = self._context_tokens.get(chat_id, "")
|
||||||
|
ctx_token = await self._refresh_context_token_if_stale(chat_id, ctx_token)
|
||||||
|
if not ctx_token:
|
||||||
|
self.logger.warning(
|
||||||
|
"Dropped {} buffered tool hint(s) for {}: no context_token",
|
||||||
|
len(hints),
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._send_text(chat_id, "\n\n".join(hints), ctx_token)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(
|
||||||
|
"Failed to flush buffered tool hints for {}", chat_id
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None:
|
async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None:
|
||||||
"""Best-effort sendtyping wrapper."""
|
"""Best-effort sendtyping wrapper."""
|
||||||
if not typing_ticket:
|
if not typing_ticket:
|
||||||
@@ -944,11 +1051,47 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._assert_session_active()
|
self._assert_session_active()
|
||||||
|
|
||||||
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
||||||
|
|
||||||
|
# Buffer tool hints to coalesce consecutive ones and avoid burning
|
||||||
|
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
|
||||||
|
if is_progress and (msg.metadata or {}).get("_tool_hint"):
|
||||||
|
if not self.send_tool_hints:
|
||||||
|
return
|
||||||
|
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
|
||||||
|
self.logger.debug(
|
||||||
|
"Buffered tool hint for {} (count={})",
|
||||||
|
msg.chat_id,
|
||||||
|
len(self._pending_tool_hints[msg.chat_id]),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reasoning deltas are invisible in WeChat (there is no reasoning
|
||||||
|
# UI). Skip them entirely — do not send and do not flush buffer.
|
||||||
|
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
|
||||||
|
self.logger.debug(
|
||||||
|
"Dropped invisible reasoning delta for {}", msg.chat_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
content = msg.content.strip()
|
||||||
|
|
||||||
|
# Empty progress messages (e.g. after_iteration tool_events) must
|
||||||
|
# NOT act as separators — they have no visible content.
|
||||||
|
if is_progress and not content and not (msg.media or []):
|
||||||
|
self.logger.debug(
|
||||||
|
"Skipped empty progress message for {} (no visible content)",
|
||||||
|
msg.chat_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Flush buffered hints before sending any visible message.
|
||||||
|
await self._flush_tool_hints(msg.chat_id)
|
||||||
|
|
||||||
if not is_progress:
|
if not is_progress:
|
||||||
await self._stop_typing(msg.chat_id, clear_remote=True)
|
await self._stop_typing(msg.chat_id, clear_remote=True)
|
||||||
|
|
||||||
content = msg.content.strip()
|
|
||||||
ctx_token = self._context_tokens.get(msg.chat_id, "")
|
ctx_token = self._context_tokens.get(msg.chat_id, "")
|
||||||
|
ctx_token = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token)
|
||||||
if not ctx_token:
|
if not ctx_token:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
||||||
@@ -1037,6 +1180,18 @@ class WeixinChannel(BaseChannel):
|
|||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
||||||
|
|
||||||
|
async def send_delta(
|
||||||
|
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Weixin iLink does not support native streaming deltas.
|
||||||
|
|
||||||
|
We only hook ``_stream_end`` so buffered tool hints are flushed even
|
||||||
|
when the final answer carries the ``_streamed`` flag and bypasses
|
||||||
|
:meth:`send`.
|
||||||
|
"""
|
||||||
|
if metadata and metadata.get("_stream_end"):
|
||||||
|
await self._flush_tool_hints(chat_id)
|
||||||
|
|
||||||
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
||||||
"""Start typing indicator immediately when a message is received."""
|
"""Start typing indicator immediately when a message is received."""
|
||||||
if not self._client or not self._token or not chat_id:
|
if not self._client or not self._token or not chat_id:
|
||||||
@@ -1120,10 +1275,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
|
f"WeChat send text error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_media_file(
|
async def _send_media_file(
|
||||||
@@ -1270,10 +1426,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
|
f"WeChat send media error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+342
-114
@@ -75,7 +75,7 @@ class SafeFileHistory(FileHistory):
|
|||||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||||
from nanobot.config.paths import get_workspace_path, is_default_workspace
|
from nanobot.config.paths import get_workspace_path, is_default_workspace
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.p2p.shell import P2PShell
|
from nanobot.utils.evaluator import evaluate_response
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
from nanobot.utils.restart import (
|
from nanobot.utils.restart import (
|
||||||
consume_restart_notice_from_env,
|
consume_restart_notice_from_env,
|
||||||
@@ -92,17 +92,41 @@ app = typer.Typer(
|
|||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
||||||
|
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
|
||||||
|
_REASONING_FLUSH_CHARS = 60
|
||||||
|
|
||||||
|
_HEARTBEAT_PREAMBLE = (
|
||||||
|
"[Your response will be delivered directly to the user's messaging app. "
|
||||||
|
"Output ONLY the final user-facing message. Never reference internal "
|
||||||
|
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
|
||||||
|
"decision process. If nothing needs reporting, respond with just "
|
||||||
|
"'All clear.' and nothing else.]\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_p2p(config: Config) -> P2PShell | None:
|
def _heartbeat_has_active_tasks(content: str) -> bool:
|
||||||
"""Resolve P2P config and create the stateless P2P shell."""
|
"""True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
|
||||||
mb_cfg = config.mailbox
|
in_comment = False
|
||||||
if not mb_cfg.enabled:
|
in_active_section: bool = False
|
||||||
return None
|
for line in content.splitlines():
|
||||||
return P2PShell(
|
stripped = line.strip()
|
||||||
agent_id=mb_cfg.agent_id,
|
if in_comment:
|
||||||
mailboxes_root=mb_cfg.mailboxes_root,
|
if "-->" in stripped:
|
||||||
)
|
in_comment = False
|
||||||
|
continue
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
if stripped.startswith("##") and not stripped.startswith("###"):
|
||||||
|
heading = stripped.lstrip("#").strip().lower()
|
||||||
|
in_active_section = heading.startswith("active tasks")
|
||||||
|
continue
|
||||||
|
if stripped.startswith("<!--"):
|
||||||
|
if "-->" not in stripped[4:]:
|
||||||
|
in_comment = True
|
||||||
|
continue
|
||||||
|
if in_active_section is False:
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI input: prompt_toolkit for editing, paste, history, and display
|
# CLI input: prompt_toolkit for editing, paste, history, and display
|
||||||
@@ -254,6 +278,35 @@ def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, render
|
|||||||
target.print(f" [dim]↳ {text}[/dim]")
|
target.print(f" [dim]↳ {text}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
class _ReasoningBuffer:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def add(self, text: str) -> str | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
self._text += text
|
||||||
|
if self._should_flush(text):
|
||||||
|
return self.flush()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def flush(self) -> str | None:
|
||||||
|
text = self._text.strip()
|
||||||
|
self._text = ""
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def _should_flush(self, text: str) -> bool:
|
||||||
|
stripped = text.rstrip()
|
||||||
|
return (
|
||||||
|
"\n" in text
|
||||||
|
or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
|
||||||
|
or len(self._text) >= _REASONING_FLUSH_CHARS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
|
def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
|
||||||
"""Print reasoning/thinking content in a distinct style."""
|
"""Print reasoning/thinking content in a distinct style."""
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
@@ -266,6 +319,16 @@ def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer:
|
|||||||
target.print(f"[dim italic]✻ {text}[/dim italic]")
|
target.print(f"[dim italic]✻ {text}[/dim italic]")
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_cli_reasoning(
|
||||||
|
reasoning_buffer: _ReasoningBuffer,
|
||||||
|
thinking: ThinkingSpinner | None,
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
) -> None:
|
||||||
|
text = reasoning_buffer.flush()
|
||||||
|
if text:
|
||||||
|
_print_cli_reasoning(text, thinking, renderer)
|
||||||
|
|
||||||
|
|
||||||
async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
|
async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
|
||||||
"""Print an interactive progress line, pausing the spinner if needed."""
|
"""Print an interactive progress line, pausing the spinner if needed."""
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
@@ -284,6 +347,7 @@ async def _maybe_print_interactive_progress(
|
|||||||
thinking: ThinkingSpinner | None,
|
thinking: ThinkingSpinner | None,
|
||||||
channels_config: Any,
|
channels_config: Any,
|
||||||
renderer: StreamRenderer | None = None,
|
renderer: StreamRenderer | None = None,
|
||||||
|
reasoning_buffer: _ReasoningBuffer | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
metadata = msg.metadata or {}
|
metadata = msg.metadata or {}
|
||||||
if metadata.get("_retry_wait"):
|
if metadata.get("_retry_wait"):
|
||||||
@@ -293,12 +357,24 @@ async def _maybe_print_interactive_progress(
|
|||||||
if not metadata.get("_progress"):
|
if not metadata.get("_progress"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
|
||||||
|
|
||||||
|
if metadata.get("_reasoning_end"):
|
||||||
|
if channels_config and not channels_config.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
|
else:
|
||||||
|
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
|
||||||
|
return True
|
||||||
|
|
||||||
is_tool_hint = metadata.get("_tool_hint", False)
|
is_tool_hint = metadata.get("_tool_hint", False)
|
||||||
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
|
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
|
||||||
if is_reasoning:
|
if is_reasoning:
|
||||||
if channels_config and not channels_config.show_reasoning:
|
if channels_config and not channels_config.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
return True
|
return True
|
||||||
_print_cli_reasoning(msg.content, thinking, renderer)
|
text = reasoning_buffer.add(msg.content)
|
||||||
|
if text:
|
||||||
|
_print_cli_reasoning(text, thinking, renderer)
|
||||||
return True
|
return True
|
||||||
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
||||||
return True
|
return True
|
||||||
@@ -578,6 +654,7 @@ def serve(
|
|||||||
|
|
||||||
from nanobot.api.server import create_app
|
from nanobot.api.server import create_app
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
@@ -593,17 +670,11 @@ def serve(
|
|||||||
sync_workspace_templates(runtime_config.workspace_path)
|
sync_workspace_templates(runtime_config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
session_manager = SessionManager(runtime_config.workspace_path)
|
session_manager = SessionManager(runtime_config.workspace_path)
|
||||||
p2p_shell = _resolve_p2p(runtime_config)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_loop = AgentLoop.from_config(
|
agent_loop = AgentLoop.from_config(
|
||||||
runtime_config, bus,
|
runtime_config, bus,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
p2p_shell=p2p_shell,
|
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||||
image_generation_provider_configs={
|
|
||||||
"openrouter": runtime_config.providers.openrouter,
|
|
||||||
"aihubmix": runtime_config.providers.aihubmix,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
@@ -667,11 +738,144 @@ def gateway(
|
|||||||
_run_gateway(cfg, port=port)
|
_run_gateway(cfg, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_or_create_desktop_config(config: str | None, workspace: str | None) -> Config:
|
||||||
|
"""Load the desktop-owned config, creating it on first launch."""
|
||||||
|
from nanobot.config.loader import (
|
||||||
|
get_config_path,
|
||||||
|
load_config,
|
||||||
|
resolve_config_env_vars,
|
||||||
|
save_config,
|
||||||
|
set_config_path,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config as NanobotConfig
|
||||||
|
|
||||||
|
config_path = Path(config).expanduser().resolve() if config else get_config_path()
|
||||||
|
set_config_path(config_path)
|
||||||
|
created = False
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
loaded = resolve_config_env_vars(load_config(config_path))
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
else:
|
||||||
|
loaded = NanobotConfig()
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if workspace:
|
||||||
|
workspace_path = Path(workspace).expanduser()
|
||||||
|
loaded.agents.defaults.workspace = str(workspace_path)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if created:
|
||||||
|
save_config(loaded, config_path)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_desktop_gateway(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
webui_port: int,
|
||||||
|
webui_socket: str | None,
|
||||||
|
token_issue_secret: str,
|
||||||
|
) -> None:
|
||||||
|
"""Force a local WebSocket-only gateway for the desktop app process."""
|
||||||
|
config.gateway.host = "127.0.0.1"
|
||||||
|
config.gateway.port = webui_port
|
||||||
|
config.gateway.heartbeat.enabled = False
|
||||||
|
|
||||||
|
extras = dict(getattr(config.channels, "__pydantic_extra__", None) or {})
|
||||||
|
for name, section in list(extras.items()):
|
||||||
|
if name == "websocket":
|
||||||
|
continue
|
||||||
|
if isinstance(section, dict):
|
||||||
|
extras[name] = {**section, "enabled": False}
|
||||||
|
else:
|
||||||
|
with suppress(Exception):
|
||||||
|
setattr(section, "enabled", False)
|
||||||
|
extras[name] = section
|
||||||
|
|
||||||
|
websocket_cfg = extras.get("websocket")
|
||||||
|
if not isinstance(websocket_cfg, dict):
|
||||||
|
websocket_cfg = {}
|
||||||
|
websocket_cfg.update(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": webui_port,
|
||||||
|
"unix_socket_path": webui_socket or "",
|
||||||
|
"path": "/",
|
||||||
|
"token_issue_secret": token_issue_secret,
|
||||||
|
"websocket_requires_token": True,
|
||||||
|
"allow_from": ["*"],
|
||||||
|
"streaming": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
extras["websocket"] = websocket_cfg
|
||||||
|
config.channels.__pydantic_extra__ = extras
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("desktop-gateway", hidden=True)
|
||||||
|
def desktop_gateway(
|
||||||
|
webui_port: int = typer.Option(0, "--webui-port", min=0, max=65535),
|
||||||
|
webui_socket: str | None = typer.Option(None, "--webui-socket", help="Unix socket path for desktop IPC"),
|
||||||
|
token_issue_secret: str = typer.Option(..., "--token-issue-secret"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Desktop workspace directory"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Desktop config file"),
|
||||||
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
|
):
|
||||||
|
"""Start the private local gateway used by nanobot Desktop."""
|
||||||
|
if not token_issue_secret.strip():
|
||||||
|
console.print("[red]Error: --token-issue-secret is required[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if webui_port <= 0 and not (webui_socket or "").strip():
|
||||||
|
console.print("[red]Error: --webui-port or --webui-socket is required[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if verbose:
|
||||||
|
logger.remove(_log_handler_id)
|
||||||
|
logger.add(
|
||||||
|
sys.stderr,
|
||||||
|
format=(
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||||
|
"<level>{level: <5}</level> | "
|
||||||
|
"<cyan>{extra[channel]}</cyan> | "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
),
|
||||||
|
level="DEBUG",
|
||||||
|
colorize=None,
|
||||||
|
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
|
||||||
|
)
|
||||||
|
cfg = _load_or_create_desktop_config(config, workspace)
|
||||||
|
_configure_desktop_gateway(
|
||||||
|
cfg,
|
||||||
|
webui_port=webui_port,
|
||||||
|
webui_socket=webui_socket,
|
||||||
|
token_issue_secret=token_issue_secret,
|
||||||
|
)
|
||||||
|
_run_gateway(
|
||||||
|
cfg,
|
||||||
|
port=webui_port,
|
||||||
|
webui_static_dist=False,
|
||||||
|
webui_runtime_surface="native",
|
||||||
|
webui_runtime_capabilities={
|
||||||
|
"can_restart_engine": True,
|
||||||
|
"can_pick_folder": True,
|
||||||
|
"can_open_logs": True,
|
||||||
|
"can_export_diagnostics": True,
|
||||||
|
},
|
||||||
|
health_server_enabled=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_gateway(
|
def _run_gateway(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
port: int | None = None,
|
port: int | None = None,
|
||||||
open_browser_url: str | None = None,
|
open_browser_url: str | None = None,
|
||||||
|
webui_static_dist: bool = True,
|
||||||
|
webui_runtime_surface: str = "browser",
|
||||||
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
|
health_server_enabled: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
@@ -681,8 +885,8 @@ def _run_gateway(
|
|||||||
from nanobot.channels.websocket import publish_runtime_model_update
|
from nanobot.channels.websocket import publish_runtime_model_update
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.heartbeat.service import HeartbeatService
|
|
||||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||||
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
port = port if port is not None else config.gateway.port
|
port = port if port is not None else config.gateway.port
|
||||||
@@ -705,8 +909,6 @@ def _run_gateway(
|
|||||||
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
p2p_shell = _resolve_p2p(config)
|
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop.from_config(
|
agent = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
@@ -715,10 +917,7 @@ def _run_gateway(
|
|||||||
context_window_tokens=provider_snapshot.context_window_tokens,
|
context_window_tokens=provider_snapshot.context_window_tokens,
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
image_generation_provider_configs={
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
"openrouter": config.providers.openrouter,
|
|
||||||
"aihubmix": config.providers.aihubmix,
|
|
||||||
},
|
|
||||||
provider_snapshot_loader=load_provider_snapshot,
|
provider_snapshot_loader=load_provider_snapshot,
|
||||||
runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
|
runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
|
||||||
bus,
|
bus,
|
||||||
@@ -726,7 +925,6 @@ def _run_gateway(
|
|||||||
preset,
|
preset,
|
||||||
),
|
),
|
||||||
provider_signature=provider_snapshot.signature,
|
provider_signature=provider_snapshot.signature,
|
||||||
p2p_shell=p2p_shell,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||||
@@ -778,6 +976,9 @@ def _run_gateway(
|
|||||||
# Set cron callback (needs agent)
|
# Set cron callback (needs agent)
|
||||||
async def on_cron_job(job: CronJob) -> str | None:
|
async def on_cron_job(job: CronJob) -> str | None:
|
||||||
"""Execute a cron job through the agent."""
|
"""Execute a cron job through the agent."""
|
||||||
|
async def _silent(*_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
# Dream is an internal job — run directly, not through the agent loop.
|
# Dream is an internal job — run directly, not through the agent loop.
|
||||||
if job.name == "dream":
|
if job.name == "dream":
|
||||||
try:
|
try:
|
||||||
@@ -787,7 +988,67 @@ def _run_gateway(
|
|||||||
logger.exception("Dream cron job failed")
|
logger.exception("Dream cron job failed")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from nanobot.utils.evaluator import evaluate_response
|
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
|
||||||
|
if job.name == "heartbeat":
|
||||||
|
heartbeat_file = config.workspace_path / "HEARTBEAT.md"
|
||||||
|
try:
|
||||||
|
content = heartbeat_file.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Heartbeat: HEARTBEAT.md missing")
|
||||||
|
return None
|
||||||
|
if not _heartbeat_has_active_tasks(content):
|
||||||
|
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel, chat_id = _pick_heartbeat_target()
|
||||||
|
if channel == "cli":
|
||||||
|
return None
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
_HEARTBEAT_PREAMBLE
|
||||||
|
+ f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Internal check: funnel all output through the post-run gate so the
|
||||||
|
# turn can't deliver directly via the message tool and skip it.
|
||||||
|
suppress_token = None
|
||||||
|
if isinstance(message_tool, MessageTool):
|
||||||
|
suppress_token = message_tool.set_suppress_delivery(True)
|
||||||
|
try:
|
||||||
|
resp = await agent.process_direct(
|
||||||
|
prompt,
|
||||||
|
session_key="heartbeat",
|
||||||
|
channel=channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
on_progress=_silent,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if isinstance(message_tool, MessageTool) and suppress_token is not None:
|
||||||
|
message_tool.reset_suppress_delivery(suppress_token)
|
||||||
|
response = resp.content if resp else ""
|
||||||
|
|
||||||
|
# Keep a small tail of heartbeat history so the loop stays bounded.
|
||||||
|
session = agent.sessions.get_or_create("heartbeat")
|
||||||
|
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
|
||||||
|
agent.sessions.save(session)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fail closed: stay silent on evaluator failure instead of notifying.
|
||||||
|
should_notify = await evaluate_response(
|
||||||
|
response, prompt, agent.provider, agent.model,
|
||||||
|
default_notify=False,
|
||||||
|
)
|
||||||
|
if should_notify:
|
||||||
|
logger.info("Heartbeat: completed, delivering response")
|
||||||
|
await _deliver_to_channel(
|
||||||
|
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
||||||
|
record=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||||
|
return response
|
||||||
|
|
||||||
reminder_note = (
|
reminder_note = (
|
||||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||||
@@ -802,9 +1063,6 @@ def _run_gateway(
|
|||||||
if isinstance(cron_tool, CronTool):
|
if isinstance(cron_tool, CronTool):
|
||||||
cron_token = cron_tool.set_cron_context(True)
|
cron_token = cron_tool.set_cron_context(True)
|
||||||
|
|
||||||
async def _silent(*_args, **_kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
message_record_token = None
|
message_record_token = None
|
||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_record_token = message_tool.set_record_channel_delivery(True)
|
message_record_token = message_tool.set_record_channel_delivery(True)
|
||||||
@@ -861,12 +1119,14 @@ def _run_gateway(
|
|||||||
bus,
|
bus,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
webui_runtime_model_name=_webui_runtime_model_name,
|
webui_runtime_model_name=_webui_runtime_model_name,
|
||||||
|
webui_static_dist=webui_static_dist,
|
||||||
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||||
enabled = set(channels.enabled_channels)
|
enabled = set(channels.enabled_channels)
|
||||||
# Prefer the most recently updated non-internal session on an enabled channel.
|
|
||||||
for item in session_manager.list_sessions():
|
for item in session_manager.list_sessions():
|
||||||
key = item.get("key") or ""
|
key = item.get("key") or ""
|
||||||
if ":" not in key:
|
if ":" not in key:
|
||||||
@@ -876,73 +1136,8 @@ def _run_gateway(
|
|||||||
continue
|
continue
|
||||||
if channel in enabled and chat_id:
|
if channel in enabled and chat_id:
|
||||||
return channel, chat_id
|
return channel, chat_id
|
||||||
# Fallback keeps prior behavior but remains explicit.
|
|
||||||
return "cli", "direct"
|
return "cli", "direct"
|
||||||
|
|
||||||
# Create heartbeat service
|
|
||||||
heartbeat_preamble = (
|
|
||||||
"[Your response will be delivered directly to the user's messaging app. "
|
|
||||||
"Output ONLY the final user-facing message. Never reference internal "
|
|
||||||
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
|
|
||||||
"decision process. If nothing needs reporting, respond with just "
|
|
||||||
"'All clear.' and nothing else.]\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_heartbeat_execute(tasks: str) -> str:
|
|
||||||
"""Phase 2: execute heartbeat tasks through the full agent loop."""
|
|
||||||
channel, chat_id = _pick_heartbeat_target()
|
|
||||||
|
|
||||||
async def _silent(*_args, **_kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
resp = await agent.process_direct(
|
|
||||||
heartbeat_preamble + tasks,
|
|
||||||
session_key="heartbeat",
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
on_progress=_silent,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Keep a small tail of heartbeat history so the loop stays bounded
|
|
||||||
# without losing all short-term context between runs.
|
|
||||||
session = agent.sessions.get_or_create("heartbeat")
|
|
||||||
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
|
|
||||||
agent.sessions.save(session)
|
|
||||||
|
|
||||||
return resp.content if resp else ""
|
|
||||||
|
|
||||||
async def on_heartbeat_notify(response: str) -> None:
|
|
||||||
"""Deliver a heartbeat response to the user's channel.
|
|
||||||
|
|
||||||
In addition to publishing the outbound message, this injects the
|
|
||||||
delivered text as an assistant turn into the *target channel's*
|
|
||||||
session. Without this, a user reply on the channel (e.g. "Sure")
|
|
||||||
lands in a session that has no context about the heartbeat message
|
|
||||||
and the agent cannot follow through.
|
|
||||||
"""
|
|
||||||
channel, chat_id = _pick_heartbeat_target()
|
|
||||||
if channel == "cli":
|
|
||||||
return # No external channel available to deliver to
|
|
||||||
|
|
||||||
await _deliver_to_channel(
|
|
||||||
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
|
||||||
record=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
hb_cfg = config.gateway.heartbeat
|
|
||||||
heartbeat = HeartbeatService(
|
|
||||||
workspace=config.workspace_path,
|
|
||||||
provider=agent.provider,
|
|
||||||
model=agent.model,
|
|
||||||
on_execute=on_heartbeat_execute,
|
|
||||||
on_notify=on_heartbeat_notify,
|
|
||||||
interval_s=hb_cfg.interval_s,
|
|
||||||
enabled=hb_cfg.enabled,
|
|
||||||
timezone=config.agents.defaults.timezone,
|
|
||||||
p2p_shell=p2p_shell,
|
|
||||||
bus=bus,
|
|
||||||
)
|
|
||||||
|
|
||||||
if channels.enabled_channels:
|
if channels.enabled_channels:
|
||||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||||
else:
|
else:
|
||||||
@@ -952,7 +1147,11 @@ def _run_gateway(
|
|||||||
if cron_status["jobs"] > 0:
|
if cron_status["jobs"] > 0:
|
||||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
hb_cfg = config.gateway.heartbeat
|
||||||
|
if hb_cfg.enabled:
|
||||||
|
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
|
||||||
|
|
||||||
async def _health_server(host: str, health_port: int):
|
async def _health_server(host: str, health_port: int):
|
||||||
"""Lightweight HTTP health endpoint on the gateway port."""
|
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||||
@@ -996,21 +1195,37 @@ def _run_gateway(
|
|||||||
console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health")
|
console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health")
|
||||||
async with server:
|
async with server:
|
||||||
await server.serve_forever()
|
await server.serve_forever()
|
||||||
# Register Dream system job (always-on, idempotent on restart)
|
# Register Dream system job (idempotent on restart)
|
||||||
dream_cfg = config.agents.defaults.dream
|
dream_cfg = config.agents.defaults.dream
|
||||||
if dream_cfg.model_override:
|
if dream_cfg.model_override:
|
||||||
agent.dream.model = dream_cfg.model_override
|
agent.dream.model = dream_cfg.model_override
|
||||||
agent.dream.max_batch_size = dream_cfg.max_batch_size
|
agent.dream.max_batch_size = dream_cfg.max_batch_size
|
||||||
agent.dream.max_iterations = dream_cfg.max_iterations
|
agent.dream.max_iterations = dream_cfg.max_iterations
|
||||||
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
|
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
|
||||||
from nanobot.cron.types import CronJob, CronPayload
|
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||||
cron.register_system_job(CronJob(
|
if dream_cfg.enabled:
|
||||||
id="dream",
|
cron.register_system_job(CronJob(
|
||||||
name="dream",
|
id="dream",
|
||||||
schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
|
name="dream",
|
||||||
payload=CronPayload(kind="system_event"),
|
schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
|
||||||
))
|
payload=CronPayload(kind="system_event"),
|
||||||
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
|
))
|
||||||
|
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]○[/yellow] Dream: disabled")
|
||||||
|
|
||||||
|
# Register Heartbeat system job (idempotent on restart)
|
||||||
|
if hb_cfg.enabled:
|
||||||
|
cron.register_system_job(CronJob(
|
||||||
|
id="heartbeat",
|
||||||
|
name="heartbeat",
|
||||||
|
schedule=CronSchedule(
|
||||||
|
kind="every",
|
||||||
|
every_ms=hb_cfg.interval_s * 1000,
|
||||||
|
tz=config.agents.defaults.timezone,
|
||||||
|
),
|
||||||
|
payload=CronPayload(kind="system_event"),
|
||||||
|
))
|
||||||
|
|
||||||
async def _open_browser_when_ready() -> None:
|
async def _open_browser_when_ready() -> None:
|
||||||
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
||||||
@@ -1038,12 +1253,12 @@ def _run_gateway(
|
|||||||
async def run():
|
async def run():
|
||||||
try:
|
try:
|
||||||
await cron.start()
|
await cron.start()
|
||||||
await heartbeat.start()
|
|
||||||
tasks = [
|
tasks = [
|
||||||
agent.run(),
|
agent.run(),
|
||||||
channels.start_all(),
|
channels.start_all(),
|
||||||
_health_server(config.gateway.host, port),
|
|
||||||
]
|
]
|
||||||
|
if health_server_enabled:
|
||||||
|
tasks.append(_health_server(config.gateway.host, port))
|
||||||
if open_browser_url:
|
if open_browser_url:
|
||||||
tasks.append(_open_browser_when_ready())
|
tasks.append(_open_browser_when_ready())
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
@@ -1056,7 +1271,6 @@ def _run_gateway(
|
|||||||
console.print(traceback.format_exc())
|
console.print(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
await agent.close_mcp()
|
await agent.close_mcp()
|
||||||
heartbeat.stop()
|
|
||||||
cron.stop()
|
cron.stop()
|
||||||
agent.stop()
|
agent.stop()
|
||||||
await channels.stop_all()
|
await channels.stop_all()
|
||||||
@@ -1089,6 +1303,7 @@ def agent(
|
|||||||
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
|
||||||
config = _load_runtime_config(config, workspace)
|
config = _load_runtime_config(config, workspace)
|
||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
@@ -1103,8 +1318,6 @@ def agent(
|
|||||||
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
p2p_shell = _resolve_p2p(config)
|
|
||||||
|
|
||||||
if logs:
|
if logs:
|
||||||
logger.enable("nanobot")
|
logger.enable("nanobot")
|
||||||
else:
|
else:
|
||||||
@@ -1114,7 +1327,7 @@ def agent(
|
|||||||
agent_loop = AgentLoop.from_config(
|
agent_loop = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
p2p_shell=p2p_shell,
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
@@ -1130,12 +1343,25 @@ def agent(
|
|||||||
_thinking: ThinkingSpinner | None = None
|
_thinking: ThinkingSpinner | None = None
|
||||||
|
|
||||||
def _make_progress(renderer: StreamRenderer | None = None):
|
def _make_progress(renderer: StreamRenderer | None = None):
|
||||||
|
reasoning_buffer = _ReasoningBuffer()
|
||||||
|
|
||||||
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
|
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
|
||||||
ch = agent_loop.channels_config
|
ch = agent_loop.channels_config
|
||||||
|
|
||||||
|
if _kwargs.get("reasoning_end"):
|
||||||
|
if ch and not ch.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
|
else:
|
||||||
|
_flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
|
||||||
|
return
|
||||||
|
|
||||||
if reasoning:
|
if reasoning:
|
||||||
if ch and not ch.show_reasoning:
|
if ch and not ch.show_reasoning:
|
||||||
|
reasoning_buffer.clear()
|
||||||
return
|
return
|
||||||
_print_cli_reasoning(content, _thinking, renderer)
|
text = reasoning_buffer.add(content)
|
||||||
|
if text:
|
||||||
|
_print_cli_reasoning(text, _thinking, renderer)
|
||||||
return
|
return
|
||||||
if ch and tool_hint and not ch.send_tool_hints:
|
if ch and tool_hint and not ch.send_tool_hints:
|
||||||
return
|
return
|
||||||
@@ -1206,6 +1432,7 @@ def agent(
|
|||||||
turn_done.set()
|
turn_done.set()
|
||||||
turn_response: list[tuple[str, dict]] = []
|
turn_response: list[tuple[str, dict]] = []
|
||||||
renderer: StreamRenderer | None = None
|
renderer: StreamRenderer | None = None
|
||||||
|
reasoning_buffer = _ReasoningBuffer()
|
||||||
|
|
||||||
async def _consume_outbound():
|
async def _consume_outbound():
|
||||||
while True:
|
while True:
|
||||||
@@ -1231,6 +1458,7 @@ def agent(
|
|||||||
renderer,
|
renderer,
|
||||||
agent_loop.channels_config,
|
agent_loop.channels_config,
|
||||||
renderer,
|
renderer,
|
||||||
|
reasoning_buffer,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1271,6 +1499,7 @@ def agent(
|
|||||||
|
|
||||||
turn_done.clear()
|
turn_done.clear()
|
||||||
turn_response.clear()
|
turn_response.clear()
|
||||||
|
reasoning_buffer.clear()
|
||||||
renderer = StreamRenderer(
|
renderer = StreamRenderer(
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
bot_name=config.agents.defaults.bot_name,
|
bot_name=config.agents.defaults.bot_name,
|
||||||
@@ -1312,7 +1541,6 @@ def agent(
|
|||||||
console.print("\nGoodbye!")
|
console.print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
pass
|
|
||||||
agent_loop.stop()
|
agent_loop.stop()
|
||||||
outbound_task.cancel()
|
outbound_task.cancel()
|
||||||
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
||||||
|
|||||||
+218
-2
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
|
|||||||
get_model_suggestions,
|
get_model_suggestions,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import get_config_path, load_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -49,6 +49,10 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
|
|||||||
|
|
||||||
_BACK_PRESSED = object() # Sentinel value for back navigation
|
_BACK_PRESSED = object() # Sentinel value for back navigation
|
||||||
|
|
||||||
|
# Cache of model-preset names populated at runtime so that field handlers can
|
||||||
|
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
|
||||||
|
_MODEL_PRESET_CACHE: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
def _get_questionary():
|
def _get_questionary():
|
||||||
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
||||||
@@ -588,9 +592,102 @@ def _handle_context_window_field(
|
|||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_model_preset_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'model_preset' field with a list of existing presets."""
|
||||||
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
choices = ["(clear/unset)"] + preset_names
|
||||||
|
default_choice = str(current_value) if current_value else "(clear/unset)"
|
||||||
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
|
if new_value is _BACK_PRESSED:
|
||||||
|
return
|
||||||
|
if new_value == "(clear/unset)":
|
||||||
|
setattr(working_model, field_name, None)
|
||||||
|
elif new_value is not None:
|
||||||
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_provider_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'provider' field with a list of registered providers."""
|
||||||
|
provider_names = sorted(_get_provider_names().keys())
|
||||||
|
choices = ["auto"] + provider_names
|
||||||
|
default_choice = str(current_value) if current_value else "auto"
|
||||||
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
|
if new_value is _BACK_PRESSED:
|
||||||
|
return
|
||||||
|
if new_value is not None:
|
||||||
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_fallback_models_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'fallback_models' field with preset-aware list management."""
|
||||||
|
from nanobot.config.schema import InlineFallbackConfig
|
||||||
|
|
||||||
|
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
|
||||||
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
console.clear()
|
||||||
|
console.print(f"[bold]{field_display}[/bold]")
|
||||||
|
if items:
|
||||||
|
for idx, item in enumerate(items, 1):
|
||||||
|
if isinstance(item, InlineFallbackConfig):
|
||||||
|
console.print(f" {idx}. {item.model} ({item.provider}) [inline]")
|
||||||
|
else:
|
||||||
|
console.print(f" {idx}. {item}")
|
||||||
|
else:
|
||||||
|
console.print(" [dim](empty)[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
choices = ["[+] Add preset"]
|
||||||
|
if items:
|
||||||
|
choices.append("[-] Remove last")
|
||||||
|
choices.append("[X] Clear all")
|
||||||
|
choices.append("[Done]")
|
||||||
|
choices.append("<- Back")
|
||||||
|
|
||||||
|
answer = _get_questionary().select(
|
||||||
|
"Manage fallback models:",
|
||||||
|
choices=choices,
|
||||||
|
qmark=">",
|
||||||
|
).ask()
|
||||||
|
|
||||||
|
if answer is None or answer == "<- Back":
|
||||||
|
return
|
||||||
|
if answer == "[Done]":
|
||||||
|
setattr(working_model, field_name, items)
|
||||||
|
return
|
||||||
|
if answer == "[+] Add preset":
|
||||||
|
if not preset_names:
|
||||||
|
console.print("[yellow]! No presets defined yet.[/yellow]")
|
||||||
|
_get_questionary().press_any_key_to_continue().ask()
|
||||||
|
continue
|
||||||
|
add_choices = [p for p in preset_names if p not in items]
|
||||||
|
if not add_choices:
|
||||||
|
console.print("[yellow]! All presets already added.[/yellow]")
|
||||||
|
_get_questionary().press_any_key_to_continue().ask()
|
||||||
|
continue
|
||||||
|
picked = _select_with_back("Select preset:", add_choices)
|
||||||
|
if picked is _BACK_PRESSED or picked is None:
|
||||||
|
continue
|
||||||
|
items.append(picked)
|
||||||
|
elif answer == "[-] Remove last" and items:
|
||||||
|
items.pop()
|
||||||
|
elif answer == "[X] Clear all" and items:
|
||||||
|
items.clear()
|
||||||
|
|
||||||
|
|
||||||
_FIELD_HANDLERS: dict[str, Any] = {
|
_FIELD_HANDLERS: dict[str, Any] = {
|
||||||
"model": _handle_model_field,
|
"model": _handle_model_field,
|
||||||
"context_window_tokens": _handle_context_window_field,
|
"context_window_tokens": _handle_context_window_field,
|
||||||
|
"model_preset": _handle_model_preset_field,
|
||||||
|
"provider": _handle_provider_field,
|
||||||
|
"fallback_models": _handle_fallback_models_field,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -757,6 +854,116 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None
|
|||||||
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
|
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Model Preset Configuration ---
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_preset_cache(config: Config) -> None:
|
||||||
|
"""Synchronise the module-level preset name cache from config."""
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_model_presets(config: Config) -> None:
|
||||||
|
"""Configure model presets (CRUD)."""
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
def get_preset_choices() -> list[str]:
|
||||||
|
choices: list[str] = []
|
||||||
|
for name, preset in config.model_presets.items():
|
||||||
|
choices.append(f"{name} ({preset.model})")
|
||||||
|
choices.append("[+] Add new preset")
|
||||||
|
choices.append("<- Back")
|
||||||
|
return choices
|
||||||
|
|
||||||
|
last_preset_name: str | None = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
console.clear()
|
||||||
|
_show_section_header(
|
||||||
|
"Model Presets",
|
||||||
|
"Create, edit or delete named model presets for quick switching",
|
||||||
|
)
|
||||||
|
choices = get_preset_choices()
|
||||||
|
default_choice = None
|
||||||
|
if last_preset_name:
|
||||||
|
for c in choices:
|
||||||
|
if c.startswith(last_preset_name + " ("):
|
||||||
|
default_choice = c
|
||||||
|
break
|
||||||
|
answer = _select_with_back(
|
||||||
|
"Select preset:", choices, default=default_choice
|
||||||
|
)
|
||||||
|
|
||||||
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
|
break
|
||||||
|
|
||||||
|
assert isinstance(answer, str)
|
||||||
|
|
||||||
|
if answer == "[+] Add new preset":
|
||||||
|
name_input = _get_questionary().text(
|
||||||
|
"Preset name:",
|
||||||
|
validate=lambda t: True if t and t.strip() else "Name cannot be empty",
|
||||||
|
).ask()
|
||||||
|
if not name_input:
|
||||||
|
continue
|
||||||
|
name = name_input.strip()
|
||||||
|
if name in config.model_presets:
|
||||||
|
console.print(f"[yellow]! Preset '{name}' already exists[/yellow]")
|
||||||
|
_pause()
|
||||||
|
continue
|
||||||
|
if name == "default":
|
||||||
|
console.print("[yellow]! 'default' is reserved (auto-generated from Agent Settings)[/yellow]")
|
||||||
|
_pause()
|
||||||
|
continue
|
||||||
|
new_preset = ModelPresetConfig(model="")
|
||||||
|
updated = _configure_pydantic_model(new_preset, f"New Preset: {name}")
|
||||||
|
if updated is not None:
|
||||||
|
config.model_presets[name] = updated
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
last_preset_name = name
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Editing / deleting an existing preset
|
||||||
|
preset_name = answer.split(" (", 1)[0]
|
||||||
|
preset = config.model_presets.get(preset_name)
|
||||||
|
if preset is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_preset_name = preset_name
|
||||||
|
|
||||||
|
choices = ["Edit", "Cancel"]
|
||||||
|
if preset_name != "default":
|
||||||
|
choices.insert(1, "Delete")
|
||||||
|
action = _select_with_back(
|
||||||
|
f"Preset: {preset_name}",
|
||||||
|
choices,
|
||||||
|
default="Edit",
|
||||||
|
)
|
||||||
|
if action is _BACK_PRESSED or action == "Cancel" or action is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action == "Delete":
|
||||||
|
confirm = _get_questionary().confirm(
|
||||||
|
f"Delete preset '{preset_name}'?",
|
||||||
|
default=False,
|
||||||
|
).ask()
|
||||||
|
if confirm:
|
||||||
|
del config.model_presets[preset_name]
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
last_preset_name = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action == "Edit":
|
||||||
|
updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}")
|
||||||
|
if updated is not None:
|
||||||
|
config.model_presets[preset_name] = updated
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[dim]Returning to main menu...[/dim]")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
# --- Provider Configuration ---
|
# --- Provider Configuration ---
|
||||||
|
|
||||||
|
|
||||||
@@ -948,7 +1155,7 @@ _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
|
|||||||
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
|
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
|
||||||
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
|
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
|
||||||
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
|
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
|
||||||
"Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None),
|
"Gateway": ("Gateway Settings", "Configure server host, port", None),
|
||||||
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,6 +1250,12 @@ def _show_summary(config: Config) -> None:
|
|||||||
channel_rows.append((display, status))
|
channel_rows.append((display, status))
|
||||||
_print_summary_panel(channel_rows, "Chat Channels")
|
_print_summary_panel(channel_rows, "Chat Channels")
|
||||||
|
|
||||||
|
# Model Presets
|
||||||
|
preset_rows = []
|
||||||
|
for name, preset in config.model_presets.items():
|
||||||
|
preset_rows.append((name, f"{preset.model} (ctx={preset.context_window_tokens})"))
|
||||||
|
_print_summary_panel(preset_rows, "Model Presets")
|
||||||
|
|
||||||
# Settings sections
|
# Settings sections
|
||||||
for title, model in [
|
for title, model in [
|
||||||
("Agent Settings", config.agents.defaults),
|
("Agent Settings", config.agents.defaults),
|
||||||
@@ -1112,6 +1325,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
|
|
||||||
original_config = base_config.model_copy(deep=True)
|
original_config = base_config.model_copy(deep=True)
|
||||||
config = base_config.model_copy(deep=True)
|
config = base_config.model_copy(deep=True)
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
last_main_choice: str | None = None
|
last_main_choice: str | None = None
|
||||||
while True:
|
while True:
|
||||||
@@ -1123,6 +1337,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
"What would you like to configure?",
|
"What would you like to configure?",
|
||||||
choices=[
|
choices=[
|
||||||
"[P] LLM Provider",
|
"[P] LLM Provider",
|
||||||
|
"[M] Model Presets",
|
||||||
"[C] Chat Channel",
|
"[C] Chat Channel",
|
||||||
"[H] Channel Common",
|
"[H] Channel Common",
|
||||||
"[A] Agent Settings",
|
"[A] Agent Settings",
|
||||||
@@ -1149,6 +1364,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
|
|
||||||
_menu_dispatch = {
|
_menu_dispatch = {
|
||||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||||
|
"[M] Model Presets": lambda: _configure_model_presets(config),
|
||||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||||
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
||||||
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
|||||||
"""Cancel all active tasks and subagents for the session."""
|
"""Cancel all active tasks and subagents for the session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
total = await loop._cancel_active_tasks(msg.session_key)
|
total = await loop._cancel_active_tasks(ctx.key)
|
||||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import pydantic
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||||
|
|
||||||
# Global variable to store current config path (for multi-instance support)
|
# Global variable to store current config path (for multi-instance support)
|
||||||
_current_config_path: Path | None = None
|
_current_config_path: Path | None = None
|
||||||
|
_schema_refs_ready = False
|
||||||
|
|
||||||
|
|
||||||
def set_config_path(path: Path) -> None:
|
def set_config_path(path: Path) -> None:
|
||||||
@@ -39,6 +40,11 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
Returns:
|
Returns:
|
||||||
Loaded configuration object.
|
Loaded configuration object.
|
||||||
"""
|
"""
|
||||||
|
global _schema_refs_ready
|
||||||
|
if not _schema_refs_ready:
|
||||||
|
_resolve_tool_config_refs()
|
||||||
|
_schema_refs_ready = True
|
||||||
|
|
||||||
path = config_path or get_config_path()
|
path = config_path or get_config_path()
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
+39
-17
@@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
|
|||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||||
from nanobot.agent.tools.self import MyToolConfig
|
from nanobot.agent.tools.self import MyToolConfig
|
||||||
from nanobot.agent.tools.shell import ExecToolConfig
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
@@ -36,6 +37,7 @@ class ChannelsConfig(Base):
|
|||||||
send_progress: bool = True # stream agent's text progress to the channel
|
send_progress: bool = True # stream agent's text progress to the channel
|
||||||
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||||
show_reasoning: bool = True # surface model reasoning when channel implements it
|
show_reasoning: bool = True # surface model reasoning when channel implements it
|
||||||
|
extract_document_text: bool = True # extract text from document attachments before sending to the model
|
||||||
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
|
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
|
||||||
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
|
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
|
||||||
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
|
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
|
||||||
@@ -46,6 +48,7 @@ class DreamConfig(Base):
|
|||||||
|
|
||||||
_HOUR_MS = 3_600_000
|
_HOUR_MS = 3_600_000
|
||||||
|
|
||||||
|
enabled: bool = True # Register the periodic Dream consolidation job on startup
|
||||||
interval_h: int = Field(default=2, ge=1) # Every 2 hours by default
|
interval_h: int = Field(default=2, ge=1) # Every 2 hours by default
|
||||||
cron: str | None = Field(default=None, exclude=True) # Legacy compatibility override
|
cron: str | None = Field(default=None, exclude=True) # Legacy compatibility override
|
||||||
model_override: str | None = Field(
|
model_override: str | None = Field(
|
||||||
@@ -91,6 +94,7 @@ FallbackCandidate = str | InlineFallbackConfig
|
|||||||
class ModelPresetConfig(Base):
|
class ModelPresetConfig(Base):
|
||||||
"""A named set of model + generation parameters for quick switching."""
|
"""A named set of model + generation parameters for quick switching."""
|
||||||
|
|
||||||
|
label: str | None = None
|
||||||
model: str
|
model: str
|
||||||
provider: str = "auto"
|
provider: str = "auto"
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 8192
|
||||||
@@ -169,8 +173,9 @@ class ProviderConfig(Base):
|
|||||||
|
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
api_base: str | None = None
|
api_base: str | None = None
|
||||||
|
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface
|
||||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||||
extra_body: dict[str, Any] | None = None # Extra fields merged into every request body
|
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
||||||
|
|
||||||
|
|
||||||
class BedrockProviderConfig(ProviderConfig):
|
class BedrockProviderConfig(ProviderConfig):
|
||||||
@@ -190,6 +195,7 @@ class ProvidersConfig(Base):
|
|||||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
huggingface: ProviderConfig = Field(default_factory=ProviderConfig)
|
huggingface: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
|
skywork: ProviderConfig = Field(default_factory=ProviderConfig) # Skywork / APIFree API gateway
|
||||||
deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
|
deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
groq: ProviderConfig = Field(default_factory=ProviderConfig)
|
groq: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
@@ -207,8 +213,10 @@ class ProvidersConfig(Base):
|
|||||||
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
||||||
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
|
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
|
||||||
longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat
|
longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat
|
||||||
|
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
|
||||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
||||||
|
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
|
||||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
||||||
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
||||||
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
||||||
@@ -218,9 +226,19 @@ class ProvidersConfig(Base):
|
|||||||
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
||||||
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
|
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_api_type_scope(self) -> "ProvidersConfig":
|
||||||
|
for name in self.__class__.model_fields:
|
||||||
|
if name == "openai":
|
||||||
|
continue
|
||||||
|
provider = getattr(self, name, None)
|
||||||
|
if isinstance(provider, ProviderConfig) and provider.api_type != "auto":
|
||||||
|
raise ValueError("providers.<name>.api_type is only supported for providers.openai")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatConfig(Base):
|
class HeartbeatConfig(Base):
|
||||||
"""Heartbeat service configuration."""
|
"""Heartbeat service configuration (now backed by cron)."""
|
||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
interval_s: int = 30 * 60 # 30 minutes
|
interval_s: int = 30 * 60 # 30 minutes
|
||||||
@@ -250,6 +268,7 @@ class MCPServerConfig(Base):
|
|||||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||||
|
cwd: str = "" # Stdio: working directory for MCP server runtime artifacts
|
||||||
url: str = "" # HTTP/SSE: endpoint URL
|
url: str = "" # HTTP/SSE: endpoint URL
|
||||||
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
|
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
|
||||||
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
||||||
@@ -273,28 +292,25 @@ class ToolsConfig(Base):
|
|||||||
|
|
||||||
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
||||||
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
||||||
|
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
|
||||||
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
|
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
|
||||||
image_generation: ImageGenerationToolConfig = Field(
|
image_generation: ImageGenerationToolConfig = Field(
|
||||||
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
|
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
|
||||||
)
|
)
|
||||||
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
|
restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible
|
||||||
|
webui_allow_local_service_access: bool = Field(
|
||||||
|
default=True,
|
||||||
|
validation_alias=AliasChoices(
|
||||||
|
"webuiAllowLocalServiceAccess",
|
||||||
|
"webui_allow_local_service_access",
|
||||||
|
"allowLocalPreviewAccess",
|
||||||
|
"allow_local_preview_access",
|
||||||
|
),
|
||||||
|
) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads
|
||||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||||
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||||
|
|
||||||
|
|
||||||
class P2PConfig(Base):
|
|
||||||
"""P2P collaboration network configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
agent_id: str = ""
|
|
||||||
description: str = ""
|
|
||||||
capabilities: list[str] = Field(default_factory=list)
|
|
||||||
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
|
||||||
max_concurrent_tasks: int = 3
|
|
||||||
poll_interval: float = 5.0
|
|
||||||
mailboxes_root: str = "~/.nanobot/mailboxes"
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
"""Root configuration for nanobot."""
|
"""Root configuration for nanobot."""
|
||||||
|
|
||||||
@@ -308,7 +324,11 @@ class Config(BaseSettings):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
||||||
)
|
)
|
||||||
mailbox: P2PConfig = Field(default_factory=P2PConfig)
|
|
||||||
|
def __init__(self, **values: Any) -> None:
|
||||||
|
if not type(self).__pydantic_complete__:
|
||||||
|
_resolve_tool_config_refs()
|
||||||
|
super().__init__(**values)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _validate_model_preset(self) -> "Config":
|
def _validate_model_preset(self) -> "Config":
|
||||||
@@ -473,6 +493,7 @@ def _resolve_tool_config_refs() -> None:
|
|||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||||
from nanobot.agent.tools.self import MyToolConfig
|
from nanobot.agent.tools.self import MyToolConfig
|
||||||
from nanobot.agent.tools.shell import ExecToolConfig
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
@@ -481,6 +502,7 @@ def _resolve_tool_config_refs() -> None:
|
|||||||
# Re-export into this module's namespace
|
# Re-export into this module's namespace
|
||||||
mod = sys.modules[__name__]
|
mod = sys.modules[__name__]
|
||||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||||
|
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||||
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
||||||
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
|
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
"""Cron service for scheduled agent tasks."""
|
"""Cron service for scheduled agent tasks."""
|
||||||
|
|
||||||
from nanobot.cron.service import CronService
|
|
||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
|
|
||||||
__all__ = ["CronService", "CronJob", "CronSchedule"]
|
__all__ = ["CronService", "CronJob", "CronSchedule"]
|
||||||
|
|
||||||
|
_LAZY = {"CronService": ".service"}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
module_path = _LAZY.get(name)
|
||||||
|
if module_path is None:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
from importlib import import_module
|
||||||
|
mod = import_module(module_path, __name__)
|
||||||
|
val = getattr(mod, name)
|
||||||
|
globals()[name] = val
|
||||||
|
return val
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Heartbeat service for periodic agent wake-ups."""
|
|
||||||
|
|
||||||
from nanobot.heartbeat.service import HeartbeatService
|
|
||||||
|
|
||||||
__all__ = ["HeartbeatService"]
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
"""Heartbeat service - periodic agent wake-up to check for tasks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.providers.base import LLMProvider
|
|
||||||
|
|
||||||
_HEARTBEAT_TOOL = [
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "heartbeat",
|
|
||||||
"description": "Report heartbeat decision after reviewing tasks.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["skip", "run"],
|
|
||||||
"description": "skip = nothing to do, run = has active tasks",
|
|
||||||
},
|
|
||||||
"tasks": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Natural-language summary of active tasks (required for run)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["action"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatService:
|
|
||||||
"""
|
|
||||||
Periodic heartbeat service that wakes the agent to check for tasks.
|
|
||||||
|
|
||||||
Phase 1 (decision): reads HEARTBEAT.md and asks the LLM — via a virtual
|
|
||||||
tool call — whether there are active tasks. This avoids free-text parsing
|
|
||||||
and the unreliable HEARTBEAT_OK token.
|
|
||||||
|
|
||||||
Phase 2 (execution): only triggered when Phase 1 returns ``run``. The
|
|
||||||
``on_execute`` callback runs the task through the full agent loop and
|
|
||||||
returns the result to deliver.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
workspace: Path,
|
|
||||||
provider: LLMProvider,
|
|
||||||
model: str,
|
|
||||||
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
|
||||||
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
||||||
interval_s: int = 30 * 60,
|
|
||||||
enabled: bool = True,
|
|
||||||
timezone: str | None = None,
|
|
||||||
p2p_shell: Any | None = None,
|
|
||||||
bus: Any | None = None,
|
|
||||||
):
|
|
||||||
self.workspace = workspace
|
|
||||||
self.provider = provider
|
|
||||||
self.model = model
|
|
||||||
self.on_execute = on_execute
|
|
||||||
self.on_notify = on_notify
|
|
||||||
self.interval_s = interval_s
|
|
||||||
self.enabled = enabled
|
|
||||||
self.timezone = timezone
|
|
||||||
self.p2p_shell = p2p_shell
|
|
||||||
self.bus = bus
|
|
||||||
self._running = False
|
|
||||||
self._task: asyncio.Task | None = None
|
|
||||||
self._last_inbox_scan: float = 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def heartbeat_file(self) -> Path:
|
|
||||||
return self.workspace / "HEARTBEAT.md"
|
|
||||||
|
|
||||||
def _read_heartbeat_file(self) -> str | None:
|
|
||||||
if self.heartbeat_file.exists():
|
|
||||||
try:
|
|
||||||
return self.heartbeat_file.read_text(encoding="utf-8")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _decide(self, content: str) -> tuple[str, str]:
|
|
||||||
"""Phase 1: ask LLM to decide skip/run via virtual tool call.
|
|
||||||
|
|
||||||
Returns (action, tasks) where action is 'skip' or 'run'.
|
|
||||||
"""
|
|
||||||
from nanobot.utils.helpers import current_time_str
|
|
||||||
|
|
||||||
response = await self.provider.chat_with_retry(
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
|
|
||||||
{"role": "user", "content": (
|
|
||||||
f"Current Time: {current_time_str(self.timezone)}\n\n"
|
|
||||||
"Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
|
|
||||||
f"{content}"
|
|
||||||
)},
|
|
||||||
],
|
|
||||||
tools=_HEARTBEAT_TOOL,
|
|
||||||
model=self.model,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.should_execute_tools:
|
|
||||||
if response.has_tool_calls:
|
|
||||||
logger.warning(
|
|
||||||
"Ignoring heartbeat tool calls under finish_reason='{}'",
|
|
||||||
response.finish_reason,
|
|
||||||
)
|
|
||||||
return "skip", ""
|
|
||||||
|
|
||||||
args = response.tool_calls[0].arguments
|
|
||||||
return args.get("action", "skip"), args.get("tasks", "")
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
"""Start the heartbeat service."""
|
|
||||||
if not self.enabled:
|
|
||||||
logger.info("Heartbeat disabled")
|
|
||||||
return
|
|
||||||
if self._running:
|
|
||||||
logger.warning("Heartbeat already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
self._task = asyncio.create_task(self._run_loop())
|
|
||||||
logger.info("Heartbeat started (every {}s)", self.interval_s)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Stop the heartbeat service."""
|
|
||||||
self._running = False
|
|
||||||
if self._task:
|
|
||||||
self._task.cancel()
|
|
||||||
self._task = None
|
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
|
||||||
"""Main heartbeat loop."""
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(self.interval_s)
|
|
||||||
if self._running:
|
|
||||||
await self._tick()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Heartbeat error")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_deliverable(response: str) -> bool:
|
|
||||||
"""Check if a heartbeat response is suitable for user delivery.
|
|
||||||
|
|
||||||
Filters out two classes of bad output before the evaluator runs:
|
|
||||||
|
|
||||||
1. **Finalization fallback** — the runner hit empty-response retries
|
|
||||||
and produced a canned error message. For heartbeat, empty output
|
|
||||||
is a valid "nothing to report" outcome, not a failure.
|
|
||||||
2. **Leaked reasoning** — the model reflected internal file names,
|
|
||||||
decision logic, or meta-commentary instead of a user-facing report.
|
|
||||||
"""
|
|
||||||
text = response.lower()
|
|
||||||
|
|
||||||
# Runner finalization fallback
|
|
||||||
if "couldn't produce a final answer" in text:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Leaked internal reasoning patterns
|
|
||||||
leaked_patterns = [
|
|
||||||
"heartbeat.md",
|
|
||||||
"awareness.md",
|
|
||||||
"judgment call:",
|
|
||||||
"decision logic",
|
|
||||||
"valid options are",
|
|
||||||
"my instructions",
|
|
||||||
"i am supposed to",
|
|
||||||
"strict heartbeat interpretation",
|
|
||||||
]
|
|
||||||
if any(pattern in text for pattern in leaked_patterns):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _tick(self) -> None:
|
|
||||||
"""Execute a single heartbeat tick."""
|
|
||||||
from nanobot.utils.evaluator import evaluate_response
|
|
||||||
|
|
||||||
# --- P2P inbox scan ---
|
|
||||||
if self.p2p_shell and self.bus:
|
|
||||||
try:
|
|
||||||
new_msgs = self.p2p_shell.scan_new_inbox(since=self._last_inbox_scan)
|
|
||||||
if new_msgs:
|
|
||||||
self._last_inbox_scan = time.time()
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
for msg in new_msgs:
|
|
||||||
await self.bus.publish_inbound(
|
|
||||||
InboundMessage(
|
|
||||||
channel="p2p",
|
|
||||||
sender_id=msg.get("from", "unknown"),
|
|
||||||
chat_id=msg.get("task_id", ""),
|
|
||||||
content=msg.get("payload", {}).get("description", ""),
|
|
||||||
metadata={"p2p_msg": msg},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Heartbeat: injected P2P task {} from {}",
|
|
||||||
msg.get("task_id", ""),
|
|
||||||
msg.get("from", "unknown"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Heartbeat P2P scan failed")
|
|
||||||
|
|
||||||
# --- Legacy heartbeat file check ---
|
|
||||||
content = self._read_heartbeat_file()
|
|
||||||
if not content:
|
|
||||||
logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Heartbeat: checking for tasks...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
action, tasks = await self._decide(content)
|
|
||||||
|
|
||||||
if action != "run":
|
|
||||||
logger.info("Heartbeat: OK (nothing to report)")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Heartbeat: tasks found, executing...")
|
|
||||||
if self.on_execute:
|
|
||||||
response = await self.on_execute(tasks)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
logger.info("Heartbeat: no response from execution")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._is_deliverable(response):
|
|
||||||
logger.info(
|
|
||||||
"Heartbeat: suppressed non-deliverable response ({})",
|
|
||||||
response[:80],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
should_notify = await evaluate_response(
|
|
||||||
response, tasks, self.provider, self.model,
|
|
||||||
)
|
|
||||||
if should_notify and self.on_notify:
|
|
||||||
logger.info("Heartbeat: completed, delivering response")
|
|
||||||
await self.on_notify(response)
|
|
||||||
else:
|
|
||||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Heartbeat execution failed")
|
|
||||||
|
|
||||||
async def trigger_now(self) -> str | None:
|
|
||||||
"""Manually trigger a heartbeat."""
|
|
||||||
content = self._read_heartbeat_file()
|
|
||||||
if not content:
|
|
||||||
return None
|
|
||||||
action, tasks = await self._decide(content)
|
|
||||||
if action != "run" or not self.on_execute:
|
|
||||||
return None
|
|
||||||
return await self.on_execute(tasks)
|
|
||||||
+2
-4
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -63,10 +64,7 @@ class Nanobot:
|
|||||||
|
|
||||||
loop = AgentLoop.from_config(
|
loop = AgentLoop.from_config(
|
||||||
config,
|
config,
|
||||||
image_generation_provider_configs={
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
"openrouter": config.providers.openrouter,
|
|
||||||
"aihubmix": config.providers.aihubmix,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return cls(loop)
|
return cls(loop)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""P2P inter-agent coordination layer."""
|
|
||||||
|
|
||||||
from nanobot.p2p.shell import P2PShell
|
|
||||||
|
|
||||||
__all__ = ["P2PShell"]
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
"""P2P shell: filesystem-backed inter-agent coordination.
|
|
||||||
|
|
||||||
All state is stored in the mailbox filesystem; this class is stateless.
|
|
||||||
Restarting the gateway restores all task state by scanning files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
class P2PShell:
|
|
||||||
"""Stateless P2P coordination shell backed by the mailbox filesystem."""
|
|
||||||
|
|
||||||
def __init__(self, agent_id: str, mailboxes_root: str):
|
|
||||||
self.agent_id = agent_id
|
|
||||||
self.root = Path(mailboxes_root).expanduser()
|
|
||||||
self.inbox = self.root / agent_id / "inbox"
|
|
||||||
self.processed = self.root / agent_id / "processed"
|
|
||||||
self.links_dir = self.root / "_links"
|
|
||||||
self.windows_dir = self.root / "_windows"
|
|
||||||
|
|
||||||
for d in (self.inbox, self.processed, self.links_dir, self.windows_dir):
|
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Discovery
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def discover(self, capability: str, top_k: int = 3) -> list[dict[str, Any]]:
|
|
||||||
"""Read _registry.json and return candidates matching capability."""
|
|
||||||
registry = self._load_json(self.root / "_registry.json", default={})
|
|
||||||
candidates: list[dict[str, Any]] = []
|
|
||||||
for aid, info in registry.items():
|
|
||||||
if aid == self.agent_id:
|
|
||||||
continue
|
|
||||||
caps = info.get("capabilities", [])
|
|
||||||
if capability.lower() in " ".join(caps).lower():
|
|
||||||
candidates.append({"agent_id": aid, **info})
|
|
||||||
# Sort: idle first, then by current task load
|
|
||||||
candidates.sort(key=lambda x: (x.get("status") != "idle", x.get("current_tasks", 0)))
|
|
||||||
return candidates[:top_k]
|
|
||||||
|
|
||||||
def heartbeat(self, description: str, capabilities: list[str]) -> None:
|
|
||||||
"""Write self state into the shared _registry.json."""
|
|
||||||
registry = self._load_json(self.root / "_registry.json", default={})
|
|
||||||
registry[self.agent_id] = {
|
|
||||||
"description": description,
|
|
||||||
"capabilities": capabilities,
|
|
||||||
"status": "idle",
|
|
||||||
"last_heartbeat": int(time.time()),
|
|
||||||
"endpoint": "",
|
|
||||||
}
|
|
||||||
self._atomic_write(self.root / "_registry.json", registry)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Task dispatch
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def dispatch(
|
|
||||||
self,
|
|
||||||
to: str,
|
|
||||||
parent_task_id: str | None,
|
|
||||||
description: str,
|
|
||||||
deadline_seconds: int = 300,
|
|
||||||
allow_redelegation: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Write a task into the target agent's inbox and return a receipt."""
|
|
||||||
task_id = (
|
|
||||||
f"{parent_task_id}.{int(time.time())}"
|
|
||||||
if parent_task_id
|
|
||||||
else f"root_{int(time.time())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
depth = self._get_depth(parent_task_id) if parent_task_id else 0
|
|
||||||
if depth >= 3:
|
|
||||||
return {"status": "rejected", "reason": "max_depth_exceeded"}
|
|
||||||
|
|
||||||
if parent_task_id and self._is_ancestor(to, parent_task_id):
|
|
||||||
return {"status": "rejected", "reason": "ancestry_loop"}
|
|
||||||
|
|
||||||
if not self._circuit_allow(to):
|
|
||||||
failover = self._find_failover(to)
|
|
||||||
return {"status": "circuit_open", "failover_to": failover}
|
|
||||||
|
|
||||||
target_inbox = self.root / to / "inbox"
|
|
||||||
target_inbox.mkdir(parents=True, exist_ok=True)
|
|
||||||
if list(target_inbox.glob(f"task_{task_id}_from_{self.agent_id}_*.json")):
|
|
||||||
return {"status": "dispatched", "task_id": task_id, "note": "cached"}
|
|
||||||
|
|
||||||
ancestry = (
|
|
||||||
(self._get_ancestry(parent_task_id) + [self.agent_id])
|
|
||||||
if parent_task_id
|
|
||||||
else [self.agent_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
msg: dict[str, Any] = {
|
|
||||||
"version": "p2p/v1",
|
|
||||||
"type": "task_dispatch",
|
|
||||||
"from": self.agent_id,
|
|
||||||
"to": to,
|
|
||||||
"task_id": task_id,
|
|
||||||
"ancestry": ancestry,
|
|
||||||
"depth": depth + 1,
|
|
||||||
"payload": {
|
|
||||||
"description": description,
|
|
||||||
"allow_redelegation": allow_redelegation,
|
|
||||||
},
|
|
||||||
"deadline": int(time.time()) + deadline_seconds,
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
}
|
|
||||||
|
|
||||||
path = target_inbox / f"task_{task_id}_from_{self.agent_id}_{os.urandom(4).hex()}.json"
|
|
||||||
self._atomic_write(path, msg)
|
|
||||||
logger.info("P2P dispatch: {} -> {} (task_id={})", self.agent_id, to, task_id)
|
|
||||||
return {"status": "dispatched", "task_id": task_id, "depth": depth + 1}
|
|
||||||
|
|
||||||
def poll(self, task_id: str) -> dict[str, Any]:
|
|
||||||
"""Scan inbox/processed and return task status."""
|
|
||||||
# Check processed results first
|
|
||||||
results = list(self.processed.glob(f"result_{task_id}_from_*.json"))
|
|
||||||
if results:
|
|
||||||
data = self._load_json(results[0])
|
|
||||||
payload = data.get("payload", {})
|
|
||||||
return {
|
|
||||||
"status": payload.get("outcome", "completed"),
|
|
||||||
"result": payload.get("content", ""),
|
|
||||||
"from": data["from"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check inbox for results (not yet moved to processed)
|
|
||||||
inbox_results = list(self.inbox.glob(f"result_{task_id}_from_*.json"))
|
|
||||||
if inbox_results:
|
|
||||||
data = self._load_json(inbox_results[0])
|
|
||||||
payload = data.get("payload", {})
|
|
||||||
return {
|
|
||||||
"status": payload.get("outcome", "completed"),
|
|
||||||
"result": payload.get("content", ""),
|
|
||||||
"from": data["from"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check inbox for pending task dispatches
|
|
||||||
pending = list(self.inbox.glob(f"task_{task_id}_from_*.json"))
|
|
||||||
if pending:
|
|
||||||
data = self._load_json(pending[0])
|
|
||||||
deadline = data.get("deadline", 0)
|
|
||||||
elapsed = int(time.time() - data["timestamp"])
|
|
||||||
if time.time() > deadline:
|
|
||||||
return {"status": "timeout", "elapsed": elapsed}
|
|
||||||
return {"status": "pending", "elapsed": elapsed}
|
|
||||||
|
|
||||||
return {"status": "not_found"}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Aggregation (broadcast + check)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def broadcast(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
subtasks: list[dict[str, Any]],
|
|
||||||
aggregation_timeout: int = 30,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Write bid requests to candidate agents and create a window descriptor."""
|
|
||||||
targets: list[tuple[str, str]] = [] # (subtask_id, agent_id)
|
|
||||||
for sub in subtasks:
|
|
||||||
caps = sub.get("capability", "")
|
|
||||||
found = self.discover(caps, top_k=3)
|
|
||||||
targets.extend([(sub["subtask_id"], a["agent_id"]) for a in found])
|
|
||||||
|
|
||||||
for subtask_id, target in targets:
|
|
||||||
msg: dict[str, Any] = {
|
|
||||||
"version": "p2p/v1",
|
|
||||||
"type": "bid_request",
|
|
||||||
"from": self.agent_id,
|
|
||||||
"to": target,
|
|
||||||
"task_id": task_id,
|
|
||||||
"subtask_id": subtask_id,
|
|
||||||
"payload": sub,
|
|
||||||
"deadline": int(time.time()) + aggregation_timeout,
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
}
|
|
||||||
target_inbox = self.root / target / "inbox"
|
|
||||||
target_inbox.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = target_inbox / f"bid_{task_id}_{subtask_id}_from_{self.agent_id}.json"
|
|
||||||
self._atomic_write(path, msg)
|
|
||||||
|
|
||||||
window: dict[str, Any] = {
|
|
||||||
"task_id": task_id,
|
|
||||||
"mode": "bid",
|
|
||||||
"expected": len(targets),
|
|
||||||
"deadline": int(time.time()) + aggregation_timeout,
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
self._atomic_write(self.windows_dir / f"{task_id}.json", window)
|
|
||||||
logger.info(
|
|
||||||
"P2P broadcast: {} invited {} agents for task_id={}",
|
|
||||||
self.agent_id,
|
|
||||||
len(targets),
|
|
||||||
task_id,
|
|
||||||
)
|
|
||||||
return {"status": "bidding_opened", "task_id": task_id, "invited": len(targets)}
|
|
||||||
|
|
||||||
def check_aggregation(self, task_id: str) -> dict[str, Any]:
|
|
||||||
"""Lazily check aggregation status by scanning files."""
|
|
||||||
window_path = self.windows_dir / f"{task_id}.json"
|
|
||||||
if not window_path.exists():
|
|
||||||
return {"status": "no_window"}
|
|
||||||
|
|
||||||
window = self._load_json(window_path)
|
|
||||||
mode = window.get("mode", "bid")
|
|
||||||
deadline = window.get("deadline", 0)
|
|
||||||
|
|
||||||
pattern = f"{mode}_{task_id}_*_from_*.json"
|
|
||||||
entries: list[dict[str, Any]] = []
|
|
||||||
for f in self.inbox.glob(pattern):
|
|
||||||
data = self._load_json(f)
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"from": data.get("from", ""),
|
|
||||||
"subtask_id": data.get("subtask_id", ""),
|
|
||||||
"payload": data.get("payload", {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
is_timeout = time.time() > deadline
|
|
||||||
is_full = window.get("expected") and len(entries) >= window["expected"]
|
|
||||||
|
|
||||||
if is_timeout or is_full:
|
|
||||||
self._atomic_write(
|
|
||||||
self.processed / f"window_{task_id}.json",
|
|
||||||
{**window, "closed_at": int(time.time()), "received": len(entries)},
|
|
||||||
)
|
|
||||||
window_path.unlink(missing_ok=True)
|
|
||||||
return {
|
|
||||||
"status": "closed",
|
|
||||||
"mode": mode,
|
|
||||||
"entries": entries,
|
|
||||||
"reason": "timeout" if is_timeout else "full",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "pending",
|
|
||||||
"received": len(entries),
|
|
||||||
"expected": window.get("expected"),
|
|
||||||
"seconds_remaining": max(0, deadline - int(time.time())),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Result reporting
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def report_result(
|
|
||||||
self,
|
|
||||||
to: str,
|
|
||||||
task_id: str,
|
|
||||||
outcome: Literal["completed", "failed", "aborted"],
|
|
||||||
content: str,
|
|
||||||
callback: dict[str, Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Worker calls this to write a result into the manager's inbox."""
|
|
||||||
msg: dict[str, Any] = {
|
|
||||||
"version": "p2p/v1",
|
|
||||||
"type": "result",
|
|
||||||
"from": self.agent_id,
|
|
||||||
"to": to,
|
|
||||||
"task_id": task_id,
|
|
||||||
"payload": {"outcome": outcome, "content": content},
|
|
||||||
"timestamp": int(time.time()),
|
|
||||||
}
|
|
||||||
if callback:
|
|
||||||
msg["callback"] = callback
|
|
||||||
target_inbox = self.root / to / "inbox"
|
|
||||||
target_inbox.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = target_inbox / f"result_{task_id}_from_{self.agent_id}_{os.urandom(4).hex()}.json"
|
|
||||||
self._atomic_write(path, msg)
|
|
||||||
logger.info("P2P result: {} -> {} (task_id={}, outcome={})", self.agent_id, to, task_id, outcome)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Finalization
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def finalize(self, task_id: str, outcome: str, reason: str = "") -> None:
|
|
||||||
"""Move all task files from inbox to processed and mark outcome."""
|
|
||||||
for src in list(self.inbox.glob(f"*{task_id}*")):
|
|
||||||
data = self._load_json(src)
|
|
||||||
data.setdefault("payload", {})
|
|
||||||
data["payload"]["outcome"] = outcome
|
|
||||||
data["payload"]["reason"] = reason
|
|
||||||
dst = self.processed / src.name
|
|
||||||
self._atomic_write(dst, data)
|
|
||||||
src.unlink(missing_ok=True)
|
|
||||||
logger.info("P2P finalize: task_id={} outcome={}", task_id, outcome)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Circuit breaker
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _circuit_allow(self, to: str) -> bool:
|
|
||||||
link = self._load_json(
|
|
||||||
self.links_dir / f"{to}.json",
|
|
||||||
default={"failures": 0, "last_failure": 0, "open": False},
|
|
||||||
)
|
|
||||||
if not link.get("open"):
|
|
||||||
return True
|
|
||||||
backoff = 300 * (2 ** max(0, link.get("failures", 0) - 3))
|
|
||||||
if time.time() - link.get("last_failure", 0) > backoff:
|
|
||||||
link["open"] = False
|
|
||||||
self._atomic_write(self.links_dir / f"{to}.json", link)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def record_failure(self, to: str) -> None:
|
|
||||||
link = self._load_json(
|
|
||||||
self.links_dir / f"{to}.json",
|
|
||||||
default={"failures": 0, "last_failure": 0, "open": False},
|
|
||||||
)
|
|
||||||
link["failures"] = link.get("failures", 0) + 1
|
|
||||||
link["last_failure"] = int(time.time())
|
|
||||||
if link["failures"] >= 3:
|
|
||||||
link["open"] = True
|
|
||||||
self._atomic_write(self.links_dir / f"{to}.json", link)
|
|
||||||
|
|
||||||
def record_success(self, to: str) -> None:
|
|
||||||
link = self._load_json(
|
|
||||||
self.links_dir / f"{to}.json",
|
|
||||||
default={"failures": 0, "last_failure": 0, "open": False},
|
|
||||||
)
|
|
||||||
link["failures"] = 0
|
|
||||||
link["open"] = False
|
|
||||||
self._atomic_write(self.links_dir / f"{to}.json", link)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Inbox scanning (for HeartbeatService)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def scan_inbox(self) -> list[dict[str, Any]]:
|
|
||||||
"""Return all task_dispatch messages currently in inbox."""
|
|
||||||
messages: list[dict[str, Any]] = []
|
|
||||||
for f in sorted(self.inbox.glob("task_*_from_*.json"), key=lambda p: p.stat().st_mtime):
|
|
||||||
data = self._load_json(f)
|
|
||||||
# Skip expired tasks
|
|
||||||
if time.time() > data.get("deadline", 0):
|
|
||||||
continue
|
|
||||||
data["_filename"] = f.name
|
|
||||||
messages.append(data)
|
|
||||||
return messages
|
|
||||||
|
|
||||||
def scan_new_inbox(self, since: float | None = None) -> list[dict[str, Any]]:
|
|
||||||
"""Return inbox messages newer than the given timestamp."""
|
|
||||||
messages: list[dict[str, Any]] = []
|
|
||||||
for f in self.inbox.glob("task_*_from_*.json"):
|
|
||||||
mtime = f.stat().st_mtime
|
|
||||||
if since is not None and mtime <= since:
|
|
||||||
continue
|
|
||||||
data = self._load_json(f)
|
|
||||||
if time.time() > data.get("deadline", 0):
|
|
||||||
continue
|
|
||||||
data["_filename"] = f.name
|
|
||||||
data["_mtime"] = mtime
|
|
||||||
messages.append(data)
|
|
||||||
return sorted(messages, key=lambda x: x.get("_mtime", 0))
|
|
||||||
|
|
||||||
def mark_processed(self, filename: str) -> None:
|
|
||||||
"""Move a single inbox file to processed."""
|
|
||||||
src = self.inbox / filename
|
|
||||||
if not src.exists():
|
|
||||||
return
|
|
||||||
dst = self.processed / filename
|
|
||||||
try:
|
|
||||||
import shutil
|
|
||||||
shutil.move(str(src), str(dst))
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to mark processed: {}", filename)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _load_json(self, path: Path, default: Any | None = None) -> Any:
|
|
||||||
if not path.exists():
|
|
||||||
return default if default is not None else {}
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
def _atomic_write(self, path: Path, data: dict[str, Any]) -> None:
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
||||||
tmp.rename(path)
|
|
||||||
|
|
||||||
def _get_depth(self, task_id: str) -> int:
|
|
||||||
return task_id.count(".")
|
|
||||||
|
|
||||||
def _is_ancestor(self, agent_id: str, parent_task_id: str) -> bool:
|
|
||||||
for f in list(self.processed.glob(f"*{parent_task_id}*")) + list(
|
|
||||||
self.inbox.glob(f"*{parent_task_id}*")
|
|
||||||
):
|
|
||||||
data = self._load_json(f)
|
|
||||||
if agent_id in data.get("ancestry", []):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_ancestry(self, task_id: str) -> list[str]:
|
|
||||||
for f in list(self.processed.glob(f"*{task_id}*")) + list(
|
|
||||||
self.inbox.glob(f"*{task_id}*")
|
|
||||||
):
|
|
||||||
data = self._load_json(f)
|
|
||||||
return data.get("ancestry", [])
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _find_failover(self, to: str) -> str | None:
|
|
||||||
registry = self._load_json(self.root / "_registry.json", default={})
|
|
||||||
target_caps = registry.get(to, {}).get("capabilities", [])
|
|
||||||
for aid, info in registry.items():
|
|
||||||
if aid == to:
|
|
||||||
continue
|
|
||||||
if any(c in info.get("capabilities", []) for c in target_caps):
|
|
||||||
return aid
|
|
||||||
return None
|
|
||||||
@@ -45,13 +45,21 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if api_key:
|
if api_key:
|
||||||
client_kw["api_key"] = api_key
|
client_kw["api_key"] = api_key
|
||||||
if api_base:
|
if api_base:
|
||||||
client_kw["base_url"] = api_base
|
client_kw["base_url"] = self._normalize_base_url(api_base)
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
client_kw["default_headers"] = extra_headers
|
client_kw["default_headers"] = extra_headers
|
||||||
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
|
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
|
||||||
client_kw["max_retries"] = 0
|
client_kw["max_retries"] = 0
|
||||||
self._client = AsyncAnthropic(**client_kw)
|
self._client = AsyncAnthropic(**client_kw)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_base_url(api_base: str) -> str:
|
||||||
|
"""Anthropic SDK appends /v1 to request paths internally."""
|
||||||
|
normalized = api_base.rstrip("/")
|
||||||
|
if normalized.endswith("/v1"):
|
||||||
|
return normalized[: -len("/v1")]
|
||||||
|
return normalized
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _handle_error(cls, e: Exception) -> LLMResponse:
|
def _handle_error(cls, e: Exception) -> LLMResponse:
|
||||||
response = getattr(e, "response", None)
|
response = getattr(e, "response", None)
|
||||||
@@ -228,6 +236,13 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if converted:
|
if converted:
|
||||||
result.append(converted)
|
result.append(converted)
|
||||||
continue
|
continue
|
||||||
|
if not item.get("type"):
|
||||||
|
# Anthropic requires every content block to declare a "type".
|
||||||
|
# A tool that returned a bare dict (or a list of dicts) lands
|
||||||
|
# here; coerce it to a text block instead of emitting a block
|
||||||
|
# the API rejects with "content.0.type: Field required".
|
||||||
|
result.append({"type": "text", "text": str(item)})
|
||||||
|
continue
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return result or "(empty)"
|
return result or "(empty)"
|
||||||
|
|
||||||
@@ -590,6 +605,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
kwargs = self._build_kwargs(
|
kwargs = self._build_kwargs(
|
||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
@@ -598,11 +614,12 @@ class AnthropicProvider(LLMProvider):
|
|||||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
try:
|
try:
|
||||||
async with self._client.messages.stream(**kwargs) as stream:
|
async with self._client.messages.stream(**kwargs) as stream:
|
||||||
if on_content_delta or on_thinking_delta:
|
if on_content_delta or on_thinking_delta or on_tool_call_delta:
|
||||||
# Idle timeout must track *any* SSE chunk (thinking_delta,
|
# Idle timeout must track *any* SSE chunk (thinking_delta,
|
||||||
# tool JSON deltas, etc.), not only text_stream tokens.
|
# tool JSON deltas, etc.), not only text_stream tokens.
|
||||||
# Otherwise extended thinking can stall text_stream for minutes
|
# Otherwise extended thinking can stall text_stream for minutes
|
||||||
# while the connection is healthy (e.g. MiniMax Anthropic).
|
# while the connection is healthy (e.g. MiniMax Anthropic).
|
||||||
|
tool_blocks: dict[int, dict[str, str]] = {}
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
chunk = await asyncio.wait_for(
|
chunk = await asyncio.wait_for(
|
||||||
@@ -611,7 +628,22 @@ class AnthropicProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
break
|
break
|
||||||
if (
|
if chunk.type == "content_block_start":
|
||||||
|
block = getattr(chunk, "content_block", None)
|
||||||
|
if getattr(block, "type", None) == "tool_use":
|
||||||
|
index = int(getattr(chunk, "index", 0) or 0)
|
||||||
|
state = {
|
||||||
|
"call_id": str(getattr(block, "id", "") or ""),
|
||||||
|
"name": str(getattr(block, "name", "") or ""),
|
||||||
|
}
|
||||||
|
tool_blocks[index] = state
|
||||||
|
if on_tool_call_delta:
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"index": index,
|
||||||
|
**state,
|
||||||
|
"arguments_delta": "",
|
||||||
|
})
|
||||||
|
elif (
|
||||||
chunk.type == "content_block_delta"
|
chunk.type == "content_block_delta"
|
||||||
and getattr(chunk.delta, "type", None) == "thinking_delta"
|
and getattr(chunk.delta, "type", None) == "thinking_delta"
|
||||||
):
|
):
|
||||||
@@ -625,6 +657,20 @@ class AnthropicProvider(LLMProvider):
|
|||||||
text = getattr(chunk.delta, "text", None) or ""
|
text = getattr(chunk.delta, "text", None) or ""
|
||||||
if text and on_content_delta:
|
if text and on_content_delta:
|
||||||
await on_content_delta(text)
|
await on_content_delta(text)
|
||||||
|
elif (
|
||||||
|
chunk.type == "content_block_delta"
|
||||||
|
and getattr(chunk.delta, "type", None) == "input_json_delta"
|
||||||
|
):
|
||||||
|
partial = getattr(chunk.delta, "partial_json", None) or ""
|
||||||
|
if partial and on_tool_call_delta:
|
||||||
|
index = int(getattr(chunk, "index", 0) or 0)
|
||||||
|
state = tool_blocks.get(index, {})
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"index": index,
|
||||||
|
"call_id": state.get("call_id", ""),
|
||||||
|
"name": state.get("name", ""),
|
||||||
|
"arguments_delta": partial,
|
||||||
|
})
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
stream.get_final_message(),
|
stream.get_final_message(),
|
||||||
timeout=idle_timeout_s,
|
timeout=idle_timeout_s,
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
_ = on_thinking_delta
|
_ = on_thinking_delta
|
||||||
body = self._build_body(
|
body = self._build_body(
|
||||||
@@ -169,7 +170,7 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
try:
|
try:
|
||||||
stream = await self._client.responses.create(**body)
|
stream = await self._client.responses.create(**body)
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = (
|
content, tool_calls, finish_reason, usage, reasoning_content = (
|
||||||
await consume_sdk_stream(stream, on_content_delta)
|
await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
|
||||||
)
|
)
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content or None,
|
content=content or None,
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ class LLMResponse:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def should_execute_tools(self) -> bool:
|
def should_execute_tools(self) -> bool:
|
||||||
"""Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``.
|
"""Tools execute only when has_tool_calls AND finish_reason is a tool-capable stop.
|
||||||
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
|
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
|
||||||
if not self.has_tool_calls:
|
if not self.has_tool_calls:
|
||||||
return False
|
return False
|
||||||
return self.finish_reason in ("tool_calls", "stop")
|
return self.finish_reason in ("tool_calls", "function_call", "stop")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -112,6 +112,7 @@ class LLMProvider(ABC):
|
|||||||
"server error",
|
"server error",
|
||||||
"temporarily unavailable",
|
"temporarily unavailable",
|
||||||
"速率限制",
|
"速率限制",
|
||||||
|
"访问量过大",
|
||||||
)
|
)
|
||||||
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
|
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
|
||||||
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
||||||
@@ -314,6 +315,29 @@ class LLMProvider(ABC):
|
|||||||
|
|
||||||
return cls._is_transient_error(response.content)
|
return cls._is_transient_error(response.content)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_arrearage_response(cls, response: LLMResponse) -> bool:
|
||||||
|
"""Detect API-key arrearage / quota / billing errors that won't clear on retry.
|
||||||
|
|
||||||
|
These surface as HTTP 402 or as billing semantic tokens (e.g.
|
||||||
|
``insufficient_quota``, ``payment_required``); reuses the same token and
|
||||||
|
text markers the 429 retry policy treats as non-retryable.
|
||||||
|
"""
|
||||||
|
if response.error_status_code is not None and int(response.error_status_code) == 402:
|
||||||
|
return True
|
||||||
|
|
||||||
|
type_token = cls._normalize_error_token(response.error_type)
|
||||||
|
code_token = cls._normalize_error_token(response.error_code)
|
||||||
|
if any(
|
||||||
|
token in cls._NON_RETRYABLE_429_ERROR_TOKENS
|
||||||
|
for token in (type_token, code_token)
|
||||||
|
if token is not None
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
content = (response.content or "").lower()
|
||||||
|
return any(marker in content for marker in cls._NON_RETRYABLE_429_TEXT_MARKERS)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_error_token(value: Any) -> str | None:
|
def _normalize_error_token(value: Any) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -500,6 +524,7 @@ class LLMProvider(ABC):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Stream a chat completion, calling *on_content_delta* for each text chunk.
|
"""Stream a chat completion, calling *on_content_delta* for each text chunk.
|
||||||
|
|
||||||
@@ -513,7 +538,7 @@ class LLMProvider(ABC):
|
|||||||
full content as a single delta. Providers that support native
|
full content as a single delta. Providers that support native
|
||||||
streaming should override this method.
|
streaming should override this method.
|
||||||
"""
|
"""
|
||||||
_ = on_thinking_delta
|
_ = on_thinking_delta, on_tool_call_delta
|
||||||
response = await self.chat(
|
response = await self.chat(
|
||||||
messages=messages, tools=tools, model=model,
|
messages=messages, tools=tools, model=model,
|
||||||
max_tokens=max_tokens, temperature=temperature,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
@@ -543,6 +568,7 @@ class LLMProvider(ABC):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@@ -554,12 +580,22 @@ class LLMProvider(ABC):
|
|||||||
if reasoning_effort is self._SENTINEL:
|
if reasoning_effort is self._SENTINEL:
|
||||||
reasoning_effort = self.generation.reasoning_effort
|
reasoning_effort = self.generation.reasoning_effort
|
||||||
|
|
||||||
|
has_streamed_content = False
|
||||||
|
|
||||||
|
async def _tracking_delta(text: str) -> None:
|
||||||
|
nonlocal has_streamed_content
|
||||||
|
if text:
|
||||||
|
has_streamed_content = True
|
||||||
|
if on_content_delta:
|
||||||
|
await on_content_delta(text)
|
||||||
|
|
||||||
kw: dict[str, Any] = dict(
|
kw: dict[str, Any] = dict(
|
||||||
messages=messages, tools=tools, model=model,
|
messages=messages, tools=tools, model=model,
|
||||||
max_tokens=max_tokens, temperature=temperature,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=_tracking_delta if on_content_delta is not None else None,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
)
|
)
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
self._safe_chat_stream,
|
self._safe_chat_stream,
|
||||||
@@ -567,6 +603,7 @@ class LLMProvider(ABC):
|
|||||||
messages,
|
messages,
|
||||||
retry_mode=retry_mode,
|
retry_mode=retry_mode,
|
||||||
on_retry_wait=on_retry_wait,
|
on_retry_wait=on_retry_wait,
|
||||||
|
should_retry_guard=lambda: not has_streamed_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_with_retry(
|
async def chat_with_retry(
|
||||||
@@ -713,6 +750,7 @@ class LLMProvider(ABC):
|
|||||||
*,
|
*,
|
||||||
retry_mode: str,
|
retry_mode: str,
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None,
|
||||||
|
should_retry_guard: Callable[[], bool] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
attempt = 0
|
attempt = 0
|
||||||
delays = list(self._CHAT_RETRY_DELAYS)
|
delays = list(self._CHAT_RETRY_DELAYS)
|
||||||
@@ -726,6 +764,11 @@ class LLMProvider(ABC):
|
|||||||
if response.finish_reason != "error":
|
if response.finish_reason != "error":
|
||||||
return response
|
return response
|
||||||
last_response = response
|
last_response = response
|
||||||
|
if should_retry_guard is not None and not should_retry_guard():
|
||||||
|
logger.warning(
|
||||||
|
"LLM stream failed after content was emitted; skipping retry"
|
||||||
|
)
|
||||||
|
return response
|
||||||
error_key = ((response.content or "").strip().lower() or None)
|
error_key = ((response.content or "").strip().lower() or None)
|
||||||
if error_key and error_key == last_error_key:
|
if error_key and error_key == last_error_key:
|
||||||
identical_error_count += 1
|
identical_error_count += 1
|
||||||
|
|||||||
@@ -704,8 +704,9 @@ class BedrockProvider(LLMProvider):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
_ = on_thinking_delta
|
_ = on_thinking_delta, on_tool_call_delta
|
||||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ def _make_provider_core(
|
|||||||
extra_headers=p.extra_headers if p else None,
|
extra_headers=p.extra_headers if p else None,
|
||||||
spec=spec,
|
spec=spec,
|
||||||
extra_body=p.extra_body if p else None,
|
extra_body=p.extra_body if p else None,
|
||||||
|
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
||||||
)
|
)
|
||||||
|
|
||||||
provider.generation = resolved.to_generation_settings()
|
provider.generation = resolved.to_generation_settings()
|
||||||
@@ -183,6 +184,7 @@ def provider_signature(
|
|||||||
config.get_api_base(fallback.model, preset=fallback),
|
config.get_api_base(fallback.model, preset=fallback),
|
||||||
fp.extra_headers if fp else None,
|
fp.extra_headers if fp else None,
|
||||||
fp.extra_body if fp else None,
|
fp.extra_body if fp else None,
|
||||||
|
fp.api_type if fp else "auto",
|
||||||
getattr(fp, "region", None) if fp else None,
|
getattr(fp, "region", None) if fp else None,
|
||||||
getattr(fp, "profile", None) if fp else None,
|
getattr(fp, "profile", None) if fp else None,
|
||||||
fallback.max_tokens,
|
fallback.max_tokens,
|
||||||
@@ -199,6 +201,7 @@ def provider_signature(
|
|||||||
config.get_api_base(resolved.model, preset=resolved),
|
config.get_api_base(resolved.model, preset=resolved),
|
||||||
p.extra_headers if p else None,
|
p.extra_headers if p else None,
|
||||||
p.extra_body if p else None,
|
p.extra_body if p else None,
|
||||||
|
p.api_type if p else "auto",
|
||||||
getattr(p, "region", None) if p else None,
|
getattr(p, "region", None) if p else None,
|
||||||
getattr(p, "profile", None) if p else None,
|
getattr(p, "profile", None) if p else None,
|
||||||
resolved.max_tokens,
|
resolved.max_tokens,
|
||||||
|
|||||||
@@ -207,8 +207,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
|
|
||||||
async def _refresh_client_api_key(self) -> str:
|
async def _refresh_client_api_key(self) -> str:
|
||||||
token = await self._get_copilot_access_token()
|
token = await self._get_copilot_access_token()
|
||||||
|
client = await self._ensure_client()
|
||||||
self.api_key = token
|
self.api_key = token
|
||||||
self._client.api_key = token
|
client.api_key = token
|
||||||
return token
|
return token
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
@@ -243,6 +244,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
tool_choice: str | dict[str, object] | None = None,
|
tool_choice: str | dict[str, object] | None = None,
|
||||||
on_content_delta: Callable[[str], None] | None = None,
|
on_content_delta: Callable[[str], None] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, object]], Awaitable[None]] | None = None,
|
||||||
):
|
):
|
||||||
await self._refresh_client_api_key()
|
await self._refresh_client_api_key()
|
||||||
return await super().chat_stream(
|
return await super().chat_stream(
|
||||||
@@ -255,4 +257,5 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ from oauth_cli_kit import get_token as get_codex_token
|
|||||||
|
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from nanobot.providers.openai_responses import (
|
from nanobot.providers.openai_responses import (
|
||||||
consume_sse,
|
consume_sse_with_reasoning,
|
||||||
convert_messages,
|
convert_messages,
|
||||||
convert_tools,
|
convert_tools,
|
||||||
)
|
)
|
||||||
@@ -40,6 +41,8 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None,
|
reasoning_effort: str | None,
|
||||||
tool_choice: str | dict[str, Any] | None,
|
tool_choice: str | dict[str, Any] | None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Shared request logic for both chat() and chat_stream()."""
|
"""Shared request logic for both chat() and chat_stream()."""
|
||||||
model = model or self.default_model
|
model = model or self.default_model
|
||||||
@@ -60,30 +63,52 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
"tool_choice": tool_choice or "auto",
|
"tool_choice": tool_choice or "auto",
|
||||||
"parallel_tool_calls": True,
|
"parallel_tool_calls": True,
|
||||||
}
|
}
|
||||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
reasoning_options = _build_reasoning_options(reasoning_effort)
|
||||||
body["reasoning"] = {"effort": reasoning_effort}
|
if reasoning_options:
|
||||||
|
body["reasoning"] = reasoning_options
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
content, tool_calls, finish_reason = await _request_codex(
|
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
|
||||||
DEFAULT_CODEX_URL, headers, body, verify=True,
|
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
|
on_thinking_delta=on_thinking_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||||
raise
|
raise
|
||||||
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
||||||
content, tool_calls, finish_reason = await _request_codex(
|
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
|
||||||
DEFAULT_CODEX_URL, headers, body, verify=False,
|
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
|
on_thinking_delta=on_thinking_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
)
|
)
|
||||||
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
|
return LLMResponse(
|
||||||
|
content=content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = f"Error calling Codex: {e}"
|
response = _codex_error_response(e)
|
||||||
retry_after = getattr(e, "retry_after", None) or self._extract_retry_after(msg)
|
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
|
||||||
return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after)
|
logger.warning(
|
||||||
|
"Codex API request failed: type={} kind={} retryable={} status={} "
|
||||||
|
"error_type={} error_code={} retry_after={} summary={}",
|
||||||
|
exc_type,
|
||||||
|
response.error_kind,
|
||||||
|
response.error_should_retry,
|
||||||
|
response.error_status_code,
|
||||||
|
response.error_type,
|
||||||
|
response.error_code,
|
||||||
|
response.retry_after,
|
||||||
|
_codex_log_summary(exc_type, response),
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||||
@@ -100,9 +125,18 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
_ = on_thinking_delta
|
return await self._call_codex(
|
||||||
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
|
messages,
|
||||||
|
tools,
|
||||||
|
model,
|
||||||
|
reasoning_effort,
|
||||||
|
tool_choice,
|
||||||
|
on_content_delta,
|
||||||
|
on_thinking_delta,
|
||||||
|
on_tool_call_delta,
|
||||||
|
)
|
||||||
|
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
return self.default_model
|
return self.default_model
|
||||||
@@ -114,6 +148,16 @@ def _strip_model_prefix(model: str) -> str:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str] | None:
|
||||||
|
"""Opt in to visible summaries without changing provider-default effort."""
|
||||||
|
if reasoning_effort and reasoning_effort.lower() == "none":
|
||||||
|
return {"effort": "none"}
|
||||||
|
options = {"summary": "auto"}
|
||||||
|
if reasoning_effort:
|
||||||
|
options["effort"] = reasoning_effort
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
def _build_headers(account_id: str, token: str) -> dict[str, str]:
|
def _build_headers(account_id: str, token: str) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
@@ -127,9 +171,22 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
class _CodexHTTPError(RuntimeError):
|
class _CodexHTTPError(RuntimeError):
|
||||||
def __init__(self, message: str, retry_after: float | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status_code: int | None = None,
|
||||||
|
retry_after: float | None = None,
|
||||||
|
error_type: str | None = None,
|
||||||
|
error_code: str | None = None,
|
||||||
|
should_retry: bool | None = None,
|
||||||
|
):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
self.retry_after = retry_after
|
self.retry_after = retry_after
|
||||||
|
self.error_type = error_type
|
||||||
|
self.error_code = error_code
|
||||||
|
self.should_retry = should_retry
|
||||||
|
|
||||||
|
|
||||||
async def _request_codex(
|
async def _request_codex(
|
||||||
@@ -138,17 +195,31 @@ async def _request_codex(
|
|||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
verify: bool,
|
verify: bool,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> tuple[str, list[ToolCallRequest], str]:
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
|
) -> tuple[str, list[ToolCallRequest], str, str | None]:
|
||||||
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
|
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
|
||||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
text = await response.aread()
|
text = await response.aread()
|
||||||
|
raw = text.decode("utf-8", "ignore")
|
||||||
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
|
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
|
||||||
|
error_type, error_code = LLMProvider._extract_error_type_code(raw)
|
||||||
raise _CodexHTTPError(
|
raise _CodexHTTPError(
|
||||||
_friendly_error(response.status_code, text.decode("utf-8", "ignore")),
|
_friendly_error(response.status_code, raw),
|
||||||
|
status_code=response.status_code,
|
||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
|
error_type=error_type,
|
||||||
|
error_code=error_code,
|
||||||
|
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
|
||||||
)
|
)
|
||||||
return await consume_sse(response, on_content_delta)
|
return await consume_sse_with_reasoning(
|
||||||
|
response,
|
||||||
|
on_content_delta=on_content_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
|
on_reasoning_delta=on_thinking_delta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
||||||
@@ -157,6 +228,94 @@ def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _friendly_error(status_code: int, raw: str) -> str:
|
def _friendly_error(status_code: int, raw: str) -> str:
|
||||||
|
_ = raw
|
||||||
if status_code == 429:
|
if status_code == 429:
|
||||||
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
|
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
|
||||||
return f"HTTP {status_code}: {raw}"
|
return f"HTTP {status_code}: Codex API request failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_error_response(exc: Exception) -> LLMResponse:
|
||||||
|
"""Convert Codex transport/API failures into actionable, retryable metadata."""
|
||||||
|
exc_type = "CodexHTTPError" if isinstance(exc, _CodexHTTPError) else type(exc).__name__
|
||||||
|
detail = str(exc).strip()
|
||||||
|
|
||||||
|
status_code = getattr(exc, "status_code", None)
|
||||||
|
error_kind: str | None = None
|
||||||
|
default_detail: str | None = None
|
||||||
|
should_retry: bool | None = getattr(exc, "should_retry", None)
|
||||||
|
|
||||||
|
if isinstance(exc, (httpx.TimeoutException, asyncio.TimeoutError)):
|
||||||
|
error_kind = "timeout"
|
||||||
|
default_detail = "timed out waiting for response"
|
||||||
|
should_retry = True if should_retry is None else should_retry
|
||||||
|
elif isinstance(exc, httpx.RemoteProtocolError):
|
||||||
|
error_kind = "connection"
|
||||||
|
default_detail = "network protocol error while reading response"
|
||||||
|
should_retry = True if should_retry is None else should_retry
|
||||||
|
elif isinstance(exc, (httpx.NetworkError, httpx.TransportError)):
|
||||||
|
error_kind = "connection"
|
||||||
|
default_detail = "network connection failed"
|
||||||
|
should_retry = True if should_retry is None else should_retry
|
||||||
|
elif isinstance(exc, _CodexHTTPError):
|
||||||
|
error_kind = "http"
|
||||||
|
default_detail = "HTTP request failed"
|
||||||
|
|
||||||
|
if status_code is not None and should_retry is None:
|
||||||
|
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
||||||
|
should_retry = _should_retry_status(
|
||||||
|
int(status_code),
|
||||||
|
getattr(exc, "error_type", None),
|
||||||
|
getattr(exc, "error_code", None),
|
||||||
|
retry_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = detail or default_detail or "unexpected error"
|
||||||
|
message = f"Error calling Codex ({exc_type}): {detail}"
|
||||||
|
retry_after = getattr(exc, "retry_after", None) or LLMProvider._extract_retry_after(message)
|
||||||
|
return LLMResponse(
|
||||||
|
content=message,
|
||||||
|
finish_reason="error",
|
||||||
|
retry_after=retry_after,
|
||||||
|
error_status_code=int(status_code) if status_code is not None else None,
|
||||||
|
error_kind=error_kind,
|
||||||
|
error_type=getattr(exc, "error_type", None),
|
||||||
|
error_code=getattr(exc, "error_code", None),
|
||||||
|
error_retry_after_s=retry_after,
|
||||||
|
error_should_retry=should_retry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
|
||||||
|
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
|
||||||
|
if response.error_status_code is not None:
|
||||||
|
parts = [f"HTTP {response.error_status_code}"]
|
||||||
|
if response.error_type:
|
||||||
|
parts.append(f"type={response.error_type}")
|
||||||
|
if response.error_code:
|
||||||
|
parts.append(f"code={response.error_code}")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
kind = (response.error_kind or "").strip()
|
||||||
|
if kind:
|
||||||
|
return f"{exc_type} {kind}"
|
||||||
|
|
||||||
|
return exc_type
|
||||||
|
|
||||||
|
|
||||||
|
def _should_retry_status(
|
||||||
|
status_code: int,
|
||||||
|
error_type: str | None,
|
||||||
|
error_code: str | None,
|
||||||
|
content: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if status_code == 429:
|
||||||
|
return LLMProvider._is_retryable_429_response(
|
||||||
|
LLMResponse(
|
||||||
|
content=content or "",
|
||||||
|
finish_reason="error",
|
||||||
|
error_status_code=status_code,
|
||||||
|
error_type=error_type,
|
||||||
|
error_code=error_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
|
||||||
|
|||||||
@@ -11,25 +11,15 @@ import secrets
|
|||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
|
||||||
import json_repair
|
import json_repair
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
|
|
||||||
from langfuse.openai import AsyncOpenAI
|
|
||||||
else:
|
|
||||||
if os.environ.get("LANGFUSE_SECRET_KEY"):
|
|
||||||
logger.warning(
|
|
||||||
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
|
|
||||||
"install with `pip install langfuse` to enable tracing"
|
|
||||||
)
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from nanobot.providers.openai_responses import (
|
from nanobot.providers.openai_responses import (
|
||||||
consume_sdk_stream,
|
consume_sdk_stream,
|
||||||
@@ -39,8 +29,15 @@ from nanobot.providers.openai_responses import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from openai import AsyncOpenAI as AsyncOpenAIType
|
||||||
|
|
||||||
from nanobot.providers.registry import ProviderSpec
|
from nanobot.providers.registry import ProviderSpec
|
||||||
|
|
||||||
|
# Module-level placeholder — set lazily by _ensure_client on first real
|
||||||
|
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
|
||||||
|
# that ``unittest.mock.patch`` can find and replace it.
|
||||||
|
AsyncOpenAI: Any = None
|
||||||
|
|
||||||
_ALLOWED_MSG_KEYS = frozenset({
|
_ALLOWED_MSG_KEYS = frozenset({
|
||||||
"role", "content", "tool_calls", "tool_call_id", "name",
|
"role", "content", "tool_calls", "tool_call_id", "name",
|
||||||
"reasoning_content", "extra_content",
|
"reasoning_content", "extra_content",
|
||||||
@@ -78,41 +75,43 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
|
|||||||
"enable_thinking": lambda on: {"enable_thinking": on},
|
"enable_thinking": lambda on: {"enable_thinking": on},
|
||||||
"reasoning_split": lambda on: {"reasoning_split": on},
|
"reasoning_split": lambda on: {"reasoning_split": on},
|
||||||
}
|
}
|
||||||
|
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
|
||||||
|
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
|
||||||
|
}
|
||||||
|
_MODEL_THINKING_STYLES: dict[str, str] = {
|
||||||
|
**dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"),
|
||||||
|
**dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_kimi_thinking_model(model_name: str) -> bool:
|
def _model_slug(model_name: str) -> str:
|
||||||
"""Return True if model_name refers to a Kimi thinking-capable model.
|
return model_name.lower().rsplit("/", 1)[-1]
|
||||||
|
|
||||||
Supports two forms:
|
|
||||||
- Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
|
|
||||||
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
|
|
||||||
is checked against _KIMI_THINKING_MODELS
|
|
||||||
|
|
||||||
This covers both the native Moonshot provider (bare slug) and
|
|
||||||
OpenRouter-style names (``"publisher/slug"``).
|
|
||||||
"""
|
|
||||||
name = model_name.lower()
|
|
||||||
if name in _KIMI_THINKING_MODELS:
|
|
||||||
return True
|
|
||||||
if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_mimo_thinking_model(model_name: str) -> bool:
|
def _model_thinking_style(model_name: str) -> str:
|
||||||
"""Return True if model_name refers to a MiMo thinking-capable model.
|
return _MODEL_THINKING_STYLES.get(_model_slug(model_name), "")
|
||||||
|
|
||||||
Mirrors _is_kimi_thinking_model: gateway providers (e.g. OpenRouter
|
|
||||||
routing ``xiaomi/mimo-v2.5-pro``) have no ``thinking_style`` on their
|
def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]:
|
||||||
spec, so the spec-driven branch in _build_kwargs misses them. The
|
styles: list[str] = []
|
||||||
model-name path catches those cases.
|
if spec and spec.thinking_style:
|
||||||
"""
|
styles.append(spec.thinking_style)
|
||||||
name = model_name.lower()
|
model_style = _model_thinking_style(model_name)
|
||||||
if name in _MIMO_THINKING_MODELS:
|
if model_style and model_style not in styles:
|
||||||
return True
|
styles.append(model_style)
|
||||||
if "/" in name and name.rsplit("/", 1)[1] in _MIMO_THINKING_MODELS:
|
return styles
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
def _thinking_extra_body(style: str, thinking_enabled: bool) -> dict[str, Any] | None:
|
||||||
|
builder = _THINKING_STYLE_MAP.get(style)
|
||||||
|
return builder(thinking_enabled) if builder else None
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_reasoning_extra_body(style: str, effort: str | None) -> dict[str, Any] | None:
|
||||||
|
if not effort:
|
||||||
|
return None
|
||||||
|
builder = _GATEWAY_REASONING_STYLE_MAP.get(style)
|
||||||
|
return builder(effort) if builder else None
|
||||||
|
|
||||||
|
|
||||||
def _openai_compat_timeout_s() -> float:
|
def _openai_compat_timeout_s() -> float:
|
||||||
@@ -275,6 +274,47 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_unique_list(base: Any, override: Any) -> Any:
|
||||||
|
"""Append list values while preserving order and removing duplicates."""
|
||||||
|
if not isinstance(base, list) or not isinstance(override, list):
|
||||||
|
return override
|
||||||
|
result: list[Any] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in [*base, *override]:
|
||||||
|
try:
|
||||||
|
key = json.dumps(value, sort_keys=True, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
key = repr(value)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_responses_extra_body(
|
||||||
|
body: dict[str, Any],
|
||||||
|
extra_body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Merge configured Responses API body fields without clobbering tools."""
|
||||||
|
reserved = {"include", "tools"}
|
||||||
|
regular_extra = {key: value for key, value in extra_body.items() if key not in reserved}
|
||||||
|
merged = _deep_merge(body, regular_extra)
|
||||||
|
|
||||||
|
if "include" in extra_body:
|
||||||
|
merged["include"] = _merge_unique_list(body.get("include"), extra_body["include"])
|
||||||
|
|
||||||
|
if "tools" in extra_body:
|
||||||
|
current_tools = body.get("tools")
|
||||||
|
configured_tools = extra_body["tools"]
|
||||||
|
if isinstance(current_tools, list) and isinstance(configured_tools, list):
|
||||||
|
merged["tools"] = [*current_tools, *configured_tools]
|
||||||
|
else:
|
||||||
|
merged["tools"] = configured_tools
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
class OpenAICompatProvider(LLMProvider):
|
class OpenAICompatProvider(LLMProvider):
|
||||||
"""Unified provider for all OpenAI-compatible APIs.
|
"""Unified provider for all OpenAI-compatible APIs.
|
||||||
|
|
||||||
@@ -290,55 +330,90 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
spec: ProviderSpec | None = None,
|
spec: ProviderSpec | None = None,
|
||||||
extra_body: dict[str, Any] | None = None,
|
extra_body: dict[str, Any] | None = None,
|
||||||
|
api_type: str = "auto",
|
||||||
):
|
):
|
||||||
super().__init__(api_key, api_base)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.extra_headers = extra_headers or {}
|
self.extra_headers = extra_headers or {}
|
||||||
self._spec = spec
|
self._spec = spec
|
||||||
self._extra_body = extra_body or {}
|
self._extra_body = extra_body or {}
|
||||||
|
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||||
|
|
||||||
if api_key and spec and spec.env_key:
|
if api_key and spec and spec.env_key:
|
||||||
self._setup_env(api_key, api_base)
|
self._setup_env(api_key, api_base)
|
||||||
|
|
||||||
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
||||||
self._effective_base = effective_base
|
self._effective_base = effective_base
|
||||||
default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
self._default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
||||||
if _uses_openrouter_attribution(spec, effective_base):
|
if _uses_openrouter_attribution(spec, effective_base):
|
||||||
default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
|
self._default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
default_headers.update(extra_headers)
|
self._default_headers.update(extra_headers)
|
||||||
|
self._api_key_for_client = api_key or "no-key"
|
||||||
|
self._is_local = _is_local_endpoint(spec, effective_base)
|
||||||
|
|
||||||
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
# Lazy-init: the OpenAI client and its httpx transport are expensive
|
||||||
# HTTP connections before the client-side keepalive expires. When
|
# to create (~700 ms on Windows). Defer until first use.
|
||||||
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
self._client: AsyncOpenAIType | None = None
|
||||||
# process_direct), the second call may grab a now-dead pooled
|
self._client_lock = asyncio.Lock()
|
||||||
# connection, causing a transient APIConnectionError on every first
|
|
||||||
# attempt. Disabling keepalive for local endpoints avoids this by
|
|
||||||
# opening a fresh connection for each request, which is cheap on a
|
|
||||||
# LAN. Cloud providers benefit from keepalive, so we leave the
|
|
||||||
# default pool settings for them.
|
|
||||||
timeout_s = _openai_compat_timeout_s()
|
|
||||||
http_client: httpx.AsyncClient | None = None
|
|
||||||
if _is_local_endpoint(spec, effective_base):
|
|
||||||
http_client = httpx.AsyncClient(
|
|
||||||
limits=httpx.Limits(keepalive_expiry=0),
|
|
||||||
timeout=timeout_s,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._client = AsyncOpenAI(
|
|
||||||
api_key=api_key or "no-key",
|
|
||||||
base_url=effective_base,
|
|
||||||
default_headers=default_headers,
|
|
||||||
max_retries=0,
|
|
||||||
timeout=timeout_s,
|
|
||||||
http_client=http_client,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Responses API circuit breaker: skip after repeated failures,
|
# Responses API circuit breaker: skip after repeated failures,
|
||||||
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
|
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
|
||||||
self._responses_failures: dict[str, int] = {}
|
self._responses_failures: dict[str, int] = {}
|
||||||
self._responses_tripped_at: dict[str, float] = {}
|
self._responses_tripped_at: dict[str, float] = {}
|
||||||
|
|
||||||
|
def _build_client(self) -> None:
|
||||||
|
"""Create the OpenAI client using the current module-level AsyncOpenAI."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
timeout_s = _openai_compat_timeout_s()
|
||||||
|
http_client: httpx.AsyncClient | None = None
|
||||||
|
if self._is_local:
|
||||||
|
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
||||||
|
# HTTP connections before the client-side keepalive expires. When
|
||||||
|
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
||||||
|
# process_direct), the second call may grab a now-dead pooled
|
||||||
|
# connection, causing a transient APIConnectionError on every first
|
||||||
|
# attempt. Disabling keepalive for local endpoints avoids this by
|
||||||
|
# opening a fresh connection for each request, which is cheap on a
|
||||||
|
# LAN. Cloud providers benefit from keepalive, so we leave the
|
||||||
|
# default pool settings for them.
|
||||||
|
http_client = httpx.AsyncClient(
|
||||||
|
limits=httpx.Limits(keepalive_expiry=0),
|
||||||
|
timeout=timeout_s,
|
||||||
|
)
|
||||||
|
self._client = AsyncOpenAI(
|
||||||
|
api_key=self._api_key_for_client,
|
||||||
|
base_url=self._effective_base,
|
||||||
|
default_headers=self._default_headers,
|
||||||
|
max_retries=0,
|
||||||
|
timeout=timeout_s,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _ensure_client(self):
|
||||||
|
"""Return the shared OpenAI client, creating it on first call."""
|
||||||
|
if self._client is not None:
|
||||||
|
return self._client
|
||||||
|
async with self._client_lock:
|
||||||
|
if self._client is not None:
|
||||||
|
return self._client
|
||||||
|
global AsyncOpenAI
|
||||||
|
if AsyncOpenAI is None:
|
||||||
|
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
|
||||||
|
from langfuse.openai import AsyncOpenAI as _AsyncOpenAI
|
||||||
|
else:
|
||||||
|
if os.environ.get("LANGFUSE_SECRET_KEY"):
|
||||||
|
logger.warning(
|
||||||
|
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
|
||||||
|
"install with `pip install langfuse` to enable tracing"
|
||||||
|
)
|
||||||
|
from openai import AsyncOpenAI as _AsyncOpenAI
|
||||||
|
AsyncOpenAI = _AsyncOpenAI
|
||||||
|
|
||||||
|
self._build_client()
|
||||||
|
return self._client
|
||||||
|
|
||||||
def _setup_env(self, api_key: str, api_base: str | None) -> None:
|
def _setup_env(self, api_key: str, api_base: str | None) -> None:
|
||||||
"""Set environment variables based on provider spec."""
|
"""Set environment variables based on provider spec."""
|
||||||
spec = self._spec
|
spec = self._spec
|
||||||
@@ -396,6 +471,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
return tool_call_id
|
return tool_call_id
|
||||||
return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
|
return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
|
||||||
|
|
||||||
|
def _should_normalize_tool_call_ids(self) -> bool:
|
||||||
|
"""Return True for providers that reject normal OpenAI tool call IDs."""
|
||||||
|
return bool(self._spec and self._spec.name == "mistral")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_tool_call_arguments(arguments: Any) -> str:
|
def _normalize_tool_call_arguments(arguments: Any) -> str:
|
||||||
"""Force function.arguments into a valid JSON object string."""
|
"""Force function.arguments into a valid JSON object string."""
|
||||||
@@ -432,22 +511,60 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||||
id_map: dict[str, str] = {}
|
id_map: dict[str, str] = {}
|
||||||
|
pending_tool_ids: dict[str, deque[str]] = {}
|
||||||
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
||||||
|
normalize_tool_ids = self._should_normalize_tool_call_ids()
|
||||||
|
|
||||||
def map_id(value: Any) -> Any:
|
def map_id(value: Any) -> Any:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
|
if not normalize_tool_ids:
|
||||||
|
return value
|
||||||
return id_map.setdefault(value, self._normalize_tool_call_id(value))
|
return id_map.setdefault(value, self._normalize_tool_call_id(value))
|
||||||
|
|
||||||
|
def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str:
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
base = map_id(value)
|
||||||
|
else:
|
||||||
|
base = _short_tool_id()
|
||||||
|
if not isinstance(base, str) or not base:
|
||||||
|
base = _short_tool_id()
|
||||||
|
if base not in used_ids:
|
||||||
|
return base
|
||||||
|
seed = value if isinstance(value, str) and value else base
|
||||||
|
salt = 1
|
||||||
|
while True:
|
||||||
|
candidate = self._normalize_tool_call_id(f"{seed}:{idx}:{salt}")
|
||||||
|
if isinstance(candidate, str) and candidate not in used_ids:
|
||||||
|
return candidate
|
||||||
|
salt += 1
|
||||||
|
|
||||||
|
def map_tool_result_id(value: Any) -> Any:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
queue = pending_tool_ids.get(value)
|
||||||
|
if queue:
|
||||||
|
mapped = queue.popleft()
|
||||||
|
if not queue:
|
||||||
|
pending_tool_ids.pop(value, None)
|
||||||
|
return mapped
|
||||||
|
return map_id(value)
|
||||||
|
|
||||||
for clean in sanitized:
|
for clean in sanitized:
|
||||||
if isinstance(clean.get("tool_calls"), list):
|
if isinstance(clean.get("tool_calls"), list):
|
||||||
normalized = []
|
normalized = []
|
||||||
for tc in clean["tool_calls"]:
|
used_ids: set[str] = set()
|
||||||
|
for idx, tc in enumerate(clean["tool_calls"]):
|
||||||
if not isinstance(tc, dict):
|
if not isinstance(tc, dict):
|
||||||
normalized.append(tc)
|
normalized.append(tc)
|
||||||
continue
|
continue
|
||||||
tc_clean = dict(tc)
|
tc_clean = dict(tc)
|
||||||
tc_clean["id"] = map_id(tc_clean.get("id"))
|
raw_id = tc_clean.get("id")
|
||||||
|
mapped_id = unique_tool_id(raw_id, used_ids, idx)
|
||||||
|
tc_clean["id"] = mapped_id
|
||||||
|
used_ids.add(mapped_id)
|
||||||
|
if isinstance(raw_id, str) and raw_id:
|
||||||
|
pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id)
|
||||||
function = tc_clean.get("function")
|
function = tc_clean.get("function")
|
||||||
if isinstance(function, dict):
|
if isinstance(function, dict):
|
||||||
function_clean = dict(function)
|
function_clean = dict(function)
|
||||||
@@ -465,7 +582,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# that mix non-empty content with tool_calls.
|
# that mix non-empty content with tool_calls.
|
||||||
clean["content"] = None
|
clean["content"] = None
|
||||||
if "tool_call_id" in clean and clean["tool_call_id"]:
|
if "tool_call_id" in clean and clean["tool_call_id"]:
|
||||||
clean["tool_call_id"] = map_id(clean["tool_call_id"])
|
clean["tool_call_id"] = map_tool_result_id(clean["tool_call_id"])
|
||||||
if (
|
if (
|
||||||
force_string_content
|
force_string_content
|
||||||
and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
|
and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
|
||||||
@@ -552,39 +669,27 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if wire_effort and semantic_effort != "none":
|
if wire_effort and semantic_effort != "none":
|
||||||
kwargs["reasoning_effort"] = wire_effort
|
kwargs["reasoning_effort"] = wire_effort
|
||||||
|
|
||||||
# Provider-specific thinking parameters.
|
# Only send thinking controls when reasoning_effort is explicit so
|
||||||
# Only sent when reasoning_effort is explicitly configured so that
|
# omitting the config preserves each provider's default.
|
||||||
# the provider default is preserved otherwise.
|
if reasoning_effort is not None:
|
||||||
# The mapping is driven by ProviderSpec.thinking_style so that adding
|
|
||||||
# a new provider never requires touching this function.
|
|
||||||
if spec and spec.thinking_style and reasoning_effort is not None:
|
|
||||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
thinking_enabled = semantic_effort not in ("none", "minimal")
|
||||||
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
|
for thinking_style in _thinking_styles_for(spec, model_name):
|
||||||
if extra:
|
extra = _thinking_extra_body(thinking_style, thinking_enabled)
|
||||||
kwargs.setdefault("extra_body", {}).update(extra)
|
if extra:
|
||||||
|
kwargs.setdefault("extra_body", {}).update(extra)
|
||||||
|
gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else ""
|
||||||
|
if gateway_style and _model_thinking_style(model_name):
|
||||||
|
extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort)
|
||||||
|
if extra:
|
||||||
|
kwargs.setdefault("extra_body", {}).update(extra)
|
||||||
|
|
||||||
# Model-level thinking injection for Kimi thinking-capable models.
|
# Moonshot rejects requests that carry both 'reasoning_effort'
|
||||||
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup
|
# and the native 'thinking' param. We already expressed the
|
||||||
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
# user's intent via the provider-native shape, so drop the
|
||||||
# identically to bare names like "kimi-k2.5".
|
# redundant wire-level kwarg. Only kimi models need this —
|
||||||
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
# Xiaomi's API accepts both params.
|
||||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
if _model_slug(model_name) in _KIMI_THINKING_MODELS:
|
||||||
kwargs.setdefault("extra_body", {}).update(
|
kwargs.pop("reasoning_effort", None)
|
||||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Model-level thinking injection for MiMo thinking-capable models.
|
|
||||||
# Same shape as Kimi: gateway providers (OpenRouter, etc.) lack the
|
|
||||||
# xiaomi_mimo spec's thinking_style, so the spec-driven branch above
|
|
||||||
# misses them — match by model name to catch "xiaomi/mimo-v2.5-pro"
|
|
||||||
# and friends. (Direct xiaomi_mimo requests are also covered here;
|
|
||||||
# both branches write the same payload, so the dict update is a
|
|
||||||
# safe no-op for already-handled cases.)
|
|
||||||
if reasoning_effort is not None and _is_mimo_thinking_model(model_name):
|
|
||||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
|
||||||
kwargs.setdefault("extra_body", {}).update(
|
|
||||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
kwargs["tools"] = tools
|
kwargs["tools"] = tools
|
||||||
@@ -599,8 +704,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
and semantic_effort not in ("none", "minimal")
|
and semantic_effort not in ("none", "minimal")
|
||||||
and (
|
and (
|
||||||
(spec and spec.thinking_style)
|
(spec and spec.thinking_style)
|
||||||
or _is_kimi_thinking_model(model_name)
|
or _model_thinking_style(model_name)
|
||||||
or _is_mimo_thinking_model(model_name)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
implicit_deepseek_thinking = (
|
implicit_deepseek_thinking = (
|
||||||
@@ -631,8 +735,14 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None,
|
reasoning_effort: str | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Use Responses API only for direct OpenAI requests that benefit from it."""
|
"""Use Responses API only for direct OpenAI requests that benefit from it."""
|
||||||
|
if self._api_type == "chat_completions":
|
||||||
|
return False
|
||||||
if self._spec and self._spec.name not in ("openai", "github_copilot"):
|
if self._spec and self._spec.name not in ("openai", "github_copilot"):
|
||||||
return False
|
return False
|
||||||
|
if self._api_type == "responses":
|
||||||
|
# Explicit configuration means Responses is mandatory; do not
|
||||||
|
# consult the circuit breaker or fall back to Chat Completions.
|
||||||
|
return True
|
||||||
if self._spec is None or self._spec.name != "github_copilot":
|
if self._spec is None or self._spec.name != "github_copilot":
|
||||||
if not _is_direct_openai_base(self._effective_base):
|
if not _is_direct_openai_base(self._effective_base):
|
||||||
return False
|
return False
|
||||||
@@ -646,7 +756,14 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if not wants:
|
if not wants:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Circuit breaker: skip after repeated failures, probe periodically.
|
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
||||||
|
|
||||||
|
def _responses_circuit_allows_probe(
|
||||||
|
self,
|
||||||
|
model: str | None,
|
||||||
|
reasoning_effort: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return False when the Responses API circuit breaker is open."""
|
||||||
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
key = _responses_circuit_key(model, self.default_model, reasoning_effort)
|
||||||
failures = self._responses_failures.get(key, 0)
|
failures = self._responses_failures.get(key, 0)
|
||||||
if failures >= _RESPONSES_FAILURE_THRESHOLD:
|
if failures >= _RESPONSES_FAILURE_THRESHOLD:
|
||||||
@@ -738,6 +855,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
body["tool_choice"] = tool_choice or "auto"
|
body["tool_choice"] = tool_choice or "auto"
|
||||||
|
|
||||||
|
extra_body = getattr(self, "_extra_body", {})
|
||||||
|
if extra_body:
|
||||||
|
body = _merge_responses_extra_body(body, extra_body)
|
||||||
|
|
||||||
return body
|
return body
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -902,7 +1023,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
args = json_repair.loads(args)
|
args = json_repair.loads(args)
|
||||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||||
parsed_tool_calls.append(ToolCallRequest(
|
parsed_tool_calls.append(ToolCallRequest(
|
||||||
id=_short_tool_id(),
|
id=str(tc_map.get("id") or _short_tool_id()),
|
||||||
name=str(fn.get("name") or ""),
|
name=str(fn.get("name") or ""),
|
||||||
arguments=args if isinstance(args, dict) else {},
|
arguments=args if isinstance(args, dict) else {},
|
||||||
extra_content=ec,
|
extra_content=ec,
|
||||||
@@ -945,7 +1066,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
args = json_repair.loads(args)
|
args = json_repair.loads(args)
|
||||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||||
tool_calls.append(ToolCallRequest(
|
tool_calls.append(ToolCallRequest(
|
||||||
id=_short_tool_id(),
|
id=str(getattr(tc, "id", None) or _short_tool_id()),
|
||||||
name=tc.function.name,
|
name=tc.function.name,
|
||||||
arguments=args,
|
arguments=args,
|
||||||
extra_content=ec,
|
extra_content=ec,
|
||||||
@@ -999,6 +1120,21 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if fn_prov:
|
if fn_prov:
|
||||||
buf["fn_prov"] = fn_prov
|
buf["fn_prov"] = fn_prov
|
||||||
|
|
||||||
|
def _accum_legacy_function_call(function_call: Any) -> None:
|
||||||
|
"""Accumulate legacy ``delta.function_call`` streaming chunks."""
|
||||||
|
if not function_call:
|
||||||
|
return
|
||||||
|
buf = tc_bufs.setdefault(0, {
|
||||||
|
"id": "", "name": "", "arguments": "",
|
||||||
|
"extra_content": None, "prov": None, "fn_prov": None,
|
||||||
|
})
|
||||||
|
fn_name = _get(function_call, "name")
|
||||||
|
if fn_name:
|
||||||
|
buf["name"] = str(fn_name)
|
||||||
|
fn_args = _get(function_call, "arguments")
|
||||||
|
if fn_args:
|
||||||
|
buf["arguments"] += str(fn_args)
|
||||||
|
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
if isinstance(chunk, str):
|
if isinstance(chunk, str):
|
||||||
content_parts.append(chunk)
|
content_parts.append(chunk)
|
||||||
@@ -1029,6 +1165,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning_parts.append(text)
|
reasoning_parts.append(text)
|
||||||
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
||||||
_accum_tc(tc, idx)
|
_accum_tc(tc, idx)
|
||||||
|
_accum_legacy_function_call(delta.get("function_call"))
|
||||||
usage = cls._extract_usage(chunk_map) or usage
|
usage = cls._extract_usage(chunk_map) or usage
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1047,8 +1184,19 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning = getattr(delta, "reasoning", None)
|
reasoning = getattr(delta, "reasoning", None)
|
||||||
if reasoning:
|
if reasoning:
|
||||||
reasoning_parts.append(reasoning)
|
reasoning_parts.append(reasoning)
|
||||||
for tc in (delta.tool_calls or []) if delta else []:
|
for tc in (getattr(delta, "tool_calls", None) or []) if delta else []:
|
||||||
_accum_tc(tc, getattr(tc, "index", 0))
|
_accum_tc(tc, getattr(tc, "index", 0))
|
||||||
|
if delta:
|
||||||
|
_accum_legacy_function_call(getattr(delta, "function_call", None))
|
||||||
|
|
||||||
|
# Some providers (e.g. Zhipu/GLM) reuse the same tool_call id for
|
||||||
|
# parallel tool calls in streaming mode. Deduplicate before building
|
||||||
|
# the response so downstream tool messages don't collide.
|
||||||
|
_seen_tc_ids: set[str] = set()
|
||||||
|
for b in tc_bufs.values():
|
||||||
|
if not b["id"] or b["id"] in _seen_tc_ids:
|
||||||
|
b["id"] = _short_tool_id()
|
||||||
|
_seen_tc_ids.add(b["id"])
|
||||||
|
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content="".join(content_parts) or None,
|
content="".join(content_parts) or None,
|
||||||
@@ -1164,6 +1312,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
await self._ensure_client()
|
||||||
try:
|
try:
|
||||||
if self._should_use_responses_api(model, reasoning_effort):
|
if self._should_use_responses_api(model, reasoning_effort):
|
||||||
try:
|
try:
|
||||||
@@ -1180,6 +1329,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# falling back to /chat/completions cannot succeed and would
|
# falling back to /chat/completions cannot succeed and would
|
||||||
# hide the real error.
|
# hide the real error.
|
||||||
raise
|
raise
|
||||||
|
if self._api_type == "responses":
|
||||||
|
raise
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
if not self._should_fallback_from_responses_error(responses_error):
|
||||||
raise
|
raise
|
||||||
self._record_responses_failure(model, reasoning_effort)
|
self._record_responses_failure(model, reasoning_effort)
|
||||||
@@ -1203,7 +1354,9 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
await self._ensure_client()
|
||||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
try:
|
try:
|
||||||
if self._should_use_responses_api(model, reasoning_effort):
|
if self._should_use_responses_api(model, reasoning_effort):
|
||||||
@@ -1226,9 +1379,16 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
break
|
break
|
||||||
|
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = await consume_sdk_stream(
|
(
|
||||||
|
content,
|
||||||
|
tool_calls,
|
||||||
|
finish_reason,
|
||||||
|
usage,
|
||||||
|
reasoning_content,
|
||||||
|
) = await consume_sdk_stream(
|
||||||
_timed_stream(),
|
_timed_stream(),
|
||||||
on_content_delta,
|
on_content_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
)
|
)
|
||||||
self._record_responses_success(model, reasoning_effort)
|
self._record_responses_success(model, reasoning_effort)
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
@@ -1244,6 +1404,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# falling back to /chat/completions cannot succeed and would
|
# falling back to /chat/completions cannot succeed and would
|
||||||
# hide the real error.
|
# hide the real error.
|
||||||
raise
|
raise
|
||||||
|
if self._api_type == "responses":
|
||||||
|
raise
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
if not self._should_fallback_from_responses_error(responses_error):
|
||||||
raise
|
raise
|
||||||
self._record_responses_failure(model, reasoning_effort)
|
self._record_responses_failure(model, reasoning_effort)
|
||||||
@@ -1252,6 +1414,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
)
|
)
|
||||||
|
if self._spec and self._spec.name == "zhipu" and tools and on_tool_call_delta:
|
||||||
|
# Z.AI/GLM keeps streaming tool-call arguments behind an
|
||||||
|
# explicit provider flag. Pass it through the OpenAI SDK's
|
||||||
|
# extra_body escape hatch so the usual delta.tool_calls path
|
||||||
|
# can surface live file-edit progress.
|
||||||
|
kwargs.setdefault("extra_body", {})["tool_stream"] = True
|
||||||
kwargs["stream"] = True
|
kwargs["stream"] = True
|
||||||
kwargs["stream_options"] = {"include_usage": True}
|
kwargs["stream_options"] = {"include_usage": True}
|
||||||
stream = await self._client.chat.completions.create(**kwargs)
|
stream = await self._client.chat.completions.create(**kwargs)
|
||||||
@@ -1279,6 +1447,28 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
r_text = self._extract_text_content(reasoning)
|
r_text = self._extract_text_content(reasoning)
|
||||||
if r_text:
|
if r_text:
|
||||||
await on_thinking_delta(r_text)
|
await on_thinking_delta(r_text)
|
||||||
|
if on_tool_call_delta:
|
||||||
|
for idx, tool_delta in enumerate(
|
||||||
|
getattr(delta_obj, "tool_calls", None) or []
|
||||||
|
):
|
||||||
|
fn = _get(tool_delta, "function")
|
||||||
|
tool_index = _get(tool_delta, "index")
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"index": tool_index if tool_index is not None else idx,
|
||||||
|
"call_id": str(_get(tool_delta, "id") or ""),
|
||||||
|
"name": str(_get(fn, "name") or "") if fn is not None else "",
|
||||||
|
"arguments_delta": (
|
||||||
|
str(_get(fn, "arguments") or "") if fn is not None else ""
|
||||||
|
),
|
||||||
|
})
|
||||||
|
function_call = getattr(delta_obj, "function_call", None)
|
||||||
|
if function_call:
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"index": 0,
|
||||||
|
"call_id": "",
|
||||||
|
"name": str(_get(function_call, "name") or ""),
|
||||||
|
"arguments_delta": str(_get(function_call, "arguments") or ""),
|
||||||
|
})
|
||||||
return self._parse_chunks(chunks)
|
return self._parse_chunks(chunks)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from nanobot.providers.openai_responses.parsing import (
|
|||||||
FINISH_REASON_MAP,
|
FINISH_REASON_MAP,
|
||||||
consume_sdk_stream,
|
consume_sdk_stream,
|
||||||
consume_sse,
|
consume_sse,
|
||||||
|
consume_sse_with_reasoning,
|
||||||
iter_sse,
|
iter_sse,
|
||||||
map_finish_reason,
|
map_finish_reason,
|
||||||
parse_response_output,
|
parse_response_output,
|
||||||
@@ -22,6 +23,7 @@ __all__ = [
|
|||||||
"split_tool_call_id",
|
"split_tool_call_id",
|
||||||
"iter_sse",
|
"iter_sse",
|
||||||
"consume_sse",
|
"consume_sse",
|
||||||
|
"consume_sse_with_reasoning",
|
||||||
"consume_sdk_stream",
|
"consume_sdk_stream",
|
||||||
"map_finish_reason",
|
"map_finish_reason",
|
||||||
"parse_response_output",
|
"parse_response_output",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
|||||||
"""
|
"""
|
||||||
system_prompt = ""
|
system_prompt = ""
|
||||||
input_items: list[dict[str, Any]] = []
|
input_items: list[dict[str, Any]] = []
|
||||||
|
used_item_ids: set[str] = set()
|
||||||
|
|
||||||
for idx, msg in enumerate(messages):
|
for idx, msg in enumerate(messages):
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
@@ -30,17 +31,19 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
|||||||
|
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
|
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||||
input_items.append({
|
input_items.append({
|
||||||
"type": "message", "role": "assistant",
|
"type": "message", "role": "assistant",
|
||||||
"content": [{"type": "output_text", "text": content}],
|
"content": [{"type": "output_text", "text": content}],
|
||||||
"status": "completed", "id": f"msg_{idx}",
|
"status": "completed", "id": message_id,
|
||||||
})
|
})
|
||||||
for tool_call in msg.get("tool_calls", []) or []:
|
for tool_call in msg.get("tool_calls", []) or []:
|
||||||
fn = tool_call.get("function") or {}
|
fn = tool_call.get("function") or {}
|
||||||
call_id, item_id = split_tool_call_id(tool_call.get("id"))
|
call_id, item_id = split_tool_call_id(tool_call.get("id"))
|
||||||
|
response_item_id = _unique_item_id(item_id or f"fc_{idx}", used_item_ids)
|
||||||
input_items.append({
|
input_items.append({
|
||||||
"type": "function_call",
|
"type": "function_call",
|
||||||
"id": item_id or f"fc_{idx}",
|
"id": response_item_id,
|
||||||
"call_id": call_id or f"call_{idx}",
|
"call_id": call_id or f"call_{idx}",
|
||||||
"name": fn.get("name"),
|
"name": fn.get("name"),
|
||||||
"arguments": fn.get("arguments") or "{}",
|
"arguments": fn.get("arguments") or "{}",
|
||||||
@@ -97,6 +100,20 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
return converted
|
return converted
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_item_id(item_id: str, used: set[str]) -> str:
|
||||||
|
"""Return a Responses input item id that is unique within one request."""
|
||||||
|
if item_id not in used:
|
||||||
|
used.add(item_id)
|
||||||
|
return item_id
|
||||||
|
|
||||||
|
suffix = 2
|
||||||
|
while f"{item_id}_{suffix}" in used:
|
||||||
|
suffix += 1
|
||||||
|
unique = f"{item_id}_{suffix}"
|
||||||
|
used.add(unique)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
||||||
"""Split a compound ``call_id|item_id`` string.
|
"""Split a compound ``call_id|item_id`` string.
|
||||||
|
|
||||||
|
|||||||
@@ -62,12 +62,31 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N
|
|||||||
async def consume_sse(
|
async def consume_sse(
|
||||||
response: httpx.Response,
|
response: httpx.Response,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> tuple[str, list[ToolCallRequest], str]:
|
) -> tuple[str, list[ToolCallRequest], str]:
|
||||||
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
|
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
|
||||||
|
content, tool_calls, finish_reason, _ = await consume_sse_with_reasoning(
|
||||||
|
response,
|
||||||
|
on_content_delta=on_content_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
|
)
|
||||||
|
return content, tool_calls, finish_reason
|
||||||
|
|
||||||
|
|
||||||
|
async def consume_sse_with_reasoning(
|
||||||
|
response: httpx.Response,
|
||||||
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
|
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
) -> tuple[str, list[ToolCallRequest], str, str | None]:
|
||||||
|
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
|
||||||
content = ""
|
content = ""
|
||||||
tool_calls: list[ToolCallRequest] = []
|
tool_calls: list[ToolCallRequest] = []
|
||||||
tool_call_buffers: dict[str, dict[str, Any]] = {}
|
tool_call_buffers: dict[str, dict[str, Any]] = {}
|
||||||
|
tool_call_args_emitted: set[str] = set()
|
||||||
finish_reason = "stop"
|
finish_reason = "stop"
|
||||||
|
reasoning_content: str | None = None
|
||||||
|
streamed_reasoning = False
|
||||||
|
|
||||||
async for event in iter_sse(response):
|
async for event in iter_sse(response):
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
@@ -82,19 +101,60 @@ async def consume_sse(
|
|||||||
"name": item.get("name"),
|
"name": item.get("name"),
|
||||||
"arguments": item.get("arguments") or "",
|
"arguments": item.get("arguments") or "",
|
||||||
}
|
}
|
||||||
|
if on_tool_call_delta:
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(item.get("name") or ""),
|
||||||
|
"arguments_delta": "",
|
||||||
|
})
|
||||||
elif event_type == "response.output_text.delta":
|
elif event_type == "response.output_text.delta":
|
||||||
delta_text = event.get("delta") or ""
|
delta_text = event.get("delta") or ""
|
||||||
content += delta_text
|
content += delta_text
|
||||||
if on_content_delta and delta_text:
|
if on_content_delta and delta_text:
|
||||||
await on_content_delta(delta_text)
|
await on_content_delta(delta_text)
|
||||||
|
elif event_type == "response.reasoning_summary_text.delta":
|
||||||
|
delta_text = event.get("delta") or ""
|
||||||
|
if delta_text:
|
||||||
|
reasoning_content = (reasoning_content or "") + delta_text
|
||||||
|
streamed_reasoning = True
|
||||||
|
if on_reasoning_delta:
|
||||||
|
await on_reasoning_delta(delta_text)
|
||||||
|
elif event_type == "response.reasoning_summary_text.done":
|
||||||
|
text = event.get("text") or ""
|
||||||
|
if text and not streamed_reasoning and not reasoning_content:
|
||||||
|
reasoning_content = text
|
||||||
|
if on_reasoning_delta:
|
||||||
|
await on_reasoning_delta(text)
|
||||||
|
elif event_type == "response.reasoning_summary_part.done":
|
||||||
|
part = event.get("part") or {}
|
||||||
|
text = part.get("text") if part.get("type") == "summary_text" else None
|
||||||
|
if text and not streamed_reasoning and not reasoning_content:
|
||||||
|
reasoning_content = text
|
||||||
|
if on_reasoning_delta:
|
||||||
|
await on_reasoning_delta(text)
|
||||||
elif event_type == "response.function_call_arguments.delta":
|
elif event_type == "response.function_call_arguments.delta":
|
||||||
call_id = event.get("call_id")
|
call_id = event.get("call_id")
|
||||||
if call_id and call_id in tool_call_buffers:
|
if call_id and call_id in tool_call_buffers:
|
||||||
tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
|
delta = event.get("delta") or ""
|
||||||
|
tool_call_buffers[call_id]["arguments"] += delta
|
||||||
|
if on_tool_call_delta and delta:
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(tool_call_buffers[call_id].get("name") or ""),
|
||||||
|
"arguments_delta": str(delta),
|
||||||
|
})
|
||||||
elif event_type == "response.function_call_arguments.done":
|
elif event_type == "response.function_call_arguments.done":
|
||||||
call_id = event.get("call_id")
|
call_id = event.get("call_id")
|
||||||
if call_id and call_id in tool_call_buffers:
|
if call_id and call_id in tool_call_buffers:
|
||||||
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
|
arguments = event.get("arguments") or ""
|
||||||
|
tool_call_buffers[call_id]["arguments"] = arguments
|
||||||
|
if on_tool_call_delta:
|
||||||
|
tool_call_args_emitted.add(str(call_id))
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(tool_call_buffers[call_id].get("name") or ""),
|
||||||
|
"arguments": str(arguments),
|
||||||
|
})
|
||||||
elif event_type == "response.output_item.done":
|
elif event_type == "response.output_item.done":
|
||||||
item = event.get("item") or {}
|
item = event.get("item") or {}
|
||||||
if item.get("type") == "function_call":
|
if item.get("type") == "function_call":
|
||||||
@@ -103,6 +163,13 @@ async def consume_sse(
|
|||||||
continue
|
continue
|
||||||
buf = tool_call_buffers.get(call_id) or {}
|
buf = tool_call_buffers.get(call_id) or {}
|
||||||
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
|
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
|
||||||
|
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
|
||||||
|
tool_call_args_emitted.add(str(call_id))
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(buf.get("name") or item.get("name") or ""),
|
||||||
|
"arguments": str(args_raw),
|
||||||
|
})
|
||||||
try:
|
try:
|
||||||
args = json.loads(args_raw)
|
args = json.loads(args_raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -121,14 +188,44 @@ async def consume_sse(
|
|||||||
arguments=args,
|
arguments=args,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif item.get("type") == "reasoning" and not reasoning_content:
|
||||||
|
summary = _extract_reasoning_summary_from_output([item])
|
||||||
|
if summary:
|
||||||
|
reasoning_content = summary
|
||||||
|
if on_reasoning_delta:
|
||||||
|
await on_reasoning_delta(summary)
|
||||||
elif event_type == "response.completed":
|
elif event_type == "response.completed":
|
||||||
status = (event.get("response") or {}).get("status")
|
response_obj = event.get("response") or {}
|
||||||
|
status = response_obj.get("status")
|
||||||
finish_reason = map_finish_reason(status)
|
finish_reason = map_finish_reason(status)
|
||||||
|
if not reasoning_content:
|
||||||
|
summary = _extract_reasoning_summary_from_output(response_obj.get("output") or [])
|
||||||
|
if summary:
|
||||||
|
reasoning_content = summary
|
||||||
|
if on_reasoning_delta:
|
||||||
|
await on_reasoning_delta(summary)
|
||||||
elif event_type in {"error", "response.failed"}:
|
elif event_type in {"error", "response.failed"}:
|
||||||
detail = event.get("error") or event.get("message") or event
|
detail = event.get("error") or event.get("message") or event
|
||||||
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
||||||
|
|
||||||
return content, tool_calls, finish_reason
|
return content, tool_calls, finish_reason, reasoning_content
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_reasoning_summary_from_output(output: Any) -> str | None:
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in output or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
dump = getattr(item, "model_dump", None)
|
||||||
|
item = dump() if callable(dump) else vars(item)
|
||||||
|
if item.get("type") != "reasoning":
|
||||||
|
continue
|
||||||
|
for summary in item.get("summary") or []:
|
||||||
|
if not isinstance(summary, dict):
|
||||||
|
dump = getattr(summary, "model_dump", None)
|
||||||
|
summary = dump() if callable(dump) else vars(summary)
|
||||||
|
if summary.get("type") == "summary_text" and summary.get("text"):
|
||||||
|
parts.append(summary["text"])
|
||||||
|
return "".join(parts) or None
|
||||||
|
|
||||||
|
|
||||||
def parse_response_output(response: Any) -> LLMResponse:
|
def parse_response_output(response: Any) -> LLMResponse:
|
||||||
@@ -210,11 +307,13 @@ def parse_response_output(response: Any) -> LLMResponse:
|
|||||||
async def consume_sdk_stream(
|
async def consume_sdk_stream(
|
||||||
stream: Any,
|
stream: Any,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||||
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
|
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
|
||||||
content = ""
|
content = ""
|
||||||
tool_calls: list[ToolCallRequest] = []
|
tool_calls: list[ToolCallRequest] = []
|
||||||
tool_call_buffers: dict[str, dict[str, Any]] = {}
|
tool_call_buffers: dict[str, dict[str, Any]] = {}
|
||||||
|
tool_call_args_emitted: set[str] = set()
|
||||||
finish_reason = "stop"
|
finish_reason = "stop"
|
||||||
usage: dict[str, int] = {}
|
usage: dict[str, int] = {}
|
||||||
reasoning_content: str | None = None
|
reasoning_content: str | None = None
|
||||||
@@ -232,6 +331,12 @@ async def consume_sdk_stream(
|
|||||||
"name": getattr(item, "name", None),
|
"name": getattr(item, "name", None),
|
||||||
"arguments": getattr(item, "arguments", None) or "",
|
"arguments": getattr(item, "arguments", None) or "",
|
||||||
}
|
}
|
||||||
|
if on_tool_call_delta:
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(getattr(item, "name", None) or ""),
|
||||||
|
"arguments_delta": "",
|
||||||
|
})
|
||||||
elif event_type == "response.output_text.delta":
|
elif event_type == "response.output_text.delta":
|
||||||
delta_text = getattr(event, "delta", "") or ""
|
delta_text = getattr(event, "delta", "") or ""
|
||||||
content += delta_text
|
content += delta_text
|
||||||
@@ -240,11 +345,26 @@ async def consume_sdk_stream(
|
|||||||
elif event_type == "response.function_call_arguments.delta":
|
elif event_type == "response.function_call_arguments.delta":
|
||||||
call_id = getattr(event, "call_id", None)
|
call_id = getattr(event, "call_id", None)
|
||||||
if call_id and call_id in tool_call_buffers:
|
if call_id and call_id in tool_call_buffers:
|
||||||
tool_call_buffers[call_id]["arguments"] += getattr(event, "delta", "") or ""
|
delta = getattr(event, "delta", "") or ""
|
||||||
|
tool_call_buffers[call_id]["arguments"] += delta
|
||||||
|
if on_tool_call_delta and delta:
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(tool_call_buffers[call_id].get("name") or ""),
|
||||||
|
"arguments_delta": str(delta),
|
||||||
|
})
|
||||||
elif event_type == "response.function_call_arguments.done":
|
elif event_type == "response.function_call_arguments.done":
|
||||||
call_id = getattr(event, "call_id", None)
|
call_id = getattr(event, "call_id", None)
|
||||||
if call_id and call_id in tool_call_buffers:
|
if call_id and call_id in tool_call_buffers:
|
||||||
tool_call_buffers[call_id]["arguments"] = getattr(event, "arguments", "") or ""
|
arguments = getattr(event, "arguments", "") or ""
|
||||||
|
tool_call_buffers[call_id]["arguments"] = arguments
|
||||||
|
if on_tool_call_delta:
|
||||||
|
tool_call_args_emitted.add(str(call_id))
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(tool_call_buffers[call_id].get("name") or ""),
|
||||||
|
"arguments": str(arguments),
|
||||||
|
})
|
||||||
elif event_type == "response.output_item.done":
|
elif event_type == "response.output_item.done":
|
||||||
item = getattr(event, "item", None)
|
item = getattr(event, "item", None)
|
||||||
if item and getattr(item, "type", None) == "function_call":
|
if item and getattr(item, "type", None) == "function_call":
|
||||||
@@ -253,6 +373,13 @@ async def consume_sdk_stream(
|
|||||||
continue
|
continue
|
||||||
buf = tool_call_buffers.get(call_id) or {}
|
buf = tool_call_buffers.get(call_id) or {}
|
||||||
args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}"
|
args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}"
|
||||||
|
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
|
||||||
|
tool_call_args_emitted.add(str(call_id))
|
||||||
|
await on_tool_call_delta({
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": str(buf.get("name") or getattr(item, "name", None) or ""),
|
||||||
|
"arguments": str(args_raw),
|
||||||
|
})
|
||||||
try:
|
try:
|
||||||
args = json.loads(args_raw)
|
args = json.loads(args_raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ class ProviderSpec:
|
|||||||
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
||||||
thinking_style: str = ""
|
thinking_style: str = ""
|
||||||
|
|
||||||
|
# Gateway-native reasoning control to pair with model-level thinking styles.
|
||||||
|
# "reasoning_effort" — {"reasoning": {"effort": <none|minimal|...>}}
|
||||||
|
# (OpenRouter)
|
||||||
|
gateway_reasoning_style: str = ""
|
||||||
|
|
||||||
# When True, treat the "reasoning" response field as formal content
|
# When True, treat the "reasoning" response field as formal content
|
||||||
# when "content" is empty. Only set this for providers (e.g. StepFun)
|
# when "content" is empty. Only set this for providers (e.g. StepFun)
|
||||||
# whose API returns the actual answer in "reasoning" instead of "content".
|
# whose API returns the actual answer in "reasoning" instead of "content".
|
||||||
@@ -142,6 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
detect_by_base_keyword="openrouter",
|
detect_by_base_keyword="openrouter",
|
||||||
default_api_base="https://openrouter.ai/api/v1",
|
default_api_base="https://openrouter.ai/api/v1",
|
||||||
supports_prompt_caching=True,
|
supports_prompt_caching=True,
|
||||||
|
gateway_reasoning_style="reasoning_effort",
|
||||||
),
|
),
|
||||||
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
@@ -155,6 +161,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
detect_by_base_keyword="huggingface",
|
detect_by_base_keyword="huggingface",
|
||||||
default_api_base="https://router.huggingface.co/v1",
|
default_api_base="https://router.huggingface.co/v1",
|
||||||
),
|
),
|
||||||
|
# Skywork API platform (APIFree): OpenAI-compatible MaaS gateway.
|
||||||
|
ProviderSpec(
|
||||||
|
name="skywork",
|
||||||
|
keywords=("skywork", "skyclaw", "apifree"),
|
||||||
|
env_key="SKYWORK_API_KEY",
|
||||||
|
display_name="Skywork",
|
||||||
|
backend="openai_compat",
|
||||||
|
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
|
||||||
|
is_gateway=True,
|
||||||
|
detect_by_base_keyword="apifree.ai",
|
||||||
|
default_api_base="https://api.apifree.ai/agent/v1",
|
||||||
|
),
|
||||||
# AiHubMix: global gateway, OpenAI-compatible interface.
|
# AiHubMix: global gateway, OpenAI-compatible interface.
|
||||||
# strip_model_prefix=True: doesn't understand "anthropic/claude-3",
|
# strip_model_prefix=True: doesn't understand "anthropic/claude-3",
|
||||||
# strips to bare "claude-3".
|
# strips to bare "claude-3".
|
||||||
@@ -181,6 +199,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
default_api_base="https://api.siliconflow.cn/v1",
|
default_api_base="https://api.siliconflow.cn/v1",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Novita AI: OpenAI-compatible gateway for hosted model APIs.
|
||||||
|
ProviderSpec(
|
||||||
|
name="novita",
|
||||||
|
keywords=("novita",),
|
||||||
|
env_key="NOVITA_API_KEY",
|
||||||
|
display_name="Novita AI",
|
||||||
|
backend="openai_compat",
|
||||||
|
is_gateway=True,
|
||||||
|
detect_by_base_keyword="novita",
|
||||||
|
default_api_base="https://api.novita.ai/openai",
|
||||||
|
),
|
||||||
|
|
||||||
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
|
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
name="volcengine",
|
name="volcengine",
|
||||||
@@ -390,13 +420,23 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.longcat.chat/openai/v1",
|
default_api_base="https://api.longcat.chat/openai/v1",
|
||||||
),
|
),
|
||||||
|
# Ant Ling: OpenAI-compatible API for Ling/Ring model families.
|
||||||
|
ProviderSpec(
|
||||||
|
name="ant_ling",
|
||||||
|
keywords=("ant_ling", "ant-ling", "ling-", "ring-"),
|
||||||
|
env_key="ANT_LING_API_KEY",
|
||||||
|
display_name="Ant Ling",
|
||||||
|
backend="openai_compat",
|
||||||
|
detect_by_base_keyword="ant-ling.com",
|
||||||
|
default_api_base="https://api.ant-ling.com/v1",
|
||||||
|
),
|
||||||
# === Local deployment (matched by config key, NOT by api_base) =========
|
# === Local deployment (matched by config key, NOT by api_base) =========
|
||||||
# vLLM / any OpenAI-compatible local server
|
# vLLM / any OpenAI-compatible local server
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
name="vllm",
|
name="vllm",
|
||||||
keywords=("vllm",),
|
keywords=("vllm",),
|
||||||
env_key="HOSTED_VLLM_API_KEY",
|
env_key="HOSTED_VLLM_API_KEY",
|
||||||
display_name="vLLM/Local",
|
display_name="vLLM",
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
is_local=True,
|
is_local=True,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ from pathlib import Path
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
_TRANSCRIPTIONS_PATH = "audio/transcriptions"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_transcription_url(api_base: str | None, default_url: str) -> str:
|
||||||
|
"""Resolve the full transcription endpoint URL.
|
||||||
|
|
||||||
|
Accepts either a chat-style base (e.g. ``https://api.groq.com/openai/v1``)
|
||||||
|
or a complete URL already ending in ``/audio/transcriptions``. A chat-style
|
||||||
|
base — the form users naturally copy from their LLM provider config — gets
|
||||||
|
the path appended instead of being POSTed verbatim and 404ing (#3637).
|
||||||
|
"""
|
||||||
|
if not api_base:
|
||||||
|
return default_url
|
||||||
|
base = api_base.rstrip("/")
|
||||||
|
if base.endswith(_TRANSCRIPTIONS_PATH):
|
||||||
|
return base
|
||||||
|
return f"{base}/{_TRANSCRIPTIONS_PATH}"
|
||||||
|
|
||||||
|
|
||||||
# Up to 3 retries (4 attempts total) with exponential backoff on transient
|
# Up to 3 retries (4 attempts total) with exponential backoff on transient
|
||||||
# failures. Whisper endpoints occasionally return 502/503 under load, and
|
# failures. Whisper endpoints occasionally return 502/503 under load, and
|
||||||
# mobile-network transcription callers hit sporadic connect/read errors.
|
# mobile-network transcription callers hit sporadic connect/read errors.
|
||||||
@@ -127,12 +146,12 @@ class OpenAITranscriptionProvider:
|
|||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
):
|
):
|
||||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||||
self.api_url = (
|
self.api_url = _resolve_transcription_url(
|
||||||
api_base
|
api_base or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL"),
|
||||||
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
"https://api.openai.com/v1/audio/transcriptions",
|
||||||
or "https://api.openai.com/v1/audio/transcriptions"
|
|
||||||
)
|
)
|
||||||
self.language = language or None
|
self.language = language or None
|
||||||
|
logger.debug("OpenAI transcription endpoint: {}", self.api_url)
|
||||||
|
|
||||||
async def transcribe(self, file_path: str | Path) -> str:
|
async def transcribe(self, file_path: str | Path) -> str:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
@@ -166,12 +185,12 @@ class GroqTranscriptionProvider:
|
|||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
):
|
):
|
||||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||||
self.api_url = (
|
self.api_url = _resolve_transcription_url(
|
||||||
api_base
|
api_base or os.environ.get("GROQ_BASE_URL"),
|
||||||
or os.environ.get("GROQ_BASE_URL")
|
"https://api.groq.com/openai/v1/audio/transcriptions",
|
||||||
or "https://api.groq.com/openai/v1/audio/transcriptions"
|
|
||||||
)
|
)
|
||||||
self.language = language or None
|
self.language = language or None
|
||||||
|
logger.debug("Groq transcription endpoint: {}", self.api_url)
|
||||||
|
|
||||||
async def transcribe(self, file_path: str | Path) -> str:
|
async def transcribe(self, file_path: str | Path) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -36,15 +36,36 @@ def configure_ssrf_whitelist(cidrs: list[str]) -> None:
|
|||||||
_allowed_networks = nets
|
_allowed_networks = nets
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_addr(
|
||||||
|
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||||
|
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
|
||||||
|
"""Normalize IPv6-mapped IPv4 addresses to their IPv4 form.
|
||||||
|
|
||||||
|
``::ffff:127.0.0.1`` is semantically identical to ``127.0.0.1`` but
|
||||||
|
Python's ipaddress treats it as an IPv6Address that matches neither
|
||||||
|
``127.0.0.0/8`` nor ``::1/128``. Converting it to IPv4 ensures
|
||||||
|
blocklist/allowlist checks work correctly.
|
||||||
|
"""
|
||||||
|
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||||
|
return addr.ipv4_mapped
|
||||||
|
return addr
|
||||||
|
|
||||||
|
|
||||||
def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
if _allowed_networks and any(addr in net for net in _allowed_networks):
|
normalized = _normalize_addr(addr)
|
||||||
|
if _allowed_networks and any(normalized in net for net in _allowed_networks):
|
||||||
return False
|
return False
|
||||||
return any(addr in net for net in _BLOCKED_NETWORKS)
|
return any(normalized in net for net in _BLOCKED_NETWORKS)
|
||||||
|
|
||||||
|
|
||||||
def validate_url_target(url: str) -> tuple[bool, str]:
|
def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]:
|
||||||
"""Validate a URL is safe to fetch: scheme, hostname, and resolved IPs.
|
"""Validate a URL is safe to fetch: scheme, hostname, and resolved IPs.
|
||||||
|
|
||||||
|
``allow_loopback`` is intentionally narrow: it only permits literal
|
||||||
|
loopback hosts (localhost, 127.0.0.0/8, ::1) when every resolved address is
|
||||||
|
loopback. It does not allow RFC1918, link-local, metadata, or public DNS
|
||||||
|
names that happen to resolve to loopback.
|
||||||
|
|
||||||
Returns (ok, error_message). When ok is True, error_message is empty.
|
Returns (ok, error_message). When ok is True, error_message is empty.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -66,11 +87,16 @@ def validate_url_target(url: str) -> tuple[bool, str]:
|
|||||||
except socket.gaierror:
|
except socket.gaierror:
|
||||||
return False, f"Cannot resolve hostname: {hostname}"
|
return False, f"Cannot resolve hostname: {hostname}"
|
||||||
|
|
||||||
|
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||||
for info in infos:
|
for info in infos:
|
||||||
try:
|
try:
|
||||||
addr = ipaddress.ip_address(info[4][0])
|
addr = ipaddress.ip_address(info[4][0])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
addrs.append(addr)
|
||||||
|
if allow_loopback and _is_allowed_loopback_target(hostname, addrs):
|
||||||
|
return True, ""
|
||||||
|
for addr in addrs:
|
||||||
if _is_private(addr):
|
if _is_private(addr):
|
||||||
return False, f"Blocked: {hostname} resolves to private/internal address {addr}"
|
return False, f"Blocked: {hostname} resolves to private/internal address {addr}"
|
||||||
|
|
||||||
@@ -109,11 +135,25 @@ def validate_resolved_url(url: str) -> tuple[bool, str]:
|
|||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def contains_internal_url(command: str) -> bool:
|
def contains_internal_url(command: str, *, allow_loopback: bool = False) -> bool:
|
||||||
"""Return True if the command string contains a URL targeting an internal/private address."""
|
"""Return True if the command string contains a URL targeting an internal/private address."""
|
||||||
for m in _URL_RE.finditer(command):
|
for m in _URL_RE.finditer(command):
|
||||||
url = m.group(0)
|
url = m.group(0)
|
||||||
ok, _ = validate_url_target(url)
|
ok, _ = validate_url_target(url, allow_loopback=allow_loopback)
|
||||||
if not ok:
|
if not ok:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_loopback_target(
|
||||||
|
hostname: str,
|
||||||
|
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address],
|
||||||
|
) -> bool:
|
||||||
|
if not addrs or not all(_normalize_addr(addr).is_loopback for addr in addrs):
|
||||||
|
return False
|
||||||
|
normalized = hostname.rstrip(".").lower()
|
||||||
|
if normalized == "localhost":
|
||||||
|
return True
|
||||||
|
with suppress(ValueError):
|
||||||
|
return ipaddress.ip_address(hostname).is_loopback
|
||||||
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""Workspace access scope and sandbox capability helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
WorkspaceAccessMode = Literal["restricted", "full"]
|
||||||
|
WORKSPACE_SCOPE_METADATA_KEY = "workspace_scope"
|
||||||
|
_ACCESS_MODES = {"restricted", "full"}
|
||||||
|
|
||||||
|
_TRUE_VALUES = {"1", "true", "yes", "on", "enabled"}
|
||||||
|
_FALSE_VALUES = {"0", "false", "no", "off", "disabled", ""}
|
||||||
|
_PROVIDER_LABELS = {
|
||||||
|
"none": "None",
|
||||||
|
"unknown": "Unknown system sandbox",
|
||||||
|
"macos_app_sandbox": "macOS App Sandbox",
|
||||||
|
"bwrap": "Bubblewrap",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CURRENT_WORKSPACE_SCOPE: ContextVar["WorkspaceScope | None"] = ContextVar(
|
||||||
|
"nanobot_workspace_scope",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceScopeError(ValueError):
|
||||||
|
"""Raised when a requested WebUI workspace scope is invalid."""
|
||||||
|
|
||||||
|
status = 400
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkspaceSandboxStatus:
|
||||||
|
"""Resolved workspace sandbox state for runtime display and tooling."""
|
||||||
|
|
||||||
|
restrict_to_workspace: bool
|
||||||
|
workspace_root: str
|
||||||
|
level: str
|
||||||
|
enforced: bool
|
||||||
|
provider: str
|
||||||
|
provider_label: str
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"restrict_to_workspace": self.restrict_to_workspace,
|
||||||
|
"workspace_root": self.workspace_root,
|
||||||
|
"level": self.level,
|
||||||
|
"enforced": self.enforced,
|
||||||
|
"provider": self.provider,
|
||||||
|
"provider_label": self.provider_label,
|
||||||
|
"summary": self.summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkspaceScope:
|
||||||
|
"""Effective project root and access mode for one agent turn."""
|
||||||
|
|
||||||
|
project_path: Path
|
||||||
|
access_mode: WorkspaceAccessMode
|
||||||
|
restrict_to_workspace: bool
|
||||||
|
sandbox_status: WorkspaceSandboxStatus
|
||||||
|
source_channel: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project_name(self) -> str:
|
||||||
|
return self.project_path.name or str(self.project_path)
|
||||||
|
|
||||||
|
def metadata(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"project_path": str(self.project_path),
|
||||||
|
"access_mode": self.access_mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
def payload(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
**self.metadata(),
|
||||||
|
"project_name": self.project_name,
|
||||||
|
"restrict_to_workspace": self.restrict_to_workspace,
|
||||||
|
"sandbox_status": self.sandbox_status.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolWorkspace:
|
||||||
|
"""Workspace policy resolved for a tool call."""
|
||||||
|
|
||||||
|
project_path: Path | None
|
||||||
|
restrict_to_workspace: bool
|
||||||
|
scope: WorkspaceScope | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_root(self) -> Path | None:
|
||||||
|
if self.restrict_to_workspace and self.project_path is not None:
|
||||||
|
return self.project_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkspaceScopeResolver:
|
||||||
|
"""Resolve the effective workspace scope at an agent turn boundary."""
|
||||||
|
|
||||||
|
default_workspace: str | Path
|
||||||
|
default_restrict_to_workspace: bool
|
||||||
|
scoped_channel: str = "websocket"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sandbox_status(self) -> WorkspaceSandboxStatus:
|
||||||
|
return self.default().sandbox_status
|
||||||
|
|
||||||
|
def default(self) -> WorkspaceScope:
|
||||||
|
return default_workspace_scope(
|
||||||
|
self.default_workspace,
|
||||||
|
self.default_restrict_to_workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
def for_message(
|
||||||
|
self,
|
||||||
|
msg: Any,
|
||||||
|
session_metadata: Any,
|
||||||
|
) -> WorkspaceScope:
|
||||||
|
return self.for_turn(
|
||||||
|
channel=getattr(msg, "channel", None),
|
||||||
|
message_metadata=getattr(msg, "metadata", None),
|
||||||
|
session_metadata=session_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def for_turn(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel: str | None,
|
||||||
|
message_metadata: Any,
|
||||||
|
session_metadata: Any,
|
||||||
|
) -> WorkspaceScope:
|
||||||
|
if channel != self.scoped_channel:
|
||||||
|
return self.default()
|
||||||
|
return resolve_effective_workspace_scope(
|
||||||
|
message_metadata=message_metadata,
|
||||||
|
session_metadata=session_metadata,
|
||||||
|
default_workspace=self.default_workspace,
|
||||||
|
default_restrict_to_workspace=self.default_restrict_to_workspace,
|
||||||
|
source_channel=channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
def persist_message_scope(self, session: Any, msg: Any) -> None:
|
||||||
|
if getattr(msg, "channel", None) != self.scoped_channel:
|
||||||
|
return
|
||||||
|
metadata = getattr(msg, "metadata", None)
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return
|
||||||
|
raw = metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_sandbox_status(
|
||||||
|
*,
|
||||||
|
restrict_to_workspace: bool,
|
||||||
|
workspace: str | Path,
|
||||||
|
environ: dict[str, str] | None = None,
|
||||||
|
) -> WorkspaceSandboxStatus:
|
||||||
|
"""Return how workspace restriction is enforced in the current host."""
|
||||||
|
|
||||||
|
workspace_root = str(Path(workspace).expanduser().resolve(strict=False))
|
||||||
|
provider = _env_system_provider(environ)
|
||||||
|
if not restrict_to_workspace:
|
||||||
|
return WorkspaceSandboxStatus(
|
||||||
|
restrict_to_workspace=False,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
level="off",
|
||||||
|
enforced=False,
|
||||||
|
provider="none",
|
||||||
|
provider_label=_provider_label("none"),
|
||||||
|
summary="Workspace restriction is disabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider:
|
||||||
|
label = _provider_label(provider)
|
||||||
|
return WorkspaceSandboxStatus(
|
||||||
|
restrict_to_workspace=True,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
level="system",
|
||||||
|
enforced=True,
|
||||||
|
provider=provider,
|
||||||
|
provider_label=label,
|
||||||
|
summary=f"Workspace restriction is system-enforced by {label}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return WorkspaceSandboxStatus(
|
||||||
|
restrict_to_workspace=True,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
level="application",
|
||||||
|
enforced=False,
|
||||||
|
provider="none",
|
||||||
|
provider_label=_provider_label("none"),
|
||||||
|
summary="Workspace restriction uses nanobot application-level guards.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_access_mode(restrict_to_workspace: bool) -> WorkspaceAccessMode:
|
||||||
|
return "restricted" if restrict_to_workspace else "full"
|
||||||
|
|
||||||
|
|
||||||
|
def build_workspace_scope(
|
||||||
|
project_path: str | Path,
|
||||||
|
access_mode: str,
|
||||||
|
*,
|
||||||
|
source_channel: str | None = None,
|
||||||
|
) -> WorkspaceScope:
|
||||||
|
mode = _normalize_access_mode(access_mode)
|
||||||
|
root = Path(project_path).expanduser().resolve(strict=False)
|
||||||
|
restrict = mode == "restricted"
|
||||||
|
return WorkspaceScope(
|
||||||
|
project_path=root,
|
||||||
|
access_mode=mode,
|
||||||
|
restrict_to_workspace=restrict,
|
||||||
|
sandbox_status=workspace_sandbox_status(
|
||||||
|
restrict_to_workspace=restrict,
|
||||||
|
workspace=root,
|
||||||
|
),
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_workspace_scope(
|
||||||
|
workspace: str | Path,
|
||||||
|
restrict_to_workspace: bool,
|
||||||
|
*,
|
||||||
|
source_channel: str | None = None,
|
||||||
|
) -> WorkspaceScope:
|
||||||
|
return build_workspace_scope(
|
||||||
|
workspace,
|
||||||
|
default_access_mode(restrict_to_workspace),
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_workspace_scope_payload(
|
||||||
|
raw: Any,
|
||||||
|
*,
|
||||||
|
default_workspace: str | Path,
|
||||||
|
default_restrict_to_workspace: bool,
|
||||||
|
source_channel: str | None = None,
|
||||||
|
) -> WorkspaceScope:
|
||||||
|
"""Validate a client-requested workspace scope."""
|
||||||
|
if raw is None:
|
||||||
|
return default_workspace_scope(
|
||||||
|
default_workspace,
|
||||||
|
default_restrict_to_workspace,
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise WorkspaceScopeError("workspace_scope must be an object")
|
||||||
|
|
||||||
|
raw_path = raw.get("project_path") or raw.get("path")
|
||||||
|
if raw_path is None or raw_path == "":
|
||||||
|
raw_path = str(Path(default_workspace).expanduser().resolve(strict=False))
|
||||||
|
if not isinstance(raw_path, str):
|
||||||
|
raise WorkspaceScopeError("project_path must be a string")
|
||||||
|
if "\0" in raw_path:
|
||||||
|
raise WorkspaceScopeError("project_path contains invalid characters")
|
||||||
|
|
||||||
|
project = Path(raw_path).expanduser()
|
||||||
|
if not project.is_absolute():
|
||||||
|
raise WorkspaceScopeError("project_path must be absolute")
|
||||||
|
project = project.resolve(strict=False)
|
||||||
|
if not project.is_dir():
|
||||||
|
raise WorkspaceScopeError("project_path must be an existing directory")
|
||||||
|
|
||||||
|
raw_mode = raw.get("access_mode")
|
||||||
|
if raw_mode is None:
|
||||||
|
raw_mode = default_access_mode(default_restrict_to_workspace)
|
||||||
|
if not isinstance(raw_mode, str):
|
||||||
|
raise WorkspaceScopeError("access_mode must be a string")
|
||||||
|
return build_workspace_scope(project, raw_mode, source_channel=source_channel)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_scope_from_metadata(
|
||||||
|
metadata: Any,
|
||||||
|
*,
|
||||||
|
default_workspace: str | Path,
|
||||||
|
default_restrict_to_workspace: bool,
|
||||||
|
source_channel: str | None = None,
|
||||||
|
) -> WorkspaceScope:
|
||||||
|
"""Resolve persisted metadata, falling back safely for old or stale sessions."""
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return default_workspace_scope(
|
||||||
|
default_workspace,
|
||||||
|
default_restrict_to_workspace,
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return validate_workspace_scope_payload(
|
||||||
|
metadata.get(WORKSPACE_SCOPE_METADATA_KEY),
|
||||||
|
default_workspace=default_workspace,
|
||||||
|
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
except WorkspaceScopeError:
|
||||||
|
return default_workspace_scope(
|
||||||
|
default_workspace,
|
||||||
|
default_restrict_to_workspace,
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_effective_workspace_scope(
|
||||||
|
*,
|
||||||
|
message_metadata: Any,
|
||||||
|
session_metadata: Any,
|
||||||
|
default_workspace: str | Path,
|
||||||
|
default_restrict_to_workspace: bool,
|
||||||
|
source_channel: str | None = None,
|
||||||
|
) -> WorkspaceScope:
|
||||||
|
if isinstance(message_metadata, dict) and WORKSPACE_SCOPE_METADATA_KEY in message_metadata:
|
||||||
|
return workspace_scope_from_metadata(
|
||||||
|
message_metadata,
|
||||||
|
default_workspace=default_workspace,
|
||||||
|
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
return workspace_scope_from_metadata(
|
||||||
|
session_metadata,
|
||||||
|
default_workspace=default_workspace,
|
||||||
|
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||||
|
source_channel=source_channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_workspace_scope(scope: WorkspaceScope) -> Token[WorkspaceScope | None]:
|
||||||
|
return _CURRENT_WORKSPACE_SCOPE.set(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_workspace_scope(token: Token[WorkspaceScope | None]) -> None:
|
||||||
|
_CURRENT_WORKSPACE_SCOPE.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def current_workspace_scope() -> WorkspaceScope | None:
|
||||||
|
return _CURRENT_WORKSPACE_SCOPE.get()
|
||||||
|
|
||||||
|
|
||||||
|
def current_tool_workspace(
|
||||||
|
default_workspace: str | Path | None,
|
||||||
|
*,
|
||||||
|
restrict_to_workspace: bool = False,
|
||||||
|
sandbox_restricts_workspace: bool = False,
|
||||||
|
) -> ToolWorkspace:
|
||||||
|
"""Return the workspace/access policy for the current tool call."""
|
||||||
|
|
||||||
|
scope = current_workspace_scope()
|
||||||
|
project_path = (
|
||||||
|
scope.project_path
|
||||||
|
if scope is not None
|
||||||
|
else Path(default_workspace).expanduser() if default_workspace is not None else None
|
||||||
|
)
|
||||||
|
restrict = (
|
||||||
|
scope.restrict_to_workspace
|
||||||
|
if scope is not None
|
||||||
|
else bool(restrict_to_workspace)
|
||||||
|
) or sandbox_restricts_workspace
|
||||||
|
return ToolWorkspace(
|
||||||
|
project_path=project_path,
|
||||||
|
restrict_to_workspace=restrict,
|
||||||
|
scope=scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def current_scope_allows_loopback(*, enabled: bool) -> bool:
|
||||||
|
"""Return True when the current WebUI Full Access turn may touch loopback URLs."""
|
||||||
|
|
||||||
|
scope = current_workspace_scope()
|
||||||
|
return bool(
|
||||||
|
enabled
|
||||||
|
and scope is not None
|
||||||
|
and scope.source_channel == "websocket"
|
||||||
|
and scope.access_mode == "full"
|
||||||
|
and not scope.restrict_to_workspace
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_system_provider(environ: dict[str, str] | None = None) -> str | None:
|
||||||
|
env = environ if environ is not None else os.environ
|
||||||
|
explicit_provider = env.get("NANOBOT_WORKSPACE_SANDBOX_PROVIDER")
|
||||||
|
enforced = env.get("NANOBOT_WORKSPACE_SANDBOX_ENFORCED")
|
||||||
|
compatibility = env.get("NANOBOT_SANDBOX_ENFORCED")
|
||||||
|
|
||||||
|
marker = enforced if enforced is not None else compatibility
|
||||||
|
if marker is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_marker = marker.strip().lower()
|
||||||
|
if normalized_marker in _FALSE_VALUES:
|
||||||
|
return None
|
||||||
|
if normalized_marker in _TRUE_VALUES:
|
||||||
|
return _normalize_provider(explicit_provider)
|
||||||
|
return _normalize_provider(marker)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_provider(value: str | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return "unknown"
|
||||||
|
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
|
||||||
|
return normalized or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_label(provider: str) -> str:
|
||||||
|
if provider in _PROVIDER_LABELS:
|
||||||
|
return _PROVIDER_LABELS[provider]
|
||||||
|
return provider.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_access_mode(value: str) -> WorkspaceAccessMode:
|
||||||
|
mode = value.strip().lower().replace("_", "-")
|
||||||
|
if mode == "restrict":
|
||||||
|
mode = "restricted"
|
||||||
|
if mode == "full-access":
|
||||||
|
mode = "full"
|
||||||
|
if mode not in _ACCESS_MODES:
|
||||||
|
raise WorkspaceScopeError("access_mode must be restricted or full")
|
||||||
|
return mode # type: ignore[return-value]
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Workspace path boundary helpers.
|
||||||
|
|
||||||
|
These helpers are application-level guards. They make path decisions
|
||||||
|
consistent across tools, but they are not a replacement for an OS sandbox.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
WORKSPACE_BOUNDARY_NOTE = (
|
||||||
|
" (this is a hard policy boundary, not a transient failure; "
|
||||||
|
"do not retry with shell tricks or alternative tools, and ask "
|
||||||
|
"the user how to proceed if the resource is genuinely required)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceBoundaryError(PermissionError):
|
||||||
|
"""Raised when a requested path escapes an allowed workspace boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(path: str | Path, workspace: str | Path | None = None, *, strict: bool = False) -> Path:
|
||||||
|
"""Resolve *path*, interpreting relative paths against *workspace* when set."""
|
||||||
|
candidate = Path(path).expanduser()
|
||||||
|
if not candidate.is_absolute() and workspace is not None:
|
||||||
|
candidate = Path(workspace).expanduser() / candidate
|
||||||
|
return candidate.resolve(strict=strict)
|
||||||
|
|
||||||
|
|
||||||
|
def is_path_within(path: str | Path, root: str | Path) -> bool:
|
||||||
|
"""Return True when *path* resolves to *root* or a descendant of *root*."""
|
||||||
|
try:
|
||||||
|
resolved_path = Path(path).expanduser().resolve(strict=False)
|
||||||
|
resolved_root = Path(root).expanduser().resolve(strict=False)
|
||||||
|
resolved_path.relative_to(resolved_root)
|
||||||
|
return True
|
||||||
|
except (OSError, RuntimeError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_path_allowed(path: str | Path, roots: Iterable[str | Path]) -> bool:
|
||||||
|
"""Return True when *path* is inside any allowed root."""
|
||||||
|
return any(is_path_within(path, root) for root in roots)
|
||||||
|
|
||||||
|
|
||||||
|
def require_path_within(
|
||||||
|
path: str | Path,
|
||||||
|
root: str | Path,
|
||||||
|
*,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Resolve *path* and require it to be inside *root*."""
|
||||||
|
resolved = Path(path).expanduser().resolve(strict=False)
|
||||||
|
if not is_path_within(resolved, root):
|
||||||
|
raise WorkspaceBoundaryError(
|
||||||
|
message
|
||||||
|
or f"Path {path} is outside allowed directory {Path(root).expanduser()}"
|
||||||
|
+ WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_allowed_path(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
workspace: str | Path | None = None,
|
||||||
|
allowed_root: str | Path | None = None,
|
||||||
|
extra_allowed_roots: Iterable[str | Path] | None = None,
|
||||||
|
strict: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Resolve a path and enforce containment in allowed roots when configured."""
|
||||||
|
resolved = resolve_path(path, workspace, strict=False)
|
||||||
|
if allowed_root is None:
|
||||||
|
return resolve_path(path, workspace, strict=strict) if strict else resolved
|
||||||
|
|
||||||
|
roots = [allowed_root, *(extra_allowed_roots or [])]
|
||||||
|
if not is_path_allowed(resolved, roots):
|
||||||
|
raise WorkspaceBoundaryError(
|
||||||
|
f"Path {path} is outside allowed directory {Path(allowed_root).expanduser()}"
|
||||||
|
+ WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
|
if strict:
|
||||||
|
return resolve_path(path, workspace, strict=True)
|
||||||
|
return resolved
|
||||||
@@ -43,6 +43,19 @@ def sustained_goal_active(metadata: Mapping[str, Any] | None) -> bool:
|
|||||||
return isinstance(goal, dict) and goal.get("status") == "active"
|
return isinstance(goal, dict) and goal.get("status") == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def sustained_goal_turn(
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
message_metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when this turn should use sustained-goal runtime limits."""
|
||||||
|
if sustained_goal_active(metadata):
|
||||||
|
return True
|
||||||
|
if not message_metadata:
|
||||||
|
return False
|
||||||
|
return str(message_metadata.get("original_command") or "").strip() == "/goal"
|
||||||
|
|
||||||
|
|
||||||
def parse_goal_state(blob: Any) -> dict[str, Any] | None:
|
def parse_goal_state(blob: Any) -> dict[str, Any] | None:
|
||||||
if blob is None:
|
if blob is None:
|
||||||
return None
|
return None
|
||||||
@@ -98,14 +111,16 @@ def runner_wall_llm_timeout_s(
|
|||||||
session_key: str | None,
|
session_key: str | None,
|
||||||
*,
|
*,
|
||||||
metadata: Mapping[str, Any] | None = None,
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
message_metadata: Mapping[str, Any] | None = None,
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
"""Wall-clock cap for :class:`~nanobot.agent.runner.AgentRunner` when streaming an LLM.
|
"""Wall-clock cap for :class:`~nanobot.agent.runner.AgentRunner` when streaming an LLM.
|
||||||
|
|
||||||
Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when a sustained goal is
|
Returns ``0.0`` to disable ``asyncio.wait_for`` around the request when this is a
|
||||||
active; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory ``metadata`` when the
|
sustained-goal turn; ``None`` means use ``NANOBOT_LLM_TIMEOUT_S``. Pass in-memory
|
||||||
caller already holds :attr:`~nanobot.session.manager.Session.metadata` for this turn.
|
``metadata`` when the caller already holds :attr:`~nanobot.session.manager.Session.metadata`
|
||||||
|
for this turn.
|
||||||
"""
|
"""
|
||||||
meta: Mapping[str, Any] | None = metadata
|
meta: Mapping[str, Any] | None = metadata
|
||||||
if meta is None and session_key:
|
if meta is None and session_key:
|
||||||
meta = sessions.get_or_create(session_key).metadata
|
meta = sessions.get_or_create(session_key).metadata
|
||||||
return 0.0 if sustained_goal_active(meta) else None
|
return 0.0 if sustained_goal_turn(meta, message_metadata=message_metadata) else None
|
||||||
|
|||||||
+109
-53
@@ -8,7 +8,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ from nanobot.utils.helpers import (
|
|||||||
find_legal_message_start,
|
find_legal_message_start,
|
||||||
image_placeholder_text,
|
image_placeholder_text,
|
||||||
safe_filename,
|
safe_filename,
|
||||||
|
strip_think,
|
||||||
)
|
)
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
|||||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||||
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
||||||
_SESSION_PREVIEW_MAX_CHARS = 120
|
_SESSION_PREVIEW_MAX_CHARS = 120
|
||||||
|
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
||||||
|
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_assistant_replay_text(content: str) -> str:
|
def _sanitize_assistant_replay_text(content: str) -> str:
|
||||||
@@ -74,6 +77,17 @@ def _message_preview_text(message: dict[str, Any]) -> str:
|
|||||||
return _text_preview(content)
|
return _text_preview(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_title(metadata: Any) -> str:
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return ""
|
||||||
|
title = metadata.get("title")
|
||||||
|
if not isinstance(title, str):
|
||||||
|
return ""
|
||||||
|
if metadata.get("title_user_edited") is True:
|
||||||
|
return title
|
||||||
|
return strip_think(title)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
"""A conversation session."""
|
"""A conversation session."""
|
||||||
@@ -165,6 +179,45 @@ class Session:
|
|||||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
||||||
)
|
)
|
||||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||||
|
cli_apps = message.get("cli_apps")
|
||||||
|
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
|
||||||
|
cli_lines: list[str] = []
|
||||||
|
for item in cli_apps[:8]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = str(item.get("name") or "").strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
entry = str(item.get("entry_point") or "unknown").strip() or "unknown"
|
||||||
|
cli_lines.append(
|
||||||
|
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry}; "
|
||||||
|
f"skill=skills/cli-app-{name}/SKILL.md]"
|
||||||
|
)
|
||||||
|
if cli_lines:
|
||||||
|
breadcrumbs = "\n".join(cli_lines)
|
||||||
|
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||||
|
mcp_presets = message.get("mcp_presets")
|
||||||
|
if (
|
||||||
|
role == "user"
|
||||||
|
and isinstance(mcp_presets, list)
|
||||||
|
and mcp_presets
|
||||||
|
and isinstance(content, str)
|
||||||
|
):
|
||||||
|
mcp_lines: list[str] = []
|
||||||
|
for item in mcp_presets[:8]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = str(item.get("name") or "").strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
transport = str(item.get("transport") or "mcp").strip() or "mcp"
|
||||||
|
mcp_lines.append(
|
||||||
|
f"[MCP Preset Attachment: @{name}; tool_prefix=mcp_{name}_; "
|
||||||
|
f"transport={transport}]"
|
||||||
|
)
|
||||||
|
if mcp_lines:
|
||||||
|
breadcrumbs = "\n".join(mcp_lines)
|
||||||
|
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||||
if include_timestamps:
|
if include_timestamps:
|
||||||
content = self._annotate_message_time(message, content)
|
content = self._annotate_message_time(message, content)
|
||||||
if role == "assistant" and isinstance(content, str) and not content.strip():
|
if role == "assistant" and isinstance(content, str) and not content.strip():
|
||||||
@@ -216,13 +269,25 @@ class Session:
|
|||||||
self.updated_at = datetime.now()
|
self.updated_at = datetime.now()
|
||||||
self.metadata.pop("_last_summary", None)
|
self.metadata.pop("_last_summary", None)
|
||||||
|
|
||||||
def retain_recent_legal_suffix(self, max_messages: int) -> None:
|
def retain_recent_legal_suffix(self, max_messages: int) -> tuple[list[dict], int]:
|
||||||
"""Keep a legal recent suffix constrained by a hard message cap."""
|
"""Keep a legal recent suffix constrained by a hard message cap.
|
||||||
|
|
||||||
|
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
|
||||||
|
the list of removed messages (in original order) and
|
||||||
|
*already_consolidated_count* is how many of those were inside the
|
||||||
|
pre-existing ``last_consolidated`` prefix and therefore do not need
|
||||||
|
raw archiving.
|
||||||
|
"""
|
||||||
if max_messages <= 0:
|
if max_messages <= 0:
|
||||||
|
dropped = list(self.messages)
|
||||||
|
lc = self.last_consolidated
|
||||||
self.clear()
|
self.clear()
|
||||||
return
|
return dropped, min(lc, len(dropped))
|
||||||
if len(self.messages) <= max_messages:
|
if len(self.messages) <= max_messages:
|
||||||
return
|
return [], 0
|
||||||
|
|
||||||
|
original = list(self.messages)
|
||||||
|
before_lc = self.last_consolidated
|
||||||
|
|
||||||
retained = list(self.messages[-max_messages:])
|
retained = list(self.messages[-max_messages:])
|
||||||
|
|
||||||
@@ -253,10 +318,32 @@ class Session:
|
|||||||
if start:
|
if start:
|
||||||
retained = retained[start:]
|
retained = retained[start:]
|
||||||
|
|
||||||
dropped = len(self.messages) - len(retained)
|
# Compute actually-dropped messages using identity comparison so that
|
||||||
|
# even when retained is a non-contiguous slice of original (the else
|
||||||
|
# branch above), we never duplicate or lose messages.
|
||||||
|
retained_ids = set(id(m) for m in retained)
|
||||||
|
dropped = [m for m in original if id(m) not in retained_ids]
|
||||||
|
|
||||||
|
# Count how many dropped messages were in the already-consolidated
|
||||||
|
# prefix of the original list. This cannot be a simple min() because
|
||||||
|
# dropped may include messages from *after* the consolidated prefix
|
||||||
|
# (e.g. in the else branch).
|
||||||
|
already_consolidated = sum(
|
||||||
|
1 for i, m in enumerate(original)
|
||||||
|
if i < before_lc and id(m) not in retained_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
# New last_consolidated = count of retained messages that were inside
|
||||||
|
# the old consolidated prefix.
|
||||||
|
new_lc = sum(
|
||||||
|
1 for i, m in enumerate(original)
|
||||||
|
if i < before_lc and id(m) in retained_ids
|
||||||
|
)
|
||||||
|
|
||||||
self.messages = retained
|
self.messages = retained
|
||||||
self.last_consolidated = max(0, self.last_consolidated - dropped)
|
self.last_consolidated = new_lc
|
||||||
self.updated_at = datetime.now()
|
self.updated_at = datetime.now()
|
||||||
|
return dropped, already_consolidated
|
||||||
|
|
||||||
def enforce_file_cap(
|
def enforce_file_cap(
|
||||||
self,
|
self,
|
||||||
@@ -267,23 +354,17 @@ class Session:
|
|||||||
if limit <= 0 or len(self.messages) <= limit:
|
if limit <= 0 or len(self.messages) <= limit:
|
||||||
return
|
return
|
||||||
|
|
||||||
before = list(self.messages)
|
dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
|
||||||
before_last_consolidated = self.last_consolidated
|
if not dropped:
|
||||||
before_count = len(before)
|
|
||||||
self.retain_recent_legal_suffix(limit)
|
|
||||||
dropped_count = before_count - len(self.messages)
|
|
||||||
if dropped_count <= 0:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
dropped = before[:dropped_count]
|
|
||||||
already_consolidated = min(before_last_consolidated, dropped_count)
|
|
||||||
archive_chunk = dropped[already_consolidated:]
|
archive_chunk = dropped[already_consolidated:]
|
||||||
if archive_chunk and on_archive:
|
if archive_chunk and on_archive:
|
||||||
on_archive(archive_chunk)
|
on_archive(archive_chunk)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||||
self.key,
|
self.key,
|
||||||
dropped_count,
|
len(dropped),
|
||||||
len(archive_chunk),
|
len(archive_chunk),
|
||||||
len(self.messages),
|
len(self.messages),
|
||||||
)
|
)
|
||||||
@@ -581,36 +662,6 @@ class SessionManager:
|
|||||||
return self._session_payload(repaired)
|
return self._session_payload(repaired)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_or_create_task_session(
|
|
||||||
self,
|
|
||||||
base_key: str,
|
|
||||||
task_id: str,
|
|
||||||
role: Literal["manager", "worker"] = "worker",
|
|
||||||
) -> Session:
|
|
||||||
"""Get or create an isolated session for a specific task.
|
|
||||||
|
|
||||||
Key format: task:{base_key}:{task_id}:{role}
|
|
||||||
Example: task:slack:C123:root_qml:manager
|
|
||||||
"""
|
|
||||||
task_key = f"task:{base_key}:{task_id}:{role}"
|
|
||||||
return self.get_or_create(task_key)
|
|
||||||
|
|
||||||
def list_task_sessions(self, base_key: str) -> list[Session]:
|
|
||||||
"""List all task-scoped sessions for a given base key."""
|
|
||||||
prefix = f"task:{base_key}:"
|
|
||||||
return [
|
|
||||||
session for key, session in self._cache.items()
|
|
||||||
if key.startswith(prefix)
|
|
||||||
]
|
|
||||||
|
|
||||||
def finalize_task_session(self, task_id: str) -> None:
|
|
||||||
"""Mark a task session as finalized (read-only) by setting metadata."""
|
|
||||||
prefix = f"task:"
|
|
||||||
for key, session in list(self._cache.items()):
|
|
||||||
if f":{task_id}:" in key and key.startswith(prefix):
|
|
||||||
session.metadata["finalized"] = True
|
|
||||||
self.save(session)
|
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict[str, Any]]:
|
def list_sessions(self) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List all sessions.
|
List all sessions.
|
||||||
@@ -631,12 +682,21 @@ class SessionManager:
|
|||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
title = metadata.get("title") if isinstance(metadata, dict) else None
|
title = _metadata_title(metadata)
|
||||||
preview = ""
|
preview = ""
|
||||||
fallback_preview = ""
|
fallback_preview = ""
|
||||||
|
scanned_records = 0
|
||||||
|
scanned_chars = 0
|
||||||
for line in f:
|
for line in f:
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
scanned_records += 1
|
||||||
|
scanned_chars += len(line)
|
||||||
|
if (
|
||||||
|
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||||
|
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
|
):
|
||||||
|
break
|
||||||
item = json.loads(line)
|
item = json.loads(line)
|
||||||
if item.get("_type") == "metadata":
|
if item.get("_type") == "metadata":
|
||||||
continue
|
continue
|
||||||
@@ -653,7 +713,7 @@ class SessionManager:
|
|||||||
"key": key,
|
"key": key,
|
||||||
"created_at": data.get("created_at"),
|
"created_at": data.get("created_at"),
|
||||||
"updated_at": data.get("updated_at"),
|
"updated_at": data.get("updated_at"),
|
||||||
"title": title if isinstance(title, str) else "",
|
"title": title,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
"path": str(path)
|
"path": str(path)
|
||||||
})
|
})
|
||||||
@@ -664,11 +724,7 @@ class SessionManager:
|
|||||||
"key": repaired.key,
|
"key": repaired.key,
|
||||||
"created_at": repaired.created_at.isoformat(),
|
"created_at": repaired.created_at.isoformat(),
|
||||||
"updated_at": repaired.updated_at.isoformat(),
|
"updated_at": repaired.updated_at.isoformat(),
|
||||||
"title": (
|
"title": _metadata_title(repaired.metadata),
|
||||||
repaired.metadata.get("title")
|
|
||||||
if isinstance(repaired.metadata.get("title"), str)
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
"preview": next(
|
"preview": next(
|
||||||
(
|
(
|
||||||
text
|
text
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Internal turn continuation helpers.
|
||||||
|
|
||||||
|
This module keeps budget-boundary continuation policy out of ``AgentLoop``.
|
||||||
|
The loop calls a small set of helpers; those helpers decide whether an internal
|
||||||
|
continuation is allowed and, when it is, queue the next turn directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from typing import Any, Mapping, MutableMapping
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.session.goal_state import (
|
||||||
|
goal_state_runtime_lines,
|
||||||
|
sustained_goal_active,
|
||||||
|
sustained_goal_turn,
|
||||||
|
)
|
||||||
|
|
||||||
|
INTERNAL_CONTINUATION_META = "_internal_continuation"
|
||||||
|
INTERNAL_CONTINUATION_KIND_META = "_internal_continuation_kind"
|
||||||
|
INTERNAL_CONTINUATION_PENDING_META = "_internal_continuation_pending"
|
||||||
|
INTERNAL_CONTINUATION_RUN_STARTED_AT_META = "_internal_continuation_run_started_at"
|
||||||
|
|
||||||
|
_GOAL_CONTINUATION_KIND = "sustained_goal"
|
||||||
|
_GOAL_CONTINUATION_SENDER = "system:continuation"
|
||||||
|
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
|
||||||
|
_MAX_GOAL_CONTINUATION_ROUNDS = 12
|
||||||
|
_STRIPPED_INBOUND_META_KEYS = {
|
||||||
|
"_stream_id",
|
||||||
|
"_stream_delta",
|
||||||
|
"_stream_end",
|
||||||
|
"_resuming",
|
||||||
|
INTERNAL_CONTINUATION_PENDING_META,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def internal_continuation_inbound(metadata: Mapping[str, Any] | None) -> bool:
|
||||||
|
"""True for an inbound message created by an internal continuation policy."""
|
||||||
|
return bool(metadata and metadata.get(INTERNAL_CONTINUATION_META) is True)
|
||||||
|
|
||||||
|
|
||||||
|
def internal_continuation_pending(metadata: Mapping[str, Any] | None) -> bool:
|
||||||
|
"""True when the current turn scheduled an invisible continuation slice."""
|
||||||
|
return bool(metadata and metadata.get(INTERNAL_CONTINUATION_PENDING_META) is True)
|
||||||
|
|
||||||
|
|
||||||
|
def internal_continuation_run_started_at(metadata: Mapping[str, Any] | None) -> float | None:
|
||||||
|
"""Return the user-visible run start propagated across continuation slices."""
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
value = metadata.get(INTERNAL_CONTINUATION_RUN_STARTED_AT_META)
|
||||||
|
if not isinstance(value, int | float):
|
||||||
|
return None
|
||||||
|
started_at = float(value)
|
||||||
|
return started_at if started_at > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def should_persist_user_message(metadata: Mapping[str, Any] | None) -> bool:
|
||||||
|
"""Return whether this inbound message should be persisted as user input."""
|
||||||
|
return not internal_continuation_inbound(metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def should_stream_budget_response(
|
||||||
|
*,
|
||||||
|
stop_reason: str,
|
||||||
|
pending_queue_available: bool,
|
||||||
|
session_metadata: Mapping[str, Any] | None,
|
||||||
|
message_metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether the budget-boundary response should be sent to the user."""
|
||||||
|
return not _continuation_available(
|
||||||
|
stop_reason=stop_reason,
|
||||||
|
pending_queue_available=pending_queue_available,
|
||||||
|
session_metadata=session_metadata,
|
||||||
|
message_metadata=message_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_continue_turn(ctx: Any) -> bool:
|
||||||
|
"""Queue an internal continuation for *ctx* when policy allows it."""
|
||||||
|
if ctx.session is None or ctx.pending_queue is None:
|
||||||
|
return False
|
||||||
|
if not _continuation_available(
|
||||||
|
stop_reason=ctx.stop_reason,
|
||||||
|
pending_queue_available=True,
|
||||||
|
session_metadata=ctx.session.metadata,
|
||||||
|
message_metadata=ctx.msg.metadata,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
metadata = _internal_continuation_metadata(
|
||||||
|
ctx.msg.metadata,
|
||||||
|
run_started_at=getattr(ctx, "visible_run_started_at", None),
|
||||||
|
)
|
||||||
|
content = _goal_continuation_prompt(ctx.session.metadata)
|
||||||
|
messages = _strip_terminal_assistant(ctx.all_messages, ctx.final_content)
|
||||||
|
_increment_goal_continuation_round(ctx.session.metadata)
|
||||||
|
|
||||||
|
logger.info("Turn budget reached; scheduling internal continuation")
|
||||||
|
ctx.msg.metadata[INTERNAL_CONTINUATION_PENDING_META] = True
|
||||||
|
ctx.final_content = ""
|
||||||
|
ctx.all_messages = messages
|
||||||
|
ctx.suppress_response = True
|
||||||
|
await ctx.pending_queue.put(
|
||||||
|
dataclasses.replace(
|
||||||
|
ctx.msg,
|
||||||
|
sender_id=_GOAL_CONTINUATION_SENDER,
|
||||||
|
content=content,
|
||||||
|
media=[],
|
||||||
|
metadata=metadata,
|
||||||
|
session_key_override=ctx.session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_save_boundary(ctx: Any) -> None:
|
||||||
|
"""Prepare continuation bookkeeping and the history append boundary."""
|
||||||
|
if ctx.session is not None:
|
||||||
|
clear_internal_continuation_state(ctx.session.metadata)
|
||||||
|
|
||||||
|
ctx.save_skip = _save_skip_for_turn(
|
||||||
|
message_metadata=ctx.msg.metadata,
|
||||||
|
initial_message_count=len(ctx.initial_messages),
|
||||||
|
history_count=len(ctx.history),
|
||||||
|
user_persisted_early=ctx.user_persisted_early,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _continuation_available(
|
||||||
|
*,
|
||||||
|
stop_reason: str,
|
||||||
|
pending_queue_available: bool,
|
||||||
|
session_metadata: Mapping[str, Any] | None,
|
||||||
|
message_metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if stop_reason != "max_iterations" or not pending_queue_available:
|
||||||
|
return False
|
||||||
|
return _goal_continuation_available(
|
||||||
|
session_metadata,
|
||||||
|
message_metadata=message_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_internal_continuation_state(metadata: MutableMapping[str, Any]) -> None:
|
||||||
|
"""Reset policy bookkeeping once its owning runtime mode is inactive."""
|
||||||
|
if not sustained_goal_active(metadata):
|
||||||
|
metadata.pop(_GOAL_CONTINUATION_ROUNDS_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_skip_for_turn(
|
||||||
|
*,
|
||||||
|
message_metadata: Mapping[str, Any] | None,
|
||||||
|
initial_message_count: int,
|
||||||
|
history_count: int,
|
||||||
|
user_persisted_early: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Return the persisted-message append boundary for this turn."""
|
||||||
|
if internal_continuation_inbound(message_metadata):
|
||||||
|
return initial_message_count
|
||||||
|
return 1 + history_count + (1 if user_persisted_early else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _goal_continuation_available(
|
||||||
|
session_metadata: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
message_metadata: Mapping[str, Any] | None = None,
|
||||||
|
max_rounds: int = _MAX_GOAL_CONTINUATION_ROUNDS,
|
||||||
|
) -> bool:
|
||||||
|
if not sustained_goal_turn(session_metadata, message_metadata=message_metadata):
|
||||||
|
return False
|
||||||
|
if not sustained_goal_active(session_metadata):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
rounds = int((session_metadata or {}).get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rounds = 0
|
||||||
|
return rounds < max(0, max_rounds)
|
||||||
|
|
||||||
|
|
||||||
|
def _increment_goal_continuation_round(session_metadata: MutableMapping[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
rounds = int(session_metadata.get(_GOAL_CONTINUATION_ROUNDS_KEY) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rounds = 0
|
||||||
|
session_metadata[_GOAL_CONTINUATION_ROUNDS_KEY] = rounds + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_continuation_metadata(
|
||||||
|
message_metadata: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
run_started_at: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
metadata = dict(message_metadata or {})
|
||||||
|
metadata[INTERNAL_CONTINUATION_META] = True
|
||||||
|
metadata[INTERNAL_CONTINUATION_KIND_META] = _GOAL_CONTINUATION_KIND
|
||||||
|
if run_started_at is not None:
|
||||||
|
metadata[INTERNAL_CONTINUATION_RUN_STARTED_AT_META] = float(run_started_at)
|
||||||
|
for key in _STRIPPED_INBOUND_META_KEYS:
|
||||||
|
metadata.pop(key, None)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _goal_continuation_prompt(metadata: Mapping[str, Any] | None) -> str:
|
||||||
|
lines = goal_state_runtime_lines(metadata)
|
||||||
|
if lines:
|
||||||
|
goal = "\n".join(lines)
|
||||||
|
return (
|
||||||
|
"Continue the active sustained goal after the previous turn reached "
|
||||||
|
"its tool-call budget.\n\n"
|
||||||
|
f"{goal}\n\n"
|
||||||
|
"Continue from the saved context. Do not mention the continuation "
|
||||||
|
"boundary to the user. Use tools as needed, and call complete_goal "
|
||||||
|
"when the objective is truly finished."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"Continue the active sustained goal after the previous turn reached "
|
||||||
|
"its tool-call budget. Continue from the saved context. Do not mention "
|
||||||
|
"the continuation boundary to the user. Use tools as needed, and call "
|
||||||
|
"complete_goal when the objective is truly finished."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_terminal_assistant(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
final_content: str | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Drop the synthetic max-iteration assistant message before saving history."""
|
||||||
|
if not messages:
|
||||||
|
return messages
|
||||||
|
last = messages[-1]
|
||||||
|
if last.get("role") != "assistant":
|
||||||
|
return messages
|
||||||
|
if final_content is None or last.get("content") != final_content:
|
||||||
|
return messages
|
||||||
|
if last.get("tool_calls"):
|
||||||
|
return messages
|
||||||
|
return messages[:-1]
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Session turn helpers for WebUI-capable WebSocket sessions.
|
||||||
|
|
||||||
|
AgentLoop uses these without importing a concrete channel plugin; only
|
||||||
|
``channel == "websocket"`` messages are affected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.providers.base import LLMProvider
|
||||||
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
from nanobot.utils.helpers import strip_think, truncate_text
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
WEBUI_SESSION_METADATA_KEY = "webui"
|
||||||
|
WEBUI_TITLE_METADATA_KEY = "title"
|
||||||
|
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
|
||||||
|
TITLE_MAX_CHARS = 60
|
||||||
|
TITLE_GENERATION_MAX_TOKENS = 96
|
||||||
|
TITLE_GENERATION_REASONING_EFFORT = "none"
|
||||||
|
|
||||||
|
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
|
||||||
|
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
|
||||||
|
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
|
||||||
|
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
|
||||||
|
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||||
|
return False
|
||||||
|
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def clean_generated_title(raw: str | None) -> str:
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
|
||||||
|
text = text.strip().strip("\"'`“”‘’")
|
||||||
|
text = strip_think(text)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
text = text.rstrip("。.!!??,,;;:")
|
||||||
|
if len(text) > TITLE_MAX_CHARS:
|
||||||
|
text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _title_inputs(session: Session) -> tuple[str, str]:
|
||||||
|
user_text = ""
|
||||||
|
assistant_text = ""
|
||||||
|
for message in session.messages:
|
||||||
|
if message.get("_command") is True:
|
||||||
|
continue
|
||||||
|
role = message.get("role")
|
||||||
|
content = message.get("content")
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
continue
|
||||||
|
content = strip_think(content)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
if role == "user" and not user_text:
|
||||||
|
user_text = content.strip()
|
||||||
|
elif role == "assistant" and not assistant_text:
|
||||||
|
assistant_text = content.strip()
|
||||||
|
if user_text and assistant_text:
|
||||||
|
break
|
||||||
|
return user_text, assistant_text
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_generate_webui_title(
|
||||||
|
*,
|
||||||
|
sessions: SessionManager,
|
||||||
|
session_key: str,
|
||||||
|
provider: LLMProvider,
|
||||||
|
model: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Generate and persist a short title for WebUI-owned sessions only."""
|
||||||
|
session = sessions.get_or_create(session_key)
|
||||||
|
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||||
|
return False
|
||||||
|
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
|
||||||
|
return False
|
||||||
|
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
|
||||||
|
if isinstance(current_title, str) and current_title.strip():
|
||||||
|
cleaned_current_title = clean_generated_title(current_title)
|
||||||
|
if cleaned_current_title:
|
||||||
|
if cleaned_current_title != current_title:
|
||||||
|
session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title
|
||||||
|
sessions.save(session)
|
||||||
|
return False
|
||||||
|
session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None)
|
||||||
|
|
||||||
|
user_text, assistant_text = _title_inputs(session)
|
||||||
|
if not user_text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"Generate a concise title for this chat.\n"
|
||||||
|
"Rules:\n"
|
||||||
|
"- Use the same language as the user when practical.\n"
|
||||||
|
"- 3 to 8 words.\n"
|
||||||
|
"- No quotes.\n"
|
||||||
|
"- No punctuation at the end.\n"
|
||||||
|
"- Return only the title.\n\n"
|
||||||
|
f"User: {truncate_text(user_text, 1_000)}"
|
||||||
|
)
|
||||||
|
if assistant_text:
|
||||||
|
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await provider.chat_with_retry(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You write short, neutral chat titles. "
|
||||||
|
"Return only the title text."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
tools=None,
|
||||||
|
model=model,
|
||||||
|
max_tokens=TITLE_GENERATION_MAX_TOKENS,
|
||||||
|
temperature=0.2,
|
||||||
|
reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
|
||||||
|
retry_mode="standard",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
title = clean_generated_title(response.content)
|
||||||
|
if not title or title.lower().startswith("error"):
|
||||||
|
logger.debug(
|
||||||
|
"WebUI title generation returned no usable title for {} (finish_reason={})",
|
||||||
|
session_key,
|
||||||
|
response.finish_reason,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
|
||||||
|
sessions.save(session)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_generate_webui_title_after_turn(
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
sessions: SessionManager,
|
||||||
|
session_key: str,
|
||||||
|
provider: LLMProvider,
|
||||||
|
model: str,
|
||||||
|
) -> bool:
|
||||||
|
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||||
|
return False
|
||||||
|
return await maybe_generate_webui_title(
|
||||||
|
sessions=sessions,
|
||||||
|
session_key=session_key,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
|
||||||
|
"""Return ``time.time()`` when the active user turn began, if still running."""
|
||||||
|
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_turn_run_status(
|
||||||
|
bus: MessageBus,
|
||||||
|
msg: InboundMessage,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
started_at: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Notify WebSocket clients while a user turn is executing (timing strip)."""
|
||||||
|
if msg.channel != "websocket":
|
||||||
|
return
|
||||||
|
cid = str(msg.chat_id)
|
||||||
|
meta: dict[str, Any] = {
|
||||||
|
**dict(msg.metadata or {}),
|
||||||
|
"_goal_status": True,
|
||||||
|
"goal_status": status,
|
||||||
|
}
|
||||||
|
if status == "running":
|
||||||
|
if isinstance(started_at, int | float) and started_at > 0:
|
||||||
|
t0 = float(started_at)
|
||||||
|
else:
|
||||||
|
t0 = time.time()
|
||||||
|
meta["started_at"] = t0
|
||||||
|
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
|
||||||
|
else:
|
||||||
|
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
|
||||||
|
await bus.publish_outbound(
|
||||||
|
OutboundMessage(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=cid,
|
||||||
|
content="",
|
||||||
|
metadata=meta,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bus_progress_callback(
|
||||||
|
bus: MessageBus,
|
||||||
|
msg: InboundMessage,
|
||||||
|
) -> Callable[..., Awaitable[None]]:
|
||||||
|
"""Return the bus progress callback for agent runtime events."""
|
||||||
|
|
||||||
|
async def _publish_progress(
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
tool_hint: bool = False,
|
||||||
|
tool_events: list[dict[str, Any]] | None = None,
|
||||||
|
file_edit_events: list[dict[str, Any]] | None = None,
|
||||||
|
reasoning: bool = False,
|
||||||
|
reasoning_end: bool = False,
|
||||||
|
) -> None:
|
||||||
|
meta = dict(msg.metadata or {})
|
||||||
|
meta["_progress"] = True
|
||||||
|
meta["_tool_hint"] = tool_hint
|
||||||
|
if reasoning:
|
||||||
|
meta["_reasoning_delta"] = True
|
||||||
|
if reasoning_end:
|
||||||
|
meta["_reasoning_end"] = True
|
||||||
|
if tool_events:
|
||||||
|
meta["_tool_events"] = tool_events
|
||||||
|
if file_edit_events:
|
||||||
|
meta["_file_edit_events"] = file_edit_events
|
||||||
|
await bus.publish_outbound(
|
||||||
|
OutboundMessage(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
content=content,
|
||||||
|
metadata=meta,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if msg.channel == "websocket":
|
||||||
|
async def _websocket_progress(
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
tool_hint: bool = False,
|
||||||
|
tool_events: list[dict[str, Any]] | None = None,
|
||||||
|
file_edit_events: list[dict[str, Any]] | None = None,
|
||||||
|
reasoning: bool = False,
|
||||||
|
reasoning_end: bool = False,
|
||||||
|
) -> None:
|
||||||
|
await _publish_progress(
|
||||||
|
content,
|
||||||
|
tool_hint=tool_hint,
|
||||||
|
tool_events=tool_events,
|
||||||
|
file_edit_events=file_edit_events,
|
||||||
|
reasoning=reasoning,
|
||||||
|
reasoning_end=reasoning_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _websocket_progress
|
||||||
|
|
||||||
|
async def _bus_progress(
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
tool_hint: bool = False,
|
||||||
|
tool_events: list[dict[str, Any]] | None = None,
|
||||||
|
reasoning: bool = False,
|
||||||
|
reasoning_end: bool = False,
|
||||||
|
) -> None:
|
||||||
|
await _publish_progress(
|
||||||
|
content,
|
||||||
|
tool_hint=tool_hint,
|
||||||
|
tool_events=tool_events,
|
||||||
|
reasoning=reasoning,
|
||||||
|
reasoning_end=reasoning_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _bus_progress
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebuiTurnCoordinator:
|
||||||
|
"""Own the WebUI/WebSocket wire details that hang off AgentLoop turns."""
|
||||||
|
|
||||||
|
bus: MessageBus
|
||||||
|
sessions: SessionManager
|
||||||
|
schedule_background: Callable[[Awaitable[None]], None]
|
||||||
|
_title_contexts: dict[str, LLMRuntime] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def capture_title_context(
|
||||||
|
self,
|
||||||
|
session_key: str,
|
||||||
|
msg: InboundMessage,
|
||||||
|
llm: LLMRuntime,
|
||||||
|
) -> None:
|
||||||
|
if msg.channel == "websocket" and msg.metadata.get("webui") is True:
|
||||||
|
self._title_contexts[session_key] = llm
|
||||||
|
|
||||||
|
def discard(self, session_key: str) -> None:
|
||||||
|
self._title_contexts.pop(session_key, None)
|
||||||
|
|
||||||
|
async def publish_run_status(
|
||||||
|
self,
|
||||||
|
msg: InboundMessage,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
started_at: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
|
||||||
|
|
||||||
|
async def handle_turn_end(
|
||||||
|
self,
|
||||||
|
msg: InboundMessage,
|
||||||
|
*,
|
||||||
|
session_key: str,
|
||||||
|
latency_ms: int | None,
|
||||||
|
) -> None:
|
||||||
|
if msg.channel != "websocket":
|
||||||
|
return
|
||||||
|
|
||||||
|
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
||||||
|
if latency_ms is not None:
|
||||||
|
turn_metadata["latency_ms"] = int(latency_ms)
|
||||||
|
session = self.sessions.get_or_create(session_key)
|
||||||
|
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
|
||||||
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
content="",
|
||||||
|
metadata=turn_metadata,
|
||||||
|
))
|
||||||
|
self._schedule_title_update(msg, session_key=session_key)
|
||||||
|
|
||||||
|
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
|
||||||
|
title_context = self._title_contexts.pop(session_key, None)
|
||||||
|
if msg.metadata.get("webui") is not True or title_context is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _generate_title_and_notify(
|
||||||
|
title_llm: LLMRuntime = title_context,
|
||||||
|
) -> None:
|
||||||
|
generated = await maybe_generate_webui_title_after_turn(
|
||||||
|
channel=msg.channel,
|
||||||
|
metadata=msg.metadata,
|
||||||
|
sessions=self.sessions,
|
||||||
|
session_key=session_key,
|
||||||
|
provider=title_llm.provider,
|
||||||
|
model=title_llm.model,
|
||||||
|
)
|
||||||
|
if generated:
|
||||||
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
content="",
|
||||||
|
metadata={
|
||||||
|
**msg.metadata,
|
||||||
|
"_session_updated": True,
|
||||||
|
"_session_update_scope": "metadata",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
self.schedule_background(_generate_title_and_notify())
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-instance
|
|
||||||
description: "Create a new nanobot instance with separate config and workspace. Use when the user wants to set up a new bot, create a new instance for a different channel, persona, or purpose. Triggers on: create instance, new bot, set up bot, add bot, create telegram/discord/feishu/slack/wechat/wecom/dingtalk/qq/email/matrix/msteams/whatsapp bot, multi-instance setup, inter-agent communication."
|
|
||||||
---
|
|
||||||
|
|
||||||
# Create Instance
|
|
||||||
|
|
||||||
Set up a new nanobot instance with its own config and workspace.
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
1. **Collect information** (ask one at a time if not already provided):
|
|
||||||
- **Instance name** (required): short identifier, e.g. `telegram-bot`, `work-slack`
|
|
||||||
- **Channel type** (required): see table below
|
|
||||||
- **Model** (optional): LLM model, defaults to current instance
|
|
||||||
|
|
||||||
2. **Do NOT collect secrets** in the chat (API keys, bot tokens). API keys are automatically inherited from the current instance via `--inherit-config`. Channel-specific tokens must be filled in manually after creation.
|
|
||||||
|
|
||||||
3. **Run the creation script**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python <skill-dir>/scripts/create_instance.py --name <name> --channel <channel> --inherit-config <current-config>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `<skill-dir>` — the directory containing this SKILL.md
|
|
||||||
- `<current-config>` — current instance's config path, typically `~/.nanobot/config.json`
|
|
||||||
- Optional: `--model <model>`, `--config-dir <path>`
|
|
||||||
|
|
||||||
**Exec tool constraints:**
|
|
||||||
- Use forward-slash paths (works on all platforms)
|
|
||||||
- Do not wrap paths in quotes
|
|
||||||
- Do not use `cd`; pass the full script path directly
|
|
||||||
|
|
||||||
4. **Report results** to the user:
|
|
||||||
- Config and workspace paths (script outputs them)
|
|
||||||
- Required fields to fill in (script lists them)
|
|
||||||
- Start command: `nanobot gateway --config <config-path>`
|
|
||||||
|
|
||||||
## Available Channels
|
|
||||||
|
|
||||||
| Channel | Key | Required Fields |
|
|
||||||
|---------|-----|-----------------|
|
|
||||||
| Telegram | `telegram` | token |
|
|
||||||
| Discord | `discord` | token |
|
|
||||||
| Feishu / Lark | `feishu` | app_id, app_secret |
|
|
||||||
| DingTalk | `dingtalk` | client_id, client_secret |
|
|
||||||
| Slack | `slack` | bot_token, app_token |
|
|
||||||
| WeCom | `wecom` | bot_id, secret |
|
|
||||||
| WeChat OA | `weixin` | token |
|
|
||||||
| WhatsApp | `whatsapp` | bridge_token |
|
|
||||||
| QQ | `qq` | app_id, secret |
|
|
||||||
| Email | `email` | imap_host, imap_username, imap_password, smtp_host, smtp_username, smtp_password, from_address |
|
|
||||||
| Matrix | `matrix` | user_id, password or access_token |
|
|
||||||
| MS Teams | `msteams` | app_id, app_password, tenant_id |
|
|
||||||
| MoChat | `mochat` | claw_token |
|
|
||||||
| WebSocket | `websocket` | token |
|
|
||||||
|
|
||||||
For detailed channel configuration including optional fields, see `references/channels.md`.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **"Unknown channel"**: Channel name must match the Key column exactly. Run the script without arguments to see usage.
|
|
||||||
- **"Config already exists"**: Use a different `--name` or `--config-dir` to create in a new location.
|
|
||||||
- **Port conflicts**: The script auto-assigns free ports for gateway and API if defaults are in use.
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Channel Configuration Reference
|
|
||||||
|
|
||||||
Detailed configuration for each supported channel.
|
|
||||||
|
|
||||||
## Field Types
|
|
||||||
|
|
||||||
- **Required**: defaults to empty string `""`, must be filled in before the instance can start
|
|
||||||
- **Optional**: has a sensible default, can be customized
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## telegram
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — Bot token from @BotFather
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `proxy` — HTTP proxy URL
|
|
||||||
- `group_policy` — `"open"` (all messages) or `"mention"` (default, only when @mentioned)
|
|
||||||
- `streaming` — Enable streaming responses (default: true)
|
|
||||||
- `reply_to_message` — Reply to the triggering message (default: false)
|
|
||||||
- `react_emoji` — Emoji for "thinking" reaction (default: `"eyes"`)
|
|
||||||
- `inline_keyboards` — Enable inline keyboard buttons (default: false)
|
|
||||||
|
|
||||||
## discord
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — Bot token from Discord Developer Portal
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `allow_channels` — Restrict to specific channel IDs
|
|
||||||
- `group_policy` — `"mention"` (default) or `"open"`
|
|
||||||
- `streaming` — Enable streaming (default: true)
|
|
||||||
- `proxy` — HTTP proxy URL
|
|
||||||
- `intents` — Discord gateway intents (default: 37377)
|
|
||||||
- `read_receipt_emoji` — Emoji for read receipt
|
|
||||||
- `working_emoji` — Emoji for "working" indicator
|
|
||||||
|
|
||||||
## feishu
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `app_id` — Feishu app ID
|
|
||||||
- `app_secret` — Feishu app secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `encrypt_key` — Event encryption key
|
|
||||||
- `verification_token` — Event verification token
|
|
||||||
- `domain` — `"feishu"` (default) or `"lark"`
|
|
||||||
- `group_policy` — `"mention"` (default) or `"open"`
|
|
||||||
- `streaming` — Enable streaming (default: true)
|
|
||||||
|
|
||||||
## dingtalk
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `client_id` — DingTalk app client ID
|
|
||||||
- `client_secret` — DingTalk app client secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `allow_from` — Allowed user IDs
|
|
||||||
|
|
||||||
## slack
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `bot_token` — Bot OAuth token (`xoxb-...`)
|
|
||||||
- `app_token` — App-level token (`xapp-...`)
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `mode` — `"socket"` (default, Socket Mode) or `"webhook"`
|
|
||||||
- `reply_in_thread` — Reply in thread (default: true)
|
|
||||||
- `react_emoji` — "thinking" emoji (default: `"eyes"`)
|
|
||||||
- `done_emoji` — "done" emoji (default: `"white_check_mark"`)
|
|
||||||
- `group_policy` — `"mention"` (default) or `"open"`
|
|
||||||
- `dm.enabled` — Enable DM support
|
|
||||||
- `dm.policy` — DM policy
|
|
||||||
- `dm.allow_from` — Allowed DM users
|
|
||||||
|
|
||||||
## wecom
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `bot_id` — WeCom bot ID
|
|
||||||
- `secret` — WeCom bot secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `allow_from` — Allowed users
|
|
||||||
- `welcome_message` — Welcome message for new chats
|
|
||||||
|
|
||||||
## weixin
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — WeChat Official Account token
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `base_url` — API base URL
|
|
||||||
- `cdn_base_url` — CDN base URL
|
|
||||||
- `state_dir` — State persistence directory
|
|
||||||
- `poll_timeout` — Long polling timeout
|
|
||||||
|
|
||||||
## whatsapp
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `bridge_token` — WhatsApp bridge token (auto-generated if absent)
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `bridge_url` — Bridge WebSocket URL (default: `"ws://localhost:3001"`)
|
|
||||||
- `group_policy` — `"open"` (default) or `"mention"`
|
|
||||||
|
|
||||||
## qq
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `app_id` — QQ bot app ID
|
|
||||||
- `secret` — QQ bot secret
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `msg_format` — `"plain"` or `"markdown"`
|
|
||||||
- `ack_message` — Acknowledgment message text
|
|
||||||
- `media_dir` — Media file directory
|
|
||||||
|
|
||||||
## email
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `imap_host` — IMAP server hostname
|
|
||||||
- `imap_username` — IMAP login username
|
|
||||||
- `imap_password` — IMAP login password
|
|
||||||
- `smtp_host` — SMTP server hostname
|
|
||||||
- `smtp_username` — SMTP login username
|
|
||||||
- `smtp_password` — SMTP login password
|
|
||||||
- `from_address` — Sender email address
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `imap_port` — IMAP port (default: 993)
|
|
||||||
- `smtp_port` — SMTP port (default: 587)
|
|
||||||
- `imap_use_ssl` — Use SSL for IMAP (default: true)
|
|
||||||
- `smtp_use_tls` — Use TLS for SMTP (default: true)
|
|
||||||
- `poll_interval_seconds` — Polling interval (default: 30)
|
|
||||||
- `mark_seen` — Mark emails as read (default: true)
|
|
||||||
- `max_body_chars` — Max email body length (default: 12000)
|
|
||||||
- `subject_prefix` — Reply subject prefix (default: `"Re: "`)
|
|
||||||
- `verify_dkim` — Verify DKIM signatures (default: true)
|
|
||||||
- `verify_spf` — Verify SPF records (default: true)
|
|
||||||
- `allowed_attachment_types` — Allowed file extensions
|
|
||||||
- `max_attachment_size` — Max attachment size in bytes
|
|
||||||
- `consent_granted` — Must be set to `true` for the channel to start (default: false)
|
|
||||||
- `auto_reply_enabled` — Enable auto-reply (default: true)
|
|
||||||
|
|
||||||
## matrix
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `user_id` — Matrix user ID (e.g. `@bot:matrix.org`)
|
|
||||||
- `password` or `access_token` — Login password OR access token
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `homeserver` — Homeserver URL (default: `"https://matrix.org"`)
|
|
||||||
- `device_id` — Device ID
|
|
||||||
- `e2eeEnabled` — Enable end-to-end encryption (default: true)
|
|
||||||
- `group_policy` — `"open"`, `"mention"`, or `"allowlist"`
|
|
||||||
- `streaming` — Enable streaming (default: false)
|
|
||||||
- `max_media_bytes` — Max media file size (default: 20MB)
|
|
||||||
|
|
||||||
## msteams
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `app_id` — Azure AD app ID
|
|
||||||
- `app_password` — Azure AD app password/secret
|
|
||||||
- `tenant_id` — Azure AD tenant ID
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `host` — Listen host (default: `"0.0.0.0"`)
|
|
||||||
- `port` — Listen port (default: 3978)
|
|
||||||
- `reply_in_thread` — Reply in thread (default: true)
|
|
||||||
- `validate_inbound_auth` — Validate incoming auth (default: true)
|
|
||||||
|
|
||||||
## mochat
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `claw_token` — MoChat Claw token
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `base_url` — API base URL
|
|
||||||
- `socket_url` — WebSocket URL
|
|
||||||
- `refresh_interval_ms` — Refresh interval in ms
|
|
||||||
- `watch_timeout_ms` — Watch timeout in ms
|
|
||||||
|
|
||||||
## websocket
|
|
||||||
|
|
||||||
Built-in WebSocket channel for programmatic access.
|
|
||||||
|
|
||||||
**Required:**
|
|
||||||
- `token` — Authentication token (enabled by default; set `websocket_requires_token: false` to disable)
|
|
||||||
|
|
||||||
**Notable optional:**
|
|
||||||
- `host` — Listen host (default: `"127.0.0.1"`)
|
|
||||||
- `port` — Listen port (default: 8765)
|
|
||||||
- `allow_from` — Allowed origins (default: `["*"]`)
|
|
||||||
- `streaming` — Enable streaming (default: true)
|
|
||||||
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Create a new nanobot instance with a dedicated config and workspace.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
create_instance.py --name <name> --channel <channel> [--model <model>] [--config-dir <dir>]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
create_instance.py --name telegram-bot --channel telegram
|
|
||||||
create_instance.py --name discord-bot --channel discord --model deepseek/deepseek-chat
|
|
||||||
create_instance.py --name my-bot --channel telegram --config-dir ~/.nanobot-custom
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_name(name: str) -> str:
|
|
||||||
"""Normalize and validate instance name."""
|
|
||||||
name = name.strip().lower()
|
|
||||||
name = re.sub(r"[^a-z0-9-]", "-", name)
|
|
||||||
name = re.sub(r"-{2,}", "-", name)
|
|
||||||
name = name.strip("-")
|
|
||||||
if not name:
|
|
||||||
print("[ERROR] Instance name must contain at least one letter or digit.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if len(name) > 64:
|
|
||||||
print(f"[ERROR] Instance name too long ({len(name)} chars, max 64).", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def _get_available_channels() -> list[str]:
|
|
||||||
"""Get list of available channel names without importing channel classes."""
|
|
||||||
from nanobot.channels.registry import discover_channel_names
|
|
||||||
|
|
||||||
return discover_channel_names()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_onboard(config_path: Path, workspace: Path) -> None:
|
|
||||||
"""Create skeleton config + workspace using nanobot's programmatic API."""
|
|
||||||
from nanobot.cli.commands import _onboard_plugins
|
|
||||||
from nanobot.config.loader import save_config, set_config_path
|
|
||||||
from nanobot.config.paths import get_workspace_path
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
config.agents.defaults.workspace = str(workspace)
|
|
||||||
set_config_path(config_path)
|
|
||||||
save_config(config, config_path)
|
|
||||||
_onboard_plugins(config_path)
|
|
||||||
|
|
||||||
workspace_path = get_workspace_path(config.workspace_path)
|
|
||||||
if not workspace_path.exists():
|
|
||||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
sync_workspace_templates(workspace_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_config(
|
|
||||||
config_path: Path,
|
|
||||||
*,
|
|
||||||
channel: str,
|
|
||||||
workspace: Path,
|
|
||||||
model: str | None,
|
|
||||||
name: str | None = None,
|
|
||||||
inherit_config_path: Path | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Patch the generated config: enable channel, set workspace, optionally set model."""
|
|
||||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
# Inherit providers and model from current instance
|
|
||||||
if inherit_config_path and inherit_config_path.exists():
|
|
||||||
try:
|
|
||||||
src = json.loads(inherit_config_path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
# Inherit providers (API keys, api_base, etc.)
|
|
||||||
src_providers = src.get("providers", {})
|
|
||||||
if src_providers:
|
|
||||||
data.setdefault("providers", {})
|
|
||||||
for key, val in src_providers.items():
|
|
||||||
if isinstance(val, dict) and val.get("apiKey"):
|
|
||||||
data["providers"][key] = val
|
|
||||||
|
|
||||||
# Inherit model if not explicitly overridden
|
|
||||||
if not model:
|
|
||||||
parent_model = src.get("agents", {}).get("defaults", {}).get("model")
|
|
||||||
if parent_model:
|
|
||||||
model = parent_model
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not inherit from {inherit_config_path}: {exc}", file=sys.stderr)
|
|
||||||
|
|
||||||
# Set workspace and model
|
|
||||||
data.setdefault("agents", {}).setdefault("defaults", {})
|
|
||||||
data["agents"]["defaults"]["workspace"] = str(workspace)
|
|
||||||
if model:
|
|
||||||
data["agents"]["defaults"]["model"] = model
|
|
||||||
|
|
||||||
# Enable the target channel
|
|
||||||
channels = data.setdefault("channels", {})
|
|
||||||
if channel in channels and isinstance(channels[channel], dict):
|
|
||||||
channels[channel]["enabled"] = True
|
|
||||||
else:
|
|
||||||
channels[channel] = {"enabled": True}
|
|
||||||
|
|
||||||
# Auto-assign ports if defaults are already in use
|
|
||||||
_assign_free_ports(data)
|
|
||||||
|
|
||||||
# Validate with Pydantic, then save
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
Config.model_validate(data)
|
|
||||||
config_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
||||||
"""Check if a port is already in use."""
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
try:
|
|
||||||
s.bind((host, port))
|
|
||||||
return False
|
|
||||||
except OSError:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _find_free_port(start: int, host: str = "127.0.0.1", max_tries: int = 100) -> int:
|
|
||||||
"""Find the first free port starting from `start`."""
|
|
||||||
for port in range(start, start + max_tries):
|
|
||||||
if not _is_port_in_use(port, host):
|
|
||||||
return port
|
|
||||||
# OS-level fallback: ask the kernel for an ephemeral port
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
s.bind((host, 0))
|
|
||||||
return s.getsockname()[1]
|
|
||||||
|
|
||||||
|
|
||||||
def _assign_free_ports(data: dict) -> None:
|
|
||||||
"""If default gateway or API ports are in use, assign free ones."""
|
|
||||||
from nanobot.config.schema import ApiConfig, GatewayConfig
|
|
||||||
|
|
||||||
defaults = [
|
|
||||||
("gateway", GatewayConfig()),
|
|
||||||
("api", ApiConfig()),
|
|
||||||
]
|
|
||||||
for key, default_cfg in defaults:
|
|
||||||
section = data.setdefault(key, {})
|
|
||||||
port = section.get("port", default_cfg.port)
|
|
||||||
host = section.get("host", default_cfg.host)
|
|
||||||
if _is_port_in_use(port, host):
|
|
||||||
section["port"] = _find_free_port(port + 1, host)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_channel_required_fields(channel: str) -> list[str]:
|
|
||||||
"""Inspect a channel's default config and list fields that are empty strings."""
|
|
||||||
try:
|
|
||||||
from nanobot.channels.registry import load_channel_class
|
|
||||||
|
|
||||||
cls = load_channel_class(channel)
|
|
||||||
default = cls.default_config()
|
|
||||||
return sorted(k for k, v in default.items() if isinstance(v, str) and v == "" and k != "enabled")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[WARN] Could not inspect channel '{channel}' defaults: {exc}", file=sys.stderr)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Create a new nanobot instance.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--name", required=True, help="Instance name (e.g. telegram-bot)")
|
|
||||||
parser.add_argument("--channel", required=True, help="Channel type (e.g. telegram, discord)")
|
|
||||||
parser.add_argument("--model", default=None, help="LLM model (default: same as current instance)")
|
|
||||||
parser.add_argument(
|
|
||||||
"--config-dir",
|
|
||||||
default=None,
|
|
||||||
help="Config directory (default: ~/.nanobot-{name})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--inherit-config",
|
|
||||||
default=None,
|
|
||||||
help="Path to current instance's config.json to copy API keys from",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Validate name
|
|
||||||
name = _validate_name(args.name)
|
|
||||||
|
|
||||||
# Validate channel
|
|
||||||
available = _get_available_channels()
|
|
||||||
if args.channel not in available:
|
|
||||||
print(f"[ERROR] Unknown channel: {args.channel}", file=sys.stderr)
|
|
||||||
print(f"Available channels: {', '.join(sorted(available))}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Resolve paths
|
|
||||||
home = Path.home()
|
|
||||||
config_dir = Path(args.config_dir).expanduser().resolve() if args.config_dir else home / f".nanobot-{name}"
|
|
||||||
config_path = config_dir / "config.json"
|
|
||||||
workspace = config_dir / "workspace"
|
|
||||||
|
|
||||||
# Check for duplicate
|
|
||||||
if config_path.exists():
|
|
||||||
print(f"[ERROR] Config already exists at {config_path}", file=sys.stderr)
|
|
||||||
print("Delete it first or use a different --config-dir.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Creating instance '{name}'...")
|
|
||||||
print(f" Config dir: {config_dir}")
|
|
||||||
print(f" Workspace: {workspace}")
|
|
||||||
print(f" Channel: {args.channel}")
|
|
||||||
if args.model:
|
|
||||||
print(f" Model: {args.model}")
|
|
||||||
|
|
||||||
# Run onboard
|
|
||||||
_run_onboard(config_path, workspace)
|
|
||||||
|
|
||||||
# Patch config
|
|
||||||
inherit_path = Path(args.inherit_config).expanduser().resolve() if args.inherit_config else None
|
|
||||||
_patch_config(
|
|
||||||
config_path,
|
|
||||||
channel=args.channel,
|
|
||||||
workspace=workspace,
|
|
||||||
model=args.model,
|
|
||||||
name=name,
|
|
||||||
inherit_config_path=inherit_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Report
|
|
||||||
print(f"\n[OK] Instance '{name}' created successfully.")
|
|
||||||
print(f" Config: {config_path}")
|
|
||||||
print(f" Workspace: {workspace}")
|
|
||||||
|
|
||||||
# List fields the user needs to fill in
|
|
||||||
required_fields = _get_channel_required_fields(args.channel)
|
|
||||||
if required_fields:
|
|
||||||
print(f"\n[IMPORTANT] Edit {config_path} and fill in these fields:")
|
|
||||||
for field in required_fields:
|
|
||||||
print(f" - channels.{args.channel}.{field}")
|
|
||||||
|
|
||||||
print(f"\nTo start the instance:")
|
|
||||||
print(f" nanobot gateway --config {config_path}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -15,7 +15,7 @@ If the `generate_image` tool is not available in the current tool list, tell the
|
|||||||
- Image editing: pass the saved artifact path or user image path in `reference_images`.
|
- Image editing: pass the saved artifact path or user image path in `reference_images`.
|
||||||
- Iterative edits in the same conversation: prefer the most recent generated image artifact if the user says things like "make it brighter", "change the background", or "try another version".
|
- Iterative edits in the same conversation: prefer the most recent generated image artifact if the user says things like "make it brighter", "change the background", or "try another version".
|
||||||
- Ambiguous edits: ask a short clarifying question if multiple recent images could be the target.
|
- Ambiguous edits: ask a short clarifying question if multiple recent images could be the target.
|
||||||
- In the current chat, do not call `message` just to announce or resend generated images. The runtime attaches images from `generate_image` to the final assistant reply automatically.
|
- After generating images, call the `message` tool with the artifact paths in the `media` parameter to deliver them to the user.
|
||||||
|
|
||||||
## Prompt Rules
|
## Prompt Rules
|
||||||
|
|
||||||
@@ -42,52 +42,6 @@ For follow-up edits, pass the prior artifact `path` to `reference_images`. If th
|
|||||||
|
|
||||||
Do not include internal replay markers such as `[Message Time: ...]`, `[image: /local/path]`, `generate_image(...)`, or `message(...)` in user-facing replies.
|
Do not include internal replay markers such as `[Message Time: ...]`, `[image: /local/path]`, `generate_image(...)`, or `message(...)` in user-facing replies.
|
||||||
|
|
||||||
## Provider Notes
|
|
||||||
|
|
||||||
Do not ask users to paste API keys into chat. If configuration is needed, describe the fields; LLM provider and BYOK changes are hot-reloaded for new turns.
|
|
||||||
|
|
||||||
For OpenRouter, the image tool expects:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"openrouter": {
|
|
||||||
"apiKey": "sk-or-..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"imageGeneration": {
|
|
||||||
"enabled": true,
|
|
||||||
"provider": "openrouter",
|
|
||||||
"model": "openai/gpt-5.4-image-2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For AIHubMix, the image tool expects:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"aihubmix": {
|
|
||||||
"apiKey": "sk-..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"imageGeneration": {
|
|
||||||
"enabled": true,
|
|
||||||
"provider": "aihubmix",
|
|
||||||
"model": "gpt-image-2-free"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
AIHubMix `gpt-image-2-free` uses AIHubMix's unified predictions endpoint internally (`/v1/models/openai/gpt-image-2-free/predictions`), not the OpenAI Images `/v1/images/generations` endpoint. If it fails with "Incorrect model ID", do not assume the key lacks permission until the provider config, model name, and gateway restart have been checked.
|
|
||||||
|
|
||||||
`providers.aihubmix.extraBody` can be used for provider-specific options. For example, `"extraBody": {"quality": "low"}` is optional but can make `gpt-image-2-free` faster and less likely to time out.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Generate a new image:
|
Generate a new image:
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Agent Instructions
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Workspace Guidance
|
||||||
|
|
||||||
|
Use this file for project-specific preferences, recurring workflow conventions, and instructions you want the agent to remember for this workspace. Keep durable facts about the user in `USER.md`, personality/style guidance in `SOUL.md`, and long-term memory in `memory/MEMORY.md`.
|
||||||
|
|
||||||
## Scheduled Reminders
|
## Scheduled Reminders
|
||||||
|
|
||||||
Before scheduling reminders, check available skills and follow skill guidance first.
|
Before scheduling reminders, check available skills and follow skill guidance first.
|
||||||
@@ -10,10 +14,10 @@ Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegr
|
|||||||
|
|
||||||
## Heartbeat Tasks
|
## Heartbeat Tasks
|
||||||
|
|
||||||
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks:
|
`HEARTBEAT.md` is checked periodically when registered as a cron job. Use the built-in `cron` tool to schedule it (e.g. `cron add --name heartbeat --schedule "every 30m" --message "Check HEARTBEAT.md"`).
|
||||||
|
|
||||||
- **Add**: `edit_file` to append new tasks
|
- Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
|
||||||
- **Remove**: `edit_file` to delete completed tasks
|
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
|
||||||
- **Rewrite**: `write_file` to replace all tasks
|
- Use `write_file` for first creation or intentional full-file rewrites.
|
||||||
|
|
||||||
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
|
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` and register it via `cron` instead of creating a one-time reminder.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user