mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1250f0d16 | ||
|
|
376d82fec7 | ||
|
|
8dd35c373a | ||
|
|
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 |
@@ -20,7 +20,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
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).
|
||||
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
||||
|
||||
|
||||
@@ -97,3 +97,5 @@ logs/
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
exp/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||

|
||||
|
||||
<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>
|
||||
<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>
|
||||
@@ -61,7 +73,7 @@
|
||||
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
||||
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
||||
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
||||
- **2026-04-10** 📓 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-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
||||
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
||||
@@ -212,6 +224,7 @@ nanobot agent
|
||||
|
||||
|
||||
- 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 Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
|
||||
|
||||
@@ -329,4 +342,4 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
|
||||
<p align="center">
|
||||
<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">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -17,6 +17,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
|
||||
| **Wecom** | Bot ID + Bot Secret |
|
||||
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
|
||||
| **Mochat** | Claw token (auto-setup available) |
|
||||
| **Signal** | signal-cli daemon + phone number |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
@@ -669,3 +670,69 @@ nanobot gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Signal</b></summary>
|
||||
|
||||
Uses **signal-cli** daemon in HTTP mode — receive messages via SSE, send via JSON-RPC.
|
||||
|
||||
**1. Install signal-cli**
|
||||
|
||||
Install [signal-cli](https://github.com/AsamK/signal-cli) and register a phone number:
|
||||
|
||||
```bash
|
||||
signal-cli -u +1234567890 register
|
||||
signal-cli -u +1234567890 verify <CODE>
|
||||
```
|
||||
|
||||
Start the daemon:
|
||||
|
||||
```bash
|
||||
signal-cli -a +1234567890 daemon --http localhost:8080
|
||||
```
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"signal": {
|
||||
"enabled": true,
|
||||
"phoneNumber": "+1234567890",
|
||||
"daemonHost": "localhost",
|
||||
"daemonPort": 8080,
|
||||
"dm": {
|
||||
"enabled": true,
|
||||
"policy": "open"
|
||||
},
|
||||
"group": {
|
||||
"enabled": true,
|
||||
"policy": "open",
|
||||
"requireMention": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> - `phoneNumber`: Your registered Signal phone number.
|
||||
> - `daemonHost` / `daemonPort`: Where signal-cli daemon is listening (default `localhost:8080`).
|
||||
> - `dm.policy`: `"open"` (anyone can DM) or `"allowlist"` (only listed numbers/UUIDs). When `"allowlist"`, unlisted DM senders receive a pairing code.
|
||||
> - `dm.allowFrom`: List of allowed phone numbers or UUIDs (used when policy is `"allowlist"`).
|
||||
> - `group.policy`: `"open"` (all groups) or `"allowlist"` (only listed group IDs).
|
||||
> - `group.requireMention`: When `true` (default), the bot only responds in groups when @mentioned.
|
||||
> - `group.allowFrom`: List of allowed group IDs (used when group policy is `"allowlist"`).
|
||||
> - `attachmentsDir`: Override the directory where signal-cli stores inbound attachments. Defaults to `~/.local/share/signal-cli/attachments` (the Linux default). Set this if signal-cli runs with a custom `XDG_DATA_HOME` or on macOS/Windows.
|
||||
> - `groupMessageBufferSize`: Number of recent group messages kept for context (default `20`, must be > 0).
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> The channel automatically reconnects to the signal-cli daemon with exponential backoff if the connection drops.
|
||||
> Markdown in bot replies is automatically converted to Signal text styles (bold, italic, code, etc.).
|
||||
|
||||
</details>
|
||||
|
||||
+179
-6
@@ -126,14 +126,17 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
||||
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
|
||||
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
||||
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
||||
> - **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.
|
||||
> - **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 |
|
||||
|----------|---------|-------------|
|
||||
| `custom` | Any OpenAI-compatible endpoint | — |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
@@ -147,11 +150,13 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||
| `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) | — |
|
||||
| `lm_studio` | LLM (local, LM Studio) | — |
|
||||
| `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — |
|
||||
@@ -163,6 +168,73 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
||||
|
||||
<details>
|
||||
<summary><b>OpenAI</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>
|
||||
<summary><b>AWS Bedrock (Converse API)</b></summary>
|
||||
|
||||
@@ -444,6 +516,96 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
|
||||
|
||||
</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>
|
||||
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
||||
|
||||
@@ -512,6 +674,8 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
|
||||
|
||||
</details>
|
||||
|
||||
<a id="local-providers"></a>
|
||||
<a id="ollama-local"></a>
|
||||
<details>
|
||||
<summary><b>Ollama (local)</b></summary>
|
||||
|
||||
@@ -577,12 +741,19 @@ ollama run llama3.2
|
||||
|
||||
</details>
|
||||
|
||||
<a id="atomic-chat-local"></a>
|
||||
<details>
|
||||
<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
|
||||
{
|
||||
@@ -595,13 +766,13 @@ ollama run llama3.2
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"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.
|
||||
|
||||
@@ -682,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.
|
||||
</details>
|
||||
|
||||
<a id="vllm-local-openai-compatible"></a>
|
||||
<details>
|
||||
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
|
||||
|
||||
@@ -885,7 +1057,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
||||
| `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`. |
|
||||
| `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"`. |
|
||||
|
||||
`sendProgress` and `sendToolHints` can also be overridden per channel. The
|
||||
@@ -1124,7 +1296,7 @@ If you want to always use the local conversion, you can force it using:
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1217,6 +1389,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
||||
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
||||
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
|
||||
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
|
||||
| `tools.exec.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`). |
|
||||
| `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. |
|
||||
|
||||
|
||||
+158
-28
@@ -6,8 +6,6 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
|
||||
|
||||
## Quick Setup
|
||||
|
||||
OpenRouter example:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
@@ -19,34 +17,13 @@ OpenRouter example:
|
||||
"imageGeneration": {
|
||||
"enabled": true,
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-5.4-image-2",
|
||||
"defaultAspectRatio": "1:1",
|
||||
"defaultImageSize": "1K"
|
||||
"model": "openai/gpt-5.4-image-2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
AIHubMix example:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"aihubmix": {
|
||||
"apiKey": "${AIHUBMIX_API_KEY}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"imageGeneration": {
|
||||
"enabled": true,
|
||||
"provider": "aihubmix",
|
||||
"model": "gpt-image-2-free",
|
||||
"defaultAspectRatio": "1:1",
|
||||
"defaultImageSize": "1K"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
|
||||
|
||||
> [!TIP]
|
||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
||||
@@ -69,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `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.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` |
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
|
||||
+19
-3
@@ -2,9 +2,10 @@
|
||||
nanobot - A lightweight AI agent framework
|
||||
"""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
||||
from pathlib import Path
|
||||
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:
|
||||
@@ -27,6 +28,21 @@ def _resolve_version() -> str:
|
||||
__version__ = _resolve_version()
|
||||
__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"]
|
||||
|
||||
@@ -10,6 +10,10 @@ from typing import Any, Mapping, Sequence
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.agent.tools import mcp as mcp_tools
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.apps.cli import utils as cli_app_utils
|
||||
from nanobot.session.goal_state import goal_state_runtime_lines
|
||||
from nanobot.utils.helpers import (
|
||||
current_time_str,
|
||||
@@ -19,10 +23,36 @@ from nanobot.utils.helpers import (
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return persisted kwargs for turn-attached capabilities."""
|
||||
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
|
||||
|
||||
|
||||
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||
"""Return model-visible runtime annotations for turn-attached capabilities."""
|
||||
return [
|
||||
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
|
||||
*mcp_tools.runtime_lines(
|
||||
msg,
|
||||
configured_server_names=set(state._mcp_servers),
|
||||
connected_server_names=set(state._mcp_stacks),
|
||||
skip=skip,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||
await mcp_tools.connect_missing_servers(state, tools)
|
||||
|
||||
|
||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||
return await mcp_tools.handle_runtime_control(state, msg, tools)
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the context (system prompt + messages) for the agent."""
|
||||
|
||||
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]"
|
||||
_MAX_RECENT_HISTORY = 50
|
||||
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
||||
@@ -47,6 +77,8 @@ class ContextBuilder:
|
||||
if bootstrap:
|
||||
parts.append(bootstrap)
|
||||
|
||||
parts.append(render_template("agent/tool_contract.md"))
|
||||
|
||||
memory = self.memory.get_memory_context()
|
||||
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n{memory}")
|
||||
@@ -154,9 +186,14 @@ class ContextBuilder:
|
||||
sender_id: str | None = None,
|
||||
session_summary: str | None = None,
|
||||
session_metadata: Mapping[str, Any] | None = None,
|
||||
current_runtime_lines: Sequence[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
extra = goal_state_runtime_lines(session_metadata)
|
||||
extra = [
|
||||
*goal_state_runtime_lines(session_metadata),
|
||||
]
|
||||
if current_runtime_lines:
|
||||
extra.extend(line for line in current_runtime_lines if line)
|
||||
runtime_ctx = self._build_runtime_context(
|
||||
channel,
|
||||
chat_id,
|
||||
@@ -210,4 +247,3 @@ class ContextBuilder:
|
||||
if not images:
|
||||
return text
|
||||
return images + [{"type": "text", "text": text}]
|
||||
|
||||
|
||||
+282
-42
@@ -8,17 +8,25 @@ import os
|
||||
import time
|
||||
from contextlib import AsyncExitStack, nullcontext, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent import context as agent_context
|
||||
from nanobot.agent import model_presets as preset_helpers
|
||||
from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.hook import AgentHook, CompositeHook
|
||||
from nanobot.agent.memory import Consolidator, Dream
|
||||
from nanobot.agent.memory import (
|
||||
_STALE_THRESHOLD_DAYS,
|
||||
Consolidator,
|
||||
Dream,
|
||||
_estimate_tokens,
|
||||
_strip_skip_lines,
|
||||
)
|
||||
from nanobot.agent.progress_hook import AgentProgressHook
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
@@ -33,21 +41,26 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.session.goal_state import (
|
||||
GOAL_STATE_KEY,
|
||||
goal_state_runtime_lines,
|
||||
runner_wall_llm_timeout_s,
|
||||
sustained_goal_active,
|
||||
)
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.artifacts import generated_image_paths_from_messages
|
||||
from nanobot.session.webui_turns import (
|
||||
WebuiTurnCoordinator,
|
||||
build_bus_progress_callback,
|
||||
mark_webui_session,
|
||||
)
|
||||
from nanobot.utils.document import extract_documents
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
|
||||
from nanobot.utils.webui_turn_helpers import (
|
||||
WebuiTurnCoordinator,
|
||||
build_bus_progress_callback,
|
||||
mark_webui_session,
|
||||
from nanobot.utils.prompt_templates import _TEMPLATES_ROOT, render_template
|
||||
from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
SUSTAINED_GOAL_CONTINUE_PROMPT,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -61,7 +74,6 @@ if TYPE_CHECKING:
|
||||
|
||||
UNIFIED_SESSION_KEY = "unified:default"
|
||||
|
||||
|
||||
class TurnState(Enum):
|
||||
RESTORE = auto()
|
||||
COMPACT = auto()
|
||||
@@ -103,7 +115,6 @@ class TurnContext:
|
||||
save_skip: int = 0
|
||||
|
||||
outbound: OutboundMessage | None = None
|
||||
generated_media: list[str] = field(default_factory=list)
|
||||
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None
|
||||
@@ -167,6 +178,7 @@ class AgentLoop:
|
||||
workspace: Path,
|
||||
model: str | None = None,
|
||||
max_iterations: int | None = None,
|
||||
max_concurrent_subagents: int | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
context_block_limit: int | None = None,
|
||||
max_tool_result_chars: int | None = None,
|
||||
@@ -193,6 +205,7 @@ class AgentLoop:
|
||||
model_preset: str | None = None,
|
||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||
dream_model_override: str | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
@@ -205,6 +218,7 @@ class AgentLoop:
|
||||
self._preset_snapshot_loader = preset_snapshot_loader
|
||||
self._runtime_model_publisher = runtime_model_publisher
|
||||
self._provider_signature = provider_signature
|
||||
self._dream_model_override = dream_model_override
|
||||
self._default_selection_signature = preset_helpers.default_selection_signature(provider_signature)
|
||||
self.workspace = workspace
|
||||
self.model = model or provider.get_default_model()
|
||||
@@ -265,6 +279,7 @@ class AgentLoop:
|
||||
restrict_to_workspace=restrict_to_workspace,
|
||||
disabled_skills=disabled_skills,
|
||||
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),
|
||||
)
|
||||
self._unified_session = unified_session
|
||||
@@ -311,6 +326,7 @@ class AgentLoop:
|
||||
self._active_preset: str | None = None
|
||||
if model_preset:
|
||||
self.set_model_preset(model_preset, publish_update=False)
|
||||
self._configure_dream()
|
||||
self._register_default_tools()
|
||||
self._runtime_vars: dict[str, Any] = {}
|
||||
self._current_iteration: int = 0
|
||||
@@ -350,6 +366,7 @@ class AgentLoop:
|
||||
workspace=config.workspace_path,
|
||||
model=model,
|
||||
max_iterations=defaults.max_tool_iterations,
|
||||
max_concurrent_subagents=defaults.max_concurrent_subagents,
|
||||
context_window_tokens=context_window_tokens,
|
||||
context_block_limit=defaults.context_block_limit,
|
||||
max_tool_result_chars=defaults.max_tool_result_chars,
|
||||
@@ -369,6 +386,7 @@ class AgentLoop:
|
||||
model_preset=defaults.model_preset,
|
||||
provider_snapshot_loader=provider_snapshot_loader,
|
||||
preset_snapshot_loader=preset_snapshot_loader,
|
||||
dream_model_override=config.agents.defaults.dream.model_override,
|
||||
**extra,
|
||||
)
|
||||
|
||||
@@ -394,7 +412,7 @@ class AgentLoop:
|
||||
self.runner.provider = provider
|
||||
self.subagents.set_provider(provider, model)
|
||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||
self.dream.set_provider(provider, model)
|
||||
self._configure_dream()
|
||||
self._provider_signature = snapshot.signature
|
||||
if publish_update and self._runtime_model_publisher is not None:
|
||||
self._runtime_model_publisher(
|
||||
@@ -403,6 +421,20 @@ class AgentLoop:
|
||||
)
|
||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
||||
|
||||
def _configure_dream(self) -> None:
|
||||
"""Apply dream.model_override, resolving preset names if needed."""
|
||||
if not self._dream_model_override:
|
||||
self.dream.set_provider(self.provider, self.model)
|
||||
return
|
||||
|
||||
if self._dream_model_override in self.model_presets:
|
||||
snapshot = self._build_model_preset_snapshot(self._dream_model_override)
|
||||
self.dream.set_provider(snapshot.provider, snapshot.model)
|
||||
return
|
||||
|
||||
# Raw model name fallback — same provider, different model
|
||||
self.dream.set_provider(self.provider, self._dream_model_override)
|
||||
|
||||
def _refresh_provider_snapshot(self) -> None:
|
||||
if self._provider_snapshot_loader is None:
|
||||
return
|
||||
@@ -479,26 +511,8 @@ class AgentLoop:
|
||||
logger.info("Registered {} tools: {}", len(registered), registered)
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
|
||||
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
|
||||
"""Connect configured MCP servers."""
|
||||
await agent_context.connect_mcp(self, self.tools)
|
||||
|
||||
def _set_tool_context(
|
||||
self, channel: str, chat_id: str,
|
||||
@@ -571,7 +585,7 @@ class AgentLoop:
|
||||
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()
|
||||
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)
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
session.add_message("user", text, **extra)
|
||||
@@ -596,7 +610,7 @@ class AgentLoop:
|
||||
chat_id=self._runtime_chat_id(msg),
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending_summary,
|
||||
session_metadata=session.metadata,
|
||||
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
|
||||
)
|
||||
|
||||
async def _dispatch_command_inline(
|
||||
@@ -747,6 +761,15 @@ class AgentLoop:
|
||||
|
||||
active_session_key = session.key if session else session_key
|
||||
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||
# 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
|
||||
try:
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
initial_messages=initial_messages,
|
||||
@@ -774,6 +797,8 @@ class AgentLoop:
|
||||
session.key if session is not None else session_key,
|
||||
metadata=(session.metadata if session is not None else None),
|
||||
),
|
||||
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
|
||||
goal_continue_message=_goal_continue,
|
||||
))
|
||||
finally:
|
||||
reset_file_states(file_state_token)
|
||||
@@ -814,6 +839,8 @@ class AgentLoop:
|
||||
logger.warning("Error consuming inbound message: {}, continuing...", e)
|
||||
continue
|
||||
|
||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||
continue
|
||||
raw = msg.content.strip()
|
||||
if self.commands.is_priority(raw):
|
||||
await self._dispatch_command_inline(
|
||||
@@ -1022,6 +1049,28 @@ class AgentLoop:
|
||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||
)
|
||||
logger.info("Processing system message from {}", msg.sender_id)
|
||||
if msg.sender_id == "dream":
|
||||
session_key = "system:dream"
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
session.metadata["is_dream"] = True
|
||||
# Capture trigger source on first batch so _dream_finalize_commit
|
||||
# can notify the user who ran /dream (cron-triggered runs have no trigger).
|
||||
if "_dream_trigger_channel" not in session.metadata:
|
||||
trigger_ch = msg.metadata.get("trigger_channel")
|
||||
trigger_ci = msg.metadata.get("trigger_chat_id")
|
||||
if trigger_ch and trigger_ci:
|
||||
session.metadata["_dream_trigger_channel"] = trigger_ch
|
||||
session.metadata["_dream_trigger_chat_id"] = trigger_ci
|
||||
if not sustained_goal_active(session.metadata):
|
||||
session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
"objective": "Dream: consolidate unprocessed memory backlog into MEMORY.md, SOUL.md, USER.md",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
}
|
||||
self.sessions.save(session)
|
||||
await self._process_dream_batch(session, msg)
|
||||
await self._dream_finalize_commit(session)
|
||||
return None
|
||||
key = msg.session_key_override or f"{channel}:{chat_id}"
|
||||
session = self.sessions.get_or_create(key)
|
||||
if self._restore_runtime_checkpoint(session):
|
||||
@@ -1061,7 +1110,7 @@ class AgentLoop:
|
||||
current_role=current_role,
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending,
|
||||
session_metadata=session.metadata,
|
||||
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
|
||||
)
|
||||
t_wall = time.time()
|
||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||
@@ -1098,6 +1147,205 @@ class AgentLoop:
|
||||
metadata=outbound_metadata,
|
||||
)
|
||||
|
||||
async def _process_dream_batch(self, session: Session, msg: InboundMessage) -> None:
|
||||
"""Process the full Dream backlog in batches within a single invocation."""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
# System prompt caching with mtime invalidation
|
||||
template_path = _TEMPLATES_ROOT / "agent" / "dream.md"
|
||||
cached_prompt = session.metadata.get("_dream_system_prompt")
|
||||
cached_mtime = session.metadata.get("_dream_system_prompt_mtime")
|
||||
current_mtime = template_path.stat().st_mtime if template_path.exists() else None
|
||||
|
||||
if cached_prompt is None or cached_mtime != current_mtime:
|
||||
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
|
||||
workspace = self.dream.store.workspace
|
||||
cached_prompt = render_template(
|
||||
"agent/dream.md",
|
||||
strip=True,
|
||||
skill_creator_path=str(skill_creator_path),
|
||||
soul_path=str(workspace / "SOUL.md"),
|
||||
user_path=str(workspace / "USER.md"),
|
||||
memory_path=str(workspace / "memory" / "MEMORY.md"),
|
||||
stale_threshold_days=_STALE_THRESHOLD_DAYS,
|
||||
dream_edit_user_skills=self.dream.edit_user_skills,
|
||||
)
|
||||
session.metadata["_dream_system_prompt"] = cached_prompt
|
||||
session.metadata["_dream_system_prompt_mtime"] = current_mtime
|
||||
|
||||
while True:
|
||||
last_cursor = self.dream.store.get_last_dream_cursor()
|
||||
entries = self.dream.store.read_unprocessed_history(since_cursor=last_cursor)
|
||||
if not entries:
|
||||
return
|
||||
|
||||
batch = entries[: self.dream.max_batch_size]
|
||||
logger.info(
|
||||
"Dream: processing {}/{} entries (cursor {}→{})",
|
||||
len(batch), len(entries), last_cursor, batch[-1]["cursor"],
|
||||
)
|
||||
|
||||
# Build history text — cap each entry and strip [skip] lines
|
||||
history_text = "\n".join(
|
||||
f"[{e['timestamp']}] "
|
||||
f"{truncate_text_fn(_strip_skip_lines(e['content']), self.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS)}"
|
||||
for e in batch
|
||||
)
|
||||
|
||||
# Current file contents + per-line age annotations
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
annotate = self.dream.annotate_line_ages
|
||||
raw_memory = self.dream.store.read_memory() or "(empty)"
|
||||
raw_soul = self.dream.store.read_soul() or "(empty)"
|
||||
raw_user = self.dream.store.read_user() or "(empty)"
|
||||
annotated_memory = (
|
||||
self.dream._annotate_with_ages(raw_memory, "memory/MEMORY.md")
|
||||
if annotate else raw_memory
|
||||
)
|
||||
annotated_soul = (
|
||||
self.dream._annotate_with_ages(raw_soul, "SOUL.md")
|
||||
if annotate else raw_soul
|
||||
)
|
||||
annotated_user = (
|
||||
self.dream._annotate_with_ages(raw_user, "USER.md")
|
||||
if annotate else raw_user
|
||||
)
|
||||
current_memory = truncate_text_fn(annotated_memory, self.dream._MEMORY_FILE_MAX_CHARS)
|
||||
current_soul = truncate_text_fn(annotated_soul, self.dream._SOUL_FILE_MAX_CHARS)
|
||||
current_user = truncate_text_fn(annotated_user, self.dream._USER_FILE_MAX_CHARS)
|
||||
|
||||
file_context = (
|
||||
f"## Current Date\n{current_date}\n\n"
|
||||
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
|
||||
f"## Current SOUL.md ({len(current_soul)} chars)\n{current_soul}\n\n"
|
||||
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
|
||||
)
|
||||
|
||||
existing_skills = self.dream._list_existing_skills(tag_origin=True)
|
||||
skills_section = ""
|
||||
if existing_skills:
|
||||
skills_section = (
|
||||
"\n\n## Existing Skills\n"
|
||||
+ "\n".join(f"- {s}" for s in existing_skills)
|
||||
)
|
||||
|
||||
user_prompt = f"## Conversation History\n{history_text}\n\n{file_context}{skills_section}"
|
||||
logger.info("Dream prompt: {} chars, ~{} tokens", len(user_prompt), _estimate_tokens(user_prompt))
|
||||
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": cached_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
result = await self.dream._runner.run(AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=self.dream._tools,
|
||||
model=self.dream.model,
|
||||
max_iterations=self.dream.max_iterations,
|
||||
max_tool_result_chars=self.dream.max_tool_result_chars,
|
||||
context_window_tokens=self.context_window_tokens,
|
||||
fail_on_tool_error=False,
|
||||
))
|
||||
elapsed = time.perf_counter() - t_start
|
||||
logger.info(
|
||||
"Dream run complete in {:.1f}s: stop_reason={}, tool_events={}",
|
||||
elapsed, result.stop_reason, len(result.tool_events),
|
||||
)
|
||||
except Exception:
|
||||
elapsed = time.perf_counter() - t_start
|
||||
logger.exception("Dream run failed after {:.1f}s", elapsed)
|
||||
result = None
|
||||
|
||||
# Build changelog from tool events
|
||||
changelog: list[str] = []
|
||||
if result and result.tool_events:
|
||||
for event in result.tool_events:
|
||||
if event.get("status") == "ok":
|
||||
changelog.append(f"{event['name']}: {event['detail']}")
|
||||
|
||||
success = result is not None and result.stop_reason == "completed"
|
||||
if success:
|
||||
new_cursor = batch[-1]["cursor"]
|
||||
self.dream.store.set_last_dream_cursor(new_cursor)
|
||||
session.metadata.setdefault("_dream_changelog", []).extend(changelog)
|
||||
self.sessions.save(session)
|
||||
logger.info(
|
||||
"Dream done: {} change(s), cursor advanced to {}",
|
||||
len(changelog), new_cursor,
|
||||
)
|
||||
else:
|
||||
reason = result.stop_reason if result else "exception"
|
||||
logger.warning(
|
||||
"Dream incomplete ({}): cursor NOT advanced, stopping",
|
||||
reason,
|
||||
)
|
||||
return
|
||||
|
||||
self.dream.store.compact_history()
|
||||
|
||||
# Persist session record for debugging / visualization
|
||||
record = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"batch": {
|
||||
"from_cursor": last_cursor,
|
||||
"to_cursor": batch[-1]["cursor"],
|
||||
"count": len(batch),
|
||||
},
|
||||
"prompt_chars": len(user_prompt),
|
||||
"elapsed_seconds": elapsed,
|
||||
"stop_reason": result.stop_reason,
|
||||
"usage": result.usage,
|
||||
"tool_events": result.tool_events,
|
||||
"changelog": changelog,
|
||||
"commit_sha": None,
|
||||
"messages": result.messages,
|
||||
}
|
||||
self.dream.store.write_dream_session(record)
|
||||
session.metadata["_dream_last_record"] = record
|
||||
|
||||
|
||||
async def _dream_finalize_commit(self, session: Session) -> None:
|
||||
"""Collapse accumulated changelog into a single git commit, clear caches, and complete the goal."""
|
||||
changelog = session.metadata.pop("_dream_changelog", [])
|
||||
sha = None
|
||||
if changelog and self.dream.store.git.is_initialized():
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
summary = f"dream: {ts}, {len(changelog)} change(s)"
|
||||
commit_msg = f"{summary}\n\n" + "\n".join(changelog)
|
||||
sha = self.dream.store.git.auto_commit(commit_msg)
|
||||
if sha:
|
||||
logger.info("Dream commit: {}", sha)
|
||||
record = session.metadata.pop("_dream_last_record", None)
|
||||
if record and sha:
|
||||
record["commit_sha"] = sha
|
||||
self.dream.store.write_dream_session(record)
|
||||
session.metadata.pop("_dream_system_prompt", None)
|
||||
session.metadata.pop("_dream_system_prompt_mtime", None)
|
||||
trigger_channel = session.metadata.pop("_dream_trigger_channel", None)
|
||||
trigger_chat_id = session.metadata.pop("_dream_trigger_chat_id", None)
|
||||
goal = session.metadata.get(GOAL_STATE_KEY)
|
||||
if isinstance(goal, dict) and goal.get("status") == "active":
|
||||
session.metadata[GOAL_STATE_KEY] = {
|
||||
**goal,
|
||||
"status": "completed",
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
"recap": f"Memory backlog consolidated ({len(changelog)} change(s)).",
|
||||
}
|
||||
self.sessions.save(session)
|
||||
session.metadata["_dream_finalized"] = True
|
||||
# Notify the user who triggered /dream
|
||||
if trigger_channel and trigger_chat_id:
|
||||
content = f"Dream completed: {len(changelog)} change(s) committed."
|
||||
if not changelog:
|
||||
content = "Dream: nothing to process."
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=trigger_channel,
|
||||
chat_id=trigger_chat_id,
|
||||
content=content,
|
||||
))
|
||||
|
||||
async def _process_message(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
@@ -1194,7 +1442,6 @@ class AgentLoop:
|
||||
all_msgs: list[dict[str, Any]],
|
||||
stop_reason: str,
|
||||
had_injections: bool,
|
||||
generated_media: list[str],
|
||||
on_stream: Callable[[str], Awaitable[None]] | None,
|
||||
*,
|
||||
turn_latency_ms: int | None = None,
|
||||
@@ -1218,7 +1465,6 @@ class AgentLoop:
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=final_content,
|
||||
media=generated_media,
|
||||
metadata=meta,
|
||||
)
|
||||
|
||||
@@ -1348,11 +1594,6 @@ class AgentLoop:
|
||||
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
|
||||
skip_msgs = ctx.all_messages[ctx.save_skip:]
|
||||
ctx.generated_media = generated_image_paths_from_messages(skip_msgs)
|
||||
mt = self.tools.get("message")
|
||||
extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else []
|
||||
merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra)
|
||||
|
||||
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
|
||||
self._save_turn(
|
||||
@@ -1380,7 +1621,6 @@ class AgentLoop:
|
||||
ctx.all_messages,
|
||||
ctx.stop_reason,
|
||||
ctx.had_injections,
|
||||
ctx.generated_media,
|
||||
ctx.on_stream,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
)
|
||||
|
||||
+168
-191
@@ -6,6 +6,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import weakref
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
@@ -15,7 +16,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||
import tiktoken
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.utils.gitstore import GitStore
|
||||
@@ -33,6 +34,20 @@ if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
# Cache the tiktoken encoding to avoid repeated instantiation on every
|
||||
# truncate/encode call. Encoding objects are thread-safe and reusable.
|
||||
try:
|
||||
_TIKTOKEN_ENC = tiktoken.get_encoding("cl100k_base")
|
||||
except Exception: # pragma: no cover
|
||||
_TIKTOKEN_ENC = None
|
||||
|
||||
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
"""Approximate token count for a text string."""
|
||||
if _TIKTOKEN_ENC is not None:
|
||||
return len(_TIKTOKEN_ENC.encode(text))
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryStore — pure file I/O layer
|
||||
@@ -400,6 +415,26 @@ class MemoryStore:
|
||||
def set_last_dream_cursor(self, cursor: int) -> None:
|
||||
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
|
||||
|
||||
def write_dream_session(self, data: dict[str, Any]) -> None:
|
||||
"""Atomic overwrite of the latest Dream run record."""
|
||||
path = self.memory_dir / ".dream_session.json"
|
||||
tmp_path = path.with_suffix(".tmp")
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
with suppress(PermissionError):
|
||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# -- message formatting utility ------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
@@ -618,19 +653,21 @@ class Consolidator:
|
||||
"""Available input token budget for consolidation LLM."""
|
||||
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||
|
||||
def _truncate_to_token_budget(self, text: str) -> str:
|
||||
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
||||
budget = self._input_token_budget
|
||||
def _truncate_to_token_budget(self, text: str, reserve_tokens: int = 0) -> str:
|
||||
"""Truncate text so it fits within the consolidation LLM's token budget.
|
||||
|
||||
reserve_tokens: additional tokens to reserve for dedup context or other
|
||||
overhead that will be appended after truncation.
|
||||
"""
|
||||
budget = self._input_token_budget - reserve_tokens
|
||||
if budget <= 0:
|
||||
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||
try:
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = enc.encode(text)
|
||||
if _TIKTOKEN_ENC is not None:
|
||||
tokens = _TIKTOKEN_ENC.encode(text)
|
||||
if len(tokens) <= budget:
|
||||
return text
|
||||
return enc.decode(tokens[:budget]) + "\n... (truncated)"
|
||||
except Exception:
|
||||
return truncate_text(text, budget * 4)
|
||||
return _TIKTOKEN_ENC.decode(tokens[:budget]) + "\n... (truncated)"
|
||||
return truncate_text(text, budget * 4)
|
||||
|
||||
async def archive(self, messages: list[dict]) -> str | None:
|
||||
"""Summarize messages via LLM and append to history.jsonl.
|
||||
@@ -639,9 +676,53 @@ class Consolidator:
|
||||
"""
|
||||
if not messages:
|
||||
return None
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
formatted = MemoryStore._format_messages(messages)
|
||||
formatted = self._truncate_to_token_budget(formatted)
|
||||
logger.debug(
|
||||
"Consolidator: {} messages, formatted={} chars",
|
||||
len(messages), len(formatted),
|
||||
)
|
||||
|
||||
# Inject current memory context for dedup-aware summarization.
|
||||
memory_preview = self.store.read_memory()[:4000]
|
||||
user_preview = self.store.read_user()[:2000]
|
||||
dedup_context = ""
|
||||
if memory_preview:
|
||||
dedup_context += f"\n\n## Current MEMORY.md (for dedup)\n{memory_preview}"
|
||||
if user_preview:
|
||||
dedup_context += f"\n\n## Current USER.md (for dedup)\n{user_preview}"
|
||||
|
||||
reserve_tokens = 0
|
||||
if dedup_context:
|
||||
if _TIKTOKEN_ENC is not None:
|
||||
reserve_tokens = len(_TIKTOKEN_ENC.encode(dedup_context)) + 100
|
||||
else:
|
||||
reserve_tokens = len(dedup_context) // 4 + 100
|
||||
|
||||
if self._input_token_budget <= reserve_tokens:
|
||||
logger.warning(
|
||||
"Consolidator: dedup_context ({} tokens) exceeds budget ({}), dropping it",
|
||||
reserve_tokens, self._input_token_budget,
|
||||
)
|
||||
dedup_context = ""
|
||||
reserve_tokens = 0
|
||||
else:
|
||||
logger.debug(
|
||||
"Consolidator: dedup_context={} chars, reserve_tokens={}",
|
||||
len(dedup_context), reserve_tokens,
|
||||
)
|
||||
|
||||
formatted_before = len(formatted)
|
||||
formatted = self._truncate_to_token_budget(
|
||||
formatted, reserve_tokens=reserve_tokens
|
||||
)
|
||||
if len(formatted) < formatted_before:
|
||||
logger.warning(
|
||||
"Consolidator: truncated formatted messages from {} to {} chars",
|
||||
formatted_before, len(formatted),
|
||||
)
|
||||
|
||||
response = await self.provider.chat_with_retry(
|
||||
model=self.model,
|
||||
messages=[
|
||||
@@ -652,18 +733,31 @@ class Consolidator:
|
||||
strip=True,
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": formatted},
|
||||
{"role": "user", "content": formatted + dedup_context},
|
||||
],
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
elapsed = time.perf_counter() - t_start
|
||||
if response.finish_reason == "error":
|
||||
logger.warning(
|
||||
"Consolidator LLM error after {:.1f}s: {}",
|
||||
elapsed, response.content,
|
||||
)
|
||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||
summary = response.content or "[no summary]"
|
||||
logger.info(
|
||||
"Consolidator: {} entries -> {} chars summary in {:.1f}s",
|
||||
len(messages), len(summary), elapsed,
|
||||
)
|
||||
self.store.append_history(summary, max_chars=_ARCHIVE_SUMMARY_MAX_CHARS)
|
||||
return summary
|
||||
except Exception:
|
||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
||||
elapsed = time.perf_counter() - t_start
|
||||
logger.warning(
|
||||
"Consolidation LLM call failed after {:.1f}s, raw-dumping to history",
|
||||
elapsed,
|
||||
)
|
||||
self.store.raw_archive(messages)
|
||||
return None
|
||||
|
||||
@@ -851,38 +945,48 @@ class Consolidator:
|
||||
|
||||
|
||||
# Single source of truth for the staleness threshold used in _annotate_with_ages
|
||||
# *and* in the Phase 1 prompt template (passed as `stale_threshold_days`).
|
||||
# *and* in the system prompt template (passed as `stale_threshold_days`).
|
||||
# Keep code and prompt aligned — if you bump this, the LLM's instruction string
|
||||
# updates automatically.
|
||||
_STALE_THRESHOLD_DAYS = 14
|
||||
|
||||
_SKIP_LINE_RE = re.compile(r"^\s*-\s*\[skip\]\s*.*$", re.MULTILINE | re.IGNORECASE)
|
||||
|
||||
|
||||
def _strip_skip_lines(text: str) -> str:
|
||||
"""Remove lines marked [skip] from history content."""
|
||||
lines = text.splitlines()
|
||||
kept = [line for line in lines if not _SKIP_LINE_RE.match(line)]
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
class Dream:
|
||||
"""Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
|
||||
"""Single-phase memory processor: analyze history.jsonl and edit files via AgentRunner.
|
||||
|
||||
Phase 1 produces an analysis summary (plain LLM call).
|
||||
Phase 2 delegates to AgentRunner with read_file / edit_file tools so the
|
||||
LLM can make targeted, incremental edits instead of replacing entire files.
|
||||
Delegates to AgentRunner with read_file / edit_file tools so the LLM can
|
||||
analyze conversation history, extract facts, deduplicate, and make targeted
|
||||
incremental edits — all in a single agent run.
|
||||
"""
|
||||
|
||||
# Caps on prompt-bound inputs so Dream's LLM calls never exceed the model's
|
||||
# context window just because a file (or a legacy large history entry) grew
|
||||
# unexpectedly. Each file still appears in full via read_file when the agent
|
||||
# needs it in Phase 2 — these caps only bound the Phase 1/2 prompt preview.
|
||||
_MEMORY_FILE_MAX_CHARS = 32_000
|
||||
_SOUL_FILE_MAX_CHARS = 16_000
|
||||
_USER_FILE_MAX_CHARS = 16_000
|
||||
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 4_000
|
||||
# needs it — these caps only bound the prompt preview.
|
||||
_MEMORY_FILE_MAX_CHARS = 16_000
|
||||
_SOUL_FILE_MAX_CHARS = 4_000
|
||||
_USER_FILE_MAX_CHARS = 4_000
|
||||
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 2_000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MemoryStore,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
max_batch_size: int = 20,
|
||||
max_batch_size: int = 5,
|
||||
max_iterations: int = 10,
|
||||
max_tool_result_chars: int = 16_000,
|
||||
annotate_line_ages: bool = True,
|
||||
edit_user_skills: bool = False,
|
||||
):
|
||||
self.store = store
|
||||
self.provider = provider
|
||||
@@ -890,10 +994,13 @@ class Dream:
|
||||
self.max_batch_size = max_batch_size
|
||||
self.max_iterations = max_iterations
|
||||
self.max_tool_result_chars = max_tool_result_chars
|
||||
# Kill switch for the git-blame-based per-line age annotation in Phase 1.
|
||||
# Default True keeps the #3212 behavior; set False to feed MEMORY.md raw
|
||||
# (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
|
||||
# Kill switch for the git-blame-based per-line age annotation in the prompt.
|
||||
# Default True keeps the #3212 behavior; set False to feed all memory
|
||||
# files raw (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
|
||||
self.annotate_line_ages = annotate_line_ages
|
||||
# When True, Dream may edit/delete user-created workspace skills.
|
||||
# When False, only skills with dream_managed: true in frontmatter are editable.
|
||||
self.edit_user_skills = edit_user_skills
|
||||
self._runner = AgentRunner(provider)
|
||||
self._tools = self._build_tools()
|
||||
|
||||
@@ -907,6 +1014,7 @@ class Dream:
|
||||
def _build_tools(self) -> ToolRegistry:
|
||||
"""Build a minimal tool registry for the Dream agent."""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
from nanobot.agent.tools.apply_patch import ApplyPatchTool
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
||||
|
||||
@@ -924,6 +1032,7 @@ class Dream:
|
||||
file_states=file_states,
|
||||
))
|
||||
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace, file_states=file_states))
|
||||
tools.register(ApplyPatchTool(workspace=workspace, allowed_dir=workspace, file_states=file_states))
|
||||
# write_file resolves relative paths from workspace root, but can only
|
||||
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
|
||||
skills_dir = workspace / "skills"
|
||||
@@ -933,15 +1042,25 @@ class Dream:
|
||||
|
||||
# -- skill listing --------------------------------------------------------
|
||||
|
||||
def _list_existing_skills(self) -> list[str]:
|
||||
"""List existing skills as 'name — description' for dedup context."""
|
||||
def _list_existing_skills(self, tag_origin: bool = False) -> list[str]:
|
||||
"""List existing skills as 'name — description [origin]' for dedup context.
|
||||
|
||||
When *tag_origin* is True each entry gets an origin tag:
|
||||
``[dream]`` for skills with ``dream_managed: true`` in frontmatter,
|
||||
``[user]`` for other workspace skills, ``[builtin]`` for bundled skills.
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
desc_re = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
|
||||
entries: dict[str, str] = {}
|
||||
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
|
||||
managed_re = _re.compile(r"^dream_managed:\s*true$", _re.MULTILINE | _re.IGNORECASE)
|
||||
|
||||
entries: dict[str, tuple[str, str]] = {} # name -> (desc, tag)
|
||||
builtin_dir = BUILTIN_SKILLS_DIR
|
||||
ws_skills_dir = self.store.workspace / "skills"
|
||||
|
||||
for base in (ws_skills_dir, builtin_dir):
|
||||
if not base.exists():
|
||||
continue
|
||||
for d in base.iterdir():
|
||||
@@ -951,18 +1070,31 @@ class Dream:
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
# Prefer workspace skills over builtin (same name)
|
||||
if d.name in entries and base == BUILTIN_SKILLS_DIR:
|
||||
if d.name in entries and base == builtin_dir:
|
||||
continue
|
||||
content = skill_md.read_text(encoding="utf-8")[:500]
|
||||
m = desc_re.search(content)
|
||||
desc = m.group(1).strip() if m else "(no description)"
|
||||
entries[d.name] = desc
|
||||
return [f"{name} — {desc}" for name, desc in sorted(entries.items())]
|
||||
|
||||
if tag_origin:
|
||||
if base == builtin_dir:
|
||||
tag = "[builtin]"
|
||||
elif managed_re.search(content):
|
||||
tag = "[dream]"
|
||||
else:
|
||||
tag = "[user]"
|
||||
entries[d.name] = (desc, tag)
|
||||
else:
|
||||
entries[d.name] = (desc, "")
|
||||
|
||||
if tag_origin:
|
||||
return [f"{name} — {desc} {tag}" for name, (desc, tag) in sorted(entries.items())]
|
||||
return [f"{name} — {desc}" for name, (desc, _) in sorted(entries.items())]
|
||||
|
||||
# -- main entry ----------------------------------------------------------
|
||||
|
||||
def _annotate_with_ages(self, content: str) -> str:
|
||||
"""Append per-line age suffixes to MEMORY.md content.
|
||||
def _annotate_with_ages(self, content: str, file_path: str = "memory/MEMORY.md") -> str:
|
||||
"""Append per-line age suffixes to file content.
|
||||
|
||||
Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
|
||||
suffix like ``← 30d`` indicating days since last modification.
|
||||
@@ -970,9 +1102,7 @@ class Dream:
|
||||
annotate fails, or the line count doesn't match the age count
|
||||
(which can happen with an uncommitted working-tree edit — better to
|
||||
skip annotation than to tag the wrong line).
|
||||
SOUL.md and USER.md are never annotated.
|
||||
"""
|
||||
file_path = "memory/MEMORY.md"
|
||||
try:
|
||||
ages = self.store.git.line_ages(file_path)
|
||||
except Exception:
|
||||
@@ -1007,156 +1137,3 @@ class Dream:
|
||||
result += "\n"
|
||||
return result
|
||||
|
||||
async def run(self) -> bool:
|
||||
"""Process unprocessed history entries. Returns True if work was done."""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
last_cursor = self.store.get_last_dream_cursor()
|
||||
entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
|
||||
if not entries:
|
||||
return False
|
||||
|
||||
batch = entries[: self.max_batch_size]
|
||||
logger.info(
|
||||
"Dream: processing {} entries (cursor {}→{}), batch={}",
|
||||
len(entries), last_cursor, batch[-1]["cursor"], len(batch),
|
||||
)
|
||||
|
||||
# Build history text for LLM — cap each entry so a legacy oversized
|
||||
# record (e.g. pre-#3412 raw_archive dump) can't blow up the prompt.
|
||||
history_text = "\n".join(
|
||||
f"[{e['timestamp']}] "
|
||||
f"{truncate_text(e['content'], self._HISTORY_ENTRY_PREVIEW_MAX_CHARS)}"
|
||||
for e in batch
|
||||
)
|
||||
|
||||
# Current file contents + per-line age annotations (MEMORY.md only).
|
||||
# Each file is capped in the *prompt preview* only; Phase 2 still sees
|
||||
# the full file via the read_file tool.
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
raw_memory = self.store.read_memory() or "(empty)"
|
||||
annotated_memory = (
|
||||
self._annotate_with_ages(raw_memory)
|
||||
if self.annotate_line_ages
|
||||
else raw_memory
|
||||
)
|
||||
current_memory = truncate_text(annotated_memory, self._MEMORY_FILE_MAX_CHARS)
|
||||
current_soul = truncate_text(
|
||||
self.store.read_soul() or "(empty)", self._SOUL_FILE_MAX_CHARS,
|
||||
)
|
||||
current_user = truncate_text(
|
||||
self.store.read_user() or "(empty)", self._USER_FILE_MAX_CHARS,
|
||||
)
|
||||
|
||||
file_context = (
|
||||
f"## Current Date\n{current_date}\n\n"
|
||||
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
|
||||
f"## Current SOUL.md ({len(current_soul)} chars)\n{current_soul}\n\n"
|
||||
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
|
||||
)
|
||||
|
||||
# Phase 1: Analyze (no skills list — dedup is Phase 2's job)
|
||||
phase1_prompt = (
|
||||
f"## Conversation History\n{history_text}\n\n{file_context}"
|
||||
)
|
||||
|
||||
try:
|
||||
phase1_response = await self.provider.chat_with_retry(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": render_template(
|
||||
"agent/dream_phase1.md",
|
||||
strip=True,
|
||||
stale_threshold_days=_STALE_THRESHOLD_DAYS,
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": phase1_prompt},
|
||||
],
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
analysis = phase1_response.content or ""
|
||||
logger.debug("Dream Phase 1 analysis ({} chars): {}", len(analysis), analysis[:500])
|
||||
except Exception:
|
||||
logger.exception("Dream Phase 1 failed")
|
||||
return False
|
||||
|
||||
# Phase 2: Delegate to AgentRunner with read_file / edit_file
|
||||
existing_skills = self._list_existing_skills()
|
||||
skills_section = ""
|
||||
if existing_skills:
|
||||
skills_section = (
|
||||
"\n\n## Existing Skills\n"
|
||||
+ "\n".join(f"- {s}" for s in existing_skills)
|
||||
)
|
||||
phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}"
|
||||
|
||||
tools = self._tools
|
||||
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": render_template(
|
||||
"agent/dream_phase2.md",
|
||||
strip=True,
|
||||
skill_creator_path=str(skill_creator_path),
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": phase2_prompt},
|
||||
]
|
||||
|
||||
try:
|
||||
result = await self._runner.run(AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model=self.model,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
fail_on_tool_error=False,
|
||||
))
|
||||
logger.debug(
|
||||
"Dream Phase 2 complete: stop_reason={}, tool_events={}",
|
||||
result.stop_reason, len(result.tool_events),
|
||||
)
|
||||
for ev in (result.tool_events or []):
|
||||
logger.info("Dream tool_event: name={}, status={}, detail={}", ev.get("name"), ev.get("status"), ev.get("detail", "")[:200])
|
||||
except Exception:
|
||||
logger.exception("Dream Phase 2 failed")
|
||||
result = None
|
||||
|
||||
# Build changelog from tool events
|
||||
changelog: list[str] = []
|
||||
if result and result.tool_events:
|
||||
for event in result.tool_events:
|
||||
if event["status"] == "ok":
|
||||
changelog.append(f"{event['name']}: {event['detail']}")
|
||||
|
||||
# Only advance cursor on successful completion to prevent silent loss
|
||||
if result and result.stop_reason == "completed":
|
||||
new_cursor = batch[-1]["cursor"]
|
||||
self.store.set_last_dream_cursor(new_cursor)
|
||||
logger.info(
|
||||
"Dream done: {} change(s), cursor advanced to {}",
|
||||
len(changelog), new_cursor,
|
||||
)
|
||||
else:
|
||||
reason = result.stop_reason if result else "exception"
|
||||
logger.warning(
|
||||
"Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle",
|
||||
reason,
|
||||
)
|
||||
|
||||
self.store.compact_history()
|
||||
|
||||
# Git auto-commit (only when there are actual changes)
|
||||
if changelog and self.store.git.is_initialized():
|
||||
ts = batch[-1]["timestamp"]
|
||||
summary = f"dream: {ts}, {len(changelog)} change(s)"
|
||||
commit_msg = f"{summary}\n\n{analysis.strip()}"
|
||||
sha = self.store.git.auto_commit(commit_msg)
|
||||
if sha:
|
||||
logger.info("Dream commit: {}", sha)
|
||||
|
||||
return True
|
||||
|
||||
+65
-14
@@ -8,7 +8,7 @@ import os
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -19,7 +19,9 @@ from nanobot.utils.file_edit_events import (
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_error_event,
|
||||
build_file_edit_start_event,
|
||||
prepare_file_edit_tracker,
|
||||
prepare_file_edit_tracker as _prepare_file_edit_tracker,
|
||||
prepare_file_edit_trackers,
|
||||
StreamingFileEditTracker,
|
||||
)
|
||||
from nanobot.utils.helpers import (
|
||||
IncrementalThinkExtractor,
|
||||
@@ -40,6 +42,7 @@ from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
build_finalization_retry_message,
|
||||
build_goal_continue_message,
|
||||
build_length_recovery_message,
|
||||
ensure_nonempty_tool_result,
|
||||
is_blank_text,
|
||||
@@ -57,11 +60,14 @@ _SNIP_SAFETY_BUFFER = 1024
|
||||
_MICROCOMPACT_KEEP_RECENT = 10
|
||||
_MICROCOMPACT_MIN_CHARS = 500
|
||||
_COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep",
|
||||
"web_search", "web_fetch", "list_dir",
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
_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)
|
||||
@@ -92,6 +98,8 @@ class AgentRunSpec:
|
||||
checkpoint_callback: Any | None = None
|
||||
injection_callback: Any | None = None
|
||||
llm_timeout_s: float | None = None
|
||||
goal_active_predicate: Callable[[], bool] | None = None
|
||||
goal_continue_message: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -162,6 +170,7 @@ class AgentRunner:
|
||||
*,
|
||||
phase: str = "after error",
|
||||
iteration: int | None = None,
|
||||
allow_goal_continue: bool = False,
|
||||
) -> tuple[bool, int]:
|
||||
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
||||
|
||||
@@ -173,6 +182,10 @@ class AgentRunner:
|
||||
if injection_cycles >= _MAX_INJECTION_CYCLES:
|
||||
return False, injection_cycles
|
||||
injections = await self._drain_injections(spec)
|
||||
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:
|
||||
return False, injection_cycles
|
||||
injection_cycles += 1
|
||||
@@ -470,6 +483,7 @@ class AgentRunner:
|
||||
spec, messages, assistant_message, injection_cycles,
|
||||
phase="after final response",
|
||||
iteration=iteration,
|
||||
allow_goal_continue=True,
|
||||
)
|
||||
if should_continue:
|
||||
had_injections = True
|
||||
@@ -629,6 +643,24 @@ class AgentRunner:
|
||||
)
|
||||
|
||||
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:
|
||||
async def _stream(delta: str) -> None:
|
||||
@@ -646,6 +678,7 @@ class AgentRunner:
|
||||
**kwargs,
|
||||
on_content_delta=_stream,
|
||||
on_thinking_delta=_thinking,
|
||||
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
|
||||
)
|
||||
elif wants_progress_streaming:
|
||||
stream_buf = ""
|
||||
@@ -675,6 +708,7 @@ class AgentRunner:
|
||||
coro = self.provider.chat_stream_with_retry(
|
||||
**kwargs,
|
||||
on_content_delta=_stream_progress,
|
||||
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
|
||||
)
|
||||
else:
|
||||
coro = self.provider.chat_with_retry(**kwargs)
|
||||
@@ -689,6 +723,14 @@ class AgentRunner:
|
||||
await coro if outer_timeout_s is None
|
||||
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:
|
||||
if outer_timeout_s is None:
|
||||
return LLMResponse(
|
||||
@@ -828,8 +870,8 @@ class AgentRunner:
|
||||
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||
)
|
||||
progress_callback = spec.progress_callback if emit_file_edit_events else None
|
||||
file_edit_tracker = (
|
||||
prepare_file_edit_tracker(
|
||||
file_edit_trackers = (
|
||||
prepare_file_edit_trackers(
|
||||
call_id=tool_call.id,
|
||||
tool_name=tool_call.name,
|
||||
tool=tool,
|
||||
@@ -839,13 +881,13 @@ class AgentRunner:
|
||||
if progress_callback is not None
|
||||
else None
|
||||
)
|
||||
if file_edit_tracker is not None and progress_callback is not 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:
|
||||
if tool is not None:
|
||||
@@ -855,10 +897,13 @@ class AgentRunner:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
if file_edit_tracker is not None and progress_callback is not None:
|
||||
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))],
|
||||
[
|
||||
build_file_edit_error_event(file_edit_tracker, str(exc))
|
||||
for file_edit_tracker in file_edit_trackers
|
||||
],
|
||||
)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
@@ -881,10 +926,13 @@ class AgentRunner:
|
||||
return payload, event, None
|
||||
|
||||
if isinstance(result, str) and result.startswith("Error"):
|
||||
if file_edit_tracker is not None and progress_callback is not None:
|
||||
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)],
|
||||
[
|
||||
build_file_edit_error_event(file_edit_tracker, result)
|
||||
for file_edit_tracker in file_edit_trackers
|
||||
],
|
||||
)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
@@ -904,10 +952,13 @@ class AgentRunner:
|
||||
return result + hint, event, RuntimeError(result)
|
||||
return result + hint, event, None
|
||||
|
||||
if file_edit_tracker is not None and progress_callback is not 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)],
|
||||
[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)
|
||||
|
||||
@@ -79,6 +79,7 @@ class SubagentManager:
|
||||
restrict_to_workspace: bool = False,
|
||||
disabled_skills: list[str] | 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,
|
||||
):
|
||||
defaults = AgentDefaults()
|
||||
@@ -95,7 +96,11 @@ class SubagentManager:
|
||||
if max_iterations is not None
|
||||
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._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
@@ -140,6 +145,7 @@ class SubagentManager:
|
||||
origin_chat_id: str = "direct",
|
||||
session_key: str | None = None,
|
||||
origin_message_id: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""Spawn a subagent to execute a task in the background."""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
@@ -155,7 +161,9 @@ class SubagentManager:
|
||||
self._task_statuses[task_id] = status
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
self._running_tasks[task_id] = bg_task
|
||||
if session_key:
|
||||
@@ -182,6 +190,7 @@ class SubagentManager:
|
||||
origin: dict[str, str],
|
||||
status: SubagentStatus,
|
||||
origin_message_id: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> None:
|
||||
"""Execute the subagent task and announce the result."""
|
||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||
@@ -208,6 +217,7 @@ class SubagentManager:
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model=self.model,
|
||||
temperature=temperature,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hook=_SubagentHook(task_id, status),
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""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 (find and replace text), add (append new content or create file), delete (remove text).",
|
||||
enum=["replace", "add", "delete"],
|
||||
),
|
||||
old_text=StringSchema(
|
||||
"Exact text to search for in the file. Required for replace and delete.",
|
||||
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/delete), and the 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] = {}
|
||||
deletes: set[Path] = set()
|
||||
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
|
||||
deletes.discard(source)
|
||||
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
|
||||
deletes.discard(source)
|
||||
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
|
||||
deletes.discard(source)
|
||||
added, deleted = _line_diff_stats(content, new_norm)
|
||||
summaries.append(
|
||||
_PatchSummary(
|
||||
action="update", path=path, added=added, deleted=deleted
|
||||
)
|
||||
)
|
||||
|
||||
elif action == "delete":
|
||||
old_text = edit.get("old_text") or ""
|
||||
if not old_text:
|
||||
raise _PatchError(f"old_text required for delete: {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}")
|
||||
|
||||
if norm_old == norm_content:
|
||||
deletes.add(source)
|
||||
writes.pop(source, None)
|
||||
added, deleted = 0, _text_line_count(content)
|
||||
summaries.append(
|
||||
_PatchSummary(
|
||||
action="delete", path=path, added=added, deleted=deleted
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_norm = (
|
||||
norm_content[:pos] + 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
|
||||
deletes.discard(source)
|
||||
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 set(writes) | deletes:
|
||||
backups[path] = path.read_bytes() if path.exists() else None
|
||||
|
||||
try:
|
||||
for path in deletes:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
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 set(writes) | deletes:
|
||||
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,127 @@
|
||||
"""Controlled runner for installed CLI Apps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.config.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:
|
||||
manager = CliAppManager(workspace=self.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=self.restrict_to_workspace,
|
||||
)
|
||||
except CliAppError as exc:
|
||||
return f"Error: {exc.message}"
|
||||
@@ -0,0 +1,592 @@
|
||||
"""Session support for long-running exec workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
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.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
|
||||
|
||||
|
||||
class _ExecSession:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
process: asyncio.subprocess.Process,
|
||||
command: str,
|
||||
cwd: str,
|
||||
timeout: int | None,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self.process = process
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
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,
|
||||
) -> 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,
|
||||
)
|
||||
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,
|
||||
) -> _SessionPoll:
|
||||
async with self._lock:
|
||||
await self._cleanup_locked()
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
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) -> 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,
|
||||
)
|
||||
for session_id, session in sorted(self._sessions.items())
|
||||
]
|
||||
|
||||
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 import shell
|
||||
|
||||
if shell._IS_WINDOWS:
|
||||
return await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||
args = [shell_program]
|
||||
if login and shell_program.rsplit("/", 1)[-1] in {"bash", "zsh"}:
|
||||
args.append("-l")
|
||||
args.extend(["-c", command])
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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()
|
||||
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}"
|
||||
@@ -132,6 +132,10 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
|
||||
minimum=1,
|
||||
),
|
||||
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"],
|
||||
)
|
||||
)
|
||||
@@ -154,7 +158,11 @@ class ReadFileTool(_FsTool):
|
||||
"Text output format: LINE_NUM|CONTENT. "
|
||||
"Images return visual content for analysis. "
|
||||
"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 force=true to re-read content even if unchanged. "
|
||||
"Reads exceeding ~128K chars are truncated."
|
||||
)
|
||||
|
||||
@@ -162,7 +170,15 @@ class ReadFileTool(_FsTool):
|
||||
def read_only(self) -> bool:
|
||||
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:
|
||||
if not path:
|
||||
return "Error reading file: Unknown path"
|
||||
@@ -202,7 +218,13 @@ class ReadFileTool(_FsTool):
|
||||
current_mtime = os.path.getmtime(fp)
|
||||
except OSError:
|
||||
current_mtime = 0.0
|
||||
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
|
||||
if (
|
||||
not force
|
||||
and entry
|
||||
and entry.can_dedup
|
||||
and entry.offset == offset
|
||||
and entry.limit == limit
|
||||
):
|
||||
if current_mtime != entry.mtime:
|
||||
# File was modified externally - force full read and mark as not dedupable
|
||||
entry.can_dedup = False
|
||||
@@ -365,9 +387,10 @@ class WriteFileTool(_FsTool):
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Write content to a file. Overwrites if the file already exists; "
|
||||
"creates parent directories as needed. "
|
||||
"For partial edits, prefer edit_file instead."
|
||||
"Create a new file or intentionally replace an entire file with "
|
||||
"the provided content. Overwrites existing files and creates parent "
|
||||
"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:
|
||||
@@ -657,6 +680,24 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
||||
old_text=StringSchema("The text to find and replace"),
|
||||
new_text=StringSchema("The text to replace with"),
|
||||
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"],
|
||||
)
|
||||
)
|
||||
@@ -674,10 +715,13 @@ class EditFileTool(_FsTool):
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Edit a file by replacing old_text with new_text. "
|
||||
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
|
||||
"If old_text matches multiple times, you must provide more context "
|
||||
"or set replace_all=true. Shows a diff of the closest match on failure."
|
||||
"Perform a small, exact replacement in one file by replacing "
|
||||
"old_text with new_text. Use this for narrow text substitutions "
|
||||
"with old_text copied from read_file. For multi-file, structural, "
|
||||
"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
|
||||
@@ -688,7 +732,8 @@ class EditFileTool(_FsTool):
|
||||
async def execute(
|
||||
self, path: str | None = None, old_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:
|
||||
try:
|
||||
if not path:
|
||||
@@ -697,10 +742,12 @@ class EditFileTool(_FsTool):
|
||||
raise ValueError("Unknown old_text")
|
||||
if new_text is None:
|
||||
raise ValueError("Unknown new_text")
|
||||
|
||||
# .ipynb detection
|
||||
if path.endswith(".ipynb"):
|
||||
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
|
||||
if occurrence is not None and occurrence < 1:
|
||||
return "Error: occurrence must be >= 1."
|
||||
if line_hint is not None and line_hint < 1:
|
||||
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)
|
||||
|
||||
@@ -743,15 +790,42 @@ class EditFileTool(_FsTool):
|
||||
if not matches:
|
||||
return self._not_found_msg(old_text, content, path)
|
||||
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:
|
||||
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 ""
|
||||
if occurrence is not None:
|
||||
if occurrence > count:
|
||||
return (
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
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 (
|
||||
f"Warning: old_text appears {count} times{location_hint}. "
|
||||
"Provide more context to make it unique, or set replace_all=true."
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
f"old_text appears {count} time."
|
||||
)
|
||||
|
||||
norm_new = new_text.replace("\r\n", "\n")
|
||||
@@ -760,7 +834,17 @@ class EditFileTool(_FsTool):
|
||||
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
|
||||
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
|
||||
for match in reversed(selected):
|
||||
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
|
||||
|
||||
@@ -17,9 +17,9 @@ from nanobot.agent.tools.schema import (
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.providers.image_generation import (
|
||||
AIHubMixImageGenerationClient,
|
||||
ImageGenerationError,
|
||||
OpenRouterImageGenerationClient,
|
||||
ImageGenerationProvider,
|
||||
get_image_gen_provider,
|
||||
)
|
||||
from nanobot.utils.artifacts import (
|
||||
ArtifactError,
|
||||
@@ -117,27 +117,18 @@ class ImageGenerationTool(Tool):
|
||||
def _provider_config(self) -> ProviderConfig | None:
|
||||
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()
|
||||
cls = get_image_gen_provider(self.config.provider)
|
||||
if cls is None:
|
||||
return None
|
||||
kwargs = {
|
||||
"api_key": provider.api_key if provider else None,
|
||||
"api_base": provider.api_base if provider else None,
|
||||
"extra_headers": provider.extra_headers if provider else None,
|
||||
"extra_body": provider.extra_body if provider else None,
|
||||
}
|
||||
if self.config.provider == "openrouter":
|
||||
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."
|
||||
return cls(**kwargs)
|
||||
|
||||
def _resolve_reference_image(self, value: str) -> str:
|
||||
raw_path = Path(value).expanduser()
|
||||
@@ -176,9 +167,6 @@ class ImageGenerationTool(Tool):
|
||||
client = self._provider_client()
|
||||
if client is None:
|
||||
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
|
||||
if requested > self.config.max_images_per_turn:
|
||||
|
||||
+279
-1
@@ -6,13 +6,20 @@ import re
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from contextlib import AsyncExitStack, suppress
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
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.
|
||||
# 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.).
|
||||
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
||||
_SANITIZE_RE = re.compile(r"_+")
|
||||
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
|
||||
|
||||
|
||||
def _sanitize_name(name: str) -> str:
|
||||
@@ -503,6 +511,7 @@ async def connect_mcp_servers(
|
||||
command=command,
|
||||
args=args,
|
||||
env=env,
|
||||
cwd=cfg.cwd or None,
|
||||
)
|
||||
read, write = await server_stack.enter_async_context(stdio_client(params))
|
||||
elif transport_type == "sse":
|
||||
@@ -662,3 +671,272 @@ async def connect_mcp_servers(
|
||||
server_stacks[result[0]] = result[1]
|
||||
|
||||
return server_stacks
|
||||
|
||||
|
||||
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return persisted session kwargs for MCP preset attachments."""
|
||||
mcp_presets = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
|
||||
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
|
||||
|
||||
|
||||
def runtime_lines(
|
||||
message: Any,
|
||||
*,
|
||||
available_server_names: set[str] | None = None,
|
||||
configured_server_names: set[str] | None = None,
|
||||
connected_server_names: set[str] | None = None,
|
||||
skip: bool = False,
|
||||
) -> list[str]:
|
||||
"""Return model-visible MCP preset annotations for the current turn."""
|
||||
if skip:
|
||||
return []
|
||||
if configured_server_names is None:
|
||||
configured_server_names = available_server_names
|
||||
if connected_server_names is None:
|
||||
connected_server_names = available_server_names
|
||||
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||
structured = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None
|
||||
if not isinstance(structured, list):
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
for item in structured[:8]:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
raw_name = str(item.get("name") or "").strip().lower()
|
||||
if not raw_name:
|
||||
continue
|
||||
display = str(item.get("display_name") or raw_name).strip() or raw_name
|
||||
transport = str(item.get("transport") or "mcp").strip() or "mcp"
|
||||
prefix = f"mcp_{raw_name}_"
|
||||
if configured_server_names is not None and raw_name not in configured_server_names:
|
||||
lines.append(
|
||||
"MCP Preset Attachment: "
|
||||
f"@{raw_name} ({display}; transport={transport}) is configured in WebUI Settings, "
|
||||
"but this gateway has not loaded the latest MCP settings yet. "
|
||||
f"Tools with prefix `{prefix}` may not be available yet; if they are missing, "
|
||||
"tell the user to restart nanobot."
|
||||
)
|
||||
continue
|
||||
if connected_server_names is not None and raw_name not in connected_server_names:
|
||||
lines.append(
|
||||
"MCP Preset Attachment: "
|
||||
f"@{raw_name} ({display}; transport={transport}) is configured, "
|
||||
"but its MCP connection is not currently live. "
|
||||
f"Tools with prefix `{prefix}` may be unavailable; tell the user to open Settings, "
|
||||
"run the preset test, and restart nanobot only if hot reload is unavailable."
|
||||
)
|
||||
continue
|
||||
lines.append(
|
||||
"MCP Preset Attachment: "
|
||||
f"@{raw_name} ({display}; transport={transport}; tool_prefix={prefix}). "
|
||||
f"Prefer available tools whose names start with `{prefix}` for this request; "
|
||||
"do not substitute shell commands for this MCP integration unless the user asks."
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||
"""Connect configured MCP servers that are not currently live."""
|
||||
missing_servers = {
|
||||
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
|
||||
}
|
||||
if state._mcp_connecting or not missing_servers:
|
||||
return
|
||||
state._mcp_connecting = True
|
||||
try:
|
||||
connected = await connect_mcp_servers(missing_servers, registry)
|
||||
state._mcp_stacks.update(connected)
|
||||
state._mcp_connected = bool(state._mcp_stacks)
|
||||
if connected:
|
||||
logger.info("MCP connected servers: {}", sorted(connected))
|
||||
else:
|
||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("MCP connection cancelled (will retry next message)")
|
||||
state._mcp_connected = bool(state._mcp_stacks)
|
||||
except BaseException as e:
|
||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||
state._mcp_connected = bool(state._mcp_stacks)
|
||||
finally:
|
||||
state._mcp_connecting = False
|
||||
|
||||
|
||||
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"""Reconcile live MCP connections with the current config file."""
|
||||
async with _reload_lock(state):
|
||||
try:
|
||||
from nanobot.config.loader import (load_config,
|
||||
resolve_config_env_vars)
|
||||
|
||||
config = resolve_config_env_vars(load_config())
|
||||
next_servers = dict(config.tools.mcp_servers)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
|
||||
"requires_restart": True,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
current_servers = dict(state._mcp_servers)
|
||||
current_names = set(current_servers)
|
||||
next_names = set(next_servers)
|
||||
removed = sorted(current_names - next_names)
|
||||
added = sorted(next_names - current_names)
|
||||
changed = sorted(
|
||||
name
|
||||
for name in current_names & next_names
|
||||
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
|
||||
)
|
||||
|
||||
tools_removed = 0
|
||||
for name in [*removed, *changed]:
|
||||
tools_removed += _unregister_server_tools(state, registry, name)
|
||||
await _close_server(state, name)
|
||||
|
||||
state._mcp_servers = next_servers
|
||||
retry_missing = sorted(
|
||||
name
|
||||
for name in next_names
|
||||
if name not in state._mcp_stacks and name not in set(added) | set(changed)
|
||||
)
|
||||
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||
connected: dict[str, AsyncExitStack] = {}
|
||||
if to_connect:
|
||||
connected = await connect_mcp_servers(to_connect, registry)
|
||||
state._mcp_stacks.update(connected)
|
||||
|
||||
state._mcp_connected = bool(state._mcp_stacks)
|
||||
failed = sorted(set(to_connect) - set(connected))
|
||||
unchanged = not removed and not added and not changed and not retry_missing
|
||||
ok = not failed
|
||||
if failed:
|
||||
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
|
||||
elif unchanged:
|
||||
message = "MCP config is already live."
|
||||
elif retry_missing and not added and not changed and not removed:
|
||||
message = "MCP connections refreshed without restarting nanobot."
|
||||
else:
|
||||
message = "MCP config reloaded without restarting nanobot."
|
||||
|
||||
logger.info(
|
||||
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
|
||||
added,
|
||||
changed,
|
||||
removed,
|
||||
retry_missing,
|
||||
sorted(connected),
|
||||
failed,
|
||||
tools_removed,
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"message": message,
|
||||
"added": added,
|
||||
"changed": changed,
|
||||
"removed": removed,
|
||||
"retried": retry_missing,
|
||||
"connected": sorted(state._mcp_stacks),
|
||||
"configured": sorted(state._mcp_servers),
|
||||
"failed": failed,
|
||||
"tools_removed": tools_removed,
|
||||
"requires_restart": False,
|
||||
}
|
||||
|
||||
|
||||
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
|
||||
"""Ask the running agent loop to reconcile live MCP connections."""
|
||||
loop = asyncio.get_running_loop()
|
||||
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||
await bus.publish_inbound(
|
||||
InboundMessage(
|
||||
channel="system",
|
||||
sender_id="webui-settings",
|
||||
chat_id="runtime",
|
||||
content=RUNTIME_CONTROL_MCP_RELOAD,
|
||||
metadata={
|
||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
|
||||
RUNTIME_CONTROL_ACK: ack,
|
||||
},
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await asyncio.wait_for(ack, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
return result if isinstance(result, dict) else {
|
||||
"ok": False,
|
||||
"message": "MCP hot reload returned an unexpected response.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
|
||||
|
||||
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
|
||||
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
|
||||
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
|
||||
if control != RUNTIME_CONTROL_MCP_RELOAD:
|
||||
return False
|
||||
|
||||
ack = metadata.get(RUNTIME_CONTROL_ACK)
|
||||
try:
|
||||
result = await reload_servers(state, registry)
|
||||
except Exception as exc:
|
||||
logger.exception("MCP hot reload failed")
|
||||
result = {
|
||||
"ok": False,
|
||||
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
|
||||
"requires_restart": True,
|
||||
"error": str(exc),
|
||||
}
|
||||
if isinstance(ack, asyncio.Future) and not ack.done():
|
||||
ack.set_result(result)
|
||||
return True
|
||||
|
||||
|
||||
def _reload_lock(state: Any) -> asyncio.Lock:
|
||||
try:
|
||||
return _RELOAD_LOCKS[state]
|
||||
except KeyError:
|
||||
lock = asyncio.Lock()
|
||||
_RELOAD_LOCKS[state] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _server_signature(cfg: Any) -> Any:
|
||||
if hasattr(cfg, "model_dump"):
|
||||
return cfg.model_dump(mode="json")
|
||||
return cfg
|
||||
|
||||
|
||||
def _tool_prefix(server_name: str) -> str:
|
||||
safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in server_name)
|
||||
while "__" in safe_name:
|
||||
safe_name = safe_name.replace("__", "_")
|
||||
return f"mcp_{safe_name}_"
|
||||
|
||||
|
||||
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
|
||||
prefix = _tool_prefix(server_name)
|
||||
removed = 0
|
||||
for tool_name in list(registry.tool_names):
|
||||
if tool_name.startswith(prefix):
|
||||
registry.unregister(tool_name)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
async def _close_server(state: Any, server_name: str) -> None:
|
||||
stack = state._mcp_stacks.pop(server_name, None)
|
||||
if stack is None:
|
||||
return
|
||||
try:
|
||||
await stack.aclose()
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||
|
||||
@@ -31,8 +31,8 @@ from nanobot.config.paths import get_workspace_path
|
||||
media=ArraySchema(
|
||||
StringSchema(""),
|
||||
description=(
|
||||
"Optional list of existing file paths to attach for proactive or cross-channel delivery. "
|
||||
"Do not use this to resend generate_image outputs in the current chat."
|
||||
"Optional list of existing file paths to attach. "
|
||||
"Use artifact paths returned by generate_image here when delivering generated images."
|
||||
),
|
||||
),
|
||||
buttons=ArraySchema(
|
||||
@@ -140,8 +140,8 @@ class MessageTool(Tool, ContextAware):
|
||||
"Do not use this for the normal reply in the current chat: answer naturally instead. "
|
||||
"If channel/chat_id would target the current runtime conversation, do not call this tool "
|
||||
"unless the user explicitly asked you to proactively send an existing file attachment. "
|
||||
"When generate_image creates images in the current chat, the final assistant reply "
|
||||
"automatically attaches them; do not call message just to announce or resend them. "
|
||||
"When generate_image creates images in the current chat, use the message tool "
|
||||
"with the artifact paths in the media parameter to deliver the images to the user. "
|
||||
"For proactive attachment delivery, use the 'media' parameter with file paths. "
|
||||
"Do NOT use read_file to send files — that only reads content for your own analysis."
|
||||
)
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""Search tools: grep."""
|
||||
"""Search tools: file discovery and grep."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Iterable, TypeVar
|
||||
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
||||
|
||||
_DEFAULT_HEAD_LIMIT = 250
|
||||
_DEFAULT_FILE_HEAD_LIMIT = 200
|
||||
T = TypeVar("T")
|
||||
_TYPE_GLOB_MAP = {
|
||||
"py": ("*.py", "*.pyi"),
|
||||
@@ -88,6 +89,14 @@ def _matches_type(name: str, file_type: str | None) -> bool:
|
||||
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
|
||||
|
||||
|
||||
def _matches_query(rel_path: str, query: str | None) -> bool:
|
||||
if not query:
|
||||
return True
|
||||
haystack = rel_path.lower()
|
||||
terms = [part for part in query.lower().split() if part]
|
||||
return all(term in haystack for term in terms)
|
||||
|
||||
|
||||
class _SearchTool(_FsTool):
|
||||
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
|
||||
|
||||
@@ -109,6 +118,163 @@ class _SearchTool(_FsTool):
|
||||
yield current / filename
|
||||
|
||||
|
||||
class FindFilesTool(_SearchTool):
|
||||
"""Find files by path fragment, glob, or type."""
|
||||
_scopes = {"core", "subagent"}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "find_files"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Find files by path fragment, glob, or file type. "
|
||||
"Use this before read_file when you need to locate files, and "
|
||||
"prefer it over shell find/ls for ordinary workspace discovery. "
|
||||
"Returns workspace-relative paths and skips common dependency/build "
|
||||
"directories."
|
||||
)
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory or file to search in (default '.')",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional case-insensitive path fragment search. "
|
||||
"Whitespace-separated terms must all be present."
|
||||
),
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
|
||||
},
|
||||
"include_dirs": {
|
||||
"type": "boolean",
|
||||
"description": "Include matching directories as well as files (default false)",
|
||||
},
|
||||
"sort": {
|
||||
"type": "string",
|
||||
"enum": ["path", "modified"],
|
||||
"description": "Sort by path or most recently modified first (default path)",
|
||||
},
|
||||
"head_limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
|
||||
"minimum": 0,
|
||||
"maximum": 1000,
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Skip the first N results before applying head_limit",
|
||||
"minimum": 0,
|
||||
"maximum": 100000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _iter_paths(self, root: Path, *, include_dirs: bool) -> Iterable[Path]:
|
||||
if root.is_file():
|
||||
yield root
|
||||
return
|
||||
if include_dirs:
|
||||
yield root
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
|
||||
current = Path(dirpath)
|
||||
if include_dirs and current != root:
|
||||
yield current
|
||||
for filename in sorted(filenames):
|
||||
yield current / filename
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
path: str = ".",
|
||||
query: str | None = None,
|
||||
glob: str | None = None,
|
||||
type: str | None = None,
|
||||
include_dirs: bool = False,
|
||||
sort: str = "path",
|
||||
head_limit: int | None = None,
|
||||
offset: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
try:
|
||||
target = self._resolve(path or ".")
|
||||
if not target.exists():
|
||||
return f"Error: Path not found: {path}"
|
||||
if not (target.is_dir() or target.is_file()):
|
||||
return f"Error: Unsupported path: {path}"
|
||||
|
||||
if sort not in {"path", "modified"}:
|
||||
return "Error: sort must be 'path' or 'modified'"
|
||||
|
||||
limit = (
|
||||
_DEFAULT_FILE_HEAD_LIMIT
|
||||
if head_limit is None
|
||||
else None if head_limit == 0 else head_limit
|
||||
)
|
||||
root = target if target.is_dir() else target.parent
|
||||
matches: list[tuple[str, float]] = []
|
||||
|
||||
for candidate in self._iter_paths(target, include_dirs=include_dirs):
|
||||
if candidate.is_dir() and not include_dirs:
|
||||
continue
|
||||
rel_path = candidate.relative_to(root).as_posix()
|
||||
display_path = self._display_path(candidate, root)
|
||||
name = candidate.name
|
||||
|
||||
if glob and not _match_glob(rel_path, name, glob):
|
||||
continue
|
||||
if candidate.is_file() and not _matches_type(name, type):
|
||||
continue
|
||||
if candidate.is_dir() and type:
|
||||
continue
|
||||
if not _matches_query(display_path, query):
|
||||
continue
|
||||
try:
|
||||
mtime = candidate.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
suffix = "/" if candidate.is_dir() else ""
|
||||
matches.append((display_path + suffix, mtime))
|
||||
|
||||
if sort == "modified":
|
||||
matches.sort(key=lambda item: (-item[1], item[0]))
|
||||
else:
|
||||
matches.sort(key=lambda item: item[0])
|
||||
|
||||
paths = [item[0] for item in matches]
|
||||
paged, truncated = _paginate(paths, limit, offset)
|
||||
if not paged:
|
||||
return "No files found"
|
||||
|
||||
result = "\n".join(paged)
|
||||
note = _pagination_note(limit, offset, truncated)
|
||||
if note:
|
||||
result += "\n\n" + note
|
||||
return result
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
return f"Error finding files: {e}"
|
||||
|
||||
|
||||
class GrepTool(_SearchTool):
|
||||
"""Search file contents using a regex-like pattern."""
|
||||
_scopes = {"core", "subagent"}
|
||||
@@ -125,7 +291,8 @@ class GrepTool(_SearchTool):
|
||||
return (
|
||||
"Search file contents with a regex pattern. "
|
||||
"Default output_mode is files_with_matches (file paths only); "
|
||||
"use content mode for matching lines with context. "
|
||||
"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."
|
||||
)
|
||||
|
||||
|
||||
+239
-53
@@ -8,6 +8,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,8 +16,17 @@ from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.exec_session import (
|
||||
DEFAULT_MAX_OUTPUT_CHARS,
|
||||
DEFAULT_YIELD_MS,
|
||||
DEFAULT_EXEC_SESSION_MANAGER,
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_YIELD_MS,
|
||||
clamp_session_int,
|
||||
format_session_poll,
|
||||
)
|
||||
from nanobot.agent.tools.sandbox import wrap_command
|
||||
from nanobot.agent.tools.schema import 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.schema import Base
|
||||
|
||||
@@ -36,7 +46,7 @@ _WORKSPACE_BOUNDARY_NOTE = (
|
||||
class ExecToolConfig(Base):
|
||||
"""Shell exec tool configuration."""
|
||||
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 = ""
|
||||
sandbox: str = ""
|
||||
allowed_env_keys: list[str] = Field(default_factory=list)
|
||||
@@ -44,10 +54,22 @@ class ExecToolConfig(Base):
|
||||
deny_patterns: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PreparedCommand:
|
||||
command: str
|
||||
cwd: str
|
||||
env: dict[str, str]
|
||||
timeout: int | None
|
||||
shell_program: str | None
|
||||
login: bool
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
command=StringSchema("The shell command to execute"),
|
||||
cmd=StringSchema("Compatibility alias for command"),
|
||||
working_dir=StringSchema("Optional working directory for the command"),
|
||||
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||
timeout=IntegerSchema(
|
||||
60,
|
||||
description=(
|
||||
@@ -57,7 +79,44 @@ class ExecToolConfig(Base):
|
||||
minimum=1,
|
||||
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):
|
||||
@@ -98,6 +157,7 @@ class ExecTool(Tool):
|
||||
sandbox: str = "",
|
||||
path_append: str = "",
|
||||
allowed_env_keys: list[str] | None = None,
|
||||
session_manager: Any | None = None,
|
||||
):
|
||||
self.timeout = timeout
|
||||
self.working_dir = working_dir
|
||||
@@ -125,6 +185,7 @@ class ExecTool(Tool):
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.path_append = path_append
|
||||
self.allowed_env_keys = allowed_env_keys or []
|
||||
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -150,10 +211,15 @@ class ExecTool(Tool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Execute a shell command and return its output. "
|
||||
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
|
||||
"and grep/glob over shell find/grep. "
|
||||
"Use this for tests, builds, package commands, git commands, and "
|
||||
"other process execution. Prefer read_file/find_files/grep for "
|
||||
"inspection and apply_patch/write_file/edit_file for file changes "
|
||||
"instead of cat, shell find/grep, echo, or sed. "
|
||||
"Use -y or --yes flags to avoid interactive prompts. "
|
||||
"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
|
||||
@@ -161,9 +227,125 @@ class ExecTool(Tool):
|
||||
return True
|
||||
|
||||
async def execute(
|
||||
self, command: str, working_dir: str | None = None,
|
||||
timeout: int | None = None, **kwargs: Any,
|
||||
self, command: str | None = None, cmd: str | None = None,
|
||||
working_dir: str | None = None, workdir: str | None = None,
|
||||
timeout: int | None = None, shell: str | None = None,
|
||||
login: bool | None = None, yield_time_ms: int | None = None,
|
||||
max_output_chars: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
command = command or cmd
|
||||
working_dir = working_dir or workdir
|
||||
if not command:
|
||||
return "Error: Missing command. Provide command or cmd."
|
||||
if max_output_chars is None:
|
||||
max_output_chars = max_output_tokens
|
||||
|
||||
prepared = self._prepare_command(command, working_dir, timeout, shell, login)
|
||||
if isinstance(prepared, str):
|
||||
return prepared
|
||||
|
||||
if yield_time_ms is not None:
|
||||
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
||||
|
||||
try:
|
||||
process = await self._spawn(
|
||||
prepared.command,
|
||||
prepared.cwd,
|
||||
prepared.env,
|
||||
prepared.shell_program,
|
||||
prepared.login,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=prepared.timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._kill_process(process)
|
||||
return f"Error: Command timed out after {prepared.timeout} seconds"
|
||||
except asyncio.CancelledError:
|
||||
await self._kill_process(process)
|
||||
raise
|
||||
|
||||
output_parts = []
|
||||
|
||||
if stdout:
|
||||
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
||||
|
||||
if stderr:
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||
if stderr_text.strip():
|
||||
output_parts.append(f"STDERR:\n{stderr_text}")
|
||||
|
||||
output_parts.append(f"\nExit code: {process.returncode}")
|
||||
|
||||
result = "\n".join(output_parts) if output_parts else "(no output)"
|
||||
|
||||
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
||||
if len(result) > max_len:
|
||||
half = max_len // 2
|
||||
result = (
|
||||
result[:half]
|
||||
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
|
||||
+ result[-half:]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
|
||||
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),
|
||||
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:
|
||||
cwd = working_dir or self.working_dir or os.getcwd()
|
||||
|
||||
# Prevent an LLM-supplied working_dir from escaping the configured
|
||||
@@ -201,7 +383,7 @@ class ExecTool(Tool):
|
||||
command = wrap_command(self.sandbox, command, workspace, cwd)
|
||||
cwd = str(Path(workspace).resolve())
|
||||
|
||||
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
||||
effective_timeout = self._resolve_timeout(timeout)
|
||||
env = self._build_env()
|
||||
|
||||
if self.path_append:
|
||||
@@ -211,52 +393,24 @@ class ExecTool(Tool):
|
||||
env["NANOBOT_PATH_APPEND"] = self.path_append
|
||||
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
|
||||
|
||||
try:
|
||||
process = await self._spawn(command, cwd, env)
|
||||
shell_program, shell_error = self._resolve_shell(shell)
|
||||
if shell_error:
|
||||
return shell_error
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=effective_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._kill_process(process)
|
||||
return f"Error: Command timed out after {effective_timeout} seconds"
|
||||
except asyncio.CancelledError:
|
||||
await self._kill_process(process)
|
||||
raise
|
||||
|
||||
output_parts = []
|
||||
|
||||
if stdout:
|
||||
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
||||
|
||||
if stderr:
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||
if stderr_text.strip():
|
||||
output_parts.append(f"STDERR:\n{stderr_text}")
|
||||
|
||||
output_parts.append(f"\nExit code: {process.returncode}")
|
||||
|
||||
result = "\n".join(output_parts) if output_parts else "(no output)"
|
||||
|
||||
max_len = self._MAX_OUTPUT
|
||||
if len(result) > max_len:
|
||||
half = max_len // 2
|
||||
result = (
|
||||
result[:half]
|
||||
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
|
||||
+ result[-half:]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
return _PreparedCommand(
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=effective_timeout,
|
||||
shell_program=shell_program,
|
||||
login=True if login is None else login,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _spawn(
|
||||
command: str, cwd: str, env: dict[str, str],
|
||||
shell_program: str | None = None,
|
||||
login: bool = True,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Launch *command* in a platform-appropriate shell."""
|
||||
if _IS_WINDOWS:
|
||||
@@ -266,20 +420,52 @@ class ExecTool(Tool):
|
||||
# the raw command string to COMSPEC without re-quoting.
|
||||
return await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
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(
|
||||
bash, "-l", "-c", command,
|
||||
*args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
|
||||
if not shell:
|
||||
return None, None
|
||||
if _IS_WINDOWS:
|
||||
return None, "Error: shell parameter is not supported on Windows"
|
||||
if "\0" in shell or "\n" in shell or "\r" in shell:
|
||||
return None, "Error: shell contains invalid characters"
|
||||
allowed = {"sh", "bash", "zsh"}
|
||||
path = Path(shell).expanduser()
|
||||
if path.is_absolute():
|
||||
if path.name not in allowed:
|
||||
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||
if not path.is_file() or not os.access(path, os.X_OK):
|
||||
return None, f"Error: shell is not executable: {shell}"
|
||||
return str(path), None
|
||||
if "/" in shell or "\\" in shell:
|
||||
return None, "Error: shell must be a shell name or absolute path"
|
||||
if shell not in allowed:
|
||||
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||
resolved = shutil.which(shell)
|
||||
if not resolved:
|
||||
return None, f"Error: shell not found: {shell}"
|
||||
return resolved, None
|
||||
|
||||
@staticmethod
|
||||
async def _kill_process(process: asyncio.subprocess.Process) -> None:
|
||||
"""Kill a subprocess and reap it to prevent zombies."""
|
||||
@@ -416,7 +602,7 @@ class ExecTool(Tool):
|
||||
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
|
||||
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
||||
win_paths = re.findall(
|
||||
r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||
command
|
||||
)
|
||||
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
@@ -17,6 +17,15 @@ if TYPE_CHECKING:
|
||||
tool_parameters_schema(
|
||||
task=StringSchema("The task for the subagent to complete"),
|
||||
label=StringSchema("Optional short label for the task (for display)"),
|
||||
temperature=NumberSchema(
|
||||
description=(
|
||||
"Optional sampling temperature for the subagent "
|
||||
"(0.0 = deterministic, higher = more creative). "
|
||||
"Defaults to the provider's configured temperature."
|
||||
),
|
||||
minimum=0.0,
|
||||
maximum=2.0,
|
||||
),
|
||||
required=["task"],
|
||||
)
|
||||
)
|
||||
@@ -58,7 +67,13 @@ class SpawnTool(Tool, ContextAware):
|
||||
"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."""
|
||||
running = self._manager.get_running_count()
|
||||
limit = self._manager.max_concurrent_subagents
|
||||
@@ -75,4 +90,5 @@ class SpawnTool(Tool, ContextAware):
|
||||
origin_chat_id=self._origin_chat_id.get(),
|
||||
session_key=self._session_key.get(),
|
||||
origin_message_id=self._origin_message_id.get(),
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
+99
-18
@@ -8,7 +8,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
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]:
|
||||
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
|
||||
from nanobot.security.network import validate_url_target
|
||||
|
||||
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:
|
||||
"""Format provider results into shared plaintext output."""
|
||||
if not items:
|
||||
@@ -488,19 +561,26 @@ class WebFetchTool(Tool):
|
||||
|
||||
# Detect and fetch images directly to avoid Jina's textual image captioning
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=self.proxy, follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=15.0) as client:
|
||||
async with client.stream("GET", url, headers={"User-Agent": self.user_agent}) as r:
|
||||
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)
|
||||
async with httpx.AsyncClient(proxy=self.proxy, timeout=15.0) as client:
|
||||
r, stream, redirect_error = await _stream_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)
|
||||
|
||||
try:
|
||||
ctype = r.headers.get("content-type", "")
|
||||
if ctype.startswith("image/"):
|
||||
r.raise_for_status()
|
||||
raw = await r.aread()
|
||||
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:
|
||||
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
|
||||
|
||||
@@ -549,23 +629,22 @@ class WebFetchTool(Tool):
|
||||
|
||||
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
|
||||
"""Local fallback using readability-lxml."""
|
||||
from readability import Document
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
max_redirects=MAX_REDIRECTS,
|
||||
timeout=30.0,
|
||||
proxy=self.proxy,
|
||||
) 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()
|
||||
|
||||
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", "")
|
||||
if ctype.startswith("image/"):
|
||||
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
|
||||
@@ -573,6 +652,8 @@ class WebFetchTool(Tool):
|
||||
if "application/json" in ctype:
|
||||
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")):
|
||||
from readability import Document
|
||||
|
||||
doc = Document(r.text)
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
class InboundMessage:
|
||||
@@ -45,4 +51,3 @@ class OutboundMessage:
|
||||
media: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
buttons: list[list[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -70,34 +70,47 @@ class ChannelManager:
|
||||
|
||||
def _init_channels(self) -> None:
|
||||
"""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_key = self._resolve_transcription_key(transcription_provider)
|
||||
transcription_base = self._resolve_transcription_base(transcription_provider)
|
||||
transcription_language = self.config.channels.transcription_language
|
||||
|
||||
for name, cls in discover_all().items():
|
||||
# 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)
|
||||
if section is None:
|
||||
continue
|
||||
enabled = (
|
||||
if (
|
||||
section.get("enabled", False)
|
||||
if isinstance(section, dict)
|
||||
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
|
||||
try:
|
||||
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 self._session_manager is not None:
|
||||
kwargs["session_manager"] = self._session_manager
|
||||
static_path = _default_webui_dist()
|
||||
if static_path is not None:
|
||||
kwargs["static_dist_path"] = static_path
|
||||
kwargs["workspace_path"] = self.config.workspace_path
|
||||
if self._webui_runtime_model_name is not None:
|
||||
kwargs["runtime_model_name"] = self._webui_runtime_model_name
|
||||
channel = cls(section, self.bus, **kwargs)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Auto-discovery for built-in channel modules and external plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
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."""
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
plugins: dict[str, type[BaseChannel]] = {}
|
||||
for ep in entry_points(group="nanobot.channels"):
|
||||
if enabled_names is not None and ep.name not in enabled_names:
|
||||
continue
|
||||
try:
|
||||
cls = ep.load()
|
||||
plugins[ep.name] = cls
|
||||
@@ -51,21 +52,44 @@ def discover_plugins() -> dict[str, type[BaseChannel]]:
|
||||
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]]:
|
||||
"""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.
|
||||
"""
|
||||
builtin: dict[str, type[BaseChannel]] = {}
|
||||
for modname in discover_channel_names():
|
||||
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}
|
||||
names = discover_channel_names()
|
||||
return discover_enabled(set(names), _names=names, _include_all_external=True)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+287
-234
@@ -30,22 +30,61 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.command.builtin import builtin_command_palette
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.paths import get_media_dir, get_workspace_path
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.media_decode import (
|
||||
FileSizeExceeded,
|
||||
save_base64_data_url,
|
||||
)
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||
from nanobot.utils.webui_thread_disk import delete_webui_thread
|
||||
from nanobot.utils.webui_transcript import append_transcript_object, build_webui_thread_response
|
||||
from nanobot.utils.webui_turn_helpers import websocket_turn_wall_started_at
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
create_model_configuration,
|
||||
settings_payload,
|
||||
update_agent_settings,
|
||||
update_image_generation_settings,
|
||||
update_provider_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
from nanobot.webui.cli_apps_api import (
|
||||
cli_apps_action,
|
||||
cli_apps_payload,
|
||||
normalize_cli_app_mentions,
|
||||
)
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
mcp_presets_settings_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import (
|
||||
read_webui_sidebar_state,
|
||||
write_webui_sidebar_state,
|
||||
)
|
||||
from nanobot.webui.thread_disk import delete_webui_thread
|
||||
from nanobot.webui.transcript import (
|
||||
append_transcript_object,
|
||||
build_webui_thread_response,
|
||||
rewrite_local_markdown_images,
|
||||
)
|
||||
|
||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/enable": "enable",
|
||||
"/api/settings/mcp-presets/remove": "remove",
|
||||
"/api/settings/mcp-presets/test": "test",
|
||||
"/api/settings/mcp-presets/custom": "custom",
|
||||
"/api/settings/mcp-presets/import": "import",
|
||||
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||
"/api/settings/mcp-presets/tools": "tools",
|
||||
}
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
@@ -216,53 +255,40 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
|
||||
return _parse_request_path(path_with_query)[1]
|
||||
|
||||
|
||||
def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
|
||||
query = _parse_query(request.path)
|
||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("MCP settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if text:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
|
||||
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
"""Return the first value for *key*, or None."""
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _mask_secret_hint(secret: str | None) -> str | None:
|
||||
if not secret:
|
||||
return None
|
||||
if len(secret) <= 8:
|
||||
return "••••"
|
||||
return f"{secret[:4]}••••{secret[-4:]}"
|
||||
|
||||
|
||||
def _provider_requires_api_key(spec: Any) -> bool:
|
||||
if spec.backend == "azure_openai":
|
||||
return True
|
||||
if spec.is_local or spec.is_direct:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
||||
if _provider_requires_api_key(spec):
|
||||
return bool(provider_config.api_key)
|
||||
return bool(
|
||||
provider_config.api_key
|
||||
or provider_config.api_base
|
||||
or getattr(provider_config, "region", None)
|
||||
or getattr(provider_config, "profile", None)
|
||||
)
|
||||
|
||||
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
|
||||
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
||||
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
||||
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
||||
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
||||
)
|
||||
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||
}
|
||||
|
||||
|
||||
def _parse_inbound_payload(raw: str) -> str | None:
|
||||
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
||||
text = raw.strip()
|
||||
@@ -449,8 +475,6 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
})
|
||||
|
||||
|
||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||
if not configured_secret:
|
||||
@@ -478,6 +502,7 @@ class WebSocketChannel(BaseChannel):
|
||||
*,
|
||||
session_manager: "SessionManager | None" = None,
|
||||
static_dist_path: Path | None = None,
|
||||
workspace_path: Path | None = None,
|
||||
runtime_model_name: Callable[[], str | None] | None = None,
|
||||
):
|
||||
if isinstance(config, dict):
|
||||
@@ -500,7 +525,14 @@ class WebSocketChannel(BaseChannel):
|
||||
self._static_dist_path: Path | None = (
|
||||
static_dist_path.resolve() if static_dist_path is not None else None
|
||||
)
|
||||
self._workspace_path = (
|
||||
Path(workspace_path).expanduser()
|
||||
if workspace_path is not None
|
||||
else get_workspace_path()
|
||||
).resolve(strict=False)
|
||||
self._runtime_model_name = runtime_model_name
|
||||
self._settings_restart_sections: set[str] = set()
|
||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
# Process-local secret used to HMAC-sign media URLs. The signed URL is
|
||||
# the capability — anyone who holds a valid URL can fetch that one
|
||||
# file, nothing else. The secret regenerates on restart so links
|
||||
@@ -663,15 +695,49 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/commands":
|
||||
return self._handle_commands(request)
|
||||
|
||||
if got == "/api/webui/sidebar-state":
|
||||
return self._handle_webui_sidebar_state(request)
|
||||
|
||||
if got == "/api/webui/sidebar-state/update":
|
||||
return self._handle_webui_sidebar_state_update(request)
|
||||
|
||||
if got == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
|
||||
if got == "/api/settings/model-configurations/create":
|
||||
return self._handle_settings_model_configuration_create(request)
|
||||
|
||||
if got == "/api/settings/provider/update":
|
||||
return self._handle_settings_provider_update(request)
|
||||
|
||||
if got == "/api/settings/web-search/update":
|
||||
return self._handle_settings_web_search_update(request)
|
||||
|
||||
if got == "/api/settings/image-generation/update":
|
||||
return self._handle_settings_image_generation_update(request)
|
||||
|
||||
if got == "/api/settings/cli-apps":
|
||||
return self._handle_settings_cli_apps(request)
|
||||
|
||||
if got == "/api/settings/cli-apps/install":
|
||||
return await self._handle_settings_cli_apps_action(request, "install")
|
||||
|
||||
if got == "/api/settings/cli-apps/update":
|
||||
return await self._handle_settings_cli_apps_action(request, "update")
|
||||
|
||||
if got == "/api/settings/cli-apps/uninstall":
|
||||
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
||||
|
||||
if got == "/api/settings/cli-apps/test":
|
||||
return await self._handle_settings_cli_apps_action(request, "test")
|
||||
|
||||
if got == "/api/settings/mcp-presets":
|
||||
return await self._handle_settings_mcp_presets(request)
|
||||
|
||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
|
||||
if mcp_action is not None:
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
|
||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||
if m:
|
||||
return self._handle_session_messages(request, m.group(1))
|
||||
@@ -783,221 +849,176 @@ class WebSocketChannel(BaseChannel):
|
||||
sessions = self._session_manager.list_sessions()
|
||||
# Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc.
|
||||
# keys are not intended for resume over this HTTP surface.
|
||||
cleaned = [
|
||||
{k: v for k, v in s.items() if k != "path"}
|
||||
for s in sessions
|
||||
if isinstance(s.get("key"), str) and s["key"].startswith("websocket:")
|
||||
]
|
||||
return _http_json_response({"sessions": cleaned})
|
||||
|
||||
def _settings_payload(self, *, requires_restart: bool = False) -> dict[str, Any]:
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||
|
||||
config = load_config()
|
||||
defaults = config.agents.defaults
|
||||
provider_name = config.get_provider_name(defaults.model) or defaults.provider
|
||||
provider = config.get_provider(defaults.model)
|
||||
selected_provider = provider_name
|
||||
if defaults.provider != "auto":
|
||||
spec = find_by_name(defaults.provider)
|
||||
selected_provider = spec.name if spec else provider_name
|
||||
providers = []
|
||||
for spec in PROVIDERS:
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None or spec.is_oauth:
|
||||
cleaned = []
|
||||
for s in sessions:
|
||||
key = s.get("key")
|
||||
if not (isinstance(key, str) and key.startswith("websocket:")):
|
||||
continue
|
||||
providers.append(
|
||||
{
|
||||
"name": spec.name,
|
||||
"label": spec.label,
|
||||
"configured": _provider_configured_for_settings(spec, provider_config),
|
||||
"api_key_required": _provider_requires_api_key(spec),
|
||||
"api_key_hint": _mask_secret_hint(provider_config.api_key),
|
||||
"api_base": provider_config.api_base,
|
||||
"default_api_base": spec.default_api_base or None,
|
||||
}
|
||||
)
|
||||
search_config = config.tools.web.search
|
||||
search_provider = (
|
||||
search_config.provider
|
||||
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
||||
else "duckduckgo"
|
||||
)
|
||||
return {
|
||||
"agent": {
|
||||
"model": defaults.model,
|
||||
"provider": selected_provider,
|
||||
"resolved_provider": provider_name,
|
||||
"has_api_key": bool(provider and provider.api_key),
|
||||
},
|
||||
"providers": providers,
|
||||
"web_search": {
|
||||
"provider": search_provider,
|
||||
"api_key_hint": _mask_secret_hint(search_config.api_key),
|
||||
"base_url": search_config.base_url or None,
|
||||
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
||||
},
|
||||
"runtime": {
|
||||
"config_path": str(get_config_path().expanduser()),
|
||||
},
|
||||
"requires_restart": requires_restart,
|
||||
}
|
||||
row = {k: v for k, v in s.items() if k != "path"}
|
||||
chat_id = key.split(":", 1)[1]
|
||||
started_at = websocket_turn_wall_started_at(chat_id)
|
||||
if started_at is not None:
|
||||
row["run_started_at"] = started_at
|
||||
cleaned.append(row)
|
||||
return _http_json_response({"sessions": cleaned})
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response(self._settings_payload())
|
||||
return _http_json_response(self._with_settings_restart_state(settings_payload()))
|
||||
|
||||
def _with_settings_restart_state(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
section: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Keep restart-required state alive for this gateway process."""
|
||||
if section and payload.get("requires_restart"):
|
||||
self._settings_restart_sections.add(section)
|
||||
if self._settings_restart_sections:
|
||||
payload = dict(payload)
|
||||
payload["requires_restart"] = True
|
||||
payload["restart_required_sections"] = sorted(self._settings_restart_sections)
|
||||
else:
|
||||
payload = dict(payload)
|
||||
payload["restart_required_sections"] = []
|
||||
return payload
|
||||
|
||||
def _handle_commands(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response({"commands": builtin_command_palette()})
|
||||
|
||||
def _handle_webui_sidebar_state(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response(read_webui_sidebar_state())
|
||||
|
||||
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
raw_state = _query_first(query, "state")
|
||||
if raw_state is None:
|
||||
return _http_error(400, "missing state")
|
||||
try:
|
||||
decoded = json.loads(raw_state)
|
||||
except json.JSONDecodeError:
|
||||
return _http_error(400, "state must be JSON")
|
||||
if not isinstance(decoded, dict):
|
||||
return _http_error(400, "state must be an object")
|
||||
try:
|
||||
state = write_webui_sidebar_state(decoded)
|
||||
except ValueError as e:
|
||||
return _http_error(400, str(e))
|
||||
except OSError:
|
||||
self.logger.exception("failed to write webui sidebar state")
|
||||
return _http_error(500, "failed to write sidebar state")
|
||||
return _http_json_response(state)
|
||||
|
||||
def _handle_settings_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
query = _parse_query(request.path)
|
||||
config = load_config()
|
||||
defaults = config.agents.defaults
|
||||
changed = False
|
||||
try:
|
||||
payload = update_agent_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(
|
||||
self._with_settings_restart_state(payload, section="runtime")
|
||||
)
|
||||
|
||||
model = _query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip()
|
||||
if not model:
|
||||
return _http_error(400, "model is required")
|
||||
if defaults.model != model:
|
||||
defaults.model = model
|
||||
changed = True
|
||||
|
||||
provider = _query_first(query, "provider")
|
||||
if provider is not None:
|
||||
provider = provider.strip()
|
||||
if not provider:
|
||||
return _http_error(400, "provider is required")
|
||||
if find_by_name(provider) is None:
|
||||
return _http_error(400, "unknown provider")
|
||||
provider_config = getattr(config.providers, provider, None)
|
||||
spec = find_by_name(provider)
|
||||
if (
|
||||
provider_config is None
|
||||
or spec is None
|
||||
or not _provider_configured_for_settings(spec, provider_config)
|
||||
):
|
||||
return _http_error(400, "provider is not configured")
|
||||
if defaults.provider != provider:
|
||||
defaults.provider = provider
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
# LLM provider/model changes are hot-reloaded by AgentLoop before each
|
||||
# new turn via the provider snapshot loader, so a restart is unnecessary.
|
||||
return _http_json_response(self._settings_payload(requires_restart=False))
|
||||
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = create_model_configuration(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload))
|
||||
|
||||
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
query = _parse_query(request.path)
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
return _http_error(400, "provider is required")
|
||||
spec = find_by_name(provider_name)
|
||||
if spec is None or spec.is_oauth:
|
||||
return _http_error(400, "unknown provider")
|
||||
|
||||
config = load_config()
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None:
|
||||
return _http_error(400, "unknown provider")
|
||||
|
||||
changed = False
|
||||
if "api_key" in query or "apiKey" in query:
|
||||
api_key = _query_first(query, "api_key")
|
||||
if api_key is None:
|
||||
api_key = _query_first(query, "apiKey")
|
||||
api_key = (api_key or "").strip() or None
|
||||
if provider_config.api_key != api_key:
|
||||
provider_config.api_key = api_key
|
||||
changed = True
|
||||
|
||||
if "api_base" in query or "apiBase" in query:
|
||||
api_base = _query_first(query, "api_base")
|
||||
if api_base is None:
|
||||
api_base = _query_first(query, "apiBase")
|
||||
api_base = (api_base or "").strip() or None
|
||||
if provider_config.api_base != api_base:
|
||||
provider_config.api_base = api_base
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
# API key/base changes are picked up by the next provider snapshot refresh.
|
||||
return _http_json_response(self._settings_payload(requires_restart=False))
|
||||
try:
|
||||
payload = update_provider_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
||||
|
||||
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
|
||||
query = _parse_query(request.path)
|
||||
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||
if provider_option is None:
|
||||
return _http_error(400, "unknown web search provider")
|
||||
try:
|
||||
payload = update_web_search_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="web"))
|
||||
|
||||
config = load_config()
|
||||
search_config = config.tools.web.search
|
||||
previous_provider = search_config.provider
|
||||
changed = False
|
||||
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = update_image_generation_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
||||
|
||||
def set_value(attr: str, value: str | None) -> None:
|
||||
nonlocal changed
|
||||
if getattr(search_config, attr) != value:
|
||||
setattr(search_config, attr, value)
|
||||
changed = True
|
||||
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
try:
|
||||
payload = cli_apps_payload()
|
||||
except Exception:
|
||||
self.logger.exception("failed to load CLI Apps payload")
|
||||
return _http_error(500, "failed to load CLI Apps")
|
||||
return _http_json_response(payload)
|
||||
|
||||
if search_config.provider != provider_name:
|
||||
search_config.provider = provider_name
|
||||
changed = True
|
||||
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = await asyncio.to_thread(cli_apps_action, action, query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||
return _http_error(status, message)
|
||||
return _http_json_response(payload)
|
||||
|
||||
credential = provider_option["credential"]
|
||||
if credential == "none":
|
||||
set_value("api_key", "")
|
||||
set_value("base_url", "")
|
||||
elif credential == "base_url":
|
||||
base_url = _query_first(query, "base_url")
|
||||
if base_url is None:
|
||||
base_url = _query_first(query, "baseUrl")
|
||||
base_url = base_url.strip() if base_url is not None else None
|
||||
if not base_url and previous_provider == provider_name and search_config.base_url:
|
||||
base_url = search_config.base_url
|
||||
if not base_url:
|
||||
return _http_error(400, "base_url is required")
|
||||
set_value("base_url", base_url)
|
||||
set_value("api_key", "")
|
||||
else:
|
||||
api_key = _query_first(query, "api_key")
|
||||
if api_key is None:
|
||||
api_key = _query_first(query, "apiKey")
|
||||
api_key = api_key.strip() if api_key is not None else None
|
||||
if not api_key and previous_provider == provider_name and search_config.api_key:
|
||||
api_key = search_config.api_key
|
||||
if not api_key:
|
||||
return _http_error(400, "api_key is required")
|
||||
set_value("api_key", api_key)
|
||||
set_value("base_url", "")
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return _http_json_response(self._settings_payload(requires_restart=False))
|
||||
async def _handle_settings_mcp_presets(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str | None = None,
|
||||
) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
try:
|
||||
payload = await mcp_presets_settings_action(
|
||||
action,
|
||||
_parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("MCP preset action '{}' failed", action or "list")
|
||||
return _http_error(status, message)
|
||||
if action is None:
|
||||
return _http_json_response(payload)
|
||||
return _http_json_response(
|
||||
self._with_settings_restart_state(payload, section="runtime")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||
@@ -1040,6 +1061,7 @@ class WebSocketChannel(BaseChannel):
|
||||
data = build_webui_thread_response(
|
||||
decoded_key,
|
||||
augment_user_media=self._augment_transcript_user_media,
|
||||
augment_assistant_text=self._rewrite_local_markdown_images,
|
||||
)
|
||||
if data is None:
|
||||
return _http_error(404, "webui thread not found")
|
||||
@@ -1086,6 +1108,12 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if media:
|
||||
user_obj["media_paths"] = list(media)
|
||||
cli_apps = meta.get("cli_apps")
|
||||
if isinstance(cli_apps, list) and cli_apps:
|
||||
user_obj["cli_apps"] = cli_apps
|
||||
mcp_presets = meta.get("mcp_presets")
|
||||
if isinstance(mcp_presets, list) and mcp_presets:
|
||||
user_obj["mcp_presets"] = mcp_presets
|
||||
self._try_append_webui_transcript(chat_id, user_obj)
|
||||
await super()._handle_message(
|
||||
sender_id,
|
||||
@@ -1175,6 +1203,13 @@ class WebSocketChannel(BaseChannel):
|
||||
return None
|
||||
return {"url": signed, "name": path.name}
|
||||
|
||||
def _rewrite_local_markdown_images(self, text: str) -> str:
|
||||
return rewrite_local_markdown_images(
|
||||
text,
|
||||
workspace_path=self._workspace_path,
|
||||
sign_path=self._sign_or_stage_media_path,
|
||||
)
|
||||
|
||||
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
|
||||
"""Serve a single media file previously signed via
|
||||
:meth:`_sign_media_path`. Validates the signature, decodes the
|
||||
@@ -1546,6 +1581,12 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||
if envelope.get("webui") is True:
|
||||
metadata["webui"] = True
|
||||
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
|
||||
if cli_apps:
|
||||
metadata["cli_apps"] = cli_apps
|
||||
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
|
||||
if mcp_presets:
|
||||
metadata["mcp_presets"] = mcp_presets
|
||||
image_generation = envelope.get("image_generation")
|
||||
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
|
||||
aspect_ratio = image_generation.get("aspect_ratio")
|
||||
@@ -1657,10 +1698,11 @@ class WebSocketChannel(BaseChannel):
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
return
|
||||
text = msg.content
|
||||
wire_text = self._rewrite_local_markdown_images(text)
|
||||
payload: dict[str, Any] = {
|
||||
"event": "message",
|
||||
"chat_id": msg.chat_id,
|
||||
"text": text,
|
||||
"text": wire_text,
|
||||
}
|
||||
if msg.media:
|
||||
payload["media"] = msg.media
|
||||
@@ -1688,7 +1730,9 @@ class WebSocketChannel(BaseChannel):
|
||||
payload["kind"] = "tool_hint"
|
||||
elif msg.metadata.get("_progress"):
|
||||
payload["kind"] = "progress"
|
||||
self._try_append_webui_transcript(msg.chat_id, payload)
|
||||
transcript_payload = dict(payload)
|
||||
transcript_payload["text"] = text
|
||||
self._try_append_webui_transcript(msg.chat_id, transcript_payload)
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
@@ -1753,14 +1797,23 @@ class WebSocketChannel(BaseChannel):
|
||||
if not conns:
|
||||
return
|
||||
meta = metadata or {}
|
||||
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
|
||||
if meta.get("_stream_end"):
|
||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||
buffered = self._stream_text_buffers.pop(stream_key, [])
|
||||
if delta:
|
||||
buffered.append(delta)
|
||||
full_text = "".join(buffered)
|
||||
rewritten = self._rewrite_local_markdown_images(full_text)
|
||||
if rewritten != full_text:
|
||||
body["text"] = rewritten
|
||||
else:
|
||||
body = {
|
||||
"event": "delta",
|
||||
"chat_id": chat_id,
|
||||
"text": delta,
|
||||
}
|
||||
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
if meta.get("_stream_id") is not None:
|
||||
body["stream_id"] = meta["_stream_id"]
|
||||
self._try_append_webui_transcript(chat_id, body)
|
||||
|
||||
+163
-6
@@ -79,6 +79,12 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
|
||||
ERRCODE_SESSION_EXPIRED = -14
|
||||
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)
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
BACKOFF_DELAY_S = 30
|
||||
@@ -159,6 +165,8 @@ class WeixinChannel(BaseChannel):
|
||||
self._session_pause_until: float = 0.0
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||
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
|
||||
@@ -486,6 +494,7 @@ class WeixinChannel(BaseChannel):
|
||||
except Exception:
|
||||
if not self._running:
|
||||
break
|
||||
self.logger.exception("WeChat poll loop error")
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
consecutive_failures = 0
|
||||
@@ -495,6 +504,7 @@ class WeixinChannel(BaseChannel):
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
self._pending_tool_hints.clear()
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
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)
|
||||
ret = data.get("ret", 0)
|
||||
errcode = data.get("errcode", 0)
|
||||
|
||||
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
|
||||
|
||||
if is_error:
|
||||
@@ -575,8 +586,10 @@ class WeixinChannel(BaseChannel):
|
||||
# Process messages (WeixinMessage[] from types.ts)
|
||||
msgs: list[dict] = data.get("msgs", []) or []
|
||||
for msg in msgs:
|
||||
with suppress(Exception):
|
||||
try:
|
||||
await self._process_message(msg)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to process WeChat message")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||
@@ -610,6 +623,7 @@ class WeixinChannel(BaseChannel):
|
||||
ctx_token = msg.get("context_token", "")
|
||||
if ctx_token:
|
||||
self._context_tokens[from_user_id] = ctx_token
|
||||
self._context_token_at[from_user_id] = time.time()
|
||||
self._save_state()
|
||||
|
||||
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
||||
@@ -915,6 +929,99 @@ class WeixinChannel(BaseChannel):
|
||||
}
|
||||
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:
|
||||
"""Best-effort sendtyping wrapper."""
|
||||
if not typing_ticket:
|
||||
@@ -944,11 +1051,47 @@ class WeixinChannel(BaseChannel):
|
||||
self._assert_session_active()
|
||||
|
||||
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:
|
||||
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 = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token)
|
||||
if not ctx_token:
|
||||
raise RuntimeError(
|
||||
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
||||
@@ -1037,6 +1180,18 @@ class WeixinChannel(BaseChannel):
|
||||
with suppress(Exception):
|
||||
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:
|
||||
"""Start typing indicator immediately when a message is received."""
|
||||
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)
|
||||
ret = data.get("ret", 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(
|
||||
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(
|
||||
@@ -1270,10 +1426,11 @@ class WeixinChannel(BaseChannel):
|
||||
}
|
||||
|
||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||
ret = data.get("ret", 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(
|
||||
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
|
||||
f"WeChat send media error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+14
-17
@@ -620,6 +620,7 @@ def serve(
|
||||
|
||||
from nanobot.api.server import create_app
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
if verbose:
|
||||
@@ -639,10 +640,7 @@ def serve(
|
||||
agent_loop = AgentLoop.from_config(
|
||||
runtime_config, bus,
|
||||
session_manager=session_manager,
|
||||
image_generation_provider_configs={
|
||||
"openrouter": runtime_config.providers.openrouter,
|
||||
"aihubmix": runtime_config.providers.aihubmix,
|
||||
},
|
||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||
)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
@@ -722,6 +720,7 @@ def _run_gateway(
|
||||
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.image_generation import image_gen_provider_configs
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
port = port if port is not None else config.gateway.port
|
||||
@@ -752,10 +751,7 @@ def _run_gateway(
|
||||
context_window_tokens=provider_snapshot.context_window_tokens,
|
||||
cron_service=cron,
|
||||
session_manager=session_manager,
|
||||
image_generation_provider_configs={
|
||||
"openrouter": config.providers.openrouter,
|
||||
"aihubmix": config.providers.aihubmix,
|
||||
},
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
provider_snapshot_loader=load_provider_snapshot,
|
||||
runtime_model_publisher=lambda model, preset: publish_runtime_model_update(
|
||||
bus,
|
||||
@@ -766,7 +762,7 @@ def _run_gateway(
|
||||
)
|
||||
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
|
||||
def _channel_session_key(channel: str, chat_id: str) -> str:
|
||||
return (
|
||||
@@ -814,13 +810,13 @@ def _run_gateway(
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
# Dream is an internal job — run directly, not through the agent loop.
|
||||
if job.name == "dream":
|
||||
try:
|
||||
await agent.dream.run()
|
||||
logger.info("Dream cron job completed")
|
||||
except Exception:
|
||||
logger.exception("Dream cron job failed")
|
||||
await bus.publish_inbound(InboundMessage(
|
||||
channel="system",
|
||||
sender_id="dream",
|
||||
chat_id="dream",
|
||||
content="",
|
||||
))
|
||||
return None
|
||||
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
@@ -1031,11 +1027,10 @@ def _run_gateway(
|
||||
await server.serve_forever()
|
||||
# Register Dream system job (always-on, idempotent on restart)
|
||||
dream_cfg = config.agents.defaults.dream
|
||||
if dream_cfg.model_override:
|
||||
agent.dream.model = dream_cfg.model_override
|
||||
agent.dream.max_batch_size = dream_cfg.max_batch_size
|
||||
agent.dream.max_iterations = dream_cfg.max_iterations
|
||||
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
|
||||
agent.dream.edit_user_skills = dream_cfg.dream_edit_user_skills
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
cron.register_system_job(CronJob(
|
||||
id="dream",
|
||||
@@ -1122,6 +1117,7 @@ def agent(
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
|
||||
config = _load_runtime_config(config, workspace)
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
@@ -1145,6 +1141,7 @@ def agent(
|
||||
agent_loop = AgentLoop.from_config(
|
||||
config, bus,
|
||||
cron_service=cron,
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
|
||||
+33
-24
@@ -299,30 +299,22 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Manually trigger a Dream consolidation run."""
|
||||
import time
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
loop = ctx.loop
|
||||
msg = ctx.msg
|
||||
|
||||
async def _run_dream():
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
did_work = await loop.dream.run()
|
||||
elapsed = time.monotonic() - t0
|
||||
if did_work:
|
||||
content = f"Dream completed in {elapsed:.1f}s."
|
||||
else:
|
||||
content = "Dream: nothing to process."
|
||||
except Exception as e:
|
||||
elapsed = time.monotonic() - t0
|
||||
content = f"Dream failed after {elapsed:.1f}s: {e}"
|
||||
await loop.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
))
|
||||
|
||||
asyncio.create_task(_run_dream())
|
||||
await ctx.loop.bus.publish_inbound(InboundMessage(
|
||||
channel="system",
|
||||
sender_id="dream",
|
||||
chat_id="dream",
|
||||
content="",
|
||||
metadata={
|
||||
"trigger_channel": ctx.msg.channel,
|
||||
"trigger_chat_id": ctx.msg.chat_id,
|
||||
},
|
||||
))
|
||||
return OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content="Dreaming...",
|
||||
channel=ctx.msg.channel,
|
||||
chat_id=ctx.msg.chat_id,
|
||||
content="Dream started. It will process memory backlog and report when done.",
|
||||
)
|
||||
|
||||
|
||||
@@ -355,6 +347,18 @@ def _format_changed_files(diff: str) -> str:
|
||||
|
||||
def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str:
|
||||
files_line = _format_changed_files(diff)
|
||||
msg_lines = commit.message.splitlines() if commit.message else []
|
||||
msg_summary = msg_lines[0] if msg_lines else ""
|
||||
msg_body = []
|
||||
in_body = False
|
||||
for line in msg_lines[1:]:
|
||||
if not in_body:
|
||||
if not line:
|
||||
in_body = True
|
||||
continue
|
||||
msg_body.append(line)
|
||||
body_text = "\n".join(msg_body).strip()
|
||||
|
||||
lines = [
|
||||
"## Dream Update",
|
||||
"",
|
||||
@@ -362,8 +366,12 @@ def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None =
|
||||
"",
|
||||
f"- Commit: `{commit.sha}`",
|
||||
f"- Time: {commit.timestamp}",
|
||||
f"- Changed files: {files_line}",
|
||||
]
|
||||
if msg_summary:
|
||||
lines.append(f"- Summary: {msg_summary}")
|
||||
lines.append(f"- Changed files: {files_line}")
|
||||
if body_text:
|
||||
lines.extend(["", "### Analysis", "", body_text])
|
||||
if diff:
|
||||
lines.extend([
|
||||
"",
|
||||
@@ -389,7 +397,8 @@ def _format_dream_restore_list(commits: list) -> str:
|
||||
"",
|
||||
]
|
||||
for c in commits:
|
||||
lines.append(f"- `{c.sha}` {c.timestamp} - {c.message.splitlines()[0]}")
|
||||
summary = c.message.splitlines()[0] if c.message else "(no message)"
|
||||
lines.append(f"- `{c.sha}` {c.timestamp} - {summary}")
|
||||
lines.extend([
|
||||
"",
|
||||
"Preview a version with `/dream-log <sha>` before restoring it.",
|
||||
|
||||
@@ -10,10 +10,11 @@ import pydantic
|
||||
from loguru import logger
|
||||
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)
|
||||
_current_config_path: Path | None = None
|
||||
_schema_refs_ready = False
|
||||
|
||||
|
||||
def set_config_path(path: Path) -> None:
|
||||
@@ -39,6 +40,11 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
Returns:
|
||||
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()
|
||||
|
||||
config = Config()
|
||||
|
||||
@@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@@ -51,14 +52,17 @@ class DreamConfig(Base):
|
||||
model_override: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
||||
) # Optional Dream-specific model override
|
||||
max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
|
||||
# Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus).
|
||||
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2
|
||||
# Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default
|
||||
# on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly
|
||||
# to the `← Nd` suffix or you want deterministic, git-independent prompts.
|
||||
) # Optional Dream-specific model override. Supports preset names (resolved against model_presets) or raw model identifiers.
|
||||
max_batch_size: int = Field(default=5, ge=1) # Max history entries per run
|
||||
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Dream run
|
||||
# Per-line git-blame age annotation in the Dream prompt (see #3212). Default
|
||||
# on — set to False to feed all memory files raw if a specific LLM reacts
|
||||
# poorly to the `← Nd` suffix or you want deterministic, git-independent prompts.
|
||||
annotate_line_ages: bool = True
|
||||
# When False (default), Dream may only modify skills it created (marked
|
||||
# dream_managed in frontmatter). When True, Dream may also edit user-created
|
||||
# workspace skills. Builtin skills are never editable.
|
||||
dream_edit_user_skills: bool = False
|
||||
|
||||
def build_schedule(self, timezone: str) -> CronSchedule:
|
||||
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
||||
@@ -91,6 +95,7 @@ FallbackCandidate = str | InlineFallbackConfig
|
||||
class ModelPresetConfig(Base):
|
||||
"""A named set of model + generation parameters for quick switching."""
|
||||
|
||||
label: str | None = None
|
||||
model: str
|
||||
provider: str = "auto"
|
||||
max_tokens: int = 8192
|
||||
@@ -169,8 +174,9 @@ class ProviderConfig(Base):
|
||||
|
||||
api_key: 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_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):
|
||||
@@ -190,6 +196,7 @@ class ProvidersConfig(Base):
|
||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openrouter: 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)
|
||||
groq: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
@@ -207,8 +214,10 @@ class ProvidersConfig(Base):
|
||||
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
||||
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
|
||||
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
|
||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
||||
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
|
||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
||||
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
||||
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
||||
@@ -218,6 +227,16 @@ class ProvidersConfig(Base):
|
||||
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
||||
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):
|
||||
"""Heartbeat service configuration."""
|
||||
@@ -250,6 +269,7 @@ class MCPServerConfig(Base):
|
||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||
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
|
||||
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
|
||||
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
||||
@@ -273,6 +293,7 @@ class ToolsConfig(Base):
|
||||
|
||||
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"))
|
||||
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"))
|
||||
image_generation: ImageGenerationToolConfig = Field(
|
||||
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
|
||||
@@ -459,6 +480,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
"""
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@@ -467,6 +489,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
# Re-export into this module's namespace
|
||||
mod = sys.modules[__name__]
|
||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
||||
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
"""Cron service for scheduled agent tasks."""
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import 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
|
||||
|
||||
+2
-4
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -63,10 +64,7 @@ class Nanobot:
|
||||
|
||||
loop = AgentLoop.from_config(
|
||||
config,
|
||||
image_generation_provider_configs={
|
||||
"openrouter": config.providers.openrouter,
|
||||
"aihubmix": config.providers.aihubmix,
|
||||
},
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
)
|
||||
return cls(loop)
|
||||
|
||||
|
||||
@@ -590,6 +590,7 @@ class AnthropicProvider(LLMProvider):
|
||||
tool_choice: str | dict[str, Any] | 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:
|
||||
kwargs = self._build_kwargs(
|
||||
messages, tools, model, max_tokens, temperature,
|
||||
@@ -598,11 +599,12 @@ class AnthropicProvider(LLMProvider):
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
try:
|
||||
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,
|
||||
# tool JSON deltas, etc.), not only text_stream tokens.
|
||||
# Otherwise extended thinking can stall text_stream for minutes
|
||||
# while the connection is healthy (e.g. MiniMax Anthropic).
|
||||
tool_blocks: dict[int, dict[str, str]] = {}
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
@@ -611,7 +613,22 @@ class AnthropicProvider(LLMProvider):
|
||||
)
|
||||
except StopAsyncIteration:
|
||||
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"
|
||||
and getattr(chunk.delta, "type", None) == "thinking_delta"
|
||||
):
|
||||
@@ -625,6 +642,20 @@ class AnthropicProvider(LLMProvider):
|
||||
text = getattr(chunk.delta, "text", None) or ""
|
||||
if text and on_content_delta:
|
||||
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(
|
||||
stream.get_final_message(),
|
||||
timeout=idle_timeout_s,
|
||||
|
||||
@@ -158,6 +158,7 @@ class AzureOpenAIProvider(LLMProvider):
|
||||
tool_choice: str | dict[str, Any] | 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:
|
||||
_ = on_thinking_delta
|
||||
body = self._build_body(
|
||||
@@ -169,7 +170,7 @@ class AzureOpenAIProvider(LLMProvider):
|
||||
try:
|
||||
stream = await self._client.responses.create(**body)
|
||||
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(
|
||||
content=content or None,
|
||||
|
||||
@@ -70,11 +70,11 @@ class LLMResponse:
|
||||
|
||||
@property
|
||||
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)."""
|
||||
if not self.has_tool_calls:
|
||||
return False
|
||||
return self.finish_reason in ("tool_calls", "stop")
|
||||
return self.finish_reason in ("tool_calls", "function_call", "stop")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -501,6 +501,7 @@ class LLMProvider(ABC):
|
||||
tool_choice: str | dict[str, Any] | 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:
|
||||
"""Stream a chat completion, calling *on_content_delta* for each text chunk.
|
||||
|
||||
@@ -514,7 +515,7 @@ class LLMProvider(ABC):
|
||||
full content as a single delta. Providers that support native
|
||||
streaming should override this method.
|
||||
"""
|
||||
_ = on_thinking_delta
|
||||
_ = on_thinking_delta, on_tool_call_delta
|
||||
response = await self.chat(
|
||||
messages=messages, tools=tools, model=model,
|
||||
max_tokens=max_tokens, temperature=temperature,
|
||||
@@ -544,6 +545,7 @@ class LLMProvider(ABC):
|
||||
tool_choice: str | dict[str, Any] | 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,
|
||||
retry_mode: str = "standard",
|
||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> LLMResponse:
|
||||
@@ -561,6 +563,7 @@ class LLMProvider(ABC):
|
||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
)
|
||||
return await self._run_with_retry(
|
||||
self._safe_chat_stream,
|
||||
|
||||
@@ -704,8 +704,9 @@ class BedrockProvider(LLMProvider):
|
||||
tool_choice: str | dict[str, Any] | 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:
|
||||
_ = on_thinking_delta
|
||||
_ = on_thinking_delta, on_tool_call_delta
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
|
||||
@@ -98,6 +98,7 @@ def _make_provider_core(
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
spec=spec,
|
||||
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()
|
||||
@@ -183,6 +184,7 @@ def provider_signature(
|
||||
config.get_api_base(fallback.model, preset=fallback),
|
||||
fp.extra_headers 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, "profile", None) if fp else None,
|
||||
fallback.max_tokens,
|
||||
@@ -199,6 +201,7 @@ def provider_signature(
|
||||
config.get_api_base(resolved.model, preset=resolved),
|
||||
p.extra_headers 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, "profile", None) if p else None,
|
||||
resolved.max_tokens,
|
||||
|
||||
@@ -207,8 +207,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
|
||||
async def _refresh_client_api_key(self) -> str:
|
||||
token = await self._get_copilot_access_token()
|
||||
client = await self._ensure_client()
|
||||
self.api_key = token
|
||||
self._client.api_key = token
|
||||
client.api_key = token
|
||||
return token
|
||||
|
||||
async def chat(
|
||||
@@ -243,6 +244,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
tool_choice: str | dict[str, object] | None = None,
|
||||
on_content_delta: Callable[[str], 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()
|
||||
return await super().chat_stream(
|
||||
@@ -255,4 +257,5 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
tool_choice=tool_choice,
|
||||
on_content_delta=on_content_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
@@ -40,6 +40,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
reasoning_effort: str | None,
|
||||
tool_choice: str | dict[str, Any] | None,
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""Shared request logic for both chat() and chat_stream()."""
|
||||
model = model or self.default_model
|
||||
@@ -70,6 +71,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
content, tool_calls, finish_reason = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||
on_content_delta=on_content_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
)
|
||||
except Exception as e:
|
||||
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||
@@ -78,6 +80,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
content, tool_calls, finish_reason = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||
on_content_delta=on_content_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
)
|
||||
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
|
||||
except Exception as e:
|
||||
@@ -100,9 +103,18 @@ class OpenAICodexProvider(LLMProvider):
|
||||
tool_choice: str | dict[str, Any] | 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:
|
||||
_ = on_thinking_delta
|
||||
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
|
||||
return await self._call_codex(
|
||||
messages,
|
||||
tools,
|
||||
model,
|
||||
reasoning_effort,
|
||||
tool_choice,
|
||||
on_content_delta,
|
||||
on_tool_call_delta,
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return self.default_model
|
||||
@@ -138,6 +150,7 @@ async def _request_codex(
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
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]:
|
||||
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
@@ -148,7 +161,7 @@ async def _request_codex(
|
||||
_friendly_error(response.status_code, text.decode("utf-8", "ignore")),
|
||||
retry_after=retry_after,
|
||||
)
|
||||
return await consume_sse(response, on_content_delta)
|
||||
return await consume_sse(response, on_content_delta, on_tool_call_delta)
|
||||
|
||||
|
||||
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
||||
|
||||
@@ -11,25 +11,15 @@ import secrets
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from ipaddress import ip_address
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import json_repair
|
||||
from loguru import logger
|
||||
|
||||
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
|
||||
from langfuse.openai import AsyncOpenAI
|
||||
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.openai_responses import (
|
||||
consume_sdk_stream,
|
||||
@@ -39,8 +29,15 @@ from nanobot.providers.openai_responses import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai import AsyncOpenAI as AsyncOpenAIType
|
||||
|
||||
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({
|
||||
"role", "content", "tool_calls", "tool_call_id", "name",
|
||||
"reasoning_content", "extra_content",
|
||||
@@ -78,41 +75,43 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
|
||||
"enable_thinking": lambda on: {"enable_thinking": 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:
|
||||
"""Return True if model_name refers to a Kimi thinking-capable model.
|
||||
|
||||
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 _model_slug(model_name: str) -> str:
|
||||
return model_name.lower().rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _is_mimo_thinking_model(model_name: str) -> bool:
|
||||
"""Return True if model_name refers to a MiMo thinking-capable model.
|
||||
def _model_thinking_style(model_name: str) -> str:
|
||||
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
|
||||
spec, so the spec-driven branch in _build_kwargs misses them. The
|
||||
model-name path catches those cases.
|
||||
"""
|
||||
name = model_name.lower()
|
||||
if name in _MIMO_THINKING_MODELS:
|
||||
return True
|
||||
if "/" in name and name.rsplit("/", 1)[1] in _MIMO_THINKING_MODELS:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]:
|
||||
styles: list[str] = []
|
||||
if spec and spec.thinking_style:
|
||||
styles.append(spec.thinking_style)
|
||||
model_style = _model_thinking_style(model_name)
|
||||
if model_style and model_style not in styles:
|
||||
styles.append(model_style)
|
||||
return styles
|
||||
|
||||
|
||||
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:
|
||||
@@ -275,6 +274,47 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
||||
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):
|
||||
"""Unified provider for all OpenAI-compatible APIs.
|
||||
|
||||
@@ -290,55 +330,90 @@ class OpenAICompatProvider(LLMProvider):
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
spec: ProviderSpec | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
api_type: str = "auto",
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
self.extra_headers = extra_headers or {}
|
||||
self._spec = spec
|
||||
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:
|
||||
self._setup_env(api_key, api_base)
|
||||
|
||||
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
||||
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):
|
||||
default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
|
||||
self._default_headers.update(_DEFAULT_OPENROUTER_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
|
||||
# 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.
|
||||
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,
|
||||
)
|
||||
# Lazy-init: the OpenAI client and its httpx transport are expensive
|
||||
# to create (~700 ms on Windows). Defer until first use.
|
||||
self._client: AsyncOpenAIType | None = None
|
||||
self._client_lock = asyncio.Lock()
|
||||
|
||||
# Responses API circuit breaker: skip after repeated failures,
|
||||
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
|
||||
self._responses_failures: dict[str, int] = {}
|
||||
self._responses_tripped_at: dict[str, float] = {}
|
||||
|
||||
def _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:
|
||||
"""Set environment variables based on provider spec."""
|
||||
spec = self._spec
|
||||
@@ -396,6 +471,10 @@ class OpenAICompatProvider(LLMProvider):
|
||||
return tool_call_id
|
||||
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
|
||||
def _normalize_tool_call_arguments(arguments: Any) -> str:
|
||||
"""Force function.arguments into a valid JSON object string."""
|
||||
@@ -432,22 +511,60 @@ class OpenAICompatProvider(LLMProvider):
|
||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||
id_map: dict[str, str] = {}
|
||||
pending_tool_ids: dict[str, deque[str]] = {}
|
||||
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:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not normalize_tool_ids:
|
||||
return 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:
|
||||
if isinstance(clean.get("tool_calls"), list):
|
||||
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):
|
||||
normalized.append(tc)
|
||||
continue
|
||||
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")
|
||||
if isinstance(function, dict):
|
||||
function_clean = dict(function)
|
||||
@@ -465,7 +582,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# that mix non-empty content with tool_calls.
|
||||
clean["content"] = None
|
||||
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 (
|
||||
force_string_content
|
||||
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":
|
||||
kwargs["reasoning_effort"] = wire_effort
|
||||
|
||||
# Provider-specific thinking parameters.
|
||||
# Only sent when reasoning_effort is explicitly configured so that
|
||||
# the provider default is preserved otherwise.
|
||||
# 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:
|
||||
# Only send thinking controls when reasoning_effort is explicit so
|
||||
# omitting the config preserves each provider's default.
|
||||
if reasoning_effort is not None:
|
||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
||||
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
|
||||
if extra:
|
||||
kwargs.setdefault("extra_body", {}).update(extra)
|
||||
for thinking_style in _thinking_styles_for(spec, model_name):
|
||||
extra = _thinking_extra_body(thinking_style, thinking_enabled)
|
||||
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.
|
||||
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup
|
||||
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
||||
# identically to bare names like "kimi-k2.5".
|
||||
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
||||
kwargs.setdefault("extra_body", {}).update(
|
||||
{"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"}}
|
||||
)
|
||||
# Moonshot rejects requests that carry both 'reasoning_effort'
|
||||
# and the native 'thinking' param. We already expressed the
|
||||
# user's intent via the provider-native shape, so drop the
|
||||
# redundant wire-level kwarg. Only kimi models need this —
|
||||
# Xiaomi's API accepts both params.
|
||||
if _model_slug(model_name) in _KIMI_THINKING_MODELS:
|
||||
kwargs.pop("reasoning_effort", None)
|
||||
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
@@ -599,8 +704,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
and semantic_effort not in ("none", "minimal")
|
||||
and (
|
||||
(spec and spec.thinking_style)
|
||||
or _is_kimi_thinking_model(model_name)
|
||||
or _is_mimo_thinking_model(model_name)
|
||||
or _model_thinking_style(model_name)
|
||||
)
|
||||
)
|
||||
implicit_deepseek_thinking = (
|
||||
@@ -631,8 +735,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
reasoning_effort: str | None,
|
||||
) -> bool:
|
||||
"""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"):
|
||||
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 not _is_direct_openai_base(self._effective_base):
|
||||
return False
|
||||
@@ -646,7 +756,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if not wants:
|
||||
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)
|
||||
failures = self._responses_failures.get(key, 0)
|
||||
if failures >= _RESPONSES_FAILURE_THRESHOLD:
|
||||
@@ -738,6 +855,10 @@ class OpenAICompatProvider(LLMProvider):
|
||||
body["tools"] = convert_tools(tools)
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -902,7 +1023,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
args = json_repair.loads(args)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
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 ""),
|
||||
arguments=args if isinstance(args, dict) else {},
|
||||
extra_content=ec,
|
||||
@@ -945,7 +1066,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
args = json_repair.loads(args)
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=_short_tool_id(),
|
||||
id=str(getattr(tc, "id", None) or _short_tool_id()),
|
||||
name=tc.function.name,
|
||||
arguments=args,
|
||||
extra_content=ec,
|
||||
@@ -999,6 +1120,21 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if 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:
|
||||
if isinstance(chunk, str):
|
||||
content_parts.append(chunk)
|
||||
@@ -1029,6 +1165,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
reasoning_parts.append(text)
|
||||
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
||||
_accum_tc(tc, idx)
|
||||
_accum_legacy_function_call(delta.get("function_call"))
|
||||
usage = cls._extract_usage(chunk_map) or usage
|
||||
continue
|
||||
|
||||
@@ -1047,8 +1184,19 @@ class OpenAICompatProvider(LLMProvider):
|
||||
reasoning = getattr(delta, "reasoning", None)
|
||||
if 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))
|
||||
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(
|
||||
content="".join(content_parts) or None,
|
||||
@@ -1164,6 +1312,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
reasoning_effort: str | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
await self._ensure_client()
|
||||
try:
|
||||
if self._should_use_responses_api(model, reasoning_effort):
|
||||
try:
|
||||
@@ -1180,6 +1329,8 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# falling back to /chat/completions cannot succeed and would
|
||||
# hide the real error.
|
||||
raise
|
||||
if self._api_type == "responses":
|
||||
raise
|
||||
if not self._should_fallback_from_responses_error(responses_error):
|
||||
raise
|
||||
self._record_responses_failure(model, reasoning_effort)
|
||||
@@ -1203,7 +1354,9 @@ class OpenAICompatProvider(LLMProvider):
|
||||
tool_choice: str | dict[str, Any] | 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:
|
||||
await self._ensure_client()
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
try:
|
||||
if self._should_use_responses_api(model, reasoning_effort):
|
||||
@@ -1226,9 +1379,16 @@ class OpenAICompatProvider(LLMProvider):
|
||||
except StopAsyncIteration:
|
||||
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(),
|
||||
on_content_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
)
|
||||
self._record_responses_success(model, reasoning_effort)
|
||||
return LLMResponse(
|
||||
@@ -1244,6 +1404,8 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# falling back to /chat/completions cannot succeed and would
|
||||
# hide the real error.
|
||||
raise
|
||||
if self._api_type == "responses":
|
||||
raise
|
||||
if not self._should_fallback_from_responses_error(responses_error):
|
||||
raise
|
||||
self._record_responses_failure(model, reasoning_effort)
|
||||
@@ -1252,6 +1414,12 @@ class OpenAICompatProvider(LLMProvider):
|
||||
messages, tools, model, max_tokens, temperature,
|
||||
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_options"] = {"include_usage": True}
|
||||
stream = await self._client.chat.completions.create(**kwargs)
|
||||
@@ -1279,6 +1447,28 @@ class OpenAICompatProvider(LLMProvider):
|
||||
r_text = self._extract_text_content(reasoning)
|
||||
if 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)
|
||||
except asyncio.TimeoutError:
|
||||
return LLMResponse(
|
||||
|
||||
@@ -15,6 +15,7 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
||||
"""
|
||||
system_prompt = ""
|
||||
input_items: list[dict[str, Any]] = []
|
||||
used_item_ids: set[str] = set()
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
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 isinstance(content, str) and content:
|
||||
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||
input_items.append({
|
||||
"type": "message", "role": "assistant",
|
||||
"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 []:
|
||||
fn = tool_call.get("function") or {}
|
||||
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({
|
||||
"type": "function_call",
|
||||
"id": item_id or f"fc_{idx}",
|
||||
"id": response_item_id,
|
||||
"call_id": call_id or f"call_{idx}",
|
||||
"name": fn.get("name"),
|
||||
"arguments": fn.get("arguments") or "{}",
|
||||
@@ -97,6 +100,20 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
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]:
|
||||
"""Split a compound ``call_id|item_id`` string.
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N
|
||||
async def consume_sse(
|
||||
response: httpx.Response,
|
||||
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]:
|
||||
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
|
||||
content = ""
|
||||
@@ -82,6 +83,12 @@ async def consume_sse(
|
||||
"name": item.get("name"),
|
||||
"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":
|
||||
delta_text = event.get("delta") or ""
|
||||
content += delta_text
|
||||
@@ -90,7 +97,14 @@ async def consume_sse(
|
||||
elif event_type == "response.function_call_arguments.delta":
|
||||
call_id = event.get("call_id")
|
||||
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":
|
||||
call_id = event.get("call_id")
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
@@ -210,6 +224,7 @@ def parse_response_output(response: Any) -> LLMResponse:
|
||||
async def consume_sdk_stream(
|
||||
stream: Any,
|
||||
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]:
|
||||
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
|
||||
content = ""
|
||||
@@ -232,6 +247,12 @@ async def consume_sdk_stream(
|
||||
"name": getattr(item, "name", None),
|
||||
"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":
|
||||
delta_text = getattr(event, "delta", "") or ""
|
||||
content += delta_text
|
||||
@@ -240,7 +261,14 @@ async def consume_sdk_stream(
|
||||
elif event_type == "response.function_call_arguments.delta":
|
||||
call_id = getattr(event, "call_id", None)
|
||||
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":
|
||||
call_id = getattr(event, "call_id", None)
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
|
||||
@@ -71,6 +71,11 @@ class ProviderSpec:
|
||||
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
||||
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 "content" is empty. Only set this for providers (e.g. StepFun)
|
||||
# whose API returns the actual answer in "reasoning" instead of "content".
|
||||
@@ -142,6 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
detect_by_base_keyword="openrouter",
|
||||
default_api_base="https://openrouter.ai/api/v1",
|
||||
supports_prompt_caching=True,
|
||||
gateway_reasoning_style="reasoning_effort",
|
||||
),
|
||||
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
||||
ProviderSpec(
|
||||
@@ -155,6 +161,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
detect_by_base_keyword="huggingface",
|
||||
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.
|
||||
# strip_model_prefix=True: doesn't understand "anthropic/claude-3",
|
||||
# strips to bare "claude-3".
|
||||
@@ -181,6 +199,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
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
|
||||
ProviderSpec(
|
||||
name="volcengine",
|
||||
@@ -390,6 +420,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
backend="openai_compat",
|
||||
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) =========
|
||||
# vLLM / any OpenAI-compatible local server
|
||||
ProviderSpec(
|
||||
|
||||
@@ -7,6 +7,25 @@ from pathlib import Path
|
||||
import httpx
|
||||
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
|
||||
# failures. Whisper endpoints occasionally return 502/503 under load, and
|
||||
# mobile-network transcription callers hit sporadic connect/read errors.
|
||||
@@ -127,12 +146,12 @@ class OpenAITranscriptionProvider:
|
||||
language: str | None = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||
self.api_url = (
|
||||
api_base
|
||||
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
||||
or "https://api.openai.com/v1/audio/transcriptions"
|
||||
self.api_url = _resolve_transcription_url(
|
||||
api_base or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL"),
|
||||
"https://api.openai.com/v1/audio/transcriptions",
|
||||
)
|
||||
self.language = language or None
|
||||
logger.debug("OpenAI transcription endpoint: {}", self.api_url)
|
||||
|
||||
async def transcribe(self, file_path: str | Path) -> str:
|
||||
if not self.api_key:
|
||||
@@ -166,12 +185,12 @@ class GroqTranscriptionProvider:
|
||||
language: str | None = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||
self.api_url = (
|
||||
api_base
|
||||
or os.environ.get("GROQ_BASE_URL")
|
||||
or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
self.api_url = _resolve_transcription_url(
|
||||
api_base or os.environ.get("GROQ_BASE_URL"),
|
||||
"https://api.groq.com/openai/v1/audio/transcriptions",
|
||||
)
|
||||
self.language = language or None
|
||||
logger.debug("Groq transcription endpoint: {}", self.api_url)
|
||||
|
||||
async def transcribe(self, file_path: str | Path) -> str:
|
||||
"""
|
||||
|
||||
@@ -27,6 +27,8 @@ _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
||||
_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:
|
||||
@@ -165,6 +167,45 @@ class Session:
|
||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
||||
)
|
||||
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:
|
||||
content = self._annotate_message_time(message, content)
|
||||
if role == "assistant" and isinstance(content, str) and not content.strip():
|
||||
@@ -604,9 +645,18 @@ class SessionManager:
|
||||
title = metadata.get("title") if isinstance(metadata, dict) else None
|
||||
preview = ""
|
||||
fallback_preview = ""
|
||||
scanned_records = 0
|
||||
scanned_chars = 0
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
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)
|
||||
if item.get("_type") == "metadata":
|
||||
continue
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Outbound helpers for the WebSocket/WebUI wire contract.
|
||||
"""Session turn helpers for WebUI-capable WebSocket sessions.
|
||||
|
||||
AgentLoop uses these without importing a concrete channel plugin; only
|
||||
``channel == "websocket"`` messages are affected.
|
||||
@@ -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`.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
Generate a new image:
|
||||
|
||||
@@ -34,3 +34,5 @@ Examples (replace `keyword`):
|
||||
- **Do NOT edit SOUL.md, USER.md, or MEMORY.md.** They are automatically managed by Dream.
|
||||
- If you notice outdated information, it will be corrected when Dream runs next.
|
||||
- Users can view Dream's activity with the `/dream-log` command.
|
||||
- Dream runs as a `system` session inside the AgentLoop, triggered by the `/dream` command or cron. Each turn processes one batch; if backlog remains, Dream automatically chains additional turns until complete. All changes are committed in a single git commit.
|
||||
- Dream can use a different model than the main agent via `agents.defaults.dream.modelOverride`. Supports preset names or raw model identifiers.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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
|
||||
|
||||
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.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks:
|
||||
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks.
|
||||
|
||||
- **Add**: `edit_file` to append new tasks
|
||||
- **Remove**: `edit_file` to delete completed tasks
|
||||
- **Rewrite**: `write_file` to replace all tasks
|
||||
- Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
|
||||
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
|
||||
- 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.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Tool Usage Notes
|
||||
|
||||
Tool signatures are provided automatically via function calling.
|
||||
This file documents non-obvious constraints and usage patterns.
|
||||
|
||||
## exec — Safety Limits
|
||||
|
||||
- Commands have a configurable timeout (default 60s)
|
||||
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
|
||||
- Output is truncated at 10,000 characters
|
||||
- `restrictToWorkspace` config can limit file access to the workspace
|
||||
|
||||
## grep — Content Search
|
||||
|
||||
- Use `grep` to search file contents inside the workspace
|
||||
- Default behavior returns only matching file paths (`output_mode="files_with_matches"`)
|
||||
- Supports optional `glob` filtering (e.g. `glob="*.py"`) plus `context_before` / `context_after`
|
||||
- Supports `type="py"`, `type="ts"`, `type="md"` and similar shorthand filters
|
||||
- Use `fixed_strings=true` for literal keywords containing regex characters
|
||||
- Use `output_mode="files_with_matches"` to get only matching file paths
|
||||
- Use `output_mode="count"` to size a search before reading full matches
|
||||
- Use `head_limit` and `offset` to page across results
|
||||
- Prefer this over `exec` for code and history searches
|
||||
- Binary or oversized files may be skipped to keep results readable
|
||||
|
||||
## cron — Scheduled Reminders
|
||||
|
||||
- Please refer to cron skill for usage.
|
||||
@@ -1,13 +1,27 @@
|
||||
Extract key facts from this conversation. Only output items matching these categories, skip everything else:
|
||||
- User facts: personal info, preferences, stated opinions, habits
|
||||
- Decisions: choices made, conclusions reached
|
||||
- Solutions: working approaches discovered through trial and error, especially non-obvious methods that succeeded after failed attempts
|
||||
- Events: plans, deadlines, notable occurrences
|
||||
- Preferences: communication style, tool preferences
|
||||
Extract key facts from this conversation. For each fact, annotate its memory attributes.
|
||||
|
||||
Priority: user corrections and preferences > solutions > decisions > events > environment facts. The most valuable memory prevents the user from having to repeat themselves.
|
||||
Only SNIP facts deserve a non-[skip] mark:
|
||||
- Signal: would the user need to repeat this if forgotten?
|
||||
- Novel: not already in MEMORY.md or USER.md (check context below)
|
||||
- Important: prevents rework or captures preferences / rules
|
||||
- Persistent: still relevant after 2 weeks
|
||||
|
||||
Skip: code patterns derivable from source, git history, or anything already captured in existing memory.
|
||||
Output one fact per line in this format:
|
||||
- [mark] fact content
|
||||
|
||||
Marks (choose the best match):
|
||||
- [permanent] Core preferences, personal traits, habits — never becomes stale
|
||||
- [durable] Technical discoveries, project knowledge, config details — valid for months
|
||||
- [ephemeral] Active task state, temporary decisions — may change in weeks
|
||||
- [correction] Correction to a previous memory — must state what it replaces
|
||||
- [skip] Does not meet SNIP criteria — still written to history.jsonl for audit, but Dream will ignore it
|
||||
|
||||
Categories to capture: people/roles, decisions/rationale, solutions, events/dates, preferences.
|
||||
Decisions must include their motivation.
|
||||
Write densely. Prefer 'X=A, Y=B' over separate bullets for tightly coupled facts.
|
||||
Priority: user corrections > decisions with rationale > solutions > specific events > general context.
|
||||
Output in the same language as the input conversation.
|
||||
CRITICAL: Never drop person names, team names, or project names.
|
||||
Skip: code patterns derivable from source, git history, or anything already in existing memory.
|
||||
|
||||
Output as concise bullet points, one fact per line. No preamble, no commentary.
|
||||
If nothing noteworthy happened, output: (nothing)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
Update memory files by analyzing conversation history and editing files directly.
|
||||
Prune before adding — removing stale content is as important as adding new facts.
|
||||
|
||||
## File routing
|
||||
Do NOT guess paths. Route each fact to its canonical file:
|
||||
|
||||
| File | Full path | Content |
|
||||
|------|------|---------|
|
||||
| SOUL.md | `{{ soul_path }}` | Agent behavior, guardrails, tone, interaction patterns |
|
||||
| USER.md | `{{ user_path }}` | Personal info, preferences, habits, work context, communication style |
|
||||
| MEMORY.md | `{{ memory_path }}` | Technical knowledge, project context, infrastructure, accounts |
|
||||
| SKILL.md | `skills/<name>/SKILL.md` | Reusable workflow templates ([SKILL] entries only) |
|
||||
|
||||
Cross-boundary rule: no technical configs in USER.md, no user facts in SOUL.md, no preferences in MEMORY.md. If a fact fits multiple files, keep the most specific copy and remove the rest.
|
||||
|
||||
## Delete-or-keep
|
||||
|
||||
**Always delete:**
|
||||
- Same fact at multiple locations — keep canonical copy only
|
||||
- Merged/closed PR notes, resolved incidents, superseded info
|
||||
- Verbose entries restatable in fewer words
|
||||
- Overlapping or nested sections covering the same topic
|
||||
|
||||
**Likely delete** (apply judgment):
|
||||
- Same fact at different detail levels — keep most complete version only
|
||||
- Debugging steps unlikely to recur
|
||||
- Ephemeral facts past their useful life
|
||||
- Tool/service details documented upstream
|
||||
- Lines with ``← Nd`` where N>{{ stale_threshold_days }} — closer review, not automatic removal
|
||||
|
||||
**Never delete:**
|
||||
- User preferences and personality traits (permanent regardless of age)
|
||||
- Active project context still referenced in conversations
|
||||
- Behavioral rules in SOUL.md
|
||||
|
||||
When removing: prefer deleting individual items over entire sections.
|
||||
|
||||
## Fact extraction
|
||||
- Atomic facts: "has a cat named Luna" not "discussed pet care"
|
||||
- Corrections: edit the existing entry, don't append a new one
|
||||
- Capture confirmed approaches the user validated
|
||||
|
||||
## Skill discovery & creation
|
||||
Flag [SKILL] only when ALL are true: repeatable workflow appeared 2+ times, involves clear steps (not vague preferences), substantial enough for its own instruction set. Check existing skills to avoid redundancy.
|
||||
|
||||
For [SKILL] entries:
|
||||
- Use write_file to create skills/<name>/SKILL.md; read_file `{{ skill_creator_path }}` for format reference
|
||||
- YAML frontmatter must include name, description, **and `dream_managed: true`** (marks this skill as Dream-created)
|
||||
- Under 2000 words: when to use, steps, output format, example
|
||||
- Do NOT overwrite existing skills — if overlapping, merge delta into the existing skill
|
||||
- Skills are instruction sets, not code. Keep concrete values in MEMORY.md; skills use placeholders
|
||||
|
||||
## Skill edit policy
|
||||
Each skill in the Existing Skills list is tagged with an origin:
|
||||
- **[dream]** — Dream-created (has `dream_managed: true` in frontmatter). You MAY edit these.
|
||||
- **[user]** — User-created workspace skill. {% if dream_edit_user_skills %}You MAY edit these.{% else %}You MUST NOT modify, rename, or delete these — you can only read them for context.{% endif %}
|
||||
- **[builtin]** — Bundled with nanobot. You MUST NEVER modify these.
|
||||
|
||||
## Editing
|
||||
- Default tool: apply_patch. Use edit_file only for small exact replacements.
|
||||
- File contents provided below — no read_file needed for initial edits.
|
||||
- Batch all changes into a single apply_patch call. Surgical edits only.
|
||||
- dry_run=true to preview. If nothing to update, stop without calling tools.
|
||||
|
||||
Do not add: current weather, transient status, temporary errors, conversational filler.
|
||||
@@ -1,40 +0,0 @@
|
||||
You have TWO equally important tasks:
|
||||
1. Extract new facts from conversation history
|
||||
2. Deduplicate existing memory files — find and flag redundant, overlapping, or stale content even if NOT mentioned in history
|
||||
|
||||
Output one line per finding:
|
||||
[FILE] atomic fact (not already in memory)
|
||||
[FILE-REMOVE] reason for removal
|
||||
[SKILL] kebab-case-name: one-line description of the reusable pattern
|
||||
|
||||
Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)
|
||||
|
||||
Rules:
|
||||
- Atomic facts: "has a cat named Luna" not "discussed pet care"
|
||||
- Corrections: [USER] location is Tokyo, not Osaka
|
||||
- Capture confirmed approaches the user validated
|
||||
|
||||
Deduplication — scan ALL memory files for these redundancy patterns:
|
||||
- Same fact stated in multiple places (e.g., "communicates in Chinese" in both USER.md and multiple MEMORY.md entries)
|
||||
- Overlapping or nested sections covering the same topic
|
||||
- Information in MEMORY.md that is already captured in USER.md or SOUL.md (MEMORY.md should not duplicate permanent-file content)
|
||||
- Verbose entries that can be condensed without losing information
|
||||
For each duplicate found, output [FILE-REMOVE] for the less authoritative copy (prefer keeping facts in their canonical location)
|
||||
|
||||
Staleness — MEMORY.md lines may have a ``← Nd`` suffix showing days since last modification:
|
||||
- SOUL.md and USER.md have no age annotations — they are permanent, only update with corrections
|
||||
- Age only indicates when content was last touched, not whether it should be removed
|
||||
- Use content judgment: user habits/preferences/personality traits are permanent regardless of age
|
||||
- Only prune content that is objectively outdated: passed events, resolved tracking, superseded approaches
|
||||
- Lines with ``← Nd`` (N>{{ stale_threshold_days }}) deserve closer review but are NOT automatically removable
|
||||
- When removing: prefer deleting individual items over entire sections
|
||||
|
||||
Skill discovery — flag [SKILL] when ALL of these are true:
|
||||
- A specific, repeatable workflow appeared 2+ times in the conversation history
|
||||
- It involves clear steps (not vague preferences like "likes concise answers")
|
||||
- It is substantial enough to warrant its own instruction set (not trivial like "read a file")
|
||||
- Do not worry about duplicates — the next phase will check against existing skills
|
||||
|
||||
Do not add: current weather, transient status, temporary errors, conversational filler.
|
||||
|
||||
[SKIP] if nothing needs updating.
|
||||
@@ -1,37 +0,0 @@
|
||||
Update memory files based on the analysis below.
|
||||
- [FILE] entries: add the described content to the appropriate file
|
||||
- [FILE-REMOVE] entries: delete the corresponding content from memory files
|
||||
- [SKILL] entries: create a new skill under skills/<name>/SKILL.md using write_file
|
||||
|
||||
## File paths (relative to workspace root)
|
||||
- SOUL.md
|
||||
- USER.md
|
||||
- memory/MEMORY.md
|
||||
- skills/<name>/SKILL.md (for [SKILL] entries only)
|
||||
|
||||
Do NOT guess paths.
|
||||
|
||||
## Editing rules
|
||||
- Edit directly — file contents provided below, no read_file needed
|
||||
- Use exact text as old_text, include surrounding blank lines for unique match
|
||||
- Batch changes to the same file into one edit_file call
|
||||
- For deletions: section header + all bullets as old_text, new_text empty
|
||||
- Surgical edits only — never rewrite entire files
|
||||
- If nothing to update, stop without calling tools
|
||||
|
||||
## Skill creation rules (for [SKILL] entries)
|
||||
- Use write_file to create skills/<name>/SKILL.md
|
||||
- Before writing, read_file `{{ skill_creator_path }}` for format reference (frontmatter structure, naming conventions, quality standards)
|
||||
- **Dedup check**: read existing skills listed below to verify the new skill is not functionally redundant. Skip creation if an existing skill already covers the same workflow.
|
||||
- Include YAML frontmatter with name and description fields
|
||||
- Keep SKILL.md under 2000 words — concise and actionable
|
||||
- Include: when to use, steps, output format, at least one example
|
||||
- Do NOT overwrite existing skills — skip if the skill directory already exists
|
||||
- Reference specific tools the agent has access to (read_file, write_file, exec, web_search, etc.)
|
||||
- Skills are instruction sets, not code — do not include implementation code
|
||||
|
||||
## Quality
|
||||
- Every line must carry standalone value
|
||||
- Concise bullets under clear headers
|
||||
- When reducing (not deleting): keep essential facts, drop verbose details
|
||||
- If uncertain whether to delete, keep but add "(verify currency)"
|
||||
@@ -30,5 +30,5 @@ Output is rendered in a terminal. Avoid markdown headings and tables. Use plain
|
||||
|
||||
Reply directly with text for the current conversation. Do not use the 'message' tool for normal replies in the current chat.
|
||||
When you need to call tools before answering, do not include the final user-visible answer in the same assistant message as the tool calls. Wait for the tool results, then answer once.
|
||||
Use the 'message' tool only for proactive sends, cross-channel delivery, or explicitly sending existing local files as attachments. When a tool such as 'generate_image' creates user-visible media, the runtime attaches those artifacts to the final assistant reply automatically, so do not call 'message' just to announce or resend them.
|
||||
Use the 'message' tool only for proactive sends, cross-channel delivery, or explicitly sending existing local files as attachments. When 'generate_image' creates images, call 'message' with the artifact paths in the 'media' parameter to deliver them to the user.
|
||||
To send an existing local file that was not automatically attached by another tool, call 'message' with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the document", channel="telegram", chat_id="...", media=["/path/to/file.pdf"])
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Tool Usage Notes
|
||||
|
||||
Tool signatures are provided automatically via function calling. This section
|
||||
documents the general tool contract and non-obvious usage patterns.
|
||||
|
||||
## General Tool Contract
|
||||
|
||||
- Use the narrowest structured tool that directly matches the task.
|
||||
- Use read-only discovery before writes when state is uncertain.
|
||||
- Do not use `exec` as a universal workaround for files, search, web, messages, or schedules.
|
||||
- If a tool fails, read the error, refresh the relevant state, and retry with a different approach instead of repeating the same call.
|
||||
- After meaningful changes, verify with the smallest reliable check: re-read changed state, run targeted tests, or inspect command output.
|
||||
- Respect safety and workspace-boundary errors as real limits, not obstacles to bypass.
|
||||
|
||||
## Discovery and Reading
|
||||
|
||||
- Use `find_files` or `list_dir` to locate workspace paths before `read_file` when a path is uncertain.
|
||||
- Use `grep` for content search inside the workspace; prefer it over shell grep for ordinary searches.
|
||||
- `grep` defaults to `output_mode="files_with_matches"`; use `output_mode="content"` for matching lines with context.
|
||||
- Use `fixed_strings=true` for literal keywords containing regex characters.
|
||||
- Use `output_mode="count"` to size a broad search before reading full matches.
|
||||
- Use `head_limit` and `offset` to page across large result sets.
|
||||
- Binary or oversized files may be skipped to keep results readable.
|
||||
|
||||
## File and Coding Workflows
|
||||
|
||||
- For code or config changes, the default loop is: locate (`find_files`/`grep`), inspect (`read_file`), edit (`apply_patch`), then verify (`exec` or re-read).
|
||||
- Use `apply_patch` as the default code editing tool, especially for multi-file changes, structural edits, generated code, moves, adds, or deletes.
|
||||
- Use `apply_patch dry_run=true` when the patch is uncertain and you want validation plus a change summary before writing.
|
||||
- Use `edit_file` only for small exact replacements in one file, with `old_text` copied from `read_file`; add `occurrence`, `line_hint`, or `expected_replacements` when ambiguity matters.
|
||||
- Use `write_file` for new files or intentional full-file rewrites, not routine partial edits.
|
||||
- If `apply_patch` or `edit_file` fails, re-read with `force=true`, narrow the context, and try a smaller patch rather than switching to shell `sed` or `echo`.
|
||||
|
||||
## Process Execution
|
||||
|
||||
- Use `exec` for tests, builds, package commands, git commands, and other process execution.
|
||||
- Prefer dedicated file/search tools over `cat`, shell `find`, shell `grep`, `sed`, or `echo` for ordinary workspace inspection and edits.
|
||||
- Use non-interactive flags such as `-y` or `--yes` when available.
|
||||
- Commands have a configurable timeout (default 60s), dangerous commands are blocked, and output is truncated.
|
||||
- For long-running or interactive commands, pass `yield_time_ms`; if the process keeps running, continue with `write_stdin`.
|
||||
- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session.
|
||||
- Use `list_exec_sessions` to recover active session IDs after context shifts.
|
||||
|
||||
## CLI App Attachments
|
||||
|
||||
- When Runtime Context lists a `CLI App Attachment` or `CLI App Mention`, treat the `@name` as an app capability the user intentionally attached to the current turn.
|
||||
- If the task may need app-specific behavior, read the listed skill first, then call `run_cli_app` with that `name`.
|
||||
- Do not run an attached CLI app through shell or generic process tools unless the user explicitly asks for that lower-level path.
|
||||
- If the app CLI is missing, lacks local desktop/app/API prerequisites, or cannot complete the requested action, explain that concrete blocker and what was attempted.
|
||||
|
||||
## Web and External Information
|
||||
|
||||
- Use web tools when the user asks for current information, a specific URL, or information likely to have changed.
|
||||
- Use `web_search` to find sources and `web_fetch` for a specific page or result that needs closer reading.
|
||||
- Do not invent freshness-sensitive facts when tools can verify them.
|
||||
|
||||
## Messaging and Media
|
||||
|
||||
- Use `message` to send content or local media to the user/channel.
|
||||
- `read_file` only reads content for your analysis; it does not deliver a file to the user.
|
||||
- When sending an existing local file, attach it through the message/media mechanism instead of pasting file contents unless the user asked for text.
|
||||
|
||||
## Scheduling and Background Work
|
||||
|
||||
- Use `cron` for scheduled reminders or recurring jobs; do not run `nanobot cron` through `exec`.
|
||||
- For heartbeat tasks, update `HEARTBEAT.md` according to the agent instructions.
|
||||
- Do not write reminders only to memory files when the user expects an actual notification.
|
||||
@@ -1,6 +1,42 @@
|
||||
"""Utility functions for nanobot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
|
||||
from nanobot.utils.helpers import ensure_dir
|
||||
from nanobot.utils.path import abbreviate_path
|
||||
|
||||
__all__ = ["ensure_dir", "abbreviate_path"]
|
||||
|
||||
|
||||
class _LazyModuleAlias(ModuleType):
|
||||
def __init__(self, name: str, target: str) -> None:
|
||||
super().__init__(name)
|
||||
self.__dict__["_target"] = target
|
||||
|
||||
def _load(self) -> ModuleType:
|
||||
module = import_module(self.__dict__["_target"])
|
||||
sys.modules[self.__name__] = module
|
||||
return module
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._load(), name)
|
||||
|
||||
def __dir__(self) -> list[str]:
|
||||
return sorted(set(super().__dir__()) | set(dir(self._load())))
|
||||
|
||||
|
||||
_LEGACY_MODULE_ALIASES = {
|
||||
"webui_thread_disk": "nanobot.webui.thread_disk",
|
||||
"webui_transcript": "nanobot.webui.transcript",
|
||||
"webui_turn_helpers": "nanobot.session.webui_turns",
|
||||
}
|
||||
|
||||
for _legacy_name, _target_name in _LEGACY_MODULE_ALIASES.items():
|
||||
sys.modules.setdefault(
|
||||
f"{__name__}.{_legacy_name}",
|
||||
_LazyModuleAlias(f"{__name__}.{_legacy_name}", _target_name),
|
||||
)
|
||||
|
||||
@@ -21,8 +21,6 @@ _MIME_EXTENSIONS = {
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
}
|
||||
_GENERATE_IMAGE_TOOL_NAME = "generate_image"
|
||||
|
||||
|
||||
class ArtifactError(ValueError):
|
||||
"""Raised when an artifact cannot be safely decoded or stored."""
|
||||
@@ -115,48 +113,10 @@ def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str:
|
||||
"artifacts": artifacts,
|
||||
"next_step": (
|
||||
"Use these artifact paths as reference_images for follow-up edits. "
|
||||
"For the current chat, reply naturally; the runtime attaches generated images automatically. "
|
||||
"Do not call message just to announce or resend them. Keep raw paths internal unless the user asks for debug details."
|
||||
"Call the message tool with the artifact paths in the media parameter "
|
||||
"to deliver the images to the user. Keep raw paths internal unless the "
|
||||
"user asks for debug details."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _extract_text_payload(content: Any) -> str | None:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and isinstance(block.get("text"), str):
|
||||
parts.append(block["text"])
|
||||
return "\n".join(parts) if parts else None
|
||||
return None
|
||||
|
||||
|
||||
def generated_image_paths_from_messages(messages: list[dict[str, Any]]) -> list[str]:
|
||||
"""Collect generated image artifact paths from generate_image tool results."""
|
||||
paths: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for message in messages:
|
||||
if message.get("role") != "tool" or message.get("name") != _GENERATE_IMAGE_TOOL_NAME:
|
||||
continue
|
||||
payload = _extract_text_payload(message.get("content"))
|
||||
if not payload:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
artifacts = data.get("artifacts") if isinstance(data, dict) else None
|
||||
if not isinstance(artifacts, list):
|
||||
continue
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
continue
|
||||
path = artifact.get("path")
|
||||
if isinstance(path, str) and path and path not in seen:
|
||||
paths.append(path)
|
||||
seen.add(path)
|
||||
return paths
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
|
||||
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
|
||||
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
|
||||
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
|
||||
_LIVE_EMIT_INTERVAL_S = 0.18
|
||||
_LIVE_EMIT_LINE_STEP = 24
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -103,6 +105,8 @@ def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
|
||||
"""Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
|
||||
if before is None or after is None:
|
||||
return 0, 0
|
||||
if before == "":
|
||||
return _text_line_count(after), 0
|
||||
before_lines = before.replace("\r\n", "\n").splitlines()
|
||||
after_lines = after.replace("\r\n", "\n").splitlines()
|
||||
added = 0
|
||||
@@ -118,6 +122,28 @@ def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
|
||||
return added, deleted
|
||||
|
||||
|
||||
def _text_line_count(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
line_count = 0
|
||||
last_was_newline = False
|
||||
last_was_cr = False
|
||||
for ch in text:
|
||||
if ch == "\r":
|
||||
line_count += 1
|
||||
last_was_newline = True
|
||||
last_was_cr = True
|
||||
elif ch == "\n":
|
||||
if not last_was_cr:
|
||||
line_count += 1
|
||||
last_was_newline = True
|
||||
last_was_cr = False
|
||||
else:
|
||||
last_was_newline = False
|
||||
last_was_cr = False
|
||||
return line_count if last_was_newline else line_count + 1
|
||||
|
||||
|
||||
def prepare_file_edit_tracker(
|
||||
*,
|
||||
call_id: str,
|
||||
@@ -126,19 +152,108 @@ def prepare_file_edit_tracker(
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> FileEditTracker | None:
|
||||
trackers = prepare_file_edit_trackers(
|
||||
call_id=call_id,
|
||||
tool_name=tool_name,
|
||||
tool=tool,
|
||||
workspace=workspace,
|
||||
params=params,
|
||||
)
|
||||
return trackers[0] if trackers else None
|
||||
|
||||
|
||||
def prepare_file_edit_trackers(
|
||||
*,
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> list[FileEditTracker]:
|
||||
if not is_file_edit_tool(tool_name):
|
||||
return None
|
||||
return []
|
||||
paths = resolve_file_edit_paths(tool_name, tool, workspace, params)
|
||||
trackers: list[FileEditTracker] = []
|
||||
seen: set[Path] = set()
|
||||
for path in paths:
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except Exception:
|
||||
resolved = path
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
before = read_file_snapshot(path)
|
||||
trackers.append(FileEditTracker(
|
||||
call_id=str(call_id or ""),
|
||||
tool=tool_name,
|
||||
path=path,
|
||||
display_path=display_file_edit_path(path, workspace),
|
||||
before=before,
|
||||
))
|
||||
return trackers
|
||||
|
||||
|
||||
def resolve_file_edit_paths(
|
||||
tool_name: str,
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> list[Path]:
|
||||
if tool_name == "apply_patch":
|
||||
return _resolve_apply_patch_paths(tool, workspace, params)
|
||||
path = resolve_file_edit_path(tool, workspace, params)
|
||||
if path is None:
|
||||
return None
|
||||
before = read_file_snapshot(path)
|
||||
return FileEditTracker(
|
||||
call_id=str(call_id or ""),
|
||||
tool=tool_name,
|
||||
path=path,
|
||||
display_path=display_file_edit_path(path, workspace),
|
||||
before=before,
|
||||
)
|
||||
return []
|
||||
return [path]
|
||||
|
||||
|
||||
def _resolve_apply_patch_paths(
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
params: dict[str, Any] | None,
|
||||
) -> list[Path]:
|
||||
if not isinstance(params, dict):
|
||||
return []
|
||||
edits = params.get("edits")
|
||||
if not isinstance(edits, list) or not edits:
|
||||
return []
|
||||
if params.get("dry_run") is True:
|
||||
return []
|
||||
|
||||
resolved: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for edit in edits:
|
||||
if not isinstance(edit, dict):
|
||||
continue
|
||||
raw_path = edit.get("path")
|
||||
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||
continue
|
||||
path = _resolve_raw_file_edit_path(tool, workspace, raw_path)
|
||||
if path is not None and path not in seen:
|
||||
seen.add(path)
|
||||
resolved.append(path)
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_raw_file_edit_path(
|
||||
tool: Any,
|
||||
workspace: Path | None,
|
||||
raw_path: str,
|
||||
) -> Path | None:
|
||||
resolver = getattr(tool, "_resolve", None)
|
||||
if callable(resolver):
|
||||
try:
|
||||
resolved = resolver(raw_path)
|
||||
if isinstance(resolved, Path):
|
||||
return resolved
|
||||
if resolved:
|
||||
return Path(resolved)
|
||||
except Exception:
|
||||
return None
|
||||
if workspace is None:
|
||||
return Path(raw_path).expanduser().resolve()
|
||||
return (workspace / raw_path).expanduser().resolve()
|
||||
|
||||
|
||||
def build_file_edit_start_event(
|
||||
@@ -160,12 +275,22 @@ def build_file_edit_start_event(
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
|
||||
def build_file_edit_end_event(
|
||||
tracker: FileEditTracker,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
after = read_file_snapshot(tracker.path)
|
||||
counted = False
|
||||
if tracker.before.countable and after.countable:
|
||||
added, deleted = line_diff_stats(tracker.before.text, after.text)
|
||||
counted = True
|
||||
else:
|
||||
added, deleted = 0, 0
|
||||
predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
|
||||
if tracker.before.countable and predicted_after is not None:
|
||||
added, deleted = line_diff_stats(tracker.before.text, predicted_after)
|
||||
counted = True
|
||||
else:
|
||||
added, deleted = 0, 0
|
||||
return _event_payload(
|
||||
tracker,
|
||||
phase="end",
|
||||
@@ -173,11 +298,14 @@ def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
approximate=False,
|
||||
binary=after.binary or after.oversized or after.unreadable,
|
||||
binary=(after.binary or after.oversized or after.unreadable) and not counted,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
|
||||
def build_file_edit_error_event(
|
||||
tracker: FileEditTracker,
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = _event_payload(
|
||||
tracker,
|
||||
phase="error",
|
||||
@@ -191,6 +319,594 @@ def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = No
|
||||
return payload
|
||||
|
||||
|
||||
def build_file_edit_live_event(
|
||||
tracker: FileEditTracker,
|
||||
*,
|
||||
added: int,
|
||||
deleted: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an approximate in-progress event while tool-call arguments stream."""
|
||||
return _event_payload(
|
||||
tracker,
|
||||
phase="start",
|
||||
status="editing",
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
approximate=True,
|
||||
)
|
||||
|
||||
|
||||
def build_file_edit_pending_event(
|
||||
*,
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
added: int = 0,
|
||||
deleted: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an early placeholder before the streamed JSON path is available."""
|
||||
return {
|
||||
"version": 1,
|
||||
"call_id": str(call_id or ""),
|
||||
"tool": tool_name,
|
||||
"path": "",
|
||||
"phase": "start",
|
||||
"added": max(0, int(added)),
|
||||
"deleted": max(0, int(deleted)),
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
"pending": True,
|
||||
}
|
||||
|
||||
|
||||
class StreamingFileEditTracker:
|
||||
"""Track file-edit tool arguments while the model is still streaming them.
|
||||
|
||||
Tool execution events only begin after the provider has completed the full
|
||||
function call. For large ``write_file`` calls, the long wait is usually the
|
||||
model producing the JSON ``content`` argument. Large ``edit_file`` calls
|
||||
can have the same wait while ``old_text`` / ``new_text`` stream in. This
|
||||
tracker converts those argument deltas into approximate WebUI file-edit
|
||||
events before the final exact diff is available.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path | None,
|
||||
tools: Any,
|
||||
emit: Callable[[list[dict[str, Any]]], Awaitable[None]],
|
||||
) -> None:
|
||||
self._workspace = workspace
|
||||
self._tools = tools
|
||||
self._emit = emit
|
||||
self._states: dict[str, _StreamingFileEditState] = {}
|
||||
|
||||
async def update(self, payload: dict[str, Any]) -> None:
|
||||
key = _stream_key(payload)
|
||||
if not key:
|
||||
return
|
||||
state = self._states.get(key)
|
||||
if state is None:
|
||||
state = _StreamingFileEditState(key=key)
|
||||
self._states[key] = state
|
||||
|
||||
state.apply_delta(payload)
|
||||
if state.name == "apply_patch":
|
||||
await self._update_apply_patch(state)
|
||||
return
|
||||
if state.name not in {"write_file", "edit_file"}:
|
||||
return
|
||||
if state.path is None:
|
||||
state.path = _extract_complete_json_string(state.arguments, "path")
|
||||
if state.path is None:
|
||||
added, deleted = state.live_diff_counts()
|
||||
now = time.monotonic()
|
||||
if state.should_emit_pending(added, deleted, now):
|
||||
state.mark_pending_emitted(added, deleted, now)
|
||||
await self._emit([build_file_edit_pending_event(
|
||||
call_id=state.call_id or state.key,
|
||||
tool_name=state.name,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
)])
|
||||
return
|
||||
if state.tracker is None:
|
||||
tool = self._tools.get(state.name) if hasattr(self._tools, "get") else None
|
||||
state.tracker = prepare_file_edit_tracker(
|
||||
call_id=state.call_id or state.key,
|
||||
tool_name=state.name,
|
||||
tool=tool,
|
||||
workspace=self._workspace,
|
||||
params={"path": state.path},
|
||||
)
|
||||
if state.tracker is None:
|
||||
return
|
||||
|
||||
added, deleted = state.live_diff_counts()
|
||||
now = time.monotonic()
|
||||
if not state.should_emit(added, deleted, now):
|
||||
return
|
||||
state.mark_emitted(added, deleted, now)
|
||||
await self._emit([build_file_edit_live_event(
|
||||
state.tracker,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
)])
|
||||
|
||||
async def _update_apply_patch(self, state: _StreamingFileEditState) -> None:
|
||||
if _json_bool_true(state.arguments, "dry_run"):
|
||||
return
|
||||
tool = self._tools.get("apply_patch") if hasattr(self._tools, "get") else None
|
||||
events: list[dict[str, Any]] = []
|
||||
now = time.monotonic()
|
||||
|
||||
path_matches = list(re.finditer(r'"path"\s*:\s*"([^"]+)"', state.arguments))
|
||||
if not path_matches:
|
||||
return
|
||||
|
||||
for i, m in enumerate(path_matches):
|
||||
raw_path = m.group(1)
|
||||
path = _resolve_raw_file_edit_path(tool, self._workspace, raw_path)
|
||||
if path is None:
|
||||
continue
|
||||
|
||||
segment_start = m.start()
|
||||
segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
|
||||
segment = state.arguments[segment_start:segment_end]
|
||||
|
||||
action_match = re.search(r'"action"\s*:\s*"(replace|add|delete)"', segment)
|
||||
action = action_match.group(1) if action_match else "replace"
|
||||
|
||||
old_text = _extract_json_string_prefix(segment, "old_text") or ""
|
||||
new_text = _extract_json_string_prefix(segment, "new_text") or ""
|
||||
|
||||
added = _text_line_count(new_text) if action in ("replace", "add") else 0
|
||||
deleted = _text_line_count(old_text) if action in ("replace", "delete") else 0
|
||||
delete_file = action == "delete"
|
||||
|
||||
file_state = state.patch_files.get(raw_path)
|
||||
if file_state is None:
|
||||
tracker = FileEditTracker(
|
||||
call_id=state.call_id or state.key,
|
||||
tool="apply_patch",
|
||||
path=path,
|
||||
display_path=display_file_edit_path(path, self._workspace),
|
||||
before=read_file_snapshot(path),
|
||||
)
|
||||
file_state = _StreamingPatchFileState(tracker=tracker)
|
||||
state.patch_files[raw_path] = file_state
|
||||
if delete_file and added == 0 and deleted == 0 and file_state.tracker.before.countable:
|
||||
deleted = _text_line_count(file_state.tracker.before.text or "")
|
||||
if not file_state.should_emit(added, deleted, now):
|
||||
continue
|
||||
file_state.mark_emitted(added, deleted, now)
|
||||
events.append(build_file_edit_live_event(
|
||||
file_state.tracker,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
))
|
||||
if events:
|
||||
await self._emit(events)
|
||||
|
||||
async def flush(self) -> None:
|
||||
events: list[dict[str, Any]] = []
|
||||
now = time.monotonic()
|
||||
for state in self._states.values():
|
||||
for file_state in state.patch_files.values():
|
||||
added, deleted = file_state.last_added, file_state.last_deleted
|
||||
if not file_state.emitted_once:
|
||||
continue
|
||||
if (
|
||||
file_state.last_emitted_added == added
|
||||
and file_state.last_emitted_deleted == deleted
|
||||
):
|
||||
continue
|
||||
file_state.mark_emitted(added, deleted, now)
|
||||
events.append(build_file_edit_live_event(
|
||||
file_state.tracker,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
))
|
||||
if state.tracker is None:
|
||||
continue
|
||||
added, deleted = state.live_diff_counts()
|
||||
if (
|
||||
state.last_emitted_added == added
|
||||
and state.last_emitted_deleted == deleted
|
||||
and state.emitted_once
|
||||
):
|
||||
continue
|
||||
state.mark_emitted(added, deleted, now)
|
||||
events.append(build_file_edit_live_event(
|
||||
state.tracker,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
))
|
||||
if events:
|
||||
await self._emit(events)
|
||||
|
||||
def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
|
||||
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
||||
used_canonicals: set[str] = set()
|
||||
for tool_call in final_tool_calls:
|
||||
canonical = self.canonical_call_id_for(tool_call)
|
||||
if canonical and canonical not in used_canonicals:
|
||||
try:
|
||||
tool_call.id = canonical
|
||||
used_canonicals.add(canonical)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
def canonical_call_id_for(self, tool_call: Any) -> str | None:
|
||||
for state in self._states.values():
|
||||
if state.matches_final_tool_call(tool_call):
|
||||
return state.call_id or (state.tracker.call_id if state.tracker else None) or state.key
|
||||
return None
|
||||
|
||||
async def error_unmatched(
|
||||
self,
|
||||
final_tool_calls: list[Any],
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Mark streamed edits as failed when no final tool call will run."""
|
||||
events: list[dict[str, Any]] = []
|
||||
for state in self._states.values():
|
||||
for file_state in state.patch_files.values():
|
||||
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
|
||||
continue
|
||||
events.append(build_file_edit_error_event(file_state.tracker, error))
|
||||
if state.tracker is None:
|
||||
continue
|
||||
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
|
||||
continue
|
||||
events.append(build_file_edit_error_event(state.tracker, error))
|
||||
if events:
|
||||
await self._emit(events)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _StreamingJsonStringField:
|
||||
key: str
|
||||
scan_pos: int | None = None
|
||||
closed: bool = False
|
||||
escape: bool = False
|
||||
unicode_remaining: int = 0
|
||||
unicode_buffer: str = ""
|
||||
newline_count: int = 0
|
||||
has_chars: bool = False
|
||||
last_char_newline: bool = False
|
||||
last_char_cr: bool = False
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
if not self.has_chars:
|
||||
return 0
|
||||
return self.newline_count + (0 if self.last_char_newline else 1)
|
||||
|
||||
def reset(self) -> None:
|
||||
self.scan_pos = None
|
||||
self.closed = False
|
||||
self.escape = False
|
||||
self.unicode_remaining = 0
|
||||
self.unicode_buffer = ""
|
||||
self.newline_count = 0
|
||||
self.has_chars = False
|
||||
self.last_char_newline = False
|
||||
self.last_char_cr = False
|
||||
|
||||
def scan(self, source: str) -> None:
|
||||
if self.closed:
|
||||
return
|
||||
if self.scan_pos is None:
|
||||
match = re.search(rf'"{re.escape(self.key)}"\s*:\s*"', source)
|
||||
if match is None:
|
||||
return
|
||||
self.scan_pos = match.end()
|
||||
i = self.scan_pos
|
||||
while i < len(source):
|
||||
ch = source[i]
|
||||
if self.unicode_remaining > 0:
|
||||
self.unicode_buffer += ch
|
||||
self.unicode_remaining -= 1
|
||||
if self.unicode_remaining == 0:
|
||||
try:
|
||||
decoded = chr(int(self.unicode_buffer, 16))
|
||||
except ValueError:
|
||||
decoded = "x"
|
||||
self.unicode_buffer = ""
|
||||
self._mark_char(decoded)
|
||||
i += 1
|
||||
continue
|
||||
if self.escape:
|
||||
self.escape = False
|
||||
if ch == "u":
|
||||
self.unicode_remaining = 4
|
||||
self.unicode_buffer = ""
|
||||
elif ch == "n":
|
||||
self._mark_char("\n")
|
||||
elif ch == "r":
|
||||
self._mark_char("\r")
|
||||
else:
|
||||
self._mark_char(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\\":
|
||||
self.escape = True
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
self.closed = True
|
||||
i += 1
|
||||
break
|
||||
self._mark_char(ch)
|
||||
i += 1
|
||||
self.scan_pos = i
|
||||
|
||||
def _mark_char(self, ch: str) -> None:
|
||||
self.has_chars = True
|
||||
if ch == "\r":
|
||||
self.newline_count += 1
|
||||
self.last_char_newline = True
|
||||
self.last_char_cr = True
|
||||
elif ch == "\n":
|
||||
if not self.last_char_cr:
|
||||
self.newline_count += 1
|
||||
self.last_char_newline = True
|
||||
self.last_char_cr = False
|
||||
else:
|
||||
self.last_char_newline = False
|
||||
self.last_char_cr = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _StreamingPatchFileState:
|
||||
tracker: FileEditTracker
|
||||
emitted_once: bool = False
|
||||
last_emitted_added: int = -1
|
||||
last_emitted_deleted: int = -1
|
||||
last_emit_at: float = 0.0
|
||||
last_added: int = 0
|
||||
last_deleted: int = 0
|
||||
|
||||
def should_emit(self, added: int, deleted: int, now: float) -> bool:
|
||||
self.last_added = added
|
||||
self.last_deleted = deleted
|
||||
if not self.emitted_once:
|
||||
return True
|
||||
if added == self.last_emitted_added and deleted == self.last_emitted_deleted:
|
||||
return False
|
||||
if max(
|
||||
abs(added - self.last_emitted_added),
|
||||
abs(deleted - self.last_emitted_deleted),
|
||||
) >= _LIVE_EMIT_LINE_STEP:
|
||||
return True
|
||||
return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S
|
||||
|
||||
def mark_emitted(self, added: int, deleted: int, now: float) -> None:
|
||||
self.emitted_once = True
|
||||
self.last_added = added
|
||||
self.last_deleted = deleted
|
||||
self.last_emitted_added = added
|
||||
self.last_emitted_deleted = deleted
|
||||
self.last_emit_at = now
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _StreamingFileEditState:
|
||||
key: str
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
arguments: str = ""
|
||||
path: str | None = None
|
||||
tracker: FileEditTracker | None = None
|
||||
content: _StreamingJsonStringField = field(
|
||||
default_factory=lambda: _StreamingJsonStringField("content")
|
||||
)
|
||||
old_text: _StreamingJsonStringField = field(
|
||||
default_factory=lambda: _StreamingJsonStringField("old_text")
|
||||
)
|
||||
new_text: _StreamingJsonStringField = field(
|
||||
default_factory=lambda: _StreamingJsonStringField("new_text")
|
||||
)
|
||||
patch_files: dict[str, _StreamingPatchFileState] = field(default_factory=dict)
|
||||
emitted_once: bool = False
|
||||
last_emitted_added: int = -1
|
||||
last_emitted_deleted: int = -1
|
||||
last_emit_at: float = 0.0
|
||||
pending_emitted: bool = False
|
||||
last_pending_added: int = -1
|
||||
last_pending_deleted: int = -1
|
||||
last_pending_at: float = 0.0
|
||||
|
||||
def apply_delta(self, payload: dict[str, Any]) -> None:
|
||||
call_id = payload.get("call_id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
self.call_id = call_id
|
||||
name = payload.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
self.name = name
|
||||
args = payload.get("arguments")
|
||||
if isinstance(args, str):
|
||||
self.arguments = args
|
||||
self.content.reset()
|
||||
self.old_text.reset()
|
||||
self.new_text.reset()
|
||||
self.patch_files.clear()
|
||||
return
|
||||
delta = payload.get("arguments_delta")
|
||||
if isinstance(delta, str) and delta:
|
||||
self.arguments += delta
|
||||
|
||||
def live_diff_counts(self) -> tuple[int, int]:
|
||||
if self.name == "write_file":
|
||||
self.content.scan(self.arguments)
|
||||
return self.content.line_count, 0
|
||||
if self.name == "edit_file":
|
||||
self.old_text.scan(self.arguments)
|
||||
self.new_text.scan(self.arguments)
|
||||
return self.new_text.line_count, self.old_text.line_count
|
||||
return 0, 0
|
||||
|
||||
def should_emit(self, added: int, deleted: int, now: float) -> bool:
|
||||
if not self.emitted_once:
|
||||
return True
|
||||
if added == self.last_emitted_added and deleted == self.last_emitted_deleted:
|
||||
return False
|
||||
if max(
|
||||
abs(added - self.last_emitted_added),
|
||||
abs(deleted - self.last_emitted_deleted),
|
||||
) >= _LIVE_EMIT_LINE_STEP:
|
||||
return True
|
||||
return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S
|
||||
|
||||
def mark_emitted(self, added: int, deleted: int, now: float) -> None:
|
||||
self.emitted_once = True
|
||||
self.last_emitted_added = added
|
||||
self.last_emitted_deleted = deleted
|
||||
self.last_emit_at = now
|
||||
|
||||
def should_emit_pending(self, added: int, deleted: int, now: float) -> bool:
|
||||
if not self.pending_emitted:
|
||||
return True
|
||||
if added == self.last_pending_added and deleted == self.last_pending_deleted:
|
||||
return False
|
||||
if max(
|
||||
abs(added - self.last_pending_added),
|
||||
abs(deleted - self.last_pending_deleted),
|
||||
) >= _LIVE_EMIT_LINE_STEP:
|
||||
return True
|
||||
return now - self.last_pending_at >= _LIVE_EMIT_INTERVAL_S
|
||||
|
||||
def mark_pending_emitted(self, added: int, deleted: int, now: float) -> None:
|
||||
self.pending_emitted = True
|
||||
self.last_pending_added = added
|
||||
self.last_pending_deleted = deleted
|
||||
self.last_pending_at = now
|
||||
|
||||
def matches_final_tool_call(self, tool_call: Any) -> bool:
|
||||
call_id = getattr(tool_call, "id", None)
|
||||
canonical = self.call_id or (self.tracker.call_id if self.tracker else "")
|
||||
if isinstance(call_id, str) and call_id and canonical and call_id == canonical:
|
||||
return True
|
||||
name = getattr(tool_call, "name", None)
|
||||
if name != self.name:
|
||||
return False
|
||||
if self.name == "apply_patch":
|
||||
arguments = getattr(tool_call, "arguments", None)
|
||||
if not isinstance(arguments, dict):
|
||||
return False
|
||||
edits = arguments.get("edits")
|
||||
if not isinstance(edits, list):
|
||||
return False
|
||||
return '"edits"' in self.arguments
|
||||
arguments = getattr(tool_call, "arguments", None)
|
||||
if not isinstance(arguments, dict):
|
||||
return False
|
||||
path = arguments.get("path")
|
||||
if self.path is None and isinstance(path, str) and path:
|
||||
self.path = path
|
||||
return True
|
||||
return isinstance(path, str) and path == self.path
|
||||
|
||||
|
||||
def _stream_key(payload: dict[str, Any]) -> str:
|
||||
index = payload.get("index")
|
||||
if isinstance(index, int):
|
||||
return f"idx:{index}"
|
||||
if isinstance(index, str) and index:
|
||||
return f"idx:{index}"
|
||||
call_id = payload.get("call_id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
return f"id:{call_id}"
|
||||
return ""
|
||||
|
||||
|
||||
def _json_bool_true(source: str, key: str) -> bool:
|
||||
return re.search(rf'"{re.escape(key)}"\s*:\s*true\b', source) is not None
|
||||
|
||||
|
||||
def _extract_json_string_prefix(source: str, key: str) -> str | None:
|
||||
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
|
||||
if match is None:
|
||||
return None
|
||||
out: list[str] = []
|
||||
i = match.end()
|
||||
escape = False
|
||||
while i < len(source):
|
||||
ch = source[i]
|
||||
if escape:
|
||||
escape = False
|
||||
if ch == "n":
|
||||
out.append("\n")
|
||||
elif ch == "r":
|
||||
out.append("\r")
|
||||
elif ch == "t":
|
||||
out.append("\t")
|
||||
elif ch == "u":
|
||||
digits = source[i + 1:i + 5]
|
||||
if len(digits) < 4:
|
||||
break
|
||||
try:
|
||||
out.append(chr(int(digits, 16)))
|
||||
except ValueError:
|
||||
break
|
||||
i += 4
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
return "".join(out)
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _extract_complete_json_string(source: str, key: str) -> str | None:
|
||||
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
|
||||
if match is None:
|
||||
return None
|
||||
out: list[str] = []
|
||||
i = match.end()
|
||||
escape = False
|
||||
while i < len(source):
|
||||
ch = source[i]
|
||||
if escape:
|
||||
escape = False
|
||||
if ch == "n":
|
||||
out.append("\n")
|
||||
elif ch == "r":
|
||||
out.append("\r")
|
||||
elif ch == "t":
|
||||
out.append("\t")
|
||||
elif ch == "u":
|
||||
digits = source[i + 1:i + 5]
|
||||
if len(digits) < 4:
|
||||
return None
|
||||
try:
|
||||
out.append(chr(int(digits, 16)))
|
||||
except ValueError:
|
||||
return None
|
||||
i += 4
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
return "".join(out)
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _event_payload(
|
||||
tracker: FileEditTracker,
|
||||
*,
|
||||
@@ -206,6 +922,7 @@ def _event_payload(
|
||||
"call_id": tracker.call_id,
|
||||
"tool": tracker.tool,
|
||||
"path": tracker.display_path,
|
||||
"absolute_path": tracker.path.as_posix(),
|
||||
"phase": phase,
|
||||
"added": max(0, int(added)),
|
||||
"deleted": max(0, int(deleted)),
|
||||
@@ -241,71 +958,4 @@ def _predict_after_text(
|
||||
return before_text.replace(old_text, new_text)
|
||||
return before_text.replace(old_text, new_text, 1)
|
||||
return None
|
||||
if tool_name == "notebook_edit":
|
||||
return _predict_notebook_after_text(params, before_text)
|
||||
return None
|
||||
|
||||
|
||||
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
|
||||
try:
|
||||
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
|
||||
except Exception:
|
||||
return None
|
||||
cells = nb.get("cells")
|
||||
if not isinstance(cells, list):
|
||||
return None
|
||||
try:
|
||||
cell_index = int(params.get("cell_index", 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
new_source = params.get("new_source")
|
||||
source = new_source if isinstance(new_source, str) else ""
|
||||
cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
|
||||
mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
|
||||
if mode == "delete":
|
||||
if 0 <= cell_index < len(cells):
|
||||
cells.pop(cell_index)
|
||||
else:
|
||||
return None
|
||||
elif mode == "insert":
|
||||
insert_at = min(max(cell_index + 1, 0), len(cells))
|
||||
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
|
||||
else:
|
||||
if not (0 <= cell_index < len(cells)):
|
||||
return None
|
||||
cell = cells[cell_index]
|
||||
if not isinstance(cell, dict):
|
||||
return None
|
||||
cell["source"] = source
|
||||
cell["cell_type"] = cell_type
|
||||
if cell_type == "code":
|
||||
cell.setdefault("outputs", [])
|
||||
cell.setdefault("execution_count", None)
|
||||
else:
|
||||
cell.pop("outputs", None)
|
||||
cell.pop("execution_count", None)
|
||||
nb["cells"] = cells
|
||||
try:
|
||||
return json.dumps(nb, indent=1, ensure_ascii=False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _empty_notebook() -> dict[str, Any]:
|
||||
return {
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {
|
||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
||||
"language_info": {"name": "python"},
|
||||
},
|
||||
"cells": [],
|
||||
}
|
||||
|
||||
|
||||
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
|
||||
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
|
||||
if cell_type == "code":
|
||||
cell["outputs"] = []
|
||||
cell["execution_count"] = None
|
||||
return cell
|
||||
|
||||
@@ -19,7 +19,8 @@ class CommitInfo:
|
||||
|
||||
def format(self, diff: str = "") -> str:
|
||||
"""Format this commit for display, optionally with a diff."""
|
||||
header = f"## {self.message.splitlines()[0]}\n`{self.sha}` — {self.timestamp}\n"
|
||||
summary = self.message.splitlines()[0] if self.message else "(no message)"
|
||||
header = f"## {summary}\n`{self.sha}` — {self.timestamp}\n"
|
||||
if diff:
|
||||
return f"{header}\n```diff\n{diff}\n```"
|
||||
return f"{header}\n(no file changes)"
|
||||
|
||||
@@ -576,7 +576,7 @@ def build_status_content(
|
||||
|
||||
|
||||
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
|
||||
"""Sync bundled templates to workspace. Only creates missing files."""
|
||||
"""Sync bundled templates to workspace. Creates missing files without overwriting user files."""
|
||||
from importlib.resources import files as pkg_files
|
||||
|
||||
try:
|
||||
@@ -589,10 +589,11 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
|
||||
added: list[str] = []
|
||||
|
||||
def _write(src, dest: Path):
|
||||
content = src.read_text(encoding="utf-8") if src else ""
|
||||
if dest.exists():
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
added.append(str(dest.relative_to(workspace)))
|
||||
|
||||
for item in tpl.iterdir():
|
||||
|
||||
@@ -29,6 +29,11 @@ LENGTH_RECOVERY_PROMPT = (
|
||||
"— no recap, no apology. Break remaining work into smaller steps if needed."
|
||||
)
|
||||
|
||||
SUSTAINED_GOAL_CONTINUE_PROMPT = (
|
||||
"You have an active sustained goal. Please continue working toward the "
|
||||
"objective using your tools, or call complete_goal if the work is truly finished."
|
||||
)
|
||||
|
||||
|
||||
def empty_tool_result_message(tool_name: str) -> str:
|
||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||
@@ -65,6 +70,11 @@ def build_length_recovery_message() -> dict[str, str]:
|
||||
return {"role": "user", "content": LENGTH_RECOVERY_PROMPT}
|
||||
|
||||
|
||||
def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
|
||||
"""Prompt the model to continue when a sustained goal is still active."""
|
||||
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
||||
|
||||
|
||||
def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None:
|
||||
"""Stable signature for repeated external lookups we want to throttle."""
|
||||
if tool_name == "web_fetch":
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Session replay: ensure assistant ``media`` paths are under the media root.
|
||||
|
||||
WebUI history signing (``/api/.../messages``) only works for files inside
|
||||
``get_media_dir``. Tool-driven attachments may live in the workspace; stage
|
||||
copies into the websocket media bucket before persisting message JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
|
||||
def stage_media_paths_for_session_replay(paths: list[str]) -> list[str]:
|
||||
"""Keep local files only; copy anything outside the media root into ``media/websocket``."""
|
||||
root = get_media_dir().resolve()
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in paths:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
if raw.startswith(("http://", "https://")):
|
||||
continue
|
||||
try:
|
||||
p = Path(raw).expanduser().resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if not p.is_file():
|
||||
continue
|
||||
try:
|
||||
p.relative_to(root)
|
||||
key = str(p)
|
||||
except ValueError:
|
||||
try:
|
||||
media_dir = get_media_dir("websocket")
|
||||
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_filename(p.name) or 'attachment'}"
|
||||
shutil.copyfile(p, staged)
|
||||
key = str(staged.resolve())
|
||||
except OSError as exc:
|
||||
logger.warning("failed to stage session media from {}: {}", raw, exc)
|
||||
continue
|
||||
if key not in seen:
|
||||
out.append(key)
|
||||
seen.add(key)
|
||||
return out
|
||||
|
||||
|
||||
def merge_turn_media_into_last_assistant(
|
||||
all_messages: list[dict[str, Any]],
|
||||
generated_image_paths: list[str],
|
||||
extra_attachment_paths: list[str],
|
||||
) -> None:
|
||||
"""Attach staged paths to the last assistant row in *all_messages* (in-place)."""
|
||||
merged = list(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*stage_media_paths_for_session_replay(generated_image_paths),
|
||||
*stage_media_paths_for_session_replay(extra_attachment_paths),
|
||||
]
|
||||
)
|
||||
)
|
||||
last = all_messages[-1] if all_messages else None
|
||||
if not merged or not last or last.get("role") != "assistant":
|
||||
return
|
||||
existing = last.get("media")
|
||||
base = existing if isinstance(existing, list) else []
|
||||
last["media"] = list(dict.fromkeys([*base, *merged]))
|
||||
@@ -11,8 +11,10 @@ _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
|
||||
"read_file": (["path", "file_path"], "read {}", True, False),
|
||||
"write_file": (["path", "file_path"], "write {}", True, False),
|
||||
"edit": (["file_path", "path"], "edit {}", True, False),
|
||||
"find_files": (["query", "glob", "path"], "find {}", False, False),
|
||||
"grep": (["pattern"], 'grep "{}"', False, False),
|
||||
"exec": (["command"], "$ {}", False, True),
|
||||
"list_exec_sessions": ([], "exec sessions", False, False),
|
||||
"web_search": (["query"], 'search "{}"', False, False),
|
||||
"web_fetch": (["url"], "fetch {}", True, False),
|
||||
"list_dir": (["path"], "ls {}", True, False),
|
||||
@@ -81,6 +83,8 @@ def _extract_arg(tc, key_args: list[str]) -> str | None:
|
||||
|
||||
def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str:
|
||||
"""Format a registered tool using its template."""
|
||||
if not fmt[0] and "{}" not in fmt[1]:
|
||||
return fmt[1]
|
||||
val = _extract_arg(tc, fmt[0])
|
||||
if val is None:
|
||||
return tc.name
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Backend helpers for the bundled WebUI surface."""
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""CLI Apps helpers for the WebUI HTTP and message surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
_CLI_APP_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||
_CLI_APP_ATTACHMENT_KEYS = (
|
||||
"name",
|
||||
"display_name",
|
||||
"category",
|
||||
"entry_point",
|
||||
"logo_url",
|
||||
"brand_color",
|
||||
)
|
||||
|
||||
|
||||
def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def normalize_cli_app_mentions(raw: Any) -> list[dict[str, str]]:
|
||||
"""Sanitize structured CLI app mentions sent by the WebUI."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw[:8]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = _clip_ws_string(item.get("name"), 64)
|
||||
if not name or _CLI_APP_NAME_RE.match(name) is None:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
row: dict[str, str] = {"name": key}
|
||||
for field in _CLI_APP_ATTACHMENT_KEYS[1:]:
|
||||
value = _clip_ws_string(item.get(field), 512 if field == "logo_url" else 160)
|
||||
if value:
|
||||
row[field] = value
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _query_first(query: QueryParams, key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _manager() -> CliAppManager:
|
||||
config = load_config()
|
||||
cli_cfg = config.tools.cli_apps
|
||||
return CliAppManager(
|
||||
workspace=config.workspace_path,
|
||||
runtime=CliAppsRuntimeConfig(
|
||||
install_timeout=cli_cfg.install_timeout,
|
||||
run_timeout=cli_cfg.run_timeout,
|
||||
catalog_ttl_seconds=cli_cfg.catalog_ttl_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def cli_apps_payload() -> dict[str, Any]:
|
||||
return _manager().payload()
|
||||
|
||||
|
||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise CliAppError("missing CLI app name")
|
||||
manager = _manager()
|
||||
if action == "install":
|
||||
return manager.install(name)
|
||||
if action == "update":
|
||||
return manager.update(name)
|
||||
if action == "uninstall":
|
||||
return manager.uninstall(name)
|
||||
if action == "test":
|
||||
return manager.test(name)
|
||||
raise CliAppError(f"unknown CLI app action '{action}'", status=404)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
"""Compatibility exports for WebUI-attached MCP preset annotations."""
|
||||
|
||||
from nanobot.agent.tools.mcp import runtime_lines, session_extra
|
||||
|
||||
__all__ = ["runtime_lines", "session_extra"]
|
||||
@@ -0,0 +1,681 @@
|
||||
"""Settings REST helpers for the WebUI HTTP surface.
|
||||
|
||||
The WebSocket channel owns transport/authentication. This module owns the
|
||||
settings payload shape and the allowlisted config mutations exposed to WebUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.providers.image_generation import (
|
||||
get_image_gen_provider,
|
||||
image_gen_provider_names,
|
||||
)
|
||||
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
|
||||
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
||||
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
||||
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
||||
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
||||
)
|
||||
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||
}
|
||||
|
||||
_IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
"1:1",
|
||||
"3:4",
|
||||
"9:16",
|
||||
"4:3",
|
||||
"16:9",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"21:9",
|
||||
}
|
||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
|
||||
|
||||
class WebUISettingsError(ValueError):
|
||||
"""User-facing settings validation failure."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def _query_first(query: QueryParams, key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
|
||||
value = _query_first(query, snake)
|
||||
return _query_first(query, camel) if value is None else value
|
||||
|
||||
|
||||
def _mask_secret_hint(secret: str | None) -> str | None:
|
||||
if not secret:
|
||||
return None
|
||||
if len(secret) <= 8:
|
||||
return "••••"
|
||||
return f"{secret[:4]}••••{secret[-4:]}"
|
||||
|
||||
|
||||
def _provider_requires_api_key(spec: Any) -> bool:
|
||||
if spec.backend == "azure_openai":
|
||||
return True
|
||||
if spec.is_oauth:
|
||||
return False
|
||||
if spec.is_local or spec.is_direct:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
||||
if spec.is_oauth:
|
||||
return True
|
||||
if _provider_requires_api_key(spec):
|
||||
return bool(provider_config.api_key)
|
||||
return bool(
|
||||
provider_config.api_key
|
||||
or provider_config.api_base
|
||||
or getattr(provider_config, "region", None)
|
||||
or getattr(provider_config, "profile", None)
|
||||
)
|
||||
|
||||
|
||||
def _parse_bool(value: str, field: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||
raise WebUISettingsError(f"{field} must be boolean")
|
||||
return normalized in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _model_configuration_slug(label: str) -> str:
|
||||
normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower())
|
||||
normalized = normalized.strip("-_")
|
||||
if not normalized:
|
||||
raise WebUISettingsError("configuration name is required")
|
||||
if normalized == "default":
|
||||
raise WebUISettingsError("configuration name is reserved")
|
||||
if len(normalized) > 48:
|
||||
normalized = normalized[:48].rstrip("-_")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_configured_provider(config: Any, provider: str) -> None:
|
||||
if provider == "auto":
|
||||
return
|
||||
spec = find_by_name(provider)
|
||||
if spec is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
provider_config = getattr(config.providers, provider, None)
|
||||
if (
|
||||
provider_config is None
|
||||
or not _provider_configured_for_settings(spec, provider_config)
|
||||
):
|
||||
raise WebUISettingsError("provider is not configured")
|
||||
|
||||
|
||||
def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name in image_gen_provider_names():
|
||||
spec = find_by_name(name)
|
||||
provider_config = getattr(config.providers, name, None)
|
||||
configured = (
|
||||
_provider_configured_for_settings(spec, provider_config)
|
||||
if spec is not None and provider_config is not None
|
||||
else bool(getattr(provider_config, "api_key", None))
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": spec.label if spec is not None else name,
|
||||
"configured": configured,
|
||||
"api_key_hint": _mask_secret_hint(
|
||||
getattr(provider_config, "api_key", None)
|
||||
),
|
||||
"api_base": getattr(provider_config, "api_base", None),
|
||||
"default_api_base": (
|
||||
spec.default_api_base if spec and spec.default_api_base else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
defaults = config.agents.defaults
|
||||
active_preset_name = defaults.model_preset or "default"
|
||||
try:
|
||||
effective_preset = config.resolve_preset()
|
||||
except Exception:
|
||||
effective_preset = config.resolve_default_preset()
|
||||
active_preset_name = "default"
|
||||
|
||||
provider_name = (
|
||||
config.get_provider_name(effective_preset.model, preset=effective_preset)
|
||||
or effective_preset.provider
|
||||
)
|
||||
provider = config.get_provider(effective_preset.model, preset=effective_preset)
|
||||
selected_provider = provider_name
|
||||
if effective_preset.provider != "auto":
|
||||
spec = find_by_name(effective_preset.provider)
|
||||
selected_provider = spec.name if spec else provider_name
|
||||
|
||||
providers = []
|
||||
for spec in PROVIDERS:
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None or spec.is_oauth:
|
||||
continue
|
||||
row = {
|
||||
"name": spec.name,
|
||||
"label": spec.label,
|
||||
"configured": _provider_configured_for_settings(spec, provider_config),
|
||||
"api_key_required": _provider_requires_api_key(spec),
|
||||
"api_key_hint": _mask_secret_hint(provider_config.api_key),
|
||||
"api_base": provider_config.api_base,
|
||||
"default_api_base": spec.default_api_base or None,
|
||||
}
|
||||
if spec.name == "openai":
|
||||
row["api_type"] = provider_config.api_type
|
||||
providers.append(row)
|
||||
|
||||
search_config = config.tools.web.search
|
||||
image_config = config.tools.image_generation
|
||||
search_provider = (
|
||||
search_config.provider
|
||||
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
||||
else "duckduckgo"
|
||||
)
|
||||
image_providers = _image_generation_provider_rows(config)
|
||||
selected_image_provider = next(
|
||||
(
|
||||
provider
|
||||
for provider in image_providers
|
||||
if provider["name"] == image_config.provider
|
||||
),
|
||||
None,
|
||||
)
|
||||
model_presets = [
|
||||
{
|
||||
"name": "default",
|
||||
"label": "Default",
|
||||
"active": active_preset_name == "default",
|
||||
"is_default": True,
|
||||
"model": defaults.model,
|
||||
"provider": defaults.provider,
|
||||
"max_tokens": defaults.max_tokens,
|
||||
"context_window_tokens": defaults.context_window_tokens,
|
||||
"temperature": defaults.temperature,
|
||||
"reasoning_effort": defaults.reasoning_effort,
|
||||
}
|
||||
]
|
||||
for name, preset in config.model_presets.items():
|
||||
model_presets.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": preset.label or name,
|
||||
"active": active_preset_name == name,
|
||||
"is_default": False,
|
||||
"model": preset.model,
|
||||
"provider": preset.provider,
|
||||
"max_tokens": preset.max_tokens,
|
||||
"context_window_tokens": preset.context_window_tokens,
|
||||
"temperature": preset.temperature,
|
||||
"reasoning_effort": preset.reasoning_effort,
|
||||
}
|
||||
)
|
||||
|
||||
exec_config = config.tools.exec
|
||||
return {
|
||||
"agent": {
|
||||
"model": effective_preset.model,
|
||||
"provider": selected_provider,
|
||||
"resolved_provider": provider_name,
|
||||
"has_api_key": bool(provider and provider.api_key),
|
||||
"model_preset": active_preset_name,
|
||||
"max_tokens": effective_preset.max_tokens,
|
||||
"context_window_tokens": effective_preset.context_window_tokens,
|
||||
"temperature": effective_preset.temperature,
|
||||
"reasoning_effort": effective_preset.reasoning_effort,
|
||||
"timezone": defaults.timezone,
|
||||
"bot_name": defaults.bot_name,
|
||||
"bot_icon": defaults.bot_icon,
|
||||
"tool_hint_max_length": defaults.tool_hint_max_length,
|
||||
},
|
||||
"model_presets": model_presets,
|
||||
"providers": providers,
|
||||
"web_search": {
|
||||
"provider": search_provider,
|
||||
"api_key_hint": _mask_secret_hint(search_config.api_key),
|
||||
"base_url": search_config.base_url or None,
|
||||
"max_results": search_config.max_results,
|
||||
"timeout": search_config.timeout,
|
||||
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
||||
},
|
||||
"web": {
|
||||
"enable": config.tools.web.enable,
|
||||
"proxy": config.tools.web.proxy,
|
||||
"user_agent": config.tools.web.user_agent,
|
||||
"search": {
|
||||
"max_results": search_config.max_results,
|
||||
"timeout": search_config.timeout,
|
||||
},
|
||||
"fetch": {
|
||||
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
||||
},
|
||||
},
|
||||
"image_generation": {
|
||||
"enabled": image_config.enabled,
|
||||
"provider": image_config.provider,
|
||||
"provider_configured": bool(
|
||||
selected_image_provider and selected_image_provider["configured"]
|
||||
),
|
||||
"model": image_config.model,
|
||||
"default_aspect_ratio": image_config.default_aspect_ratio,
|
||||
"default_image_size": image_config.default_image_size,
|
||||
"max_images_per_turn": image_config.max_images_per_turn,
|
||||
"save_dir": image_config.save_dir,
|
||||
"providers": image_providers,
|
||||
},
|
||||
"runtime": {
|
||||
"config_path": str(get_config_path().expanduser()),
|
||||
"workspace_path": str(config.workspace_path),
|
||||
"gateway_host": config.gateway.host,
|
||||
"gateway_port": config.gateway.port,
|
||||
"heartbeat": {
|
||||
"enabled": config.gateway.heartbeat.enabled,
|
||||
"interval_s": config.gateway.heartbeat.interval_s,
|
||||
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
||||
},
|
||||
"dream": {
|
||||
"schedule": defaults.dream.describe_schedule(),
|
||||
"max_batch_size": defaults.dream.max_batch_size,
|
||||
"max_iterations": defaults.dream.max_iterations,
|
||||
"annotate_line_ages": defaults.dream.annotate_line_ages,
|
||||
"dream_edit_user_skills": defaults.dream.dream_edit_user_skills,
|
||||
},
|
||||
"unified_session": defaults.unified_session,
|
||||
},
|
||||
"advanced": {
|
||||
"restrict_to_workspace": config.tools.restrict_to_workspace,
|
||||
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
|
||||
"mcp_server_count": len(config.tools.mcp_servers),
|
||||
"exec_enabled": exec_config.enable,
|
||||
"exec_sandbox": exec_config.sandbox or None,
|
||||
"exec_path_append_set": bool(exec_config.path_append),
|
||||
},
|
||||
"requires_restart": requires_restart,
|
||||
}
|
||||
|
||||
|
||||
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
defaults = config.agents.defaults
|
||||
changed = False
|
||||
restart_required = False
|
||||
|
||||
if "model_preset" in query or "modelPreset" in query:
|
||||
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
||||
preset_value = None if not preset or preset == "default" else preset
|
||||
if preset_value is not None and preset_value not in config.model_presets:
|
||||
raise WebUISettingsError("unknown model preset")
|
||||
if defaults.model_preset != preset_value:
|
||||
defaults.model_preset = preset_value
|
||||
changed = True
|
||||
|
||||
model = _query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip()
|
||||
if not model:
|
||||
raise WebUISettingsError("model is required")
|
||||
if defaults.model != model:
|
||||
defaults.model = model
|
||||
changed = True
|
||||
|
||||
provider = _query_first(query, "provider")
|
||||
if provider is not None:
|
||||
provider = provider.strip()
|
||||
if not provider:
|
||||
raise WebUISettingsError("provider is required")
|
||||
_validate_configured_provider(config, provider)
|
||||
if defaults.provider != provider:
|
||||
defaults.provider = provider
|
||||
changed = True
|
||||
|
||||
timezone = _query_first(query, "timezone")
|
||||
if timezone is not None:
|
||||
timezone = timezone.strip()
|
||||
if not timezone:
|
||||
raise WebUISettingsError("timezone is required")
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except Exception:
|
||||
raise WebUISettingsError("invalid timezone") from None
|
||||
if defaults.timezone != timezone:
|
||||
defaults.timezone = timezone
|
||||
changed = True
|
||||
restart_required = True
|
||||
|
||||
bot_name = _query_first_alias(query, "bot_name", "botName")
|
||||
if bot_name is not None:
|
||||
bot_name = bot_name.strip()
|
||||
if not bot_name:
|
||||
raise WebUISettingsError("bot_name is required")
|
||||
if defaults.bot_name != bot_name:
|
||||
defaults.bot_name = bot_name
|
||||
changed = True
|
||||
restart_required = True
|
||||
|
||||
bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
|
||||
if bot_icon is not None:
|
||||
bot_icon = bot_icon.strip()
|
||||
if defaults.bot_icon != bot_icon:
|
||||
defaults.bot_icon = bot_icon
|
||||
changed = True
|
||||
restart_required = True
|
||||
|
||||
tool_hint_max_length = _query_first_alias(
|
||||
query,
|
||||
"tool_hint_max_length",
|
||||
"toolHintMaxLength",
|
||||
)
|
||||
if tool_hint_max_length is not None:
|
||||
try:
|
||||
parsed = int(tool_hint_max_length)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("tool_hint_max_length must be an integer") from None
|
||||
if parsed < 20 or parsed > 500:
|
||||
raise WebUISettingsError("tool_hint_max_length must be between 20 and 500")
|
||||
if defaults.tool_hint_max_length != parsed:
|
||||
defaults.tool_hint_max_length = parsed
|
||||
changed = True
|
||||
restart_required = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
|
||||
|
||||
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
||||
raw_name = (_query_first(query, "name") or label).strip()
|
||||
model = (_query_first(query, "model") or "").strip()
|
||||
provider = (_query_first(query, "provider") or "").strip()
|
||||
|
||||
if not label:
|
||||
label = raw_name
|
||||
if not model:
|
||||
raise WebUISettingsError("model is required")
|
||||
if not provider:
|
||||
raise WebUISettingsError("provider is required")
|
||||
|
||||
name = _model_configuration_slug(raw_name or label)
|
||||
config = load_config()
|
||||
if name in config.model_presets:
|
||||
raise WebUISettingsError("configuration already exists", status=409)
|
||||
_validate_configured_provider(config, provider)
|
||||
|
||||
base = config.resolve_default_preset()
|
||||
config.model_presets[name] = ModelPresetConfig(
|
||||
label=label,
|
||||
model=model,
|
||||
provider=provider,
|
||||
max_tokens=base.max_tokens,
|
||||
context_window_tokens=base.context_window_tokens,
|
||||
temperature=base.temperature,
|
||||
reasoning_effort=base.reasoning_effort,
|
||||
)
|
||||
config.agents.defaults.model_preset = name
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
|
||||
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
spec = find_by_name(provider_name)
|
||||
if spec is None or spec.is_oauth:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
|
||||
config = load_config()
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
|
||||
changed = False
|
||||
if "api_key" in query or "apiKey" in query:
|
||||
api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||
api_key = (api_key or "").strip() or None
|
||||
if provider_config.api_key != api_key:
|
||||
provider_config.api_key = api_key
|
||||
changed = True
|
||||
|
||||
if "api_base" in query or "apiBase" in query:
|
||||
api_base = _query_first_alias(query, "api_base", "apiBase")
|
||||
api_base = (api_base or "").strip() or None
|
||||
if provider_config.api_base != api_base:
|
||||
provider_config.api_base = api_base
|
||||
changed = True
|
||||
|
||||
if "api_type" in query:
|
||||
if spec.name == "openai":
|
||||
api_type = (_query_first(query, "api_type") or "").strip()
|
||||
try:
|
||||
parsed_api_type = type(provider_config)(api_type=api_type).api_type
|
||||
except Exception:
|
||||
raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None
|
||||
if provider_config.api_type != parsed_api_type:
|
||||
provider_config.api_type = parsed_api_type
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
image_config = config.tools.image_generation
|
||||
restart_required = (
|
||||
changed
|
||||
and image_config.enabled
|
||||
and image_config.provider == spec.name
|
||||
and get_image_gen_provider(spec.name) is not None
|
||||
)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
|
||||
|
||||
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||
if provider_option is None:
|
||||
raise WebUISettingsError("unknown web search provider")
|
||||
|
||||
config = load_config()
|
||||
search_config = config.tools.web.search
|
||||
web_config = config.tools.web
|
||||
previous_provider = search_config.provider
|
||||
changed = False
|
||||
restart_required = False
|
||||
|
||||
def set_search_value(attr: str, value: object) -> None:
|
||||
nonlocal changed
|
||||
if getattr(search_config, attr) != value:
|
||||
setattr(search_config, attr, value)
|
||||
changed = True
|
||||
|
||||
def set_fetch_value(attr: str, value: object) -> None:
|
||||
nonlocal changed
|
||||
if getattr(web_config.fetch, attr) != value:
|
||||
setattr(web_config.fetch, attr, value)
|
||||
changed = True
|
||||
|
||||
if search_config.provider != provider_name:
|
||||
search_config.provider = provider_name
|
||||
changed = True
|
||||
|
||||
credential = provider_option["credential"]
|
||||
if credential == "none":
|
||||
set_search_value("api_key", "")
|
||||
set_search_value("base_url", "")
|
||||
elif credential == "base_url":
|
||||
base_url = _query_first_alias(query, "base_url", "baseUrl")
|
||||
base_url = base_url.strip() if base_url is not None else None
|
||||
if not base_url and previous_provider == provider_name and search_config.base_url:
|
||||
base_url = search_config.base_url
|
||||
if not base_url:
|
||||
raise WebUISettingsError("base_url is required")
|
||||
set_search_value("base_url", base_url)
|
||||
set_search_value("api_key", "")
|
||||
else:
|
||||
api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||
api_key = api_key.strip() if api_key is not None else None
|
||||
if not api_key and previous_provider == provider_name and search_config.api_key:
|
||||
api_key = search_config.api_key
|
||||
if not api_key:
|
||||
raise WebUISettingsError("api_key is required")
|
||||
set_search_value("api_key", api_key)
|
||||
set_search_value("base_url", "")
|
||||
|
||||
max_results = _query_first_alias(query, "max_results", "maxResults")
|
||||
if max_results is not None:
|
||||
try:
|
||||
parsed = int(max_results)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_results must be an integer") from None
|
||||
if parsed < 1 or parsed > 10:
|
||||
raise WebUISettingsError("max_results must be between 1 and 10")
|
||||
set_search_value("max_results", parsed)
|
||||
|
||||
timeout = _query_first(query, "timeout")
|
||||
if timeout is not None:
|
||||
try:
|
||||
parsed_timeout = int(timeout)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("timeout must be an integer") from None
|
||||
if parsed_timeout < 1 or parsed_timeout > 120:
|
||||
raise WebUISettingsError("timeout must be between 1 and 120")
|
||||
set_search_value("timeout", parsed_timeout)
|
||||
|
||||
use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader")
|
||||
if use_jina_reader is not None:
|
||||
normalized = use_jina_reader.strip().lower()
|
||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||
raise WebUISettingsError("use_jina_reader must be boolean")
|
||||
previous_jina_reader = web_config.fetch.use_jina_reader
|
||||
set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"})
|
||||
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
||||
restart_required = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
|
||||
|
||||
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
image_config = config.tools.image_generation
|
||||
changed = False
|
||||
|
||||
provider_name = _query_first(query, "provider")
|
||||
if provider_name is not None:
|
||||
provider_name = provider_name.strip().lower()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("image generation provider is required")
|
||||
if get_image_gen_provider(provider_name) is None:
|
||||
raise WebUISettingsError("unknown image generation provider")
|
||||
if image_config.provider != provider_name:
|
||||
image_config.provider = provider_name
|
||||
changed = True
|
||||
|
||||
enabled = _query_first(query, "enabled")
|
||||
if enabled is not None:
|
||||
parsed_enabled = _parse_bool(enabled, "enabled")
|
||||
if image_config.enabled != parsed_enabled:
|
||||
image_config.enabled = parsed_enabled
|
||||
changed = True
|
||||
|
||||
model = _query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip()
|
||||
if not model:
|
||||
raise WebUISettingsError("image generation model is required")
|
||||
if len(model) > 200:
|
||||
raise WebUISettingsError("image generation model is too long")
|
||||
if image_config.model != model:
|
||||
image_config.model = model
|
||||
changed = True
|
||||
|
||||
default_aspect_ratio = _query_first_alias(
|
||||
query,
|
||||
"default_aspect_ratio",
|
||||
"defaultAspectRatio",
|
||||
)
|
||||
if default_aspect_ratio is not None:
|
||||
default_aspect_ratio = default_aspect_ratio.strip()
|
||||
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
|
||||
raise WebUISettingsError("unsupported image generation aspect ratio")
|
||||
if image_config.default_aspect_ratio != default_aspect_ratio:
|
||||
image_config.default_aspect_ratio = default_aspect_ratio
|
||||
changed = True
|
||||
|
||||
default_image_size = _query_first_alias(
|
||||
query,
|
||||
"default_image_size",
|
||||
"defaultImageSize",
|
||||
)
|
||||
if default_image_size is not None:
|
||||
default_image_size = default_image_size.strip()
|
||||
if not default_image_size:
|
||||
raise WebUISettingsError("default image size is required")
|
||||
if len(default_image_size) > 32 or not all(
|
||||
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
|
||||
for char in default_image_size
|
||||
):
|
||||
raise WebUISettingsError("unsupported image generation size")
|
||||
if image_config.default_image_size != default_image_size:
|
||||
image_config.default_image_size = default_image_size
|
||||
changed = True
|
||||
|
||||
max_images_per_turn = _query_first_alias(
|
||||
query,
|
||||
"max_images_per_turn",
|
||||
"maxImagesPerTurn",
|
||||
)
|
||||
if max_images_per_turn is not None:
|
||||
try:
|
||||
parsed_max = int(max_images_per_turn)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_images_per_turn must be an integer") from None
|
||||
if parsed_max < 1 or parsed_max > 8:
|
||||
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
|
||||
if image_config.max_images_per_turn != parsed_max:
|
||||
image_config.max_images_per_turn = parsed_max
|
||||
changed = True
|
||||
|
||||
if image_config.enabled:
|
||||
selected_provider = next(
|
||||
(
|
||||
provider
|
||||
for provider in _image_generation_provider_rows(config)
|
||||
if provider["name"] == image_config.provider
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not selected_provider or not selected_provider["configured"]:
|
||||
raise WebUISettingsError("image generation provider is not configured")
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=changed)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Persisted WebUI sidebar workspace state.
|
||||
|
||||
This state is UI-only metadata, scoped to the active nanobot instance data
|
||||
directory (the directory containing the current config.json). It deliberately
|
||||
does not modify agent sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_webui_dir
|
||||
|
||||
WEBUI_SIDEBAR_STATE_SCHEMA_VERSION = 1
|
||||
_MAX_STATE_FILE_BYTES = 256 * 1024
|
||||
_MAX_LIST_ITEMS = 2_000
|
||||
_MAX_MAP_ITEMS = 2_000
|
||||
_MAX_KEY_LEN = 512
|
||||
_MAX_TITLE_LEN = 160
|
||||
_MAX_TAG_LEN = 40
|
||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"}
|
||||
|
||||
|
||||
def webui_sidebar_state_path() -> Path:
|
||||
return get_webui_dir() / "sidebar-state.json"
|
||||
|
||||
|
||||
def default_webui_sidebar_state() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
|
||||
"pinned_keys": [],
|
||||
"archived_keys": [],
|
||||
"title_overrides": {},
|
||||
"tags_by_key": {},
|
||||
"collapsed_groups": {},
|
||||
"view": {
|
||||
"density": "comfortable",
|
||||
"show_previews": False,
|
||||
"show_timestamps": False,
|
||||
"show_archived": False,
|
||||
"sort": "updated_desc",
|
||||
},
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _clean_string(value: Any, *, max_len: int = _MAX_KEY_LEN) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
return cleaned[:max_len]
|
||||
|
||||
|
||||
def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value[:_MAX_LIST_ITEMS]:
|
||||
cleaned = _clean_string(item, max_len=max_len)
|
||||
if cleaned is None or cleaned in seen:
|
||||
continue
|
||||
seen.add(cleaned)
|
||||
out.append(cleaned)
|
||||
return out
|
||||
|
||||
|
||||
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
out: dict[str, bool] = {}
|
||||
for key, raw in list(value.items())[:_MAX_MAP_ITEMS]:
|
||||
cleaned_key = _clean_string(key)
|
||||
if cleaned_key is None:
|
||||
continue
|
||||
out[cleaned_key] = bool(raw)
|
||||
return out
|
||||
|
||||
|
||||
def _clean_title_overrides(value: Any) -> dict[str, str]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for key, raw_title in list(value.items())[:_MAX_MAP_ITEMS]:
|
||||
cleaned_key = _clean_string(key)
|
||||
cleaned_title = _clean_string(raw_title, max_len=_MAX_TITLE_LEN)
|
||||
if cleaned_key is None or cleaned_title is None:
|
||||
continue
|
||||
out[cleaned_key] = cleaned_title
|
||||
return out
|
||||
|
||||
|
||||
def _clean_tags_by_key(value: Any) -> dict[str, list[str]]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for key, raw_tags in list(value.items())[:_MAX_MAP_ITEMS]:
|
||||
cleaned_key = _clean_string(key)
|
||||
if cleaned_key is None:
|
||||
continue
|
||||
tags = _clean_string_list(raw_tags, max_len=_MAX_TAG_LEN)[:12]
|
||||
if tags:
|
||||
out[cleaned_key] = tags
|
||||
return out
|
||||
|
||||
|
||||
def _clean_view(value: Any) -> dict[str, Any]:
|
||||
default = default_webui_sidebar_state()["view"]
|
||||
if not isinstance(value, dict):
|
||||
return dict(default)
|
||||
density = value.get("density")
|
||||
sort = value.get("sort")
|
||||
return {
|
||||
"density": density if density in _ALLOWED_DENSITIES else default["density"],
|
||||
"show_previews": bool(value.get("show_previews", default["show_previews"])),
|
||||
"show_timestamps": bool(value.get("show_timestamps", default["show_timestamps"])),
|
||||
"show_archived": bool(value.get("show_archived", default["show_archived"])),
|
||||
"sort": sort if sort in _ALLOWED_SORTS else default["sort"],
|
||||
}
|
||||
|
||||
|
||||
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||
"""Return a schema-v1 sidebar state from any older/partial input."""
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
state = default_webui_sidebar_state()
|
||||
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
|
||||
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
|
||||
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
|
||||
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||
state["view"] = _clean_view(raw.get("view"))
|
||||
updated_at = raw.get("updated_at")
|
||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
||||
return state
|
||||
|
||||
|
||||
def read_webui_sidebar_state() -> dict[str, Any]:
|
||||
path = webui_sidebar_state_path()
|
||||
if not path.is_file():
|
||||
return default_webui_sidebar_state()
|
||||
try:
|
||||
if path.stat().st_size > _MAX_STATE_FILE_BYTES:
|
||||
logger.warning("webui sidebar state too large, ignoring: {}", path)
|
||||
return default_webui_sidebar_state()
|
||||
with open(path, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("read webui sidebar state failed {}: {}", path, e)
|
||||
return default_webui_sidebar_state()
|
||||
return normalize_webui_sidebar_state(raw)
|
||||
|
||||
|
||||
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
state = normalize_webui_sidebar_state(raw)
|
||||
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
encoded = json.dumps(
|
||||
state,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
if len(encoded) > _MAX_STATE_FILE_BYTES:
|
||||
raise ValueError("sidebar state is too large")
|
||||
|
||||
path = webui_sidebar_state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(encoded)
|
||||
f.write(b"\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
dir_fd = os.open(path.parent, os.O_RDONLY)
|
||||
except OSError:
|
||||
return state
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
return state
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use webui_transcript."""
|
||||
"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use transcript."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,7 +8,7 @@ from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_webui_dir
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.utils.webui_transcript import delete_webui_transcript
|
||||
from nanobot.webui.transcript import delete_webui_transcript
|
||||
|
||||
|
||||
def webui_thread_file_path(session_key: str) -> Path:
|
||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -16,6 +18,61 @@ from nanobot.session.manager import SessionManager
|
||||
|
||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
||||
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||
)
|
||||
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
})
|
||||
|
||||
|
||||
def rewrite_local_markdown_images(
|
||||
text: str,
|
||||
*,
|
||||
workspace_path: Path,
|
||||
sign_path: Callable[[Path], Mapping[str, Any] | None],
|
||||
) -> str:
|
||||
"""Rewrite markdown image paths inside the workspace to signed WebUI media URLs."""
|
||||
if "![" not in text:
|
||||
return text
|
||||
|
||||
def resolve_url(raw_url: str) -> str | None:
|
||||
url = raw_url.strip()
|
||||
if url.startswith("<") and url.endswith(">"):
|
||||
url = url[1:-1].strip()
|
||||
if not url or url.startswith(("/api/media/", "#")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
||||
return None
|
||||
path_text = unquote(url)
|
||||
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
|
||||
return None
|
||||
candidate = Path(path_text).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = workspace_path / candidate
|
||||
try:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
resolved.relative_to(workspace_path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
signed = sign_path(resolved)
|
||||
return str(signed.get("url")) if signed and signed.get("url") else None
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
signed_url = resolve_url(match.group(2))
|
||||
if not signed_url:
|
||||
return match.group(0)
|
||||
title = match.group(3) or ""
|
||||
return f""
|
||||
|
||||
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
||||
|
||||
|
||||
def webui_transcript_path(session_key: str) -> Path:
|
||||
@@ -99,21 +156,93 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
|
||||
if not isinstance(events, list):
|
||||
return []
|
||||
lines: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for event in events:
|
||||
if not event or not isinstance(event, dict):
|
||||
continue
|
||||
if event.get("phase") != "start":
|
||||
if event.get("phase") not in {"start", "end", "error"}:
|
||||
continue
|
||||
call_id = event.get("call_id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
if call_id in seen:
|
||||
continue
|
||||
seen.add(call_id)
|
||||
t = _format_tool_call_trace(event)
|
||||
if t:
|
||||
lines.append(t)
|
||||
return lines
|
||||
|
||||
|
||||
_PHASE_RANK = {"start": 1, "end": 2, "error": 3}
|
||||
|
||||
|
||||
def _normalize_tool_events(events: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(events, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
if not event or not isinstance(event, dict):
|
||||
continue
|
||||
if event.get("phase") not in {"start", "end", "error"}:
|
||||
continue
|
||||
if not isinstance(event.get("name"), str):
|
||||
fn = event.get("function")
|
||||
if not (isinstance(fn, dict) and isinstance(fn.get("name"), str)):
|
||||
continue
|
||||
out.append(dict(event))
|
||||
return out
|
||||
|
||||
|
||||
def _tool_event_key(event: dict[str, Any]) -> str:
|
||||
call_id = event.get("call_id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
return f"call:{call_id}"
|
||||
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not isinstance(previous, list) or not previous:
|
||||
return incoming
|
||||
if not incoming:
|
||||
return [dict(event) for event in previous if isinstance(event, dict)]
|
||||
merged = [dict(event) for event in previous if isinstance(event, dict)]
|
||||
index_by_key = {_tool_event_key(event): idx for idx, event in enumerate(merged)}
|
||||
for event in incoming:
|
||||
key = _tool_event_key(event)
|
||||
existing_index = index_by_key.get(key)
|
||||
if existing_index is None:
|
||||
index_by_key[key] = len(merged)
|
||||
merged.append(event)
|
||||
continue
|
||||
existing = merged[existing_index]
|
||||
incoming_rank = _PHASE_RANK.get(str(event.get("phase")), 0)
|
||||
existing_rank = _PHASE_RANK.get(str(existing.get("phase")), 0)
|
||||
if incoming_rank >= existing_rank:
|
||||
merged[existing_index] = {**existing, **event}
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_unique_tool_trace_lines(
|
||||
previous_traces: list[str],
|
||||
lines: list[str],
|
||||
) -> tuple[list[str], bool]:
|
||||
seen_lines = set(previous_traces)
|
||||
traces = list(previous_traces)
|
||||
added = False
|
||||
for line in lines:
|
||||
if line in seen_lines:
|
||||
continue
|
||||
seen_lines.add(line)
|
||||
traces.append(line)
|
||||
added = True
|
||||
return traces, added
|
||||
|
||||
|
||||
def replay_transcript_to_ui_messages(
|
||||
lines: list[dict[str, Any]],
|
||||
*,
|
||||
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||
augment_assistant_text: Callable[[str], str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
|
||||
|
||||
@@ -144,6 +273,17 @@ def replay_transcript_to_ui_messages(
|
||||
def _ensure_activity_segment() -> str:
|
||||
return active_activity_segment_id or _new_activity_segment()
|
||||
|
||||
def close_activity_for_answer() -> None:
|
||||
nonlocal active_activity_segment_id, active_file_edit_segment_id
|
||||
active_activity_segment_id = None
|
||||
active_file_edit_segment_id = None
|
||||
|
||||
def close_file_edit_phase_before_activity() -> None:
|
||||
nonlocal active_activity_segment_id, active_file_edit_segment_id
|
||||
if active_file_edit_segment_id:
|
||||
active_activity_segment_id = None
|
||||
active_file_edit_segment_id = None
|
||||
|
||||
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
|
||||
for i in range(len(prev) - 1, -1, -1):
|
||||
candidate = prev[i]
|
||||
@@ -243,7 +383,7 @@ def replay_transcript_to_ui_messages(
|
||||
return
|
||||
|
||||
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
|
||||
nonlocal active_activity_segment_id
|
||||
nonlocal active_activity_segment_id, active_file_edit_segment_id
|
||||
last = messages[-1] if messages else None
|
||||
if last and is_reasoning_only_placeholder(last):
|
||||
messages[-1] = {
|
||||
@@ -262,35 +402,50 @@ def replay_transcript_to_ui_messages(
|
||||
},
|
||||
)
|
||||
active_activity_segment_id = None
|
||||
active_file_edit_segment_id = None
|
||||
|
||||
def _file_edit_key(edit: dict[str, Any]) -> str:
|
||||
return "|".join(
|
||||
str(edit.get(k) or "")
|
||||
for k in ("call_id", "tool", "path")
|
||||
)
|
||||
call_id = str(edit.get("call_id") or "")
|
||||
tool = str(edit.get("tool") or "")
|
||||
if call_id:
|
||||
return f"{call_id}|{tool}"
|
||||
return f"{tool}|{edit.get('path') or ''}"
|
||||
|
||||
def find_file_edit_trace_index(
|
||||
segment: str | None,
|
||||
edits: list[dict[str, Any]],
|
||||
) -> int | None:
|
||||
incoming_keys = {_file_edit_key(edit) for edit in edits if isinstance(edit, dict)}
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
candidate = messages[i]
|
||||
if candidate.get("role") == "user":
|
||||
break
|
||||
if candidate.get("kind") != "trace" or not candidate.get("fileEdits"):
|
||||
continue
|
||||
if segment and candidate.get("activitySegmentId") == segment:
|
||||
return i
|
||||
existing_edits = candidate.get("fileEdits")
|
||||
if not isinstance(existing_edits, list):
|
||||
continue
|
||||
for existing in existing_edits:
|
||||
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
|
||||
return i
|
||||
return None
|
||||
|
||||
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
|
||||
nonlocal active_file_edit_segment_id
|
||||
if not edits:
|
||||
return
|
||||
last = messages[-1] if messages else None
|
||||
if (
|
||||
active_file_edit_segment_id
|
||||
and last
|
||||
and last.get("kind") == "trace"
|
||||
and last.get("fileEdits")
|
||||
):
|
||||
segment = active_file_edit_segment_id
|
||||
segment = active_file_edit_segment_id
|
||||
target_index = find_file_edit_trace_index(segment, edits)
|
||||
if target_index is not None:
|
||||
last = messages[target_index]
|
||||
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
|
||||
active_file_edit_segment_id = segment
|
||||
else:
|
||||
segment = _new_activity_segment(activate=False)
|
||||
if not segment:
|
||||
segment = _new_activity_segment(activate=False)
|
||||
active_file_edit_segment_id = segment
|
||||
if not (
|
||||
last
|
||||
and last.get("kind") == "trace"
|
||||
and not last.get("isStreaming")
|
||||
and last.get("fileEdits")
|
||||
and last.get("activitySegmentId") == segment
|
||||
):
|
||||
messages.append(
|
||||
{
|
||||
"id": _new_id("tr", idx),
|
||||
@@ -303,7 +458,11 @@ def replay_transcript_to_ui_messages(
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
)
|
||||
last = messages[-1]
|
||||
target_index = len(messages) - 1
|
||||
last = messages[target_index]
|
||||
if not segment:
|
||||
segment = _new_activity_segment(activate=False)
|
||||
active_file_edit_segment_id = segment
|
||||
existing = list(last.get("fileEdits") or [])
|
||||
index_by_key = {
|
||||
_file_edit_key(edit): pos
|
||||
@@ -316,11 +475,14 @@ def replay_transcript_to_ui_messages(
|
||||
key = _file_edit_key(edit)
|
||||
if key in index_by_key:
|
||||
pos = index_by_key[key]
|
||||
existing[pos] = {**existing[pos], **edit}
|
||||
merged = {**existing[pos], **edit}
|
||||
if edit.get("path") and not edit.get("pending"):
|
||||
merged.pop("pending", None)
|
||||
existing[pos] = merged
|
||||
else:
|
||||
index_by_key[key] = len(existing)
|
||||
existing.append(dict(edit))
|
||||
messages[-1] = {
|
||||
messages[target_index] = {
|
||||
**last,
|
||||
"fileEdits": existing,
|
||||
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||
@@ -350,6 +512,14 @@ def replay_transcript_to_ui_messages(
|
||||
row["media"] = media_att
|
||||
if all(m.get("kind") == "image" for m in media_att):
|
||||
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
|
||||
cli_apps = rec.get("cli_apps")
|
||||
if isinstance(cli_apps, list) and cli_apps:
|
||||
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
|
||||
mcp_presets = rec.get("mcp_presets")
|
||||
if isinstance(mcp_presets, list) and mcp_presets:
|
||||
row["mcpPresets"] = [
|
||||
dict(preset) for preset in mcp_presets if isinstance(preset, dict)
|
||||
]
|
||||
messages.append(row)
|
||||
continue
|
||||
|
||||
@@ -365,6 +535,7 @@ def replay_transcript_to_ui_messages(
|
||||
chunk = rec.get("text")
|
||||
if not isinstance(chunk, str):
|
||||
continue
|
||||
close_activity_for_answer()
|
||||
adopted = find_active_placeholder(messages) if buffer_message_id is None else None
|
||||
if buffer_message_id is None:
|
||||
if adopted:
|
||||
@@ -393,6 +564,24 @@ def replay_transcript_to_ui_messages(
|
||||
buffer_message_id = None
|
||||
buffer_parts = []
|
||||
continue
|
||||
final_text = rec.get("text")
|
||||
if isinstance(final_text, str):
|
||||
if buffer_message_id is None:
|
||||
buffer_message_id = _new_id("buf", idx)
|
||||
messages.append(
|
||||
{
|
||||
"id": buffer_message_id,
|
||||
"role": "assistant",
|
||||
"content": final_text,
|
||||
"isStreaming": True,
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
)
|
||||
else:
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("id") == buffer_message_id:
|
||||
messages[i] = {**m, "content": final_text, "isStreaming": True}
|
||||
break
|
||||
buffer_message_id = None
|
||||
buffer_parts = []
|
||||
continue
|
||||
@@ -403,6 +592,7 @@ def replay_transcript_to_ui_messages(
|
||||
chunk = rec.get("text")
|
||||
if not isinstance(chunk, str) or not chunk:
|
||||
continue
|
||||
close_file_edit_phase_before_activity()
|
||||
attach_reasoning_chunk(messages, chunk, idx)
|
||||
continue
|
||||
|
||||
@@ -424,10 +614,12 @@ def replay_transcript_to_ui_messages(
|
||||
line = rec.get("text")
|
||||
if not isinstance(line, str) or not line:
|
||||
continue
|
||||
close_file_edit_phase_before_activity()
|
||||
attach_reasoning_chunk(messages, line, idx)
|
||||
close_reasoning(messages)
|
||||
continue
|
||||
if kind in ("tool_hint", "progress"):
|
||||
structured_events = _normalize_tool_events(rec.get("tool_events"))
|
||||
structured = tool_trace_lines_from_events(rec.get("tool_events"))
|
||||
text = rec.get("text")
|
||||
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
|
||||
@@ -442,13 +634,22 @@ def replay_transcript_to_ui_messages(
|
||||
and (last.get("activitySegmentId") in (None, segment))
|
||||
):
|
||||
prev_traces = list(last.get("traces") or [last.get("content")])
|
||||
merged_traces = prev_traces + trace_lines
|
||||
messages[-1] = {
|
||||
if structured:
|
||||
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
|
||||
if not added and not structured_events:
|
||||
continue
|
||||
else:
|
||||
merged_traces = prev_traces + trace_lines
|
||||
merged = {
|
||||
**last,
|
||||
"traces": merged_traces,
|
||||
"content": trace_lines[-1],
|
||||
"content": merged_traces[-1],
|
||||
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
|
||||
if structured_events
|
||||
else last.get("toolEvents"),
|
||||
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||
}
|
||||
messages[-1] = merged
|
||||
else:
|
||||
messages.append(
|
||||
{
|
||||
@@ -457,6 +658,7 @@ def replay_transcript_to_ui_messages(
|
||||
"kind": "trace",
|
||||
"content": trace_lines[-1],
|
||||
"traces": trace_lines,
|
||||
**({"toolEvents": structured_events} if structured_events else {}),
|
||||
"activitySegmentId": segment,
|
||||
"createdAt": _ts_base + idx,
|
||||
},
|
||||
@@ -505,7 +707,14 @@ def replay_transcript_to_ui_messages(
|
||||
buffer_parts = []
|
||||
continue
|
||||
|
||||
for m in messages:
|
||||
for i, m in enumerate(messages):
|
||||
if (
|
||||
augment_assistant_text is not None
|
||||
and m.get("role") == "assistant"
|
||||
and m.get("kind") != "trace"
|
||||
and isinstance(m.get("content"), str)
|
||||
):
|
||||
messages[i] = {**m, "content": augment_assistant_text(m["content"])}
|
||||
m.pop("isStreaming", None)
|
||||
m.pop("reasoningStreaming", None)
|
||||
return messages
|
||||
@@ -515,12 +724,17 @@ def build_webui_thread_response(
|
||||
session_key: str,
|
||||
*,
|
||||
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||
augment_assistant_text: Callable[[str], str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
|
||||
lines = read_transcript_lines(session_key)
|
||||
if not lines:
|
||||
return None
|
||||
msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media)
|
||||
msgs = replay_transcript_to_ui_messages(
|
||||
lines,
|
||||
augment_user_media=augment_user_media,
|
||||
augment_assistant_text=augment_assistant_text,
|
||||
)
|
||||
return {
|
||||
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||
"sessionKey": session_key,
|
||||
@@ -139,6 +139,13 @@ class TestLoadBootstrapFiles:
|
||||
for name in ContextBuilder.BOOTSTRAP_FILES:
|
||||
assert f"## {name}" in result
|
||||
|
||||
def test_legacy_tools_md_is_not_bootstrapped(self, tmp_path):
|
||||
(tmp_path / "TOOLS.md").write_text("workspace tool notes", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._load_bootstrap_files()
|
||||
assert "TOOLS.md" not in result
|
||||
assert "workspace tool notes" not in result
|
||||
|
||||
def test_utf8_content(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("用中文回复", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
@@ -171,6 +178,37 @@ class TestIsTemplateContent:
|
||||
assert ContextBuilder._is_template_content("totally different", "memory/MEMORY.md") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundled bootstrap templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBundledToolContract:
|
||||
def test_tool_contract_balances_general_and_coding_workflows(self):
|
||||
from importlib.resources import files as pkg_files
|
||||
|
||||
tpl = pkg_files("nanobot") / "templates" / "agent" / "tool_contract.md"
|
||||
content = tpl.read_text(encoding="utf-8")
|
||||
|
||||
assert "## General Tool Contract" in content
|
||||
assert "Use the narrowest structured tool" in content
|
||||
assert "Do not use `exec` as a universal workaround" in content
|
||||
assert "## File and Coding Workflows" in content
|
||||
assert "apply_patch" in content
|
||||
assert "## Web and External Information" in content
|
||||
assert "## Messaging and Media" in content
|
||||
assert "## Scheduling and Background Work" in content
|
||||
assert "pure coding" not in content.lower()
|
||||
|
||||
def test_tool_contract_is_injected_without_workspace_file(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
prompt = builder.build_system_prompt()
|
||||
|
||||
assert "# Tool Usage Notes" in prompt
|
||||
assert "## General Tool Contract" in prompt
|
||||
assert "Do not use `exec` as a universal workaround" in prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_user_content
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -324,6 +362,21 @@ class TestBuildMessages:
|
||||
assert "Other chat goal." not in str(without_goal[-1]["content"])
|
||||
assert "Goal (active):" not in str(without_goal[-1]["content"])
|
||||
|
||||
def test_current_runtime_lines_are_injected(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages(
|
||||
[],
|
||||
"please use @zoom tonight",
|
||||
current_runtime_lines=[
|
||||
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
|
||||
],
|
||||
)
|
||||
user_msg = str(messages[-1]["content"])
|
||||
|
||||
assert "CLI App Attachment: @zoom" in user_msg
|
||||
assert "tool=run_cli_app" in user_msg
|
||||
assert "entry_point=cli-anything-zoom" in user_msg
|
||||
|
||||
def test_consecutive_same_role_merged(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "user", "content": "previous user message"}]
|
||||
|
||||
@@ -314,8 +314,8 @@ def test_system_prompt_keeps_message_tool_out_of_current_chat_replies(tmp_path)
|
||||
prompt = builder.build_system_prompt(channel="slack")
|
||||
|
||||
assert "Do not use the 'message' tool for normal replies in the current chat" in prompt
|
||||
assert "the runtime attaches those artifacts to the final assistant reply automatically" in prompt
|
||||
assert "do not call 'message' just to announce or resend them" in prompt
|
||||
assert "When 'generate_image' creates images" in prompt
|
||||
assert "call 'message' with the artifact paths in the 'media' parameter" in prompt
|
||||
assert "Wait for the tool results, then answer once" in prompt
|
||||
|
||||
|
||||
|
||||
+397
-216
@@ -1,19 +1,32 @@
|
||||
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
||||
"""Tests for Dream driven through AgentLoop._process_system_message."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.memory import Dream, MemoryStore
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.utils.gitstore import LineAge
|
||||
|
||||
|
||||
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = default_model
|
||||
provider.generation = SimpleNamespace(
|
||||
max_tokens=max_tokens, temperature=0.1, reasoning_effort=None
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
|
||||
s = MemoryStore(tmp_path)
|
||||
s.write_soul("# Soul\n- Helpful")
|
||||
s.write_user("# User\n- Developer")
|
||||
@@ -23,9 +36,7 @@ def store(tmp_path):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
p = MagicMock()
|
||||
p.chat_with_retry = AsyncMock()
|
||||
return p
|
||||
return _provider("test-model")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -34,10 +45,16 @@ def mock_runner():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dream(store, mock_provider, mock_runner):
|
||||
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
|
||||
d._runner = mock_runner
|
||||
return d
|
||||
def loop(tmp_path, mock_provider, mock_runner):
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=mock_provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
context_window_tokens=1000,
|
||||
)
|
||||
loop.dream._runner = mock_runner
|
||||
return loop
|
||||
|
||||
|
||||
def _make_run_result(
|
||||
@@ -56,254 +73,418 @@ def _make_run_result(
|
||||
)
|
||||
|
||||
|
||||
class TestDreamRun:
|
||||
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should not call LLM when there's nothing to process."""
|
||||
result = await dream.run()
|
||||
assert result is False
|
||||
mock_provider.chat_with_retry.assert_not_called()
|
||||
class TestDreamAgentLoopIntegration:
|
||||
async def test_completes_goal_state_after_full_backlog(self, loop, mock_runner, store):
|
||||
"""Goal should be completed after processing all backlog in internal loop."""
|
||||
for i in range(6):
|
||||
store.append_history(f"event {i}")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
session = loop.sessions.get_or_create("system:dream")
|
||||
goal = session.metadata.get("goal_state")
|
||||
assert isinstance(goal, dict)
|
||||
assert goal["status"] == "completed"
|
||||
assert store.get_last_dream_cursor() == 6
|
||||
|
||||
async def test_completes_goal_state_on_finish(self, loop, mock_runner, store):
|
||||
"""Goal should be marked completed when backlog is fully processed."""
|
||||
store.append_history("event 1")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
session = loop.sessions.get_or_create("system:dream")
|
||||
goal = session.metadata.get("goal_state")
|
||||
assert goal["status"] == "completed"
|
||||
assert "completed_at" in goal
|
||||
assert "recap" in goal
|
||||
|
||||
async def test_noop_when_no_unprocessed_history(self, loop, mock_runner):
|
||||
"""Dream should not call runner when there's nothing to process."""
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
result = await loop._process_system_message(msg)
|
||||
assert result is None
|
||||
mock_runner.run.assert_not_called()
|
||||
|
||||
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
|
||||
async def test_calls_runner_for_unprocessed_entries(self, loop, mock_runner, store):
|
||||
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
||||
store.append_history("User prefers dark mode")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="New fact")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result(
|
||||
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
|
||||
))
|
||||
result = await dream.run()
|
||||
assert result is True
|
||||
mock_runner.run = AsyncMock(
|
||||
return_value=_make_run_result(
|
||||
tool_events=[
|
||||
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
||||
],
|
||||
)
|
||||
)
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
mock_runner.run.assert_called_once()
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
assert spec.max_iterations == 10
|
||||
assert spec.fail_on_tool_error is False
|
||||
|
||||
async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store):
|
||||
async def test_advances_dream_cursor(self, loop, mock_runner, store):
|
||||
"""Dream should advance the cursor after processing."""
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
await dream.run()
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
assert store.get_last_dream_cursor() == 2
|
||||
|
||||
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
|
||||
async def test_compacts_processed_history(self, loop, mock_runner, store):
|
||||
"""Dream should compact history after processing."""
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
store.append_history("event 3")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
await dream.run()
|
||||
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert all(e["cursor"] > 0 for e in entries)
|
||||
|
||||
async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should point skill creation guidance at the builtin skill-creator template."""
|
||||
async def test_processes_full_backlog_in_one_call(self, loop, mock_runner, store):
|
||||
"""Backlog larger than max_batch_size should be fully processed in one call."""
|
||||
for i in range(12):
|
||||
store.append_history(f"event {i}")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
assert store.get_last_dream_cursor() == 12
|
||||
assert mock_runner.run.call_count == 3 # 5 + 5 + 2
|
||||
|
||||
async def test_single_git_commit_for_multi_batch(self, loop, mock_runner, store):
|
||||
"""Multi-batch run should collapse into exactly one git commit."""
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial")
|
||||
for i in range(12):
|
||||
store.append_history(f"event {i}")
|
||||
mock_runner.run = AsyncMock(
|
||||
return_value=_make_run_result(
|
||||
tool_events=[
|
||||
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
||||
],
|
||||
)
|
||||
)
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
commits = store.git.log()
|
||||
dream_commits = [c for c in commits if c.message.startswith("dream:")]
|
||||
assert len(dream_commits) == 1
|
||||
|
||||
async def test_system_prompt_cached(self, loop, mock_runner, store):
|
||||
"""Batches within one run should reuse cached system prompt when template mtime unchanged."""
|
||||
for i in range(6):
|
||||
store.append_history(f"event {i}")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
# Two batches (5 + 1), both should use the same cached prompt
|
||||
assert mock_runner.run.call_count == 2
|
||||
first_prompt = mock_runner.run.call_args_list[0][0][0].initial_messages[0]["content"]
|
||||
second_prompt = mock_runner.run.call_args_list[1][0][0].initial_messages[0]["content"]
|
||||
assert second_prompt is first_prompt
|
||||
|
||||
async def test_noop_when_empty_backlog(self, loop, mock_runner, store):
|
||||
"""Empty backlog should not advance cursor or create a commit."""
|
||||
store.git.init()
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
assert store.get_last_dream_cursor() == 0
|
||||
commits = store.git.log()
|
||||
assert len([c for c in commits if c.message.startswith("dream:")]) == 0
|
||||
|
||||
|
||||
class TestDreamPrompt:
|
||||
async def test_prompt_contains_mece_rules(self, loop, mock_runner, store):
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
system_prompt = spec.initial_messages[0]["content"]
|
||||
assert "Do NOT guess paths" in system_prompt
|
||||
assert "SOUL.md" in system_prompt
|
||||
assert "USER.md" in system_prompt
|
||||
assert "MEMORY.md" in system_prompt
|
||||
|
||||
async def test_skill_phase_uses_builtin_skill_creator_path(self, loop, mock_runner, store):
|
||||
store.append_history("Repeated workflow one")
|
||||
store.append_history("Repeated workflow two")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKILL] test-skill: test description")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
system_prompt = spec.initial_messages[0]["content"]
|
||||
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
||||
assert expected in system_prompt
|
||||
|
||||
async def test_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
|
||||
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
|
||||
write_tool = dream._tools.get("write_file")
|
||||
assert write_tool is not None
|
||||
|
||||
result = await write_tool.execute(
|
||||
path="skills/test-skill/SKILL.md",
|
||||
content="---\nname: test-skill\ndescription: Test\n---\n",
|
||||
async def test_system_prompt_uses_threshold_from_template_var(self, loop, mock_runner, store):
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
|
||||
assert "Successfully wrote" in result
|
||||
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
|
||||
|
||||
async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store):
|
||||
"""Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
# Init git so line_ages works
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial memory state")
|
||||
|
||||
await dream.run()
|
||||
|
||||
# The MEMORY.md section should not crash and should contain the memory content
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
assert "## Current MEMORY.md" in user_msg
|
||||
|
||||
async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store):
|
||||
"""SOUL.md and USER.md should never have age annotations — they are permanent."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial state")
|
||||
|
||||
await dream.run()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
# The ← suffix should only appear in MEMORY.md section
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0]
|
||||
user_section = user_msg.split("## Current USER.md")[1]
|
||||
# SOUL and USER should not contain age arrows
|
||||
assert "\u2190" not in soul_section
|
||||
assert "\u2190" not in user_section
|
||||
|
||||
async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store):
|
||||
"""Phase 1 should work fine even if git is not initialized (no age annotations)."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
# Should still succeed — just without age annotations
|
||||
mock_provider.chat_with_retry.assert_called_once()
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
assert "## Current MEMORY.md" in user_msg
|
||||
|
||||
async def test_phase1_prompt_carries_age_suffix_for_stale_lines(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""End-to-end: ages >14d must appear verbatim in the LLM prompt, ages ≤14d must not."""
|
||||
# MEMORY.md fixture has 2 non-blank lines ("# Memory" and "- Project X active").
|
||||
# Inject four ages to cover threshold boundaries: >14 suffix, ==14 no suffix, <14 no suffix.
|
||||
store.write_memory("# Memory\n- Project X active\n- fresh item\n- edge case line")
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
fake_ages = [
|
||||
LineAge(age_days=30), # "# Memory" → should get ← 30d
|
||||
LineAge(age_days=20), # "- Project X..." → should get ← 20d
|
||||
LineAge(age_days=14), # "- fresh item" → ==14, threshold is strictly >14, no suffix
|
||||
LineAge(age_days=5), # "- edge case..." → no suffix
|
||||
]
|
||||
with patch.object(store.git, "line_ages", return_value=fake_ages):
|
||||
await dream.run()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
assert "\u2190 30d" in memory_section
|
||||
assert "\u2190 20d" in memory_section
|
||||
assert "\u2190 14d" not in memory_section
|
||||
assert "\u2190 5d" not in memory_section
|
||||
|
||||
async def test_phase1_skips_annotation_when_disabled(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""`annotate_line_ages=False` must bypass the git lookup entirely and keep MEMORY.md raw."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
dream.annotate_line_ages = False
|
||||
# line_ages must be bypassed entirely — verify with a spy rather than a
|
||||
# raising side_effect, because _annotate_with_ages catches Exception
|
||||
# (which swallows AssertionError) and would hide an accidental call.
|
||||
with patch.object(store.git, "line_ages") as mock_line_ages:
|
||||
await dream.run()
|
||||
mock_line_ages.assert_not_called()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
assert "\u2190" not in user_msg
|
||||
|
||||
async def test_phase1_skips_annotation_on_line_ages_length_mismatch(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""If ages length != lines length (dirty working tree), skip annotation instead of mis-tagging."""
|
||||
# MEMORY.md has 2 non-blank lines but we hand back only 1 age → mismatch.
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
with patch.object(store.git, "line_ages", return_value=[LineAge(age_days=999)]):
|
||||
await dream.run()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
# No age arrow at all — we refused to annotate rather than tag the wrong line.
|
||||
assert "\u2190" not in memory_section
|
||||
|
||||
async def test_phase1_prompt_uses_threshold_from_template_var(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""System prompt should reference the stale-threshold constant, not a hardcoded 14."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
system_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][0]["content"]
|
||||
# The template renders with stale_threshold_days=14 → LLM must see "N>14"
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
system_msg = spec.initial_messages[0]["content"]
|
||||
assert "N>14" in system_msg
|
||||
|
||||
|
||||
class TestDreamPromptCaps:
|
||||
"""Dream's Phase 1/2 prompt must not be poisoned by a legacy oversized
|
||||
history entry or a runaway MEMORY.md. Without caps, a single pre-#3412
|
||||
raw_archive dump in history.jsonl would make every subsequent Dream run
|
||||
exceed the context window and silently advance the cursor past real work.
|
||||
"""
|
||||
|
||||
async def test_phase1_caps_huge_memory_file(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""A MEMORY.md much larger than _MEMORY_FILE_MAX_CHARS must be truncated
|
||||
in the prompt preview (full content is still reachable via read_file)."""
|
||||
store.write_memory("M" * (dream._MEMORY_FILE_MAX_CHARS * 5))
|
||||
async def test_caps_huge_memory_file(self, loop, mock_runner, store):
|
||||
store.write_memory("M" * (loop.dream._MEMORY_FILE_MAX_CHARS * 5))
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
||||
"## Current SOUL.md"
|
||||
)[0]
|
||||
assert len(memory_section) < loop.dream._MEMORY_FILE_MAX_CHARS + 500
|
||||
|
||||
await dream.run()
|
||||
|
||||
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
assert len(memory_section) < dream._MEMORY_FILE_MAX_CHARS + 500
|
||||
|
||||
async def test_phase1_caps_huge_history_entry(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""A legacy oversized history entry (e.g. pre-#3412 raw_archive dump)
|
||||
must not explode the Phase 1 prompt — each entry is capped in the
|
||||
preview, even though the JSONL record itself stays full-size."""
|
||||
# Bypass the append_history cap by writing directly, simulating a
|
||||
# record that was written by an older nanobot build before any caps.
|
||||
async def test_caps_huge_history_entry(self, loop, mock_runner, store):
|
||||
store.history_file.write_text(
|
||||
json.dumps({
|
||||
"cursor": 1,
|
||||
"timestamp": "2026-04-01 10:00",
|
||||
"content": "H" * (dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
||||
}) + "\n",
|
||||
json.dumps(
|
||||
{
|
||||
"cursor": 1,
|
||||
"timestamp": "2026-04-01 10:00",
|
||||
"content": "H" * (loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
history_section = user_msg.split("## Conversation History\n")[1].split(
|
||||
"\n\n## Current Date"
|
||||
)[0]
|
||||
assert len(history_section) < loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
||||
|
||||
await dream.run()
|
||||
|
||||
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
history_section = user_msg.split("## Conversation History\n")[1].split("\n\n## Current Date")[0]
|
||||
assert len(history_section) < dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
||||
class TestDreamTools:
|
||||
def test_apply_patch_tool_registered(self, loop):
|
||||
tool = loop.dream._tools.get("apply_patch")
|
||||
assert tool is not None
|
||||
|
||||
|
||||
class TestDreamCaps:
|
||||
def test_batch_size_default_is_5(self):
|
||||
from nanobot.config.schema import DreamConfig
|
||||
|
||||
assert DreamConfig().max_batch_size == 5
|
||||
|
||||
def test_memory_cap_is_16k(self, loop):
|
||||
assert loop.dream._MEMORY_FILE_MAX_CHARS == 16_000
|
||||
|
||||
|
||||
class TestDreamSkipFiltering:
|
||||
async def test_skip_entries_removed_from_prompt(self, loop, mock_runner, store):
|
||||
store.append_history("- [skip] greeting\n- [permanent] User prefers dark mode")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
assert "User prefers dark mode" in user_msg
|
||||
assert "[skip]" not in user_msg
|
||||
assert "greeting" not in user_msg
|
||||
|
||||
|
||||
class TestDreamAgeAnnotations:
|
||||
async def test_prompt_includes_line_age_annotations(self, loop, mock_runner, store):
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial memory state")
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
assert "## Current MEMORY.md" in user_msg
|
||||
|
||||
async def test_annotates_only_memory_not_soul_or_user(self, loop, mock_runner, store):
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial state")
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
soul_section = user_msg.split("## Current SOUL.md")[1].split(
|
||||
"## Current USER.md"
|
||||
)[0]
|
||||
user_section = user_msg.split("## Current USER.md")[1]
|
||||
assert "←" not in soul_section
|
||||
assert "←" not in user_section
|
||||
|
||||
async def test_prompt_works_without_git(self, loop, mock_runner, store):
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
mock_runner.run.assert_called_once()
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
assert "## Current MEMORY.md" in user_msg
|
||||
|
||||
async def test_prompt_carries_age_suffix_for_stale_lines(self, loop, mock_runner, store):
|
||||
store.write_memory(
|
||||
"# Memory\n- Project X active\n- fresh item\n- edge case line"
|
||||
)
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
fake_ages = [
|
||||
LineAge(age_days=30),
|
||||
LineAge(age_days=20),
|
||||
LineAge(age_days=14),
|
||||
LineAge(age_days=5),
|
||||
]
|
||||
with patch.object(loop.dream.store.git, "line_ages", return_value=fake_ages):
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
||||
"## Current SOUL.md"
|
||||
)[0]
|
||||
assert "← 30d" in memory_section
|
||||
assert "← 20d" in memory_section
|
||||
assert "← 14d" not in memory_section
|
||||
assert "← 5d" not in memory_section
|
||||
|
||||
async def test_skips_annotation_when_disabled(self, loop, mock_runner, store):
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
loop.dream.annotate_line_ages = False
|
||||
with patch.object(loop.dream.store.git, "line_ages") as mock_line_ages:
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
mock_line_ages.assert_not_called()
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
assert "←" not in user_msg
|
||||
|
||||
async def test_skips_annotation_on_line_ages_length_mismatch(self, loop, mock_runner, store):
|
||||
store.append_history("some event")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
with patch.object(
|
||||
loop.dream.store.git, "line_ages", return_value=[LineAge(age_days=999)]
|
||||
):
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
user_msg = spec.initial_messages[1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
||||
"## Current SOUL.md"
|
||||
)[0]
|
||||
assert "←" not in memory_section
|
||||
|
||||
|
||||
class TestDreamSessionPersistence:
|
||||
async def test_writes_session_on_success(self, loop, mock_runner, store):
|
||||
store.append_history("event one")
|
||||
store.append_history("event two")
|
||||
mock_runner.run = AsyncMock(
|
||||
return_value=_make_run_result(
|
||||
tool_events=[
|
||||
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
||||
],
|
||||
)
|
||||
)
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
session_path = store.memory_dir / ".dream_session.json"
|
||||
assert session_path.exists()
|
||||
data = json.loads(session_path.read_text(encoding="utf-8"))
|
||||
assert data["batch"]["from_cursor"] == 0
|
||||
assert data["batch"]["to_cursor"] == 2
|
||||
assert data["batch"]["count"] == 2
|
||||
assert data["stop_reason"] == "completed"
|
||||
assert data["changelog"] == ["edit_file: memory/MEMORY.md"]
|
||||
assert "timestamp" in data
|
||||
assert "elapsed_seconds" in data
|
||||
assert "messages" in data
|
||||
|
||||
async def test_no_session_record_on_failure(self, loop, mock_runner, store):
|
||||
"""Failed batch should not write a session record (cursor stays put for retry)."""
|
||||
store.append_history("event one")
|
||||
mock_runner.run = AsyncMock(side_effect=RuntimeError("LLM error"))
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
session_path = store.memory_dir / ".dream_session.json"
|
||||
assert not session_path.exists()
|
||||
assert store.get_last_dream_cursor() == 0
|
||||
|
||||
async def test_session_contains_full_messages(self, loop, mock_runner, store):
|
||||
store.append_history("event one")
|
||||
messages = [
|
||||
{"role": "system", "content": "you are a memory bot"},
|
||||
{"role": "user", "content": "history here"},
|
||||
{"role": "assistant", "content": "I will edit MEMORY.md"},
|
||||
]
|
||||
result = _make_run_result()
|
||||
result.messages = messages
|
||||
mock_runner.run = AsyncMock(return_value=result)
|
||||
msg = InboundMessage(
|
||||
channel="system", sender_id="dream", chat_id="dream", content=""
|
||||
)
|
||||
await loop._process_system_message(msg)
|
||||
session_path = store.memory_dir / ".dream_session.json"
|
||||
data = json.loads(session_path.read_text(encoding="utf-8"))
|
||||
assert data["messages"] == messages
|
||||
assert data["prompt_chars"] > 0
|
||||
assert data["commit_sha"] is None
|
||||
|
||||
@@ -29,14 +29,15 @@ class FakeImageClient:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_image_media_is_attached_to_final_assistant_message(
|
||||
async def test_outbound_no_longer_carries_generated_media(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Media delivery is now the LLM's responsibility via the message tool."""
|
||||
set_config_path(tmp_path / "config.json")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.tools.image_generation.OpenRouterImageGenerationClient",
|
||||
FakeImageClient,
|
||||
"nanobot.agent.tools.image_generation.get_image_gen_provider",
|
||||
lambda name: FakeImageClient if name == "openrouter" else None,
|
||||
)
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
@@ -81,9 +82,6 @@ async def test_generated_image_media_is_attached_to_final_assistant_message(
|
||||
|
||||
assert result is not None
|
||||
assert result.content == "Done"
|
||||
assert len(result.media) == 1
|
||||
assert Path(result.media[0]).is_file()
|
||||
|
||||
session = loop.sessions.get_or_create("websocket:chat-image")
|
||||
assert session.messages[-1]["role"] == "assistant"
|
||||
assert session.messages[-1]["media"] == result.media
|
||||
# OutboundMessage no longer carries generated media —
|
||||
# the LLM sends images via the message tool instead.
|
||||
assert result.media == []
|
||||
|
||||
@@ -133,6 +133,7 @@ class TestToolEventProgress:
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"absolute_path": (tmp_path / "foo.txt").resolve().as_posix(),
|
||||
"phase": "start",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
@@ -309,6 +310,100 @@ class TestToolEventProgress:
|
||||
await invoke_file_edit_progress(telegram_progress, edit_events)
|
||||
assert bus.outbound_size == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
|
||||
"""The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
call_count = 0
|
||||
target = tmp_path / "goal.txt"
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
assert on_tool_call_delta is not None
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"call_id": "call-goal-write",
|
||||
"name": "write_file",
|
||||
"arguments_delta": '{"path":"goal.txt","content":"',
|
||||
})
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"arguments_delta": "one\\ntwo\\nthree\\n",
|
||||
})
|
||||
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call-goal-write",
|
||||
name="write_file",
|
||||
arguments={
|
||||
"path": "goal.txt",
|
||||
"content": "one\ntwo\nthree\n",
|
||||
},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
)
|
||||
return LLMResponse(content="Done", tool_calls=[], usage={})
|
||||
|
||||
async def execute(name: str, params: dict) -> str:
|
||||
assert name == "write_file"
|
||||
target.write_text(params["content"], encoding="utf-8")
|
||||
return "ok"
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[
|
||||
{"type": "function", "function": {"name": "write_file"}},
|
||||
])
|
||||
loop.tools.prepare_call = MagicMock(
|
||||
return_value=(
|
||||
None,
|
||||
{"path": "goal.txt", "content": "one\ntwo\nthree\n"},
|
||||
None,
|
||||
),
|
||||
)
|
||||
loop.tools.execute = AsyncMock(side_effect=execute)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="/goal create goal file",
|
||||
metadata={"_wants_stream": True},
|
||||
))
|
||||
|
||||
outbound = []
|
||||
while bus.outbound_size > 0:
|
||||
outbound.append(await bus.consume_outbound())
|
||||
|
||||
edit_events = [
|
||||
event
|
||||
for msg in outbound
|
||||
for event in msg.metadata.get("_file_edit_events", [])
|
||||
]
|
||||
assert any(
|
||||
event["status"] == "editing"
|
||||
and event["approximate"]
|
||||
and event["added"] == 3
|
||||
for event in edit_events
|
||||
)
|
||||
assert any(
|
||||
event["status"] == "done"
|
||||
and not event["approximate"]
|
||||
and event["added"] == 3
|
||||
for event in edit_events
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
|
||||
self,
|
||||
@@ -556,7 +651,7 @@ class TestToolEventProgress:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
|
||||
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
|
||||
fake_title_after_turn,
|
||||
)
|
||||
scheduled_title: list[object] = []
|
||||
@@ -603,7 +698,7 @@ class TestToolEventProgress:
|
||||
raise AssertionError("command-only turns should not generate titles")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
|
||||
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
|
||||
fake_title_after_turn,
|
||||
)
|
||||
scheduled: list[object] = []
|
||||
|
||||
@@ -11,7 +11,7 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.webui_turn_helpers import (
|
||||
from nanobot.session.webui_turns import (
|
||||
TITLE_GENERATION_MAX_TOKENS,
|
||||
TITLE_GENERATION_REASONING_EFFORT,
|
||||
WEBUI_SESSION_METADATA_KEY,
|
||||
@@ -143,7 +143,7 @@ def test_webui_title_update_uses_captured_llm_runtime(
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
|
||||
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
|
||||
fake_title_after_turn,
|
||||
)
|
||||
coordinator = WebuiTurnCoordinator(
|
||||
|
||||
@@ -2,12 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools import mcp as mcp_runtime
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
|
||||
class _FakeMcpTool(Tool):
|
||||
def __init__(self, name: str) -> None:
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "fake MCP tool"
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
async def execute(self, **_kwargs: Any) -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
|
||||
@@ -42,3 +69,152 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
|
||||
assert attempts == 2
|
||||
assert loop._mcp_connected is False
|
||||
assert loop._mcp_stacks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||
type="stdio",
|
||||
command="browserbase-mcp",
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
closed: list[str] = []
|
||||
|
||||
async def _mark_closed(name: str) -> None:
|
||||
closed.append(name)
|
||||
|
||||
async def _fake_connect(servers, registry):
|
||||
stacks = {}
|
||||
for name in servers:
|
||||
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
stack.push_async_callback(_mark_closed, name)
|
||||
stacks[name] = stack
|
||||
return stacks
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
loop = _make_loop(tmp_path, mcp_servers={})
|
||||
|
||||
added = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert added["ok"] is True
|
||||
assert added["added"] == ["browserbase"]
|
||||
assert loop.tools.has("mcp_browserbase_navigate")
|
||||
assert "browserbase" in loop._mcp_stacks
|
||||
|
||||
config = load_config()
|
||||
del config.tools.mcp_servers["browserbase"]
|
||||
save_config(config)
|
||||
|
||||
removed = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert removed["ok"] is True
|
||||
assert removed["removed"] == ["browserbase"]
|
||||
assert not loop.tools.has("mcp_browserbase_navigate")
|
||||
assert "browserbase" not in loop._mcp_stacks
|
||||
assert closed == ["browserbase"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_mcp_reload_reaches_runtime_control_without_restart(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||
type="stdio",
|
||||
command="browserbase-mcp",
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
closed: list[str] = []
|
||||
|
||||
async def _mark_closed(name: str) -> None:
|
||||
closed.append(name)
|
||||
|
||||
async def _fake_connect(servers, registry):
|
||||
stacks = {}
|
||||
for name in servers:
|
||||
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
stack.push_async_callback(_mark_closed, name)
|
||||
stacks[name] = stack
|
||||
return stacks
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
loop = _make_loop(tmp_path, mcp_servers={})
|
||||
|
||||
async def _handle_one_runtime_control() -> None:
|
||||
msg = await loop.bus.consume_inbound()
|
||||
handled = await mcp_runtime.handle_runtime_control(loop, msg, loop.tools)
|
||||
assert handled is True
|
||||
|
||||
consumer = asyncio.create_task(_handle_one_runtime_control())
|
||||
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
|
||||
await consumer
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["added"] == ["browserbase"]
|
||||
assert result["requires_restart"] is False
|
||||
assert loop.tools.has("mcp_browserbase_navigate")
|
||||
|
||||
config = load_config()
|
||||
del config.tools.mcp_servers["browserbase"]
|
||||
save_config(config)
|
||||
|
||||
consumer = asyncio.create_task(_handle_one_runtime_control())
|
||||
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
|
||||
await consumer
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["removed"] == ["browserbase"]
|
||||
assert result["requires_restart"] is False
|
||||
assert not loop.tools.has("mcp_browserbase_navigate")
|
||||
assert closed == ["browserbase"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||
type="stdio",
|
||||
command="browserbase-mcp",
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
async def _fake_connect(servers, registry):
|
||||
stacks = {}
|
||||
for name in servers:
|
||||
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
stacks[name] = stack
|
||||
return stacks
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
loop = _make_loop(tmp_path, mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]})
|
||||
|
||||
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["added"] == []
|
||||
assert result["changed"] == []
|
||||
assert result["retried"] == ["browserbase"]
|
||||
assert loop.tools.has("mcp_browserbase_navigate")
|
||||
await loop.close_mcp()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for memory system: Consolidator, token estimation, truncation."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.memory import _TIKTOKEN_ENC, Consolidator, MemoryStore, _estimate_tokens
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
s = MemoryStore(tmp_path)
|
||||
s.write_soul("# Soul\n- Helpful")
|
||||
s.write_user("# User\n- Developer")
|
||||
s.write_memory("# Memory\n- Project X active")
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
p = MagicMock()
|
||||
p.chat_with_retry = AsyncMock()
|
||||
p.generation.max_tokens = 4096
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sessions():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_build_messages():
|
||||
return MagicMock(return_value=[])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_tool_definitions():
|
||||
return MagicMock(return_value=[])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def consolidator(store, mock_provider, mock_sessions, mock_build_messages, mock_get_tool_definitions):
|
||||
return Consolidator(
|
||||
store=store,
|
||||
provider=mock_provider,
|
||||
model="test-model",
|
||||
sessions=mock_sessions,
|
||||
context_window_tokens=128_000,
|
||||
build_messages=mock_build_messages,
|
||||
get_tool_definitions=mock_get_tool_definitions,
|
||||
)
|
||||
|
||||
|
||||
class TestEstimateTokens:
|
||||
def test_estimate_tokens_returns_positive(self):
|
||||
assert _estimate_tokens("hello world") > 0
|
||||
|
||||
def test_estimate_tokens_english_approximate(self):
|
||||
# English is roughly 1 token per 4 chars as fallback
|
||||
text = "a " * 100
|
||||
if _TIKTOKEN_ENC is not None:
|
||||
expected = len(_TIKTOKEN_ENC.encode(text))
|
||||
else:
|
||||
expected = len(text) // 4
|
||||
assert _estimate_tokens(text) == expected
|
||||
|
||||
|
||||
class TestTruncateToTokenBudget:
|
||||
def test_reserve_tokens_reduces_budget(self, consolidator):
|
||||
long_text = "word " * 200_000
|
||||
# Without reserve, more text survives
|
||||
no_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=0)
|
||||
with_reserve = consolidator._truncate_to_token_budget(long_text, reserve_tokens=500)
|
||||
assert len(with_reserve) < len(no_reserve)
|
||||
|
||||
def test_reserve_tokens_zero_default(self, consolidator):
|
||||
text = "hello world"
|
||||
result = consolidator._truncate_to_token_budget(text)
|
||||
assert result == text
|
||||
|
||||
|
||||
class TestConsolidatorPrompt:
|
||||
def test_prompt_contains_snip(self):
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
text = render_template("agent/consolidator_archive.md", strip=True)
|
||||
assert "SNIP" in text
|
||||
assert "[permanent]" in text
|
||||
assert "[skip]" in text
|
||||
|
||||
|
||||
class TestConsolidatorArchive:
|
||||
async def test_archive_injects_dedup_context(self, consolidator, mock_provider, store):
|
||||
store.write_memory("- User prefers dark mode")
|
||||
store.write_user("- Developer")
|
||||
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
|
||||
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="(nothing)", finish_reason="stop"
|
||||
)
|
||||
await consolidator.archive(messages)
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs["messages"][1]["content"]
|
||||
assert "## Current MEMORY.md (for dedup)" in user_msg
|
||||
assert "User prefers dark mode" in user_msg
|
||||
assert "## Current USER.md (for dedup)" in user_msg
|
||||
assert "Developer" in user_msg
|
||||
|
||||
async def test_archive_skips_dedup_when_budget_exhausted(self, consolidator, mock_provider, store):
|
||||
# Shrink token budget so dedup context (always capped at ~6000 chars)
|
||||
# exceeds the available room.
|
||||
consolidator.context_window_tokens = 6_000
|
||||
store.write_memory("word " * 10_000)
|
||||
messages = [{"role": "user", "content": "hello", "timestamp": "2026-01-01 10:00"}]
|
||||
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="(nothing)", finish_reason="stop"
|
||||
)
|
||||
await consolidator.archive(messages)
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs["messages"][1]["content"]
|
||||
# Should not contain dedup context when budget is exhausted
|
||||
assert "## Current MEMORY.md (for dedup)" not in user_msg
|
||||
@@ -346,6 +346,26 @@ class TestSyncWorkspaceTemplates:
|
||||
content = (workspace / "AGENTS.md").read_text()
|
||||
assert content == "existing content"
|
||||
|
||||
def test_does_not_create_tools_md(self, tmp_path):
|
||||
"""Tool contract is injected internally, not copied into user workspaces."""
|
||||
workspace = tmp_path / "workspace"
|
||||
|
||||
added = sync_workspace_templates(workspace, silent=True)
|
||||
|
||||
assert "TOOLS.md" not in added
|
||||
assert not (workspace / "TOOLS.md").exists()
|
||||
|
||||
def test_preserves_existing_tools_md_without_overwriting(self, tmp_path):
|
||||
"""Legacy user workspaces may have TOOLS.md; sync should leave it untouched."""
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir(parents=True)
|
||||
tools_path = workspace / "TOOLS.md"
|
||||
tools_path.write_text("custom tool notes", encoding="utf-8")
|
||||
|
||||
sync_workspace_templates(workspace, silent=True)
|
||||
|
||||
assert tools_path.read_text(encoding="utf-8") == "custom tool notes"
|
||||
|
||||
def test_creates_memory_directory(self, tmp_path):
|
||||
"""Should create memory directory structure."""
|
||||
workspace = tmp_path / "workspace"
|
||||
|
||||
@@ -241,7 +241,7 @@ def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
|
||||
signature = provider_signature(config)
|
||||
fallback_signatures = signature[-1]
|
||||
|
||||
assert fallback_signatures[0][11] is None
|
||||
assert fallback_signatures[0][12] is None
|
||||
|
||||
|
||||
# -- FallbackProvider tests --
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for sustained-goal continuation in AgentRunner.
|
||||
|
||||
When a goal_active_predicate returns True, the runner must not exit with
|
||||
stop_reason="completed" after a plain-text final response. Instead it should
|
||||
inject a continuation message and keep looping (similar to mid-turn injection).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_exits_normally_without_predicate():
|
||||
"""Baseline: no predicate, runner exits with completed on final text."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="all done", tool_calls=[], usage={},
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "completed"
|
||||
assert result.final_content == "all done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_exits_normally_with_inactive_goal():
|
||||
"""Predicate returns False, runner should exit normally."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="all done", tool_calls=[], usage={},
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: False,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "completed"
|
||||
assert result.final_content == "all done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_forces_continue_when_goal_active():
|
||||
"""Predicate returns True on final text → runner injects continuation and loops.
|
||||
|
||||
We set max_iterations=3 and let the provider return final text every time.
|
||||
Without the fix this would exit on the first iteration with stop_reason
|
||||
"completed". With the fix the runner is forced to continue until
|
||||
max_iterations is hit.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="still working", tool_calls=[], usage={},
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
))
|
||||
|
||||
# Because the predicate keeps returning True, the runner should never
|
||||
# naturally complete. It loops until max_iterations is exhausted.
|
||||
assert result.stop_reason == "max_iterations"
|
||||
# The injected continuation message should be present in the message list.
|
||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||
assert any("active sustained goal" in str(m.get("content", "")) for m in user_msgs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_respects_max_iterations_even_with_active_goal():
|
||||
"""A single iteration with active goal still hits max_iterations."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="still working", tool_calls=[], usage={},
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "max_iterations"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_does_not_force_continue_on_error():
|
||||
"""Even with active goal, an LLM error should exit with stop_reason="error"."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content=None, tool_calls=[], usage={},
|
||||
finish_reason="error",
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_uses_custom_goal_continue_message():
|
||||
"""Custom goal_continue_message should be injected instead of the default."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="still working", tool_calls=[], usage={},
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
custom_msg = "CUSTOM_CONTINUE_PLEASE"
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
goal_active_predicate=lambda: True,
|
||||
goal_continue_message=custom_msg,
|
||||
))
|
||||
|
||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||
assert any(custom_msg in str(m.get("content", "")) for m in user_msgs)
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
@@ -77,3 +77,220 @@ async def test_runner_streams_provider_progress_deltas_by_default():
|
||||
assert result.final_content == "hello"
|
||||
assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"]
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas(tmp_path):
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
progress_events: list[dict] = []
|
||||
|
||||
async def progress_cb(content, *, file_edit_events=None, **kwargs):
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "write_file"}}]
|
||||
|
||||
def get(self, name):
|
||||
return None
|
||||
|
||||
async def execute(self, name, params):
|
||||
assert name == "write_file"
|
||||
assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
|
||||
target = tmp_path / params["path"]
|
||||
target.write_text(params["content"], encoding="utf-8")
|
||||
return "ok"
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
assert on_tool_call_delta is not None
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments_delta": '{"path":"big.txt","content":"',
|
||||
})
|
||||
await on_tool_call_delta({"index": 0, "arguments_delta": "line\\n" * 24})
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call-write",
|
||||
name="write_file",
|
||||
arguments={"path": "big.txt", "content": "line\n" * 24},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "write a large file"}],
|
||||
tools=Tools(),
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
|
||||
assert any(
|
||||
not event["approximate"] and event["phase"] == "end" and event["added"] == 24
|
||||
for event in progress_events
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(tmp_path):
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
progress_events: list[dict] = []
|
||||
target = tmp_path / "notes.txt"
|
||||
target.write_text("old\nkeep\n", encoding="utf-8")
|
||||
|
||||
async def progress_cb(content, *, file_edit_events=None, **kwargs):
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "edit_file"}}]
|
||||
|
||||
def get(self, name):
|
||||
return None
|
||||
|
||||
async def execute(self, name, params):
|
||||
assert name == "edit_file"
|
||||
assert any(
|
||||
event["tool"] == "edit_file"
|
||||
and event["approximate"]
|
||||
and event["added"] == 3
|
||||
and event["deleted"] == 2
|
||||
for event in progress_events
|
||||
)
|
||||
target.write_text(params["new_text"], encoding="utf-8")
|
||||
return "ok"
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
assert on_tool_call_delta is not None
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"call_id": "call-edit",
|
||||
"name": "edit_file",
|
||||
"arguments_delta": (
|
||||
'{"path":"notes.txt","old_text":"old\\nkeep\\n","new_text":"'
|
||||
),
|
||||
})
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"arguments_delta": "new\\nkeep\\nextra\\n",
|
||||
})
|
||||
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call-edit",
|
||||
name="edit_file",
|
||||
arguments={
|
||||
"path": "notes.txt",
|
||||
"old_text": "old\nkeep\n",
|
||||
"new_text": "new\nkeep\nextra\n",
|
||||
},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "edit a file"}],
|
||||
tools=Tools(),
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert any(
|
||||
event["tool"] == "edit_file"
|
||||
and event["approximate"]
|
||||
and event["added"] == 3
|
||||
and event["deleted"] == 2
|
||||
for event in progress_events
|
||||
)
|
||||
assert any(
|
||||
event["tool"] == "edit_file"
|
||||
and not event["approximate"]
|
||||
and event["phase"] == "end"
|
||||
and event["added"] == 2
|
||||
and event["deleted"] == 1
|
||||
for event in progress_events
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_marks_unfinished_live_write_file_activity_failed(tmp_path):
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
progress_events: list[dict] = []
|
||||
|
||||
async def progress_cb(content, *, file_edit_events=None, **kwargs):
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
assert on_tool_call_delta is not None
|
||||
await on_tool_call_delta({
|
||||
"index": 0,
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments_delta": '{"path":"aborted.txt","content":"partial\\n',
|
||||
})
|
||||
return LLMResponse(content="stopped", tool_calls=[], finish_reason="stop", usage={})
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "write_file"}}]
|
||||
tools.get.return_value = None
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "write a large file"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
))
|
||||
|
||||
assert result.final_content == "stopped"
|
||||
assert progress_events[-1]["path"] == "aborted.txt"
|
||||
assert progress_events[-1]["phase"] == "error"
|
||||
assert progress_events[-1]["status"] == "error"
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
@@ -292,3 +292,95 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
|
||||
loop = AgentLoop.from_config(config)
|
||||
assert loop._provider_snapshot_loader is None
|
||||
assert loop._preset_snapshot_loader is not None
|
||||
|
||||
|
||||
class TestDreamModelOverride:
|
||||
def test_dream_follows_main_when_no_override(self, tmp_path) -> None:
|
||||
provider = _provider("base-model")
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
)
|
||||
assert loop.dream.model == "base-model"
|
||||
assert loop.dream.provider is provider
|
||||
|
||||
def test_dream_raw_model_override(self, tmp_path) -> None:
|
||||
provider = _provider("base-model")
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
dream_model_override="custom-model-v2",
|
||||
)
|
||||
assert loop.dream.model == "custom-model-v2"
|
||||
assert loop.dream.provider is provider
|
||||
|
||||
def test_dream_preset_override(self, tmp_path) -> None:
|
||||
cheap_provider = _provider("openai/gpt-4.1-mini", max_tokens=2048)
|
||||
preset = ModelPresetConfig(
|
||||
model="openai/gpt-4.1-mini",
|
||||
provider="openai",
|
||||
max_tokens=2048,
|
||||
context_window_tokens=128_000,
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_provider("base-model"),
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets={"cheap": preset},
|
||||
dream_model_override="cheap",
|
||||
preset_snapshot_loader=lambda _name: ProviderSnapshot(
|
||||
provider=cheap_provider,
|
||||
model=preset.model,
|
||||
context_window_tokens=preset.context_window_tokens,
|
||||
signature=("cheap", preset.model),
|
||||
),
|
||||
)
|
||||
assert loop.dream.model == "openai/gpt-4.1-mini"
|
||||
assert loop.dream.provider is cheap_provider
|
||||
assert loop.dream._runner.provider is cheap_provider
|
||||
|
||||
def test_dream_override_survives_main_preset_switch(self, tmp_path) -> None:
|
||||
base_provider = _provider("base-model")
|
||||
fast_provider = _provider("openai/gpt-4.1", max_tokens=4096)
|
||||
cheap_provider = _provider("openai/gpt-4.1-mini", max_tokens=2048)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=base_provider,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets={
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
"cheap": ModelPresetConfig(model="openai/gpt-4.1-mini"),
|
||||
},
|
||||
dream_model_override="cheap",
|
||||
preset_snapshot_loader=lambda name: ProviderSnapshot(
|
||||
provider=fast_provider if name == "fast" else cheap_provider,
|
||||
model="openai/gpt-4.1" if name == "fast" else "openai/gpt-4.1-mini",
|
||||
context_window_tokens=32_768 if name == "fast" else 128_000,
|
||||
signature=(name, "model"),
|
||||
),
|
||||
)
|
||||
# Initially dream is on cheap
|
||||
assert loop.dream.model == "openai/gpt-4.1-mini"
|
||||
assert loop.dream.provider is cheap_provider
|
||||
|
||||
# Switch main preset to fast
|
||||
loop.set_model_preset("fast")
|
||||
|
||||
# Main agent should be on fast
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
assert loop.provider is fast_provider
|
||||
|
||||
# Dream should still be on cheap override
|
||||
assert loop.dream.model == "openai/gpt-4.1-mini"
|
||||
assert loop.dream.provider is cheap_provider
|
||||
assert loop.dream._runner.provider is cheap_provider
|
||||
|
||||
@@ -56,6 +56,20 @@ def test_list_sessions_includes_user_preview(tmp_path):
|
||||
assert rows[0]["preview"] == "帮我总结一下 OpenAI 的最新硬件计划"
|
||||
|
||||
|
||||
def test_list_sessions_bounds_preview_scan(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:chat-long-preview")
|
||||
for index in range(220):
|
||||
session.add_message("assistant", f"assistant trace {index}")
|
||||
session.add_message("user", "this should not force a full sidebar scan")
|
||||
manager.save(session)
|
||||
|
||||
rows = manager.list_sessions()
|
||||
|
||||
assert rows[0]["key"] == "websocket:chat-long-preview"
|
||||
assert rows[0]["preview"] == "assistant trace 0"
|
||||
|
||||
|
||||
# --- Original regression test (from PR 2075) ---
|
||||
|
||||
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
||||
@@ -359,6 +373,31 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
|
||||
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
||||
|
||||
|
||||
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
||||
session = Session(key="test:cli-app")
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "please use @drawio",
|
||||
"cli_apps": [{
|
||||
"name": "drawio",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
}],
|
||||
}
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history == [{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"please use @drawio\n"
|
||||
"[CLI App Attachment: @drawio; tool=run_cli_app; "
|
||||
"entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]"
|
||||
),
|
||||
}]
|
||||
|
||||
|
||||
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||
"""``media`` only ever appears on user entries in practice, but the
|
||||
synthesizer must be defensive: assistants / tools with list content
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Tests for staging attachment paths into the media bucket for session replay."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.loader import set_config_path
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.utils.session_attachments import stage_media_paths_for_session_replay
|
||||
|
||||
|
||||
def test_persist_media_stages_workspace_file(tmp_path: Path) -> None:
|
||||
set_config_path(tmp_path / "config.json")
|
||||
outside = tmp_path / "workspace" / "report.md"
|
||||
outside.parent.mkdir(parents=True)
|
||||
outside.write_text("body", encoding="utf-8")
|
||||
|
||||
out = stage_media_paths_for_session_replay([str(outside)])
|
||||
|
||||
assert len(out) == 1
|
||||
staged = Path(out[0])
|
||||
assert staged.is_file()
|
||||
assert staged.read_text(encoding="utf-8") == "body"
|
||||
assert staged.resolve().is_relative_to(get_media_dir().resolve())
|
||||
|
||||
|
||||
def test_persist_media_keeps_files_already_under_media_root(tmp_path: Path) -> None:
|
||||
set_config_path(tmp_path / "config.json")
|
||||
media = get_media_dir("websocket")
|
||||
media.mkdir(parents=True, exist_ok=True)
|
||||
inside = media / "keep-me.txt"
|
||||
inside.write_text("x", encoding="utf-8")
|
||||
|
||||
out = stage_media_paths_for_session_replay([str(inside.resolve())])
|
||||
|
||||
assert out == [str(inside.resolve())]
|
||||
@@ -94,6 +94,39 @@ async def test_subagent_uses_configured_max_iterations(tmp_path):
|
||||
mgr.runner.run.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_forwards_temperature_to_run_spec(tmp_path):
|
||||
"""A temperature passed to spawn() should reach the AgentRunSpec."""
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
seen = {}
|
||||
|
||||
async def fake_run(spec):
|
||||
seen["temperature"] = spec.temperature
|
||||
return SimpleNamespace(
|
||||
stop_reason="done", final_content="done", error=None, tool_events=[],
|
||||
)
|
||||
|
||||
mgr.runner.run = AsyncMock(side_effect=fake_run)
|
||||
|
||||
await mgr.spawn(task="do task", temperature=0.9)
|
||||
await asyncio.gather(*mgr._running_tasks.values(), return_exceptions=True)
|
||||
|
||||
assert seen["temperature"] == 0.9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
|
||||
"""SpawnTool should return an error string when the concurrency limit is reached."""
|
||||
|
||||
@@ -111,6 +111,23 @@ def test_discover_plugins_loads_entry_points():
|
||||
assert result["line"] is _FakePlugin
|
||||
|
||||
|
||||
def test_discover_plugins_skips_names_outside_enabled_set():
|
||||
from nanobot.channels.registry import discover_plugins
|
||||
|
||||
loaded: list[str] = []
|
||||
|
||||
def _load_disabled():
|
||||
loaded.append("disabled")
|
||||
return _FakePlugin
|
||||
|
||||
ep = SimpleNamespace(name="disabled", load=_load_disabled)
|
||||
with patch(_EP_TARGET, return_value=[ep]):
|
||||
result = discover_plugins({"enabled"})
|
||||
|
||||
assert result == {}
|
||||
assert loaded == []
|
||||
|
||||
|
||||
def test_discover_plugins_handles_load_error():
|
||||
from nanobot.channels.registry import discover_plugins
|
||||
|
||||
@@ -152,6 +169,25 @@ def test_discover_all_includes_external_plugin():
|
||||
assert result["line"] is _FakePlugin
|
||||
|
||||
|
||||
def test_discover_enabled_imports_only_enabled_builtins():
|
||||
from nanobot.channels.registry import discover_enabled
|
||||
|
||||
loaded: list[str] = []
|
||||
|
||||
def _load_channel(name: str):
|
||||
loaded.append(name)
|
||||
return _FakePlugin
|
||||
|
||||
with (
|
||||
patch("nanobot.channels.registry.load_channel_class", side_effect=_load_channel),
|
||||
patch(_EP_TARGET, return_value=[]),
|
||||
):
|
||||
result = discover_enabled({"enabled"}, _names=["enabled", "disabled"])
|
||||
|
||||
assert result == {"enabled": _FakePlugin}
|
||||
assert loaded == ["enabled"]
|
||||
|
||||
|
||||
def test_discover_all_builtin_shadows_plugin():
|
||||
from nanobot.channels.registry import discover_all
|
||||
|
||||
@@ -180,7 +216,7 @@ async def test_manager_loads_plugin_from_dict_config():
|
||||
)
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.registry.discover_all",
|
||||
"nanobot.channels.registry.discover_enabled",
|
||||
return_value={"fakeplugin": _FakePlugin},
|
||||
):
|
||||
mgr = ChannelManager.__new__(ChannelManager)
|
||||
@@ -210,7 +246,7 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
|
||||
)
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.registry.discover_all",
|
||||
"nanobot.channels.registry.discover_enabled",
|
||||
return_value={"fakeplugin": _FakePlugin},
|
||||
):
|
||||
mgr = ChannelManager.__new__(ChannelManager)
|
||||
@@ -246,7 +282,7 @@ async def test_manager_propagates_openai_transcription_api_base_to_channels():
|
||||
)
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.registry.discover_all",
|
||||
"nanobot.channels.registry.discover_enabled",
|
||||
return_value={"fakeplugin": _FakePlugin},
|
||||
):
|
||||
mgr = ChannelManager.__new__(ChannelManager)
|
||||
@@ -498,10 +534,8 @@ async def test_manager_skips_disabled_plugin():
|
||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.registry.discover_all",
|
||||
return_value={"fakeplugin": _FakePlugin},
|
||||
):
|
||||
ep = _make_entry_point("fakeplugin", _FakePlugin)
|
||||
with patch(_EP_TARGET, return_value=[ep]):
|
||||
mgr = ChannelManager.__new__(ChannelManager)
|
||||
mgr.config = fake_config
|
||||
mgr.bus = MessageBus()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user