mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 06:48:39 +03:00
Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
584072cf63 | ||
|
|
7c270577e1 | ||
|
|
2e5930e355 | ||
|
|
83f437a088 | ||
|
|
e34b7fd086 | ||
|
|
12005c20f0 | ||
|
|
9fefb31344 | ||
|
|
28358980ed | ||
|
|
e9f4a868a8 | ||
|
|
2a318d6991 | ||
|
|
22b3010bd0 | ||
|
|
ac18a8baad | ||
|
|
49c07aa45a | ||
|
|
98c2f7cc27 | ||
|
|
4efd904ccc | ||
|
|
034bea1a44 | ||
|
|
bad584cb0e | ||
|
|
790a03ec28 | ||
|
|
d8fd4c80bf | ||
|
|
40b4e01b13 | ||
|
|
4fad19dc17 | ||
|
|
99209a806d | ||
|
|
67875d7a15 | ||
|
|
daa4a25c9b | ||
|
|
653de4a7ef | ||
|
|
05e0106592 | ||
|
|
3437ff273f | ||
|
|
7ebf611be8 | ||
|
|
c4b2d9f53b | ||
|
|
84e8aed6b1 | ||
|
|
fb313bd8d1 | ||
|
|
7d3337a98e | ||
|
|
f256d7ab9b | ||
|
|
e54fbfeb2a | ||
|
|
db14685a69 | ||
|
|
d97e177981 | ||
|
|
ca7877f272 | ||
|
|
4db50f2e32 | ||
|
|
1813fc5021 | ||
|
|
5aa61e08d3 | ||
|
|
358997554c | ||
|
|
9fa90b1034 | ||
|
|
c30e4d86f3 | ||
|
|
3baa869fdb | ||
|
|
2103cd5602 | ||
|
|
5b45191cd9 | ||
|
|
a5fcf7786d | ||
|
|
9d6afd86b5 | ||
|
|
3ceabdecd5 | ||
|
|
807b8188e3 | ||
|
|
387988b8e9 | ||
|
|
0f32c0451e | ||
|
|
614b21368f | ||
|
|
d3689d143c | ||
|
|
2a7433b7ec | ||
|
|
b8406be215 | ||
|
|
7742f8fbdc | ||
|
|
2a67663fab | ||
|
|
059a265078 | ||
|
|
9bcb17abe1 | ||
|
|
9a9e446f3f | ||
|
|
75c2506c07 | ||
|
|
66682eb46f | ||
|
|
c15d816d9c | ||
|
|
7faa339902 | ||
|
|
96da6d8190 | ||
|
|
be83525f99 | ||
|
|
08744ce408 | ||
|
|
76e3f74df7 | ||
|
|
016fd15a00 | ||
|
|
5853d5dfda | ||
|
|
2fa15ccf1b | ||
|
|
fde530de01 | ||
|
|
861fbb0dde | ||
|
|
051037ff08 | ||
|
|
ee364c6ac1 | ||
|
|
fd1a5a6267 | ||
|
|
4c54a2b153 | ||
|
|
4860a9a6c9 | ||
|
|
539d82eadc | ||
|
|
188e6df757 | ||
|
|
2c397ad442 | ||
|
|
aea5948b11 | ||
|
|
5dc96505e8 | ||
|
|
43a58335f6 | ||
|
|
8ca575bdeb | ||
|
|
e16fa7c6b1 | ||
|
|
e157392250 | ||
|
|
08f326ec55 | ||
|
|
c4170fa9ba | ||
|
|
1040124ede | ||
|
|
73840b0af6 | ||
|
|
ad952e0da2 | ||
|
|
0284174df9 | ||
|
|
15007afd4a | ||
|
|
d9800ecdd2 | ||
|
|
1c24f10236 | ||
|
|
39c38b593f | ||
|
|
fae38319ca | ||
|
|
58ae2d5b7e | ||
|
|
6891a7a4d4 | ||
|
|
830730f82d | ||
|
|
306958d6e6 | ||
|
|
61a8ad27d9 | ||
|
|
4e06c00b46 | ||
|
|
7988ce5b74 | ||
|
|
3c20d16117 | ||
|
|
f8fd9f0011 | ||
|
|
d82f25e4d4 | ||
|
|
26e953f0b9 | ||
|
|
651b6b933f | ||
|
|
71eff09653 | ||
|
|
d23bcae5a3 | ||
|
|
69bcf26ef4 | ||
|
|
ce4ad50c7d | ||
|
|
4d72e40d35 | ||
|
|
4e314aff0c | ||
|
|
02cad2aa74 | ||
|
|
bcfdd49fa4 | ||
|
|
9cf9272920 | ||
|
|
407314a672 | ||
|
|
ee1365bcf1 | ||
|
|
ebd1891f45 |
@@ -43,6 +43,26 @@ We use a two-branch model to balance stability and exploration:
|
|||||||
**When in doubt, target `nightly`.** It is easier to move a stable idea from `nightly`
|
**When in doubt, target `nightly`.** It is easier to move a stable idea from `nightly`
|
||||||
to `main` than to undo a risky change after it lands in the stable branch.
|
to `main` than to undo a risky change after it lands in the stable branch.
|
||||||
|
|
||||||
|
### Starting Work
|
||||||
|
|
||||||
|
Before making changes, sync the target branch and create a topic branch from it.
|
||||||
|
For stable bug fixes and documentation-only changes, start from the latest `main`.
|
||||||
|
For experimental work, start from the latest `nightly`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git switch main
|
||||||
|
git pull --ff-only upstream main
|
||||||
|
git switch -c your-topic-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Use your primary HKUDS/nanobot remote in place of `upstream` if your checkout
|
||||||
|
uses a different remote name.
|
||||||
|
|
||||||
|
Keep unrelated local changes out of the topic branch. If your checkout already has
|
||||||
|
work in progress, use a separate worktree or finish that work before starting a
|
||||||
|
new branch.
|
||||||
|
|
||||||
### How Does Nightly Get Merged to Main?
|
### How Does Nightly Get Merged to Main?
|
||||||
|
|
||||||
We don't merge the entire `nightly` branch. Instead, stable features are **cherry-picked** from `nightly` into individual PRs targeting `main`:
|
We don't merge the entire `nightly` branch. Instead, stable features are **cherry-picked** from `nightly` into individual PRs targeting `main`:
|
||||||
|
|||||||
@@ -23,11 +23,12 @@
|
|||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
|
||||||
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
|
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
|
||||||
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack / Telegram threads.
|
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
|
||||||
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
|
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
|
||||||
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
|
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
|
||||||
- **2026-04-24** 🎥 Video attachments for Telegram / WebSocket / WebUI, DeepSeek thinking control, faster document startup.
|
- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup.
|
||||||
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
|
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
|
||||||
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
|
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
|
||||||
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
|
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
|
||||||
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
|
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
|
||||||
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
|
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
|
||||||
|
- **Runtime model switching**: define [model presets](docs/configuration.md#model-presets) and switch between cheap/fast and powerful models mid-conversation — no restart required.
|
||||||
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
|
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
|
||||||
|
|
||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|||||||
+11
-6
@@ -17,7 +17,7 @@ import { Boom } from '@hapi/boom';
|
|||||||
import qrcode from 'qrcode-terminal';
|
import qrcode from 'qrcode-terminal';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||||
import { join, basename } from 'path';
|
import { join, basename, resolve, sep } from 'path';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
const VERSION = '0.1.0';
|
const VERSION = '0.1.0';
|
||||||
@@ -165,6 +165,10 @@ export class WhatsAppClient {
|
|||||||
fallbackContent = '[Video]';
|
fallbackContent = '[Video]';
|
||||||
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
|
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
|
||||||
if (path) mediaPaths.push(path);
|
if (path) mediaPaths.push(path);
|
||||||
|
} else if (unwrapped.audioMessage) {
|
||||||
|
fallbackContent = '[Voice Message]';
|
||||||
|
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
|
||||||
|
if (path) mediaPaths.push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
|
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
|
||||||
@@ -196,17 +200,18 @@ export class WhatsAppClient {
|
|||||||
|
|
||||||
let outFilename: string;
|
let outFilename: string;
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
// Documents have a filename — use it with a unique prefix to avoid collisions
|
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
|
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
|
||||||
outFilename = prefix + fileName;
|
|
||||||
} else {
|
} else {
|
||||||
const mime = mimetype || 'application/octet-stream';
|
const mime = mimetype || 'application/octet-stream';
|
||||||
// Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf")
|
|
||||||
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
|
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
|
||||||
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
|
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = join(mediaDir, outFilename);
|
const filepath = resolve(mediaDir, outFilename);
|
||||||
|
if (!filepath.startsWith(resolve(mediaDir) + sep)) {
|
||||||
|
throw new Error(`Path traversal blocked: ${outFilename}`);
|
||||||
|
}
|
||||||
await writeFile(filepath, buffer);
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
return filepath;
|
return filepath;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ IMAP_PASSWORD=your-password-here
|
|||||||
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
|
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
|
||||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) |
|
| `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) |
|
||||||
|
| `bedrock` | LLM (AWS Bedrock Converse, Claude/Nova/Llama/etc.) | [aws.amazon.com/bedrock](https://aws.amazon.com/bedrock/) |
|
||||||
| `openai` | LLM + Voice transcription (Whisper) | [platform.openai.com](https://platform.openai.com) |
|
| `openai` | LLM + Voice transcription (Whisper) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) |
|
||||||
@@ -75,6 +76,7 @@ IMAP_PASSWORD=your-password-here
|
|||||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||||
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||||
|
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
|
||||||
| `ollama` | LLM (local, Ollama) | — |
|
| `ollama` | LLM (local, Ollama) | — |
|
||||||
| `lm_studio` | LLM (local, LM Studio) | — |
|
| `lm_studio` | LLM (local, LM Studio) | — |
|
||||||
| `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) |
|
| `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) |
|
||||||
@@ -85,6 +87,183 @@ IMAP_PASSWORD=your-password-here
|
|||||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||||
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>AWS Bedrock (Converse API)</b></summary>
|
||||||
|
|
||||||
|
Bedrock uses the native `bedrock-runtime` Converse API, so it can call Bedrock model IDs such as Claude Opus 4.7, Claude Sonnet, Amazon Nova, Meta Llama, Mistral, Qwen, and other models that support Converse. It supports normal chat, streaming, tool calling, tool results, token usage, and Bedrock error metadata.
|
||||||
|
|
||||||
|
This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface.
|
||||||
|
|
||||||
|
**1. Configure credentials**
|
||||||
|
|
||||||
|
Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"bedrock:InvokeModel",
|
||||||
|
"bedrock:InvokeModelWithResponseStream"
|
||||||
|
],
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set `providers.bedrock.apiKey` to a Bedrock API key; nanobot exports it as `AWS_BEARER_TOKEN_BEDROCK` for the AWS SDK.
|
||||||
|
|
||||||
|
Credential options:
|
||||||
|
|
||||||
|
- **AWS CLI/default profile**: leave `apiKey` and `profile` empty, then run `aws configure` or provide `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`.
|
||||||
|
- **Named AWS profile**: set `profile` to a profile from `~/.aws/config` or `~/.aws/credentials`.
|
||||||
|
- **IAM role**: on EC2/ECS/Lambda, leave `apiKey` and `profile` empty and attach a role with Bedrock permissions.
|
||||||
|
- **Bedrock API key**: set `apiKey` or `AWS_BEARER_TOKEN_BEDROCK`; `profile` can stay `null`.
|
||||||
|
|
||||||
|
**2. Minimal config**
|
||||||
|
|
||||||
|
For a non-Anthropic model such as Amazon Nova:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"bedrock": {
|
||||||
|
"region": "us-east-1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "bedrock",
|
||||||
|
"model": "bedrock/amazon.nova-lite-v1:0",
|
||||||
|
"reasoningEffort": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With a Bedrock API key:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"bedrock": {
|
||||||
|
"region": "us-east-1",
|
||||||
|
"apiKey": "${AWS_BEARER_TOKEN_BEDROCK}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "bedrock",
|
||||||
|
"model": "bedrock/amazon.nova-lite-v1:0",
|
||||||
|
"reasoningEffort": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With a named AWS profile:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"bedrock": {
|
||||||
|
"region": "us-east-1",
|
||||||
|
"profile": "my-bedrock-profile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "bedrock",
|
||||||
|
"model": "bedrock/amazon.nova-lite-v1:0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Claude Opus 4.7 example**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"bedrock": {
|
||||||
|
"region": "us-east-1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "bedrock",
|
||||||
|
"model": "bedrock/global.anthropic.claude-opus-4-7",
|
||||||
|
"reasoningEffort": "medium",
|
||||||
|
"maxTokens": 8192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For regional routing, use one of Bedrock's inference IDs, for example `bedrock/us.anthropic.claude-opus-4-7`, `bedrock/eu.anthropic.claude-opus-4-7`, or `bedrock/jp.anthropic.claude-opus-4-7`.
|
||||||
|
|
||||||
|
Claude Opus 4.7 does not accept `temperature`, `top_p`, or `top_k`; nanobot omits `temperature` automatically for this model. If `reasoningEffort` is set to `low`, `medium`, `high`, `max`, or `adaptive`, nanobot sends Bedrock's adaptive thinking parameter.
|
||||||
|
|
||||||
|
Anthropic models on Bedrock can also require Anthropic use-case registration and are subject to Anthropic-supported country/region restrictions. If Claude fails with a `ValidationException` about unsupported countries or regions, try a non-Anthropic Bedrock model such as Amazon Nova to verify the provider setup.
|
||||||
|
|
||||||
|
**4. Model IDs**
|
||||||
|
|
||||||
|
Use Bedrock model IDs or inference profile IDs with a `bedrock/` prefix in nanobot config. nanobot removes the prefix before calling AWS.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `bedrock/amazon.nova-micro-v1:0`
|
||||||
|
- `bedrock/amazon.nova-lite-v1:0`
|
||||||
|
- `bedrock/global.anthropic.claude-opus-4-7`
|
||||||
|
- `bedrock/us.anthropic.claude-opus-4-7`
|
||||||
|
- `bedrock/openai.gpt-oss-20b-1:0`
|
||||||
|
- `bedrock/meta.llama...`
|
||||||
|
- `bedrock/mistral...`
|
||||||
|
|
||||||
|
Check the Bedrock console for the exact model ID and region availability. Some models require cross-region inference profile IDs such as `us.*`, `eu.*`, or `global.*`.
|
||||||
|
|
||||||
|
**5. Advanced model fields**
|
||||||
|
|
||||||
|
Model-specific fields can be supplied with `extraBody`; nanobot merges it into Converse `additionalModelRequestFields`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"bedrock": {
|
||||||
|
"region": "us-east-1",
|
||||||
|
"extraBody": {
|
||||||
|
"thinking": {
|
||||||
|
"type": "adaptive",
|
||||||
|
"effort": "medium",
|
||||||
|
"display": "summarized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `apiBase` only for a custom Bedrock Runtime endpoint URL, such as a VPC endpoint or proxy. It is not needed for normal AWS regions.
|
||||||
|
|
||||||
|
Current scope: nanobot passes `messages`, `system`, `inferenceConfig`, `toolConfig`, and `additionalModelRequestFields`. Bedrock Prompt Management, Guardrails, `serviceTier`, and other top-level Converse options are not first-class config fields yet.
|
||||||
|
|
||||||
|
**6. Quick checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For AWS credential-chain usage:
|
||||||
|
aws sts get-caller-identity
|
||||||
|
|
||||||
|
# For API-key usage:
|
||||||
|
export AWS_BEARER_TOKEN_BEDROCK="your-bedrock-api-key"
|
||||||
|
export AWS_REGION="us-east-1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Reply with one short sentence."
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||||
@@ -161,6 +340,34 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LongCat (OpenAI-compatible)</b></summary>
|
||||||
|
|
||||||
|
LongCat is available through nanobot's built-in OpenAI-compatible provider flow.
|
||||||
|
The default API base already points to `https://api.longcat.chat/openai/v1`, so you
|
||||||
|
usually only need to set `apiKey`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"longcat": {
|
||||||
|
"apiKey": "${LONGCAT_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "longcat",
|
||||||
|
"model": "LongCat-Flash-Chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`,
|
||||||
|
`LongCat-Flash-Thinking-2601`, and `LongCat-Flash-Lite`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
||||||
|
|
||||||
@@ -449,6 +656,146 @@ That's it! Environment variables, model routing, config matching, and `nanobot s
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Agent Settings
|
||||||
|
|
||||||
|
### Model Presets
|
||||||
|
|
||||||
|
Model presets let you define **named bundles** of model + generation parameters and switch between them instantly — no restart required.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Config fields in `config.json` use **camelCase** (`modelPreset`, `contextWindowTokens`).
|
||||||
|
> The [`my` tool](./my-tool.md) uses **snake_case** (`model_preset`, `context_window_tokens`).
|
||||||
|
> Both refer to the same thing — just different naming conventions for config vs. runtime API.
|
||||||
|
|
||||||
|
**Why use presets?**
|
||||||
|
- Switch between a cheap/fast model and a powerful model mid-conversation.
|
||||||
|
- Share the same config across different tasks without manually editing `model`, `provider`, `temperature`, etc.
|
||||||
|
- Runtime switching via the [`my` tool](./my-tool.md).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> The easiest way to set up presets and fallback models is through the interactive wizard:
|
||||||
|
> ```bash
|
||||||
|
> nanobot onboard --wizard
|
||||||
|
> ```
|
||||||
|
> Choose **"[M] Model Presets"** to create, edit, or delete presets interactively.
|
||||||
|
|
||||||
|
**Configuration example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modelPresets": {
|
||||||
|
"fast": {
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"provider": "openai",
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"contextWindowTokens": 128000,
|
||||||
|
"temperature": 0.3
|
||||||
|
},
|
||||||
|
"deep": {
|
||||||
|
"model": "claude-opus-4-7",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"maxTokens": 8192,
|
||||||
|
"contextWindowTokens": 200000,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"reasoningEffort": "high"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"modelPreset": "fast"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preset fields:**
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `model` | string | *(required)* | Model identifier, e.g. `anthropic/claude-opus-4-7` or `gpt-4.1` |
|
||||||
|
| `provider` | string | `"auto"` | Provider name or `"auto"` to infer from the model string |
|
||||||
|
| `maxTokens` | integer | `8192` | Max completion tokens per turn |
|
||||||
|
| `contextWindowTokens` | integer | `65536` | Context window size for token budgeting |
|
||||||
|
| `temperature` | float | `0.1` | Sampling temperature |
|
||||||
|
| `reasoningEffort` | string or null | `null` | Thinking mode: `low`, `medium`, `high`, `adaptive` |
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- When `modelPreset` is set, the preset **completely overrides** all model-specific fields in `agents.defaults`.
|
||||||
|
- When `modelPreset` is omitted, nanobot automatically creates an implicit `"default"` preset from your existing `agents.defaults.model`, `provider`, `temperature`, etc. — **zero migration required** for existing configs.
|
||||||
|
|
||||||
|
**Runtime switching** (requires `tools.my.allowSet: true`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
my(action="set", key="model_preset", value="deep")
|
||||||
|
```
|
||||||
|
|
||||||
|
This atomically swaps the model, provider, generation parameters, and context window for the next turn.
|
||||||
|
|
||||||
|
If the preset name does not exist, the agent receives an error such as `model_preset 'unknown' not found. Available: fast, deep`.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Directly modifying `model` or `contextWindowTokens` via `my(action="set", key="model", ...)` still works, but it automatically clears the active preset because the live state no longer matches the preset bundle. Use `model_preset` for atomic switches instead.
|
||||||
|
|
||||||
|
See [`my-tool.md`](./my-tool.md) for more runtime examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fallback Models
|
||||||
|
|
||||||
|
When the primary model returns a transient error (rate limit, server overload, quota exhausted), nanobot can automatically fail over to a chain of backup models.
|
||||||
|
|
||||||
|
**Configuration example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"modelPreset": "fast",
|
||||||
|
"fallbackModels": ["deep", "backup"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. nanobot tries the primary model first (the one from the active preset).
|
||||||
|
2. The provider retries transient errors internally (e.g. 3 attempts with exponential backoff for 503/429).
|
||||||
|
3. Only after the provider's own retries are exhausted and the final response still has `finish_reason == "error"` with a retryable error kind, nanobot moves to the next candidate in `fallbackModels`.
|
||||||
|
4. Each candidate must be a preset name defined in `modelPresets`. The preset's full config (model, provider, generation params) is used.
|
||||||
|
5. If all candidates are exhausted, the final error is returned to the user.
|
||||||
|
|
||||||
|
**Failover triggers on:**
|
||||||
|
- `server_error` (503, 502, 500)
|
||||||
|
- `rate_limit` (429)
|
||||||
|
- `insufficient_quota` / `quota_exhausted` (429)
|
||||||
|
|
||||||
|
**Failover does NOT trigger on:**
|
||||||
|
- Authentication errors (401) — rotating to another model with the same key won't help
|
||||||
|
- Invalid request errors (400) — the request itself is malformed
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Fallback models must reference preset names defined in `modelPresets`. Define a preset for each fallback model you want to use: `["cheap-preset", "backup", "emergency"]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Other Agent Defaults
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `agents.defaults.model` | string | `"anthropic/claude-opus-4-5"` | Default model when no preset is active |
|
||||||
|
| `agents.defaults.provider` | string | `"auto"` | Default provider when no preset is active |
|
||||||
|
| `agents.defaults.maxTokens` | integer | `8192` | Max completion tokens when no preset is active |
|
||||||
|
| `agents.defaults.temperature` | float | `0.1` | Sampling temperature when no preset is active |
|
||||||
|
| `agents.defaults.reasoningEffort` | string or null | `null` | Thinking mode when no preset is active |
|
||||||
|
| `agents.defaults.maxToolIterations` | integer | `200` | Max tool calls per conversation turn |
|
||||||
|
| `agents.defaults.maxToolResultChars` | integer | `16000` | Max characters per tool result |
|
||||||
|
| `agents.defaults.providerRetryMode` | string | `"standard"` | `"standard"` or `"persistent"` — how aggressively to retry provider-level errors |
|
||||||
|
| `agents.defaults.timezone` | string | `"UTC"` | IANA timezone for runtime context |
|
||||||
|
| `agents.defaults.unifiedSession` | boolean | `false` | Share one session across all channels |
|
||||||
|
| `agents.defaults.sessionTtlMinutes` | integer | `0` | Auto-compact idle threshold (0 = disabled) |
|
||||||
|
| `agents.defaults.maxMessages` | integer | `120` | Max messages to replay from session history |
|
||||||
|
| `agents.defaults.consolidationRatio` | float | `0.5` | Target ratio retained after context compression |
|
||||||
|
|
||||||
## Channel Settings
|
## Channel Settings
|
||||||
|
|
||||||
Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
|
Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
|
||||||
@@ -802,6 +1149,28 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
|
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
|
||||||
|
|
||||||
|
|
||||||
|
## Subagent Concurrency
|
||||||
|
|
||||||
|
By default, nanobot only allows one spawned subagent at a time. When the limit is
|
||||||
|
reached, the `spawn` tool returns an error so the agent can decide to wait or
|
||||||
|
rearrange its work. This protects local LLM servers from loading multiple KV caches
|
||||||
|
at once. If your provider can handle more parallel work, raise the limit:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"maxConcurrentSubagents": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. |
|
||||||
|
|
||||||
|
|
||||||
## Auto Compact
|
## Auto Compact
|
||||||
|
|
||||||
When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input.
|
When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input.
|
||||||
@@ -902,3 +1271,23 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
|||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
||||||
|
|
||||||
|
## Tool Hint Max Length
|
||||||
|
|
||||||
|
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||||
|
|
||||||
|
Set `agents.defaults.toolHintMaxLength` to control the truncation threshold:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"toolHintMaxLength": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `agents.defaults.toolHintMaxLength` | `40` | Maximum characters for tool hint display. Range: 20–500. Higher values show more of the command or path; lower values keep hints compact. |
|
||||||
|
|||||||
+29
-15
@@ -12,6 +12,11 @@ My tool fills this gap. With it, the agent can:
|
|||||||
- **Adapt on the fly**: Complex task? Expand the context window. Simple chat? Switch to a faster model.
|
- **Adapt on the fly**: Complex task? Expand the context window. Simple chat? Switch to a faster model.
|
||||||
- **Remember across turns**: Store notes in your scratchpad that persist into the next conversation turn.
|
- **Remember across turns**: Store notes in your scratchpad that persist into the next conversation turn.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This tool uses **snake_case** keys (`model_preset`, `context_window_tokens`).
|
||||||
|
> The matching config fields in `config.json` are **camelCase** (`modelPreset`, `contextWindowTokens`).
|
||||||
|
> See [`configuration.md`](./configuration.md#model-presets) for how to define presets in your config.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Enabled by default (read-only mode). The agent can check its state but not set it.
|
Enabled by default (read-only mode). The agent can check its state but not set it.
|
||||||
@@ -39,8 +44,7 @@ Without parameters, returns a key config overview:
|
|||||||
```text
|
```text
|
||||||
my(action="check")
|
my(action="check")
|
||||||
# → max_iterations: 40
|
# → max_iterations: 40
|
||||||
# context_window_tokens: 65536
|
# model_preset: 'fast'
|
||||||
# model: 'anthropic/claude-sonnet-4-20250514'
|
|
||||||
# workspace: PosixPath('/tmp/workspace')
|
# workspace: PosixPath('/tmp/workspace')
|
||||||
# provider_retry_mode: 'standard'
|
# provider_retry_mode: 'standard'
|
||||||
# max_tool_result_chars: 16000
|
# max_tool_result_chars: 16000
|
||||||
@@ -55,8 +59,13 @@ With a key parameter, drill into a specific config:
|
|||||||
my(action="check", key="_last_usage.prompt_tokens")
|
my(action="check", key="_last_usage.prompt_tokens")
|
||||||
# → How many prompt tokens I've used so far
|
# → How many prompt tokens I've used so far
|
||||||
|
|
||||||
my(action="check", key="model")
|
my(action="check", key="model_preset")
|
||||||
# → What model I'm currently running on
|
# → Current active preset name (e.g. 'fast')
|
||||||
|
|
||||||
|
my(action="check", key="model_presets")
|
||||||
|
# → Lists all preset names and their models, e.g.:
|
||||||
|
# fast → gpt-4.1-mini (openai)
|
||||||
|
# deep → claude-opus-4-7 (anthropic)
|
||||||
|
|
||||||
my(action="check", key="web_config.enable")
|
my(action="check", key="web_config.enable")
|
||||||
# → Whether web search is enabled
|
# → Whether web search is enabled
|
||||||
@@ -66,7 +75,7 @@ my(action="check", key="web_config.enable")
|
|||||||
|
|
||||||
| Scenario | How |
|
| Scenario | How |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
| "What model are you using?" | `check("model")` |
|
| "What model are you using?" | `check("model_preset")` |
|
||||||
| "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` |
|
| "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` |
|
||||||
| "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
|
| "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
|
||||||
| "Where is your working directory?" | `check("workspace")` |
|
| "Where is your working directory?" | `check("workspace")` |
|
||||||
@@ -83,8 +92,11 @@ Changes take effect immediately, no restart required.
|
|||||||
my(action="set", key="max_iterations", value=80)
|
my(action="set", key="max_iterations", value=80)
|
||||||
# → Bump iteration limit from 40 to 80
|
# → Bump iteration limit from 40 to 80
|
||||||
|
|
||||||
my(action="set", key="model", value="fast-model")
|
my(action="set", key="model_preset", value="fast")
|
||||||
# → Switch to a faster model
|
# → Switch to the 'fast' preset (model, provider, temperature, etc. all at once)
|
||||||
|
#
|
||||||
|
# If the preset name does not exist:
|
||||||
|
# → Error: model_preset 'unknown' not found. Available: fast, deep
|
||||||
|
|
||||||
my(action="set", key="context_window_tokens", value=131072)
|
my(action="set", key="context_window_tokens", value=131072)
|
||||||
# → Expand context window for long documents
|
# → Expand context window for long documents
|
||||||
@@ -101,15 +113,17 @@ my(action="set", key="task_complexity", value="high")
|
|||||||
|
|
||||||
### Protected parameters
|
### Protected parameters
|
||||||
|
|
||||||
These parameters have type and range validation — invalid values are rejected:
|
These parameters have validation — invalid values are rejected:
|
||||||
|
|
||||||
| Parameter | Type | Range | Purpose |
|
| Parameter | Type | Range / Constraint | Purpose |
|
||||||
|-----------|------|-------|---------|
|
|-----------|------|-------------------|---------|
|
||||||
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
||||||
| `context_window_tokens` | int | 4,096–1,000,000 | Context window size |
|
| `model_preset` | str | must exist in `model_presets` | Switch to a named preset bundle |
|
||||||
| `model` | str | non-empty | LLM model to use |
|
|
||||||
|
|
||||||
Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
|
Other parameters (e.g. `model`, `context_window_tokens`, `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Setting `model` or `context_window_tokens` directly automatically clears the active `model_preset`, because the live state no longer matches the preset bundle. Use `model_preset` for atomic switches instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,8 +139,8 @@ Agent: This codebase is large, let me expand my context window to handle it.
|
|||||||
### "Simple question, don't waste compute"
|
### "Simple question, don't waste compute"
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Agent: This is a straightforward question, let me switch to a faster model.
|
Agent: This is a straightforward question, let me switch to the fast preset.
|
||||||
→ my(action="set", key="model", value="fast-model")
|
→ my(action="set", key="model_preset", value="fast")
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Remember user preferences across turns"
|
### "Remember user preferences across turns"
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ Configure these **two parts** in your config (other options have defaults).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*Want to switch models mid-conversation?* Define [`modelPresets`](./configuration.md#model-presets) and switch instantly with `my(action="set", key="model_preset", value="fast")`.
|
||||||
|
|
||||||
**3. Chat**
|
**3. Chat**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import platform
|
import platform
|
||||||
|
from contextlib import suppress
|
||||||
from importlib.resources import files as pkg_files
|
from importlib.resources import files as pkg_files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -82,12 +83,14 @@ class ContextBuilder:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_runtime_context(
|
def _build_runtime_context(
|
||||||
channel: str | None, chat_id: str | None, timezone: str | None = None,
|
channel: str | None, chat_id: str | None, timezone: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None, sender_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build untrusted runtime metadata block for injection before the user message."""
|
"""Build untrusted runtime metadata block for injection before the user message."""
|
||||||
lines = [f"Current Time: {current_time_str(timezone)}"]
|
lines = [f"Current Time: {current_time_str(timezone)}"]
|
||||||
if channel and chat_id:
|
if channel and chat_id:
|
||||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
||||||
|
if sender_id:
|
||||||
|
lines += [f"Sender ID: {sender_id}"]
|
||||||
if session_summary:
|
if session_summary:
|
||||||
lines += ["", "[Resumed Session]", session_summary]
|
lines += ["", "[Resumed Session]", session_summary]
|
||||||
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
|
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
|
||||||
@@ -121,12 +124,10 @@ class ContextBuilder:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_template_content(content: str, template_path: str) -> bool:
|
def _is_template_content(content: str, template_path: str) -> bool:
|
||||||
"""Check if *content* is identical to the bundled template (user hasn't customized it)."""
|
"""Check if *content* is identical to the bundled template (user hasn't customized it)."""
|
||||||
try:
|
with suppress(Exception):
|
||||||
tpl = pkg_files("nanobot") / "templates" / template_path
|
tpl = pkg_files("nanobot") / "templates" / template_path
|
||||||
if tpl.is_file():
|
if tpl.is_file():
|
||||||
return content.strip() == tpl.read_text(encoding="utf-8").strip()
|
return content.strip() == tpl.read_text(encoding="utf-8").strip()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def build_messages(
|
def build_messages(
|
||||||
@@ -139,9 +140,10 @@ class ContextBuilder:
|
|||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
current_role: str = "user",
|
current_role: str = "user",
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
|
sender_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
runtime_ctx = self._build_runtime_context(channel, chat_id, self.timezone, session_summary=session_summary)
|
runtime_ctx = self._build_runtime_context(channel, chat_id, self.timezone, session_summary=session_summary, sender_id=sender_id)
|
||||||
user_content = self._build_user_content(current_message, media)
|
user_content = self._build_user_content(current_message, media)
|
||||||
|
|
||||||
# Merge runtime context and user content into a single user message
|
# Merge runtime context and user content into a single user message
|
||||||
|
|||||||
@@ -102,3 +102,22 @@ class CompositeHook(AgentHook):
|
|||||||
for h in self._hooks:
|
for h in self._hooks:
|
||||||
content = h.finalize_content(context, content)
|
content = h.finalize_content(context, content)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
class SDKCaptureHook(AgentHook):
|
||||||
|
"""Record tool names and the final message list for ``RunResult``.
|
||||||
|
|
||||||
|
The runner mutates ``context.messages`` in place across iterations, so the
|
||||||
|
snapshot is refreshed on every ``after_iteration`` call; the last call
|
||||||
|
reflects the end-of-turn state the SDK caller cares about.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.tools_used: list[str] = []
|
||||||
|
self.messages: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||||
|
for call in context.tool_calls:
|
||||||
|
self.tools_used.append(call.name)
|
||||||
|
self.messages = list(context.messages)
|
||||||
|
|||||||
+249
-43
@@ -7,7 +7,7 @@ import dataclasses
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from contextlib import AsyncExitStack, nullcontext
|
from contextlib import AsyncExitStack, nullcontext, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ from nanobot.agent.tools.ask import (
|
|||||||
pending_ask_user_id,
|
pending_ask_user_id,
|
||||||
)
|
)
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.notebook import NotebookEditTool
|
from nanobot.agent.tools.notebook import NotebookEditTool
|
||||||
@@ -40,7 +41,7 @@ from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
|||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
@@ -54,6 +55,7 @@ from nanobot.utils.progress_events import (
|
|||||||
on_progress_accepts_tool_events,
|
on_progress_accepts_tool_events,
|
||||||
)
|
)
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
|
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
|
||||||
@@ -111,6 +113,11 @@ class _LoopHook(AgentHook):
|
|||||||
|
|
||||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||||
self._loop._current_iteration = context.iteration
|
self._loop._current_iteration = context.iteration
|
||||||
|
logger.debug(
|
||||||
|
"Starting agent loop iteration {} for session {}",
|
||||||
|
context.iteration,
|
||||||
|
self._session_key,
|
||||||
|
)
|
||||||
|
|
||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
if self._on_progress:
|
if self._on_progress:
|
||||||
@@ -181,6 +188,50 @@ class AgentLoop:
|
|||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(
|
||||||
|
cls,
|
||||||
|
config: Any,
|
||||||
|
bus: MessageBus | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> AgentLoop:
|
||||||
|
"""Create an AgentLoop from config with the common parameter set."""
|
||||||
|
from nanobot.providers.factory import build_provider_for_preset, make_provider_factory
|
||||||
|
|
||||||
|
if bus is None:
|
||||||
|
bus = MessageBus()
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
resolved_preset = config.resolve_preset()
|
||||||
|
provider = build_provider_for_preset(config, resolved_preset)
|
||||||
|
return cls(
|
||||||
|
bus=bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=config.workspace_path,
|
||||||
|
model=resolved_preset.model,
|
||||||
|
max_iterations=defaults.max_tool_iterations,
|
||||||
|
context_window_tokens=resolved_preset.context_window_tokens,
|
||||||
|
context_block_limit=defaults.context_block_limit,
|
||||||
|
max_tool_result_chars=defaults.max_tool_result_chars,
|
||||||
|
provider_retry_mode=defaults.provider_retry_mode,
|
||||||
|
fallback_presets=defaults.fallback_presets,
|
||||||
|
provider_factory=make_provider_factory(config),
|
||||||
|
web_config=config.tools.web,
|
||||||
|
exec_config=config.tools.exec,
|
||||||
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
|
mcp_servers=config.tools.mcp_servers,
|
||||||
|
channels_config=config.channels,
|
||||||
|
timezone=defaults.timezone,
|
||||||
|
unified_session=defaults.unified_session,
|
||||||
|
disabled_skills=defaults.disabled_skills,
|
||||||
|
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||||
|
consolidation_ratio=defaults.consolidation_ratio,
|
||||||
|
max_messages=defaults.max_messages,
|
||||||
|
tools_config=config.tools,
|
||||||
|
model_presets=config.model_presets,
|
||||||
|
model_preset=defaults.model_preset,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
@@ -192,6 +243,9 @@ class AgentLoop:
|
|||||||
context_block_limit: int | None = None,
|
context_block_limit: int | None = None,
|
||||||
max_tool_result_chars: int | None = None,
|
max_tool_result_chars: int | None = None,
|
||||||
provider_retry_mode: str = "standard",
|
provider_retry_mode: str = "standard",
|
||||||
|
tool_hint_max_length: int | None = None,
|
||||||
|
fallback_presets: list[str] | None = None,
|
||||||
|
provider_factory: Callable[[str], LLMProvider] | None = None,
|
||||||
web_config: WebToolsConfig | None = None,
|
web_config: WebToolsConfig | None = None,
|
||||||
exec_config: ExecToolConfig | None = None,
|
exec_config: ExecToolConfig | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
@@ -209,6 +263,8 @@ class AgentLoop:
|
|||||||
tools_config: ToolsConfig | None = None,
|
tools_config: ToolsConfig | None = None,
|
||||||
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
||||||
provider_signature: tuple[object, ...] | None = None,
|
provider_signature: tuple[object, ...] | None = None,
|
||||||
|
model_presets: dict[str, ModelPresetConfig] | None = None,
|
||||||
|
model_preset: str | None = None,
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
|
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
|
||||||
|
|
||||||
@@ -216,7 +272,12 @@ class AgentLoop:
|
|||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.channels_config = channels_config
|
self.channels_config = channels_config
|
||||||
self.provider = provider
|
self.provider_factory = provider_factory
|
||||||
|
self.fallback_presets = fallback_presets or []
|
||||||
|
wrapped_provider = self._wrap_with_failover(
|
||||||
|
provider, model or provider.get_default_model()
|
||||||
|
)
|
||||||
|
self.provider = wrapped_provider
|
||||||
self._provider_snapshot_loader = provider_snapshot_loader
|
self._provider_snapshot_loader = provider_snapshot_loader
|
||||||
self._provider_signature = provider_signature
|
self._provider_signature = provider_signature
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@@ -236,6 +297,10 @@ class AgentLoop:
|
|||||||
else defaults.max_tool_result_chars
|
else defaults.max_tool_result_chars
|
||||||
)
|
)
|
||||||
self.provider_retry_mode = provider_retry_mode
|
self.provider_retry_mode = provider_retry_mode
|
||||||
|
self.tool_hint_max_length = (
|
||||||
|
tool_hint_max_length if tool_hint_max_length is not None
|
||||||
|
else defaults.tool_hint_max_length
|
||||||
|
)
|
||||||
self.web_config = web_config or WebToolsConfig()
|
self.web_config = web_config or WebToolsConfig()
|
||||||
self.exec_config = exec_config or ExecToolConfig()
|
self.exec_config = exec_config or ExecToolConfig()
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
@@ -247,9 +312,12 @@ class AgentLoop:
|
|||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
self.sessions = session_manager or SessionManager(workspace)
|
||||||
self.tools = ToolRegistry()
|
self.tools = ToolRegistry()
|
||||||
self.runner = AgentRunner(provider)
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
|
self._file_state_store = FileStateStore()
|
||||||
|
self.runner = AgentRunner(wrapped_provider)
|
||||||
self.subagents = SubagentManager(
|
self.subagents = SubagentManager(
|
||||||
provider=provider,
|
provider=wrapped_provider,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
@@ -258,6 +326,7 @@ class AgentLoop:
|
|||||||
exec_config=self.exec_config,
|
exec_config=self.exec_config,
|
||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
|
max_iterations=self.max_iterations,
|
||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
self._unified_session = unified_session
|
||||||
self._max_messages = max_messages if max_messages > 0 else 120
|
self._max_messages = max_messages if max_messages > 0 else 120
|
||||||
@@ -280,13 +349,13 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self.consolidator = Consolidator(
|
self.consolidator = Consolidator(
|
||||||
store=self.context.memory,
|
store=self.context.memory,
|
||||||
provider=provider,
|
provider=wrapped_provider,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
sessions=self.sessions,
|
sessions=self.sessions,
|
||||||
context_window_tokens=self.context_window_tokens,
|
context_window_tokens=self.context_window_tokens,
|
||||||
build_messages=self.context.build_messages,
|
build_messages=self.context.build_messages,
|
||||||
get_tool_definitions=self.tools.get_definitions,
|
get_tool_definitions=self.tools.get_definitions,
|
||||||
max_completion_tokens=provider.generation.max_tokens,
|
max_completion_tokens=wrapped_provider.generation.max_tokens,
|
||||||
consolidation_ratio=consolidation_ratio,
|
consolidation_ratio=consolidation_ratio,
|
||||||
)
|
)
|
||||||
self.auto_compact = AutoCompact(
|
self.auto_compact = AutoCompact(
|
||||||
@@ -296,9 +365,13 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self.dream = Dream(
|
self.dream = Dream(
|
||||||
store=self.context.memory,
|
store=self.context.memory,
|
||||||
provider=provider,
|
provider=wrapped_provider,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
)
|
)
|
||||||
|
self.model_presets: dict[str, ModelPresetConfig] = model_presets or {}
|
||||||
|
self._active_preset: str | None = (
|
||||||
|
model_preset if model_preset in self.model_presets else None
|
||||||
|
)
|
||||||
self._register_default_tools()
|
self._register_default_tools()
|
||||||
if _tc.my.enable:
|
if _tc.my.enable:
|
||||||
self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set))
|
self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set))
|
||||||
@@ -307,6 +380,42 @@ class AgentLoop:
|
|||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
register_builtin_commands(self.commands)
|
register_builtin_commands(self.commands)
|
||||||
|
|
||||||
|
def _sync_subagent_runtime_limits(self) -> None:
|
||||||
|
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
||||||
|
self.subagents.max_iterations = self.max_iterations
|
||||||
|
|
||||||
|
def _wrap_with_failover(self, provider: LLMProvider, model: str) -> LLMProvider:
|
||||||
|
"""Wrap provider with failover router when fallback_presets are configured."""
|
||||||
|
if not self.fallback_presets or not self.provider_factory:
|
||||||
|
return provider
|
||||||
|
from nanobot.providers.failover import ModelRouter
|
||||||
|
|
||||||
|
if isinstance(provider, ModelRouter):
|
||||||
|
return provider
|
||||||
|
|
||||||
|
return ModelRouter(
|
||||||
|
primary_provider=provider,
|
||||||
|
primary_model=model,
|
||||||
|
fallback_presets=self.fallback_presets,
|
||||||
|
provider_factory=self.provider_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_provider_state(
|
||||||
|
self,
|
||||||
|
provider: LLMProvider,
|
||||||
|
model: str,
|
||||||
|
context_window_tokens: int,
|
||||||
|
) -> None:
|
||||||
|
"""Push provider/model/context_window to all LLM-consuming subsystems."""
|
||||||
|
self.provider = provider
|
||||||
|
# Bypass property setters so internal updates don't clear _active_preset.
|
||||||
|
object.__setattr__(self, "_model", model)
|
||||||
|
object.__setattr__(self, "_context_window_tokens", context_window_tokens)
|
||||||
|
self.runner.provider = provider
|
||||||
|
self.subagents.set_provider(provider, model)
|
||||||
|
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||||
|
self.dream.set_provider(provider, model)
|
||||||
|
|
||||||
def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None:
|
def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None:
|
||||||
"""Swap model/provider for future turns without disturbing an active one."""
|
"""Swap model/provider for future turns without disturbing an active one."""
|
||||||
provider = snapshot.provider
|
provider = snapshot.provider
|
||||||
@@ -315,14 +424,13 @@ class AgentLoop:
|
|||||||
if self.provider is provider and self.model == model:
|
if self.provider is provider and self.model == model:
|
||||||
return
|
return
|
||||||
old_model = self.model
|
old_model = self.model
|
||||||
self.provider = provider
|
provider = self._wrap_with_failover(provider, model)
|
||||||
self.model = model
|
self._apply_provider_state(provider, model, context_window_tokens)
|
||||||
self.context_window_tokens = context_window_tokens
|
|
||||||
self.runner.provider = provider
|
|
||||||
self.subagents.set_provider(provider, model)
|
|
||||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
|
||||||
self.dream.set_provider(provider, model)
|
|
||||||
self._provider_signature = snapshot.signature
|
self._provider_signature = snapshot.signature
|
||||||
|
if self._active_preset:
|
||||||
|
preset = self.model_presets.get(self._active_preset)
|
||||||
|
if preset and preset.model != model:
|
||||||
|
self._active_preset = None
|
||||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
||||||
|
|
||||||
def _refresh_provider_snapshot(self) -> None:
|
def _refresh_provider_snapshot(self) -> None:
|
||||||
@@ -337,6 +445,58 @@ class AgentLoop:
|
|||||||
return
|
return
|
||||||
self._apply_provider_snapshot(snapshot)
|
self._apply_provider_snapshot(snapshot)
|
||||||
|
|
||||||
|
# -- model / context_window_tokens properties with preset invalidation --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
return self._model
|
||||||
|
|
||||||
|
@model.setter
|
||||||
|
def model(self, value: str) -> None:
|
||||||
|
self._model = value
|
||||||
|
if hasattr(self, "_active_preset"):
|
||||||
|
self._active_preset = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context_window_tokens(self) -> int:
|
||||||
|
return self._context_window_tokens
|
||||||
|
|
||||||
|
@context_window_tokens.setter
|
||||||
|
def context_window_tokens(self, value: int) -> None:
|
||||||
|
self._context_window_tokens = value
|
||||||
|
if hasattr(self, "_active_preset"):
|
||||||
|
self._active_preset = None
|
||||||
|
|
||||||
|
# -- model_preset property --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_preset(self) -> str | None:
|
||||||
|
return self._active_preset
|
||||||
|
|
||||||
|
@model_preset.setter
|
||||||
|
def model_preset(self, name: str) -> None:
|
||||||
|
"""Resolve a preset by name and apply all fields."""
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise ValueError("model_preset must be a non-empty string")
|
||||||
|
if name not in self.model_presets:
|
||||||
|
raise KeyError(
|
||||||
|
f"model_preset {name!r} not found. Available: {', '.join(self.model_presets) or '(none)'}"
|
||||||
|
)
|
||||||
|
if self.provider_factory is None:
|
||||||
|
raise ValueError("provider_factory is not configured; cannot switch model preset")
|
||||||
|
|
||||||
|
p = self.model_presets[name]
|
||||||
|
new_provider = self._wrap_with_failover(self.provider_factory(name), p.model)
|
||||||
|
|
||||||
|
# Preserve dream model_override if it differs from the current loop model.
|
||||||
|
old_dream_model = self.dream.model
|
||||||
|
dream_had_override = old_dream_model != self.model
|
||||||
|
|
||||||
|
self._apply_provider_state(new_provider, p.model, p.context_window_tokens)
|
||||||
|
if dream_had_override:
|
||||||
|
self.dream.model = old_dream_model
|
||||||
|
self._active_preset = name
|
||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
"""Register the default set of tools."""
|
"""Register the default set of tools."""
|
||||||
allowed_dir = (
|
allowed_dir = (
|
||||||
@@ -346,7 +506,9 @@ class AgentLoop:
|
|||||||
self.tools.register(AskUserTool())
|
self.tools.register(AskUserTool())
|
||||||
self.tools.register(
|
self.tools.register(
|
||||||
ReadFileTool(
|
ReadFileTool(
|
||||||
workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read
|
workspace=self.workspace,
|
||||||
|
allowed_dir=allowed_dir,
|
||||||
|
extra_allowed_dirs=extra_read,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
||||||
@@ -363,6 +525,8 @@ class AgentLoop:
|
|||||||
sandbox=self.exec_config.sandbox,
|
sandbox=self.exec_config.sandbox,
|
||||||
path_append=self.exec_config.path_append,
|
path_append=self.exec_config.path_append,
|
||||||
allowed_env_keys=self.exec_config.allowed_env_keys,
|
allowed_env_keys=self.exec_config.allowed_env_keys,
|
||||||
|
allow_patterns=self.exec_config.allow_patterns,
|
||||||
|
deny_patterns=self.exec_config.deny_patterns,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.web_config.enable:
|
if self.web_config.enable:
|
||||||
@@ -404,7 +568,7 @@ class AgentLoop:
|
|||||||
logger.warning("MCP connection cancelled (will retry next message)")
|
logger.warning("MCP connection cancelled (will retry next message)")
|
||||||
self._mcp_stacks.clear()
|
self._mcp_stacks.clear()
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.error("Failed to connect MCP servers (will retry next message): {}", e)
|
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
self._mcp_stacks.clear()
|
self._mcp_stacks.clear()
|
||||||
finally:
|
finally:
|
||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
@@ -430,6 +594,8 @@ class AgentLoop:
|
|||||||
if hasattr(tool, "set_context"):
|
if hasattr(tool, "set_context"):
|
||||||
if name == "spawn":
|
if name == "spawn":
|
||||||
tool.set_context(channel, chat_id, effective_key=effective_key)
|
tool.set_context(channel, chat_id, effective_key=effective_key)
|
||||||
|
if hasattr(tool, "set_origin_message_id"):
|
||||||
|
tool.set_origin_message_id(message_id)
|
||||||
elif name == "cron":
|
elif name == "cron":
|
||||||
tool.set_context(channel, chat_id, metadata=metadata, session_key=session_key)
|
tool.set_context(channel, chat_id, metadata=metadata, session_key=session_key)
|
||||||
elif name == "message":
|
elif name == "message":
|
||||||
@@ -451,12 +617,11 @@ class AgentLoop:
|
|||||||
"""Return the chat id shown in runtime metadata for the model."""
|
"""Return the chat id shown in runtime metadata for the model."""
|
||||||
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
||||||
|
|
||||||
@staticmethod
|
def _tool_hint(self, tool_calls: list) -> str:
|
||||||
def _tool_hint(tool_calls: list) -> str:
|
|
||||||
"""Format tool calls as concise hints with smart abbreviation."""
|
"""Format tool calls as concise hints with smart abbreviation."""
|
||||||
from nanobot.utils.tool_hints import format_tool_hints
|
from nanobot.utils.tool_hints import format_tool_hints
|
||||||
|
|
||||||
return format_tool_hints(tool_calls)
|
return format_tool_hints(tool_calls, max_length=self.tool_hint_max_length)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
self,
|
self,
|
||||||
@@ -481,10 +646,8 @@ class AgentLoop:
|
|||||||
tasks = self._active_tasks.pop(key, [])
|
tasks = self._active_tasks.pop(key, [])
|
||||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
try:
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
await t
|
await t
|
||||||
except (asyncio.CancelledError, Exception):
|
|
||||||
pass
|
|
||||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||||
return cancelled + sub_cancelled
|
return cancelled + sub_cancelled
|
||||||
|
|
||||||
@@ -531,6 +694,8 @@ class AgentLoop:
|
|||||||
|
|
||||||
Returns (final_content, tools_used, messages, stop_reason, had_injections).
|
Returns (final_content, tools_used, messages, stop_reason, had_injections).
|
||||||
"""
|
"""
|
||||||
|
self._sync_subagent_runtime_limits()
|
||||||
|
|
||||||
loop_hook = _LoopHook(
|
loop_hook = _LoopHook(
|
||||||
self,
|
self,
|
||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
@@ -611,25 +776,31 @@ class AgentLoop:
|
|||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
result = await self.runner.run(AgentRunSpec(
|
active_session_key = session.key if session else session_key
|
||||||
initial_messages=initial_messages,
|
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||||
tools=self.tools,
|
try:
|
||||||
model=self.model,
|
result = await self.runner.run(AgentRunSpec(
|
||||||
max_iterations=self.max_iterations,
|
initial_messages=initial_messages,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
tools=self.tools,
|
||||||
hook=hook,
|
model=self.model,
|
||||||
error_message="Sorry, I encountered an error calling the AI model.",
|
max_iterations=self.max_iterations,
|
||||||
concurrent_tools=True,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
workspace=self.workspace,
|
hook=hook,
|
||||||
session_key=session.key if session else None,
|
error_message="Sorry, I encountered an error calling the AI model.",
|
||||||
context_window_tokens=self.context_window_tokens,
|
concurrent_tools=True,
|
||||||
context_block_limit=self.context_block_limit,
|
workspace=self.workspace,
|
||||||
provider_retry_mode=self.provider_retry_mode,
|
session_key=session.key if session else None,
|
||||||
progress_callback=on_progress,
|
context_window_tokens=self.context_window_tokens,
|
||||||
retry_wait_callback=on_retry_wait,
|
context_block_limit=self.context_block_limit,
|
||||||
checkpoint_callback=_checkpoint,
|
provider_retry_mode=self.provider_retry_mode,
|
||||||
injection_callback=_drain_pending,
|
progress_callback=on_progress,
|
||||||
))
|
stream_progress_deltas=on_stream is not None,
|
||||||
|
retry_wait_callback=on_retry_wait,
|
||||||
|
checkpoint_callback=_checkpoint,
|
||||||
|
injection_callback=_drain_pending,
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
reset_file_states(file_state_token)
|
||||||
self._last_usage = result.usage
|
self._last_usage = result.usage
|
||||||
if result.stop_reason == "max_iterations":
|
if result.stop_reason == "max_iterations":
|
||||||
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
||||||
@@ -776,6 +947,33 @@ class AgentLoop:
|
|||||||
channel=msg.channel, chat_id=msg.chat_id,
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="", metadata=msg.metadata or {},
|
content="", metadata=msg.metadata or {},
|
||||||
))
|
))
|
||||||
|
if msg.channel == "websocket":
|
||||||
|
# Signal that the turn is fully complete (all tools executed,
|
||||||
|
# final text streamed). This lets WS clients know when to
|
||||||
|
# definitively stop the loading indicator.
|
||||||
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
|
content="", metadata={**msg.metadata, "_turn_end": True},
|
||||||
|
))
|
||||||
|
if msg.metadata.get("webui") is True:
|
||||||
|
async def _generate_title_and_notify() -> None:
|
||||||
|
generated = await maybe_generate_webui_title_after_turn(
|
||||||
|
channel=msg.channel,
|
||||||
|
metadata=msg.metadata,
|
||||||
|
sessions=self.sessions,
|
||||||
|
session_key=session_key,
|
||||||
|
provider=self.provider,
|
||||||
|
model=self.model,
|
||||||
|
)
|
||||||
|
if generated:
|
||||||
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
content="",
|
||||||
|
metadata={**msg.metadata, "_session_updated": True},
|
||||||
|
))
|
||||||
|
|
||||||
|
self._schedule_background(_generate_title_and_notify())
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Task cancelled for session {}", session_key)
|
logger.info("Task cancelled for session {}", session_key)
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
@@ -879,6 +1077,8 @@ class AgentLoop:
|
|||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
session, pending = self.auto_compact.prepare_session(session, key)
|
session, pending = self.auto_compact.prepare_session(session, key)
|
||||||
|
if pending:
|
||||||
|
logger.info("Memory compact triggered for session {}", key)
|
||||||
|
|
||||||
await self.consolidator.maybe_consolidate_by_tokens(
|
await self.consolidator.maybe_consolidate_by_tokens(
|
||||||
session,
|
session,
|
||||||
@@ -891,6 +1091,7 @@ class AgentLoop:
|
|||||||
# LLM via the merged prompt. See _persist_subagent_followup.
|
# LLM via the merged prompt. See _persist_subagent_followup.
|
||||||
is_subagent = msg.sender_id == "subagent"
|
is_subagent = msg.sender_id == "subagent"
|
||||||
if is_subagent and self._persist_subagent_followup(session, msg):
|
if is_subagent and self._persist_subagent_followup(session, msg):
|
||||||
|
logger.debug("Subagent result persisted for session {}", key)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
self._set_tool_context(
|
self._set_tool_context(
|
||||||
channel, chat_id, msg.metadata.get("message_id"),
|
channel, chat_id, msg.metadata.get("message_id"),
|
||||||
@@ -913,6 +1114,7 @@ class AgentLoop:
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
current_role=current_role,
|
current_role=current_role,
|
||||||
|
sender_id=msg.sender_id,
|
||||||
)
|
)
|
||||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||||
messages, session=session, channel=channel, chat_id=chat_id,
|
messages, session=session, channel=channel, chat_id=chat_id,
|
||||||
@@ -940,6 +1142,8 @@ class AgentLoop:
|
|||||||
outbound_metadata: dict[str, Any] = {}
|
outbound_metadata: dict[str, Any] = {}
|
||||||
if channel == "slack" and key.startswith("slack:") and key.count(":") >= 2:
|
if channel == "slack" and key.startswith("slack:") and key.count(":") >= 2:
|
||||||
outbound_metadata["slack"] = {"thread_ts": key.split(":", 2)[2]}
|
outbound_metadata["slack"] = {"thread_ts": key.split(":", 2)[2]}
|
||||||
|
if origin_message_id := msg.metadata.get("origin_message_id"):
|
||||||
|
outbound_metadata["origin_message_id"] = origin_message_id
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
@@ -959,6 +1163,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
key = session_key or msg.session_key
|
key = session_key or msg.session_key
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
|
mark_webui_session(session, msg.metadata)
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if self._restore_pending_user_turn(session):
|
if self._restore_pending_user_turn(session):
|
||||||
@@ -1008,6 +1213,7 @@ class AgentLoop:
|
|||||||
media=msg.media if msg.media else None,
|
media=msg.media if msg.media else None,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=self._runtime_chat_id(msg),
|
chat_id=self._runtime_chat_id(msg),
|
||||||
|
sender_id=msg.sender_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _bus_progress(
|
async def _bus_progress(
|
||||||
@@ -1103,7 +1309,7 @@ class AgentLoop:
|
|||||||
ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [],
|
ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [],
|
||||||
msg.channel,
|
msg.channel,
|
||||||
)
|
)
|
||||||
if on_stream is not None and stop_reason not in {"ask_user", "error"}:
|
if on_stream is not None and stop_reason not in {"ask_user", "error", "tool_error"}:
|
||||||
meta["_streamed"] = True
|
meta["_streamed"] = True
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
|
|||||||
+21
-21
@@ -7,6 +7,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import weakref
|
import weakref
|
||||||
|
from contextlib import suppress
|
||||||
import tiktoken
|
import tiktoken
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -296,10 +297,8 @@ class MemoryStore:
|
|||||||
def _next_cursor(self) -> int:
|
def _next_cursor(self) -> int:
|
||||||
"""Read the current cursor counter and return the next value."""
|
"""Read the current cursor counter and return the next value."""
|
||||||
if self._cursor_file.exists():
|
if self._cursor_file.exists():
|
||||||
try:
|
with suppress(ValueError, OSError):
|
||||||
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
|
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
|
||||||
except (ValueError, OSError):
|
|
||||||
pass
|
|
||||||
# Fast path: trust the tail when intact. Otherwise scan the whole
|
# Fast path: trust the tail when intact. Otherwise scan the whole
|
||||||
# file and take ``max`` — that stays correct even if the monotonic
|
# file and take ``max`` — that stays correct even if the monotonic
|
||||||
# invariant was broken by external writes.
|
# invariant was broken by external writes.
|
||||||
@@ -328,7 +327,7 @@ class MemoryStore:
|
|||||||
def _read_entries(self) -> list[dict[str, Any]]:
|
def _read_entries(self) -> list[dict[str, Any]]:
|
||||||
"""Read all entries from history.jsonl."""
|
"""Read all entries from history.jsonl."""
|
||||||
entries: list[dict[str, Any]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
try:
|
with suppress(FileNotFoundError):
|
||||||
with open(self.history_file, "r", encoding="utf-8") as f:
|
with open(self.history_file, "r", encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -337,8 +336,7 @@ class MemoryStore:
|
|||||||
entries.append(json.loads(line))
|
entries.append(json.loads(line))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
def _read_last_entry(self) -> dict[str, Any] | None:
|
def _read_last_entry(self) -> dict[str, Any] | None:
|
||||||
@@ -374,14 +372,12 @@ class MemoryStore:
|
|||||||
# On Windows, opening a directory with O_RDONLY raises
|
# On Windows, opening a directory with O_RDONLY raises
|
||||||
# PermissionError — skip the dir sync there (NTFS
|
# PermissionError — skip the dir sync there (NTFS
|
||||||
# journals metadata synchronously).
|
# journals metadata synchronously).
|
||||||
try:
|
with suppress(PermissionError):
|
||||||
fd = os.open(str(self.history_file.parent), os.O_RDONLY)
|
fd = os.open(str(self.history_file.parent), os.O_RDONLY)
|
||||||
try:
|
try:
|
||||||
os.fsync(fd)
|
os.fsync(fd)
|
||||||
finally:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
except PermissionError:
|
|
||||||
pass # Windows — directory fsync not supported
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
@@ -390,10 +386,8 @@ class MemoryStore:
|
|||||||
|
|
||||||
def get_last_dream_cursor(self) -> int:
|
def get_last_dream_cursor(self) -> int:
|
||||||
if self._dream_cursor_file.exists():
|
if self._dream_cursor_file.exists():
|
||||||
try:
|
with suppress(ValueError, OSError):
|
||||||
return int(self._dream_cursor_file.read_text(encoding="utf-8").strip())
|
return int(self._dream_cursor_file.read_text(encoding="utf-8").strip())
|
||||||
except (ValueError, OSError):
|
|
||||||
pass
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def set_last_dream_cursor(self, cursor: int) -> None:
|
def set_last_dream_cursor(self, cursor: int) -> None:
|
||||||
@@ -524,6 +518,7 @@ class Consolidator:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
session_summary=session_summary,
|
session_summary=session_summary,
|
||||||
|
sender_id=None,
|
||||||
)
|
)
|
||||||
return estimate_prompt_tokens_chain(
|
return estimate_prompt_tokens_chain(
|
||||||
self.provider,
|
self.provider,
|
||||||
@@ -753,23 +748,28 @@ class Dream:
|
|||||||
def _build_tools(self) -> ToolRegistry:
|
def _build_tools(self) -> ToolRegistry:
|
||||||
"""Build a minimal tool registry for the Dream agent."""
|
"""Build a minimal tool registry for the Dream agent."""
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
from nanobot.agent.tools.file_state import FileStates
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
||||||
|
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
workspace = self.store.workspace
|
workspace = self.store.workspace
|
||||||
# Allow reading builtin skills for reference during skill creation
|
# Allow reading builtin skills for reference during skill creation
|
||||||
extra_read = [BUILTIN_SKILLS_DIR] if BUILTIN_SKILLS_DIR.exists() else None
|
extra_read = [BUILTIN_SKILLS_DIR] if BUILTIN_SKILLS_DIR.exists() else None
|
||||||
|
# Dream gets its own FileStates so its caches stay isolated from the
|
||||||
|
# main loop's sessions (issue #3571).
|
||||||
|
file_states = FileStates()
|
||||||
tools.register(ReadFileTool(
|
tools.register(ReadFileTool(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
allowed_dir=workspace,
|
allowed_dir=workspace,
|
||||||
extra_allowed_dirs=extra_read,
|
extra_allowed_dirs=extra_read,
|
||||||
|
file_states=file_states,
|
||||||
))
|
))
|
||||||
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace))
|
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace, file_states=file_states))
|
||||||
# write_file resolves relative paths from workspace root, but can only
|
# write_file resolves relative paths from workspace root, but can only
|
||||||
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
|
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
|
||||||
skills_dir = workspace / "skills"
|
skills_dir = workspace / "skills"
|
||||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
tools.register(WriteFileTool(workspace=workspace, allowed_dir=skills_dir))
|
tools.register(WriteFileTool(workspace=workspace, allowed_dir=skills_dir, file_states=file_states))
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# -- skill listing --------------------------------------------------------
|
# -- skill listing --------------------------------------------------------
|
||||||
@@ -974,12 +974,10 @@ class Dream:
|
|||||||
if event["status"] == "ok":
|
if event["status"] == "ok":
|
||||||
changelog.append(f"{event['name']}: {event['detail']}")
|
changelog.append(f"{event['name']}: {event['detail']}")
|
||||||
|
|
||||||
# Advance cursor — always, to avoid re-processing Phase 1
|
# Only advance cursor on successful completion to prevent silent loss
|
||||||
new_cursor = batch[-1]["cursor"]
|
|
||||||
self.store.set_last_dream_cursor(new_cursor)
|
|
||||||
self.store.compact_history()
|
|
||||||
|
|
||||||
if result and result.stop_reason == "completed":
|
if result and result.stop_reason == "completed":
|
||||||
|
new_cursor = batch[-1]["cursor"]
|
||||||
|
self.store.set_last_dream_cursor(new_cursor)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Dream done: {} change(s), cursor advanced to {}",
|
"Dream done: {} change(s), cursor advanced to {}",
|
||||||
len(changelog), new_cursor,
|
len(changelog), new_cursor,
|
||||||
@@ -987,10 +985,12 @@ class Dream:
|
|||||||
else:
|
else:
|
||||||
reason = result.stop_reason if result else "exception"
|
reason = result.stop_reason if result else "exception"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Dream incomplete ({}): cursor advanced to {}",
|
"Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle",
|
||||||
reason, new_cursor,
|
reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.store.compact_history()
|
||||||
|
|
||||||
# Git auto-commit (only when there are actual changes)
|
# Git auto-commit (only when there are actual changes)
|
||||||
if changelog and self.store.git.is_initialized():
|
if changelog and self.store.git.is_initialized():
|
||||||
ts = batch[-1]["timestamp"]
|
ts = batch[-1]["timestamp"]
|
||||||
|
|||||||
+136
-51
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -32,6 +33,7 @@ from nanobot.utils.runtime import (
|
|||||||
ensure_nonempty_tool_result,
|
ensure_nonempty_tool_result,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
repeated_external_lookup_error,
|
repeated_external_lookup_error,
|
||||||
|
repeated_workspace_violation_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
||||||
@@ -74,6 +76,7 @@ class AgentRunSpec:
|
|||||||
context_block_limit: int | None = None
|
context_block_limit: int | None = None
|
||||||
provider_retry_mode: str = "standard"
|
provider_retry_mode: str = "standard"
|
||||||
progress_callback: Any | None = None
|
progress_callback: Any | None = None
|
||||||
|
stream_progress_deltas: bool = True
|
||||||
retry_wait_callback: Any | None = None
|
retry_wait_callback: Any | None = None
|
||||||
checkpoint_callback: Any | None = None
|
checkpoint_callback: Any | None = None
|
||||||
injection_callback: Any | None = None
|
injection_callback: Any | None = None
|
||||||
@@ -238,6 +241,8 @@ class AgentRunner:
|
|||||||
stop_reason = "completed"
|
stop_reason = "completed"
|
||||||
tool_events: list[dict[str, str]] = []
|
tool_events: list[dict[str, str]] = []
|
||||||
external_lookup_counts: dict[str, int] = {}
|
external_lookup_counts: dict[str, int] = {}
|
||||||
|
# Per-turn throttle for repeated attempts against the same outside target.
|
||||||
|
workspace_violation_counts: dict[str, int] = {}
|
||||||
empty_content_retries = 0
|
empty_content_retries = 0
|
||||||
length_recovery_count = 0
|
length_recovery_count = 0
|
||||||
had_injections = False
|
had_injections = False
|
||||||
@@ -257,12 +262,11 @@ class AgentRunner:
|
|||||||
# Snipping may have created new orphans; clean them up.
|
# Snipping may have created new orphans; clean them up.
|
||||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.warning(
|
logger.exception(
|
||||||
"Context governance failed on turn {} for {}: {}; applying minimal repair",
|
"Context governance failed on turn {} for {}; applying minimal repair",
|
||||||
iteration,
|
iteration,
|
||||||
spec.session_key or "default",
|
spec.session_key or "default",
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||||
@@ -313,6 +317,7 @@ class AgentRunner:
|
|||||||
spec,
|
spec,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
external_lookup_counts,
|
external_lookup_counts,
|
||||||
|
workspace_violation_counts,
|
||||||
)
|
)
|
||||||
tool_events.extend(new_events)
|
tool_events.extend(new_events)
|
||||||
context.tool_results = list(results)
|
context.tool_results = list(results)
|
||||||
@@ -611,6 +616,7 @@ class AgentRunner:
|
|||||||
wants_streaming = hook.wants_streaming()
|
wants_streaming = hook.wants_streaming()
|
||||||
wants_progress_streaming = (
|
wants_progress_streaming = (
|
||||||
not wants_streaming
|
not wants_streaming
|
||||||
|
and spec.stream_progress_deltas
|
||||||
and spec.progress_callback is not None
|
and spec.progress_callback is not None
|
||||||
and getattr(self.provider, "supports_progress_deltas", False) is True
|
and getattr(self.provider, "supports_progress_deltas", False) is True
|
||||||
)
|
)
|
||||||
@@ -697,20 +703,25 @@ class AgentRunner:
|
|||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
tool_calls: list[ToolCallRequest],
|
tool_calls: list[ToolCallRequest],
|
||||||
external_lookup_counts: dict[str, int],
|
external_lookup_counts: dict[str, int],
|
||||||
|
workspace_violation_counts: dict[str, int],
|
||||||
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
|
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
|
||||||
batches = self._partition_tool_batches(spec, tool_calls)
|
batches = self._partition_tool_batches(spec, tool_calls)
|
||||||
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
||||||
for batch in batches:
|
for batch in batches:
|
||||||
if spec.concurrent_tools and len(batch) > 1:
|
if spec.concurrent_tools and len(batch) > 1:
|
||||||
batch_results = await asyncio.gather(*(
|
batch_results = await asyncio.gather(*(
|
||||||
self._run_tool(spec, tool_call, external_lookup_counts)
|
self._run_tool(
|
||||||
|
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
||||||
|
)
|
||||||
for tool_call in batch
|
for tool_call in batch
|
||||||
))
|
))
|
||||||
tool_results.extend(batch_results)
|
tool_results.extend(batch_results)
|
||||||
else:
|
else:
|
||||||
batch_results = []
|
batch_results = []
|
||||||
for tool_call in batch:
|
for tool_call in batch:
|
||||||
result = await self._run_tool(spec, tool_call, external_lookup_counts)
|
result = await self._run_tool(
|
||||||
|
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
||||||
|
)
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
batch_results.append(result)
|
batch_results.append(result)
|
||||||
if isinstance(result[2], AskUserInterrupt):
|
if isinstance(result[2], AskUserInterrupt):
|
||||||
@@ -733,6 +744,7 @@ class AgentRunner:
|
|||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
tool_call: ToolCallRequest,
|
tool_call: ToolCallRequest,
|
||||||
external_lookup_counts: dict[str, int],
|
external_lookup_counts: dict[str, int],
|
||||||
|
workspace_violation_counts: dict[str, int],
|
||||||
) -> tuple[Any, dict[str, str], BaseException | None]:
|
) -> tuple[Any, dict[str, str], BaseException | None]:
|
||||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||||
lookup_error = repeated_external_lookup_error(
|
lookup_error = repeated_external_lookup_error(
|
||||||
@@ -752,28 +764,28 @@ class AgentRunner:
|
|||||||
prepare_call = getattr(spec.tools, "prepare_call", None)
|
prepare_call = getattr(spec.tools, "prepare_call", None)
|
||||||
tool, params, prep_error = None, tool_call.arguments, None
|
tool, params, prep_error = None, tool_call.arguments, None
|
||||||
if callable(prepare_call):
|
if callable(prepare_call):
|
||||||
try:
|
with suppress(Exception):
|
||||||
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
||||||
if isinstance(prepared, tuple) and len(prepared) == 3:
|
if isinstance(prepared, tuple) and len(prepared) == 3:
|
||||||
tool, params, prep_error = prepared
|
tool, params, prep_error = prepared
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if prep_error:
|
if prep_error:
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"detail": prep_error.split(": ", 1)[-1][:120],
|
"detail": prep_error.split(": ", 1)[-1][:120],
|
||||||
}
|
}
|
||||||
if self._is_workspace_violation(prep_error):
|
handled = self._classify_violation(
|
||||||
logger.warning(
|
raw_text=prep_error,
|
||||||
"Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}",
|
soft_payload=prep_error + hint,
|
||||||
tool_call.name,
|
event=event,
|
||||||
prep_error.replace("\n", " ").strip()[:200],
|
tool_call=tool_call,
|
||||||
)
|
workspace_violation_counts=workspace_violation_counts,
|
||||||
event["detail"] = ("workspace_violation: "
|
)
|
||||||
+ prep_error.replace("\n", " ").strip())[:160]
|
if handled is not None:
|
||||||
return prep_error, event, RuntimeError(prep_error)
|
return handled
|
||||||
return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
return prep_error + hint, event, (
|
||||||
|
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if tool is not None:
|
if tool is not None:
|
||||||
result = await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
@@ -790,18 +802,20 @@ class AgentRunner:
|
|||||||
if isinstance(exc, AskUserInterrupt):
|
if isinstance(exc, AskUserInterrupt):
|
||||||
event["status"] = "waiting"
|
event["status"] = "waiting"
|
||||||
return "", event, exc
|
return "", event, exc
|
||||||
if self._is_workspace_violation(str(exc)):
|
payload = f"Error: {type(exc).__name__}: {exc}"
|
||||||
logger.warning(
|
handled = self._classify_violation(
|
||||||
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
|
raw_text=str(exc),
|
||||||
tool_call.name,
|
# Preserve legacy exception payloads without the retry hint.
|
||||||
str(exc).replace("\n", " ").strip()[:200],
|
soft_payload=payload,
|
||||||
)
|
event=event,
|
||||||
event["detail"] = ("workspace_violation: "
|
tool_call=tool_call,
|
||||||
+ str(exc).replace("\n", " ").strip())[:160]
|
workspace_violation_counts=workspace_violation_counts,
|
||||||
return f"Error: {type(exc).__name__}: {exc}", event, exc
|
)
|
||||||
|
if handled is not None:
|
||||||
|
return handled
|
||||||
if spec.fail_on_tool_error:
|
if spec.fail_on_tool_error:
|
||||||
return f"Error: {type(exc).__name__}: {exc}", event, exc
|
return payload, event, exc
|
||||||
return f"Error: {type(exc).__name__}: {exc}", event, None
|
return payload, event, None
|
||||||
|
|
||||||
if isinstance(result, str) and result.startswith("Error"):
|
if isinstance(result, str) and result.startswith("Error"):
|
||||||
event = {
|
event = {
|
||||||
@@ -809,17 +823,15 @@ class AgentRunner:
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"detail": result.replace("\n", " ").strip()[:120],
|
"detail": result.replace("\n", " ").strip()[:120],
|
||||||
}
|
}
|
||||||
|
handled = self._classify_violation(
|
||||||
# check the outside workspace error and break loop
|
raw_text=result,
|
||||||
if self._is_workspace_violation(result):
|
soft_payload=result + hint,
|
||||||
logger.warning(
|
event=event,
|
||||||
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
|
tool_call=tool_call,
|
||||||
tool_call.name,
|
workspace_violation_counts=workspace_violation_counts,
|
||||||
result.replace("\n", " ").strip()[:200],
|
)
|
||||||
)
|
if handled is not None:
|
||||||
event["detail"] = ("workspace_violation: "
|
return handled
|
||||||
+ result.replace("\n", " ").strip())[:160]
|
|
||||||
return result, event, RuntimeError(result)
|
|
||||||
if spec.fail_on_tool_error:
|
if spec.fail_on_tool_error:
|
||||||
return result + hint, event, RuntimeError(result)
|
return result + hint, event, RuntimeError(result)
|
||||||
return result + hint, event, None
|
return result + hint, event, None
|
||||||
@@ -832,23 +844,97 @@ class AgentRunner:
|
|||||||
detail = detail[:120] + "..."
|
detail = detail[:120] + "..."
|
||||||
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
|
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
|
||||||
|
|
||||||
# Markers identifying tool results that represent a workspace / safety boundary rejection.
|
# SSRF is a hard security block at the tool boundary, but the agent turn
|
||||||
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
|
# should recover conversationally instead of aborting the runtime.
|
||||||
"blocked by safety guard",
|
_SSRF_MARKERS: tuple[str, ...] = (
|
||||||
|
"internal/private url detected",
|
||||||
|
"private/internal address",
|
||||||
|
"private address",
|
||||||
|
)
|
||||||
|
_SSRF_BOUNDARY_NOTE: str = (
|
||||||
|
"This is a non-bypassable security boundary. Stop trying to access "
|
||||||
|
"private/internal URLs. Do not retry with curl, wget, encoded IPs, "
|
||||||
|
"alternate DNS, redirects, proxies, or another tool. Ask the user for "
|
||||||
|
"local files, logs, screenshots, or an explicit safe public URL instead. "
|
||||||
|
"If the user explicitly trusts this private URL, ask them to whitelist "
|
||||||
|
"the exact IP/CIDR via tools.ssrfWhitelist."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Non-SSRF boundary markers returned to the LLM as recoverable tool errors.
|
||||||
|
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
|
||||||
"outside the configured workspace",
|
"outside the configured workspace",
|
||||||
"outside allowed directory",
|
"outside allowed directory",
|
||||||
"working_dir is outside",
|
"working_dir is outside",
|
||||||
"working_dir could not be resolved",
|
"working_dir could not be resolved",
|
||||||
"path traversal detected",
|
|
||||||
"path outside working dir",
|
"path outside working dir",
|
||||||
|
"path traversal detected",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _is_workspace_violation(cls, text: str) -> bool:
|
def _is_ssrf_violation(cls, text: str) -> bool:
|
||||||
if not text:
|
if not text:
|
||||||
return False
|
return False
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
return any(marker in lowered for marker in cls._WORKSPACE_BLOCK_MARKERS)
|
return any(marker in lowered for marker in cls._SSRF_MARKERS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_workspace_violation(cls, text: str) -> bool:
|
||||||
|
"""True when *text* looks like any policy boundary rejection."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
lowered = text.lower()
|
||||||
|
if cls._is_ssrf_violation(lowered):
|
||||||
|
return True
|
||||||
|
return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS)
|
||||||
|
|
||||||
|
def _classify_violation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
soft_payload: str,
|
||||||
|
event: dict[str, str],
|
||||||
|
tool_call: ToolCallRequest,
|
||||||
|
workspace_violation_counts: dict[str, int],
|
||||||
|
) -> tuple[Any, dict[str, str], BaseException | None] | None:
|
||||||
|
"""Classify safety-boundary failures, or return ``None`` to pass through."""
|
||||||
|
if self._is_ssrf_violation(raw_text):
|
||||||
|
logger.warning(
|
||||||
|
"Tool {} blocked by SSRF guard; returning non-retryable tool error: {}",
|
||||||
|
tool_call.name,
|
||||||
|
raw_text.replace("\n", " ").strip()[:200],
|
||||||
|
)
|
||||||
|
event["detail"] = self._event_detail("ssrf_violation: ", raw_text)
|
||||||
|
return self._ssrf_soft_payload(raw_text), event, None
|
||||||
|
|
||||||
|
if self._is_workspace_violation(raw_text):
|
||||||
|
escalation = repeated_workspace_violation_error(
|
||||||
|
tool_call.name,
|
||||||
|
tool_call.arguments,
|
||||||
|
workspace_violation_counts,
|
||||||
|
)
|
||||||
|
event["detail"] = self._event_detail("workspace_violation: ", raw_text)
|
||||||
|
if escalation is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Tool {} hit workspace boundary repeatedly; escalating hint",
|
||||||
|
tool_call.name,
|
||||||
|
)
|
||||||
|
event["detail"] = self._event_detail(
|
||||||
|
"workspace_violation_escalated: ",
|
||||||
|
raw_text,
|
||||||
|
)
|
||||||
|
return escalation, event, None
|
||||||
|
return soft_payload, event, None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _ssrf_soft_payload(cls, raw_text: str) -> str:
|
||||||
|
text = raw_text.strip() or "Error: request blocked by SSRF guard"
|
||||||
|
return f"{text}\n\n{cls._SSRF_BOUNDARY_NOTE}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_detail(prefix: str, text: str, limit: int = 160) -> str:
|
||||||
|
return (prefix + text.replace("\n", " ").strip())[:limit]
|
||||||
|
|
||||||
async def _emit_checkpoint(
|
async def _emit_checkpoint(
|
||||||
self,
|
self,
|
||||||
@@ -896,12 +982,11 @@ class AgentRunner:
|
|||||||
result,
|
result,
|
||||||
max_chars=spec.max_tool_result_chars,
|
max_chars=spec.max_tool_result_chars,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.warning(
|
logger.exception(
|
||||||
"Tool result persist failed for {} in {}: {}; using raw result",
|
"Tool result persist failed for {} in {}; using raw result",
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
spec.session_key or "default",
|
spec.session_key or "default",
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
content = result
|
content = result
|
||||||
if isinstance(content, str) and len(content) > spec.max_tool_result_chars:
|
if isinstance(content, str) and len(content) > spec.max_tool_result_chars:
|
||||||
|
|||||||
+38
-18
@@ -20,7 +20,7 @@ from nanobot.agent.tools.shell import ExecTool
|
|||||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ExecToolConfig, WebToolsConfig
|
from nanobot.config.schema import AgentDefaults, ExecToolConfig, WebToolsConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
@@ -81,7 +81,9 @@ class SubagentManager:
|
|||||||
exec_config: "ExecToolConfig | None" = None,
|
exec_config: "ExecToolConfig | None" = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
disabled_skills: list[str] | None = None,
|
disabled_skills: list[str] | None = None,
|
||||||
|
max_iterations: int | None = None,
|
||||||
):
|
):
|
||||||
|
defaults = AgentDefaults()
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@@ -91,6 +93,12 @@ class SubagentManager:
|
|||||||
self.exec_config = exec_config or ExecToolConfig()
|
self.exec_config = exec_config or ExecToolConfig()
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.disabled_skills = set(disabled_skills or [])
|
self.disabled_skills = set(disabled_skills or [])
|
||||||
|
self.max_iterations = (
|
||||||
|
max_iterations
|
||||||
|
if max_iterations is not None
|
||||||
|
else defaults.max_tool_iterations
|
||||||
|
)
|
||||||
|
self.max_concurrent_subagents = defaults.max_concurrent_subagents
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner(provider)
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
@@ -108,6 +116,7 @@ class SubagentManager:
|
|||||||
origin_channel: str = "cli",
|
origin_channel: str = "cli",
|
||||||
origin_chat_id: str = "direct",
|
origin_chat_id: str = "direct",
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
|
origin_message_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Spawn a subagent to execute a task in the background."""
|
"""Spawn a subagent to execute a task in the background."""
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
@@ -123,7 +132,7 @@ class SubagentManager:
|
|||||||
self._task_statuses[task_id] = status
|
self._task_statuses[task_id] = status
|
||||||
|
|
||||||
bg_task = asyncio.create_task(
|
bg_task = asyncio.create_task(
|
||||||
self._run_subagent(task_id, task, display_label, origin, status)
|
self._run_subagent(task_id, task, display_label, origin, status, origin_message_id)
|
||||||
)
|
)
|
||||||
self._running_tasks[task_id] = bg_task
|
self._running_tasks[task_id] = bg_task
|
||||||
if session_key:
|
if session_key:
|
||||||
@@ -149,6 +158,7 @@ class SubagentManager:
|
|||||||
label: str,
|
label: str,
|
||||||
origin: dict[str, str],
|
origin: dict[str, str],
|
||||||
status: SubagentStatus,
|
status: SubagentStatus,
|
||||||
|
origin_message_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute the subagent task and announce the result."""
|
"""Execute the subagent task and announce the result."""
|
||||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||||
@@ -162,12 +172,16 @@ class SubagentManager:
|
|||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
|
allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
|
||||||
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
||||||
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
# Subagent gets its own FileStates so its read-dedup cache is
|
||||||
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
# isolated from the parent loop's sessions (issue #3571).
|
||||||
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
from nanobot.agent.tools.file_state import FileStates
|
||||||
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
file_states = FileStates()
|
||||||
tools.register(GlobTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read, file_states=file_states))
|
||||||
tools.register(GrepTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
|
||||||
|
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
|
||||||
|
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
|
||||||
|
tools.register(GlobTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
|
||||||
|
tools.register(GrepTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
|
||||||
if self.exec_config.enable:
|
if self.exec_config.enable:
|
||||||
tools.register(ExecTool(
|
tools.register(ExecTool(
|
||||||
working_dir=str(self.workspace),
|
working_dir=str(self.workspace),
|
||||||
@@ -176,6 +190,8 @@ class SubagentManager:
|
|||||||
sandbox=self.exec_config.sandbox,
|
sandbox=self.exec_config.sandbox,
|
||||||
path_append=self.exec_config.path_append,
|
path_append=self.exec_config.path_append,
|
||||||
allowed_env_keys=self.exec_config.allowed_env_keys,
|
allowed_env_keys=self.exec_config.allowed_env_keys,
|
||||||
|
allow_patterns=self.exec_config.allow_patterns,
|
||||||
|
deny_patterns=self.exec_config.deny_patterns,
|
||||||
))
|
))
|
||||||
if self.web_config.enable:
|
if self.web_config.enable:
|
||||||
tools.register(
|
tools.register(
|
||||||
@@ -202,7 +218,7 @@ class SubagentManager:
|
|||||||
initial_messages=messages,
|
initial_messages=messages,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
max_iterations=15,
|
max_iterations=self.max_iterations,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
hook=_SubagentHook(task_id, status),
|
hook=_SubagentHook(task_id, status),
|
||||||
max_iterations_message="Task completed but no final response was generated.",
|
max_iterations_message="Task completed but no final response was generated.",
|
||||||
@@ -218,24 +234,24 @@ class SubagentManager:
|
|||||||
await self._announce_result(
|
await self._announce_result(
|
||||||
task_id, label, task,
|
task_id, label, task,
|
||||||
self._format_partial_progress(result),
|
self._format_partial_progress(result),
|
||||||
origin, "error",
|
origin, "error", origin_message_id,
|
||||||
)
|
)
|
||||||
elif result.stop_reason == "error":
|
elif result.stop_reason == "error":
|
||||||
await self._announce_result(
|
await self._announce_result(
|
||||||
task_id, label, task,
|
task_id, label, task,
|
||||||
result.error or "Error: subagent execution failed.",
|
result.error or "Error: subagent execution failed.",
|
||||||
origin, "error",
|
origin, "error", origin_message_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
final_result = result.final_content or "Task completed but no final response was generated."
|
final_result = result.final_content or "Task completed but no final response was generated."
|
||||||
logger.info("Subagent [{}] completed successfully", task_id)
|
logger.info("Subagent [{}] completed successfully", task_id)
|
||||||
await self._announce_result(task_id, label, task, final_result, origin, "ok")
|
await self._announce_result(task_id, label, task, final_result, origin, "ok", origin_message_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status.phase = "error"
|
status.phase = "error"
|
||||||
status.error = str(e)
|
status.error = str(e)
|
||||||
logger.error("Subagent [{}] failed: {}", task_id, e)
|
logger.exception("Subagent [{}] failed", task_id)
|
||||||
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error")
|
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
|
||||||
|
|
||||||
async def _announce_result(
|
async def _announce_result(
|
||||||
self,
|
self,
|
||||||
@@ -245,6 +261,7 @@ class SubagentManager:
|
|||||||
result: str,
|
result: str,
|
||||||
origin: dict[str, str],
|
origin: dict[str, str],
|
||||||
status: str,
|
status: str,
|
||||||
|
origin_message_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Announce the subagent result to the main agent via the message bus."""
|
"""Announce the subagent result to the main agent via the message bus."""
|
||||||
status_text = "completed successfully" if status == "ok" else "failed"
|
status_text = "completed successfully" if status == "ok" else "failed"
|
||||||
@@ -263,16 +280,19 @@ class SubagentManager:
|
|||||||
# routed to the correct pending queue (mid-turn injection) instead of
|
# routed to the correct pending queue (mid-turn injection) instead of
|
||||||
# being dispatched as a competing independent task.
|
# being dispatched as a competing independent task.
|
||||||
override = origin.get("session_key") or f"{origin['channel']}:{origin['chat_id']}"
|
override = origin.get("session_key") or f"{origin['channel']}:{origin['chat_id']}"
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"injected_event": "subagent_result",
|
||||||
|
"subagent_task_id": task_id,
|
||||||
|
}
|
||||||
|
if origin_message_id:
|
||||||
|
metadata["origin_message_id"] = origin_message_id
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel="system",
|
channel="system",
|
||||||
sender_id="subagent",
|
sender_id="subagent",
|
||||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||||
content=announce_content,
|
content=announce_content,
|
||||||
session_key_override=override,
|
session_key_override=override,
|
||||||
metadata={
|
metadata=metadata,
|
||||||
"injected_event": "subagent_result",
|
|
||||||
"subagent_task_id": task_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -17,9 +18,6 @@ class ReadState:
|
|||||||
can_dedup: bool
|
can_dedup: bool
|
||||||
|
|
||||||
|
|
||||||
_state: dict[str, ReadState] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_file(p: str) -> str | None:
|
def _hash_file(p: str) -> str | None:
|
||||||
try:
|
try:
|
||||||
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
|
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
|
||||||
@@ -27,93 +25,181 @@ def _hash_file(p: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FileStates:
|
||||||
|
"""Per-session read/write tracker.
|
||||||
|
|
||||||
|
Owns its own state dict so read-dedup ("File unchanged since last read")
|
||||||
|
and read-before-edit warnings stay scoped to one agent session and do
|
||||||
|
not leak across sessions sharing this process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_state",)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._state: dict[str, ReadState] = {}
|
||||||
|
|
||||||
|
def record_read(self, path: str | Path, offset: int = 1, limit: int | None = None) -> None:
|
||||||
|
"""Record that a file was read (called after successful read)."""
|
||||||
|
p = str(Path(path).resolve())
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(p)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
self._state[p] = ReadState(
|
||||||
|
mtime=mtime,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
content_hash=_hash_file(p),
|
||||||
|
can_dedup=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_write(self, path: str | Path) -> None:
|
||||||
|
"""Record that a file was written (updates mtime in state)."""
|
||||||
|
p = str(Path(path).resolve())
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(p)
|
||||||
|
except OSError:
|
||||||
|
self._state.pop(p, None)
|
||||||
|
return
|
||||||
|
self._state[p] = ReadState(
|
||||||
|
mtime=mtime,
|
||||||
|
offset=1,
|
||||||
|
limit=None,
|
||||||
|
content_hash=_hash_file(p),
|
||||||
|
can_dedup=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_read(self, path: str | Path) -> str | None:
|
||||||
|
"""Check if a file has been read and is fresh.
|
||||||
|
|
||||||
|
Returns None if OK, or a warning string.
|
||||||
|
When mtime changed but file content is identical (e.g. touch, editor save),
|
||||||
|
the check passes to avoid false-positive staleness warnings.
|
||||||
|
"""
|
||||||
|
p = str(Path(path).resolve())
|
||||||
|
entry = self._state.get(p)
|
||||||
|
if entry is None:
|
||||||
|
return "Warning: file has not been read yet. Read it first to verify content before editing."
|
||||||
|
try:
|
||||||
|
current_mtime = os.path.getmtime(p)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if current_mtime != entry.mtime:
|
||||||
|
if entry.content_hash and _hash_file(p) == entry.content_hash:
|
||||||
|
entry.mtime = current_mtime
|
||||||
|
return None
|
||||||
|
return "Warning: file has been modified since last read. Re-read to verify content before editing."
|
||||||
|
# mtime unchanged - still check content hash to detect quick modifications
|
||||||
|
if entry.content_hash and _hash_file(p) != entry.content_hash:
|
||||||
|
return "Warning: file has been modified since last read. Re-read to verify content before editing."
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_unchanged(self, path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
|
||||||
|
"""Return True if file was previously read with same params and content is unchanged."""
|
||||||
|
p = str(Path(path).resolve())
|
||||||
|
entry = self._state.get(p)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
if not entry.can_dedup:
|
||||||
|
return False
|
||||||
|
if entry.offset != offset or entry.limit != limit:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
current_mtime = os.path.getmtime(p)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if current_mtime != entry.mtime:
|
||||||
|
# mtime changed - check if content also changed
|
||||||
|
current_hash = _hash_file(p)
|
||||||
|
if current_hash != entry.content_hash:
|
||||||
|
# Content actually changed - don't dedup
|
||||||
|
entry.can_dedup = False
|
||||||
|
return False
|
||||||
|
# Content identical despite mtime change (e.g. touch) - mark as not dedupable to force full read next time
|
||||||
|
entry.can_dedup = False
|
||||||
|
return True
|
||||||
|
# mtime unchanged - content must be identical
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get(self, path: str | Path) -> ReadState | None:
|
||||||
|
"""Return the raw ReadState entry for a path, or None."""
|
||||||
|
return self._state.get(str(Path(path).resolve()))
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all tracked state (useful for testing)."""
|
||||||
|
self._state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class FileStateStore:
|
||||||
|
"""Lookup table for per-session file read/write state."""
|
||||||
|
|
||||||
|
__slots__ = ("_states_by_key",)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._states_by_key: dict[str, FileStates] = {}
|
||||||
|
|
||||||
|
def for_session(self, session_key: str | None) -> FileStates:
|
||||||
|
key = session_key or "__default__"
|
||||||
|
states = self._states_by_key.get(key)
|
||||||
|
if states is None:
|
||||||
|
states = FileStates()
|
||||||
|
self._states_by_key[key] = states
|
||||||
|
return states
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._states_by_key.clear()
|
||||||
|
|
||||||
|
|
||||||
|
_current_file_states: ContextVar[FileStates | None] = ContextVar(
|
||||||
|
"nanobot_file_states",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def current_file_states(default: FileStates) -> FileStates:
|
||||||
|
"""Return the FileStates bound to the current agent task, or a fallback."""
|
||||||
|
return _current_file_states.get() or default
|
||||||
|
|
||||||
|
|
||||||
|
def bind_file_states(file_states: FileStates) -> Token[FileStates | None]:
|
||||||
|
"""Bind file read/write state for the current async task."""
|
||||||
|
return _current_file_states.set(file_states)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_file_states(token: Token[FileStates | None]) -> None:
|
||||||
|
_current_file_states.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level default instance, retained for backward compatibility with
|
||||||
|
# tests and callers that reach in directly. Per-session callers should hold
|
||||||
|
# their own FileStates instance instead of touching this one.
|
||||||
|
_default = FileStates()
|
||||||
|
|
||||||
|
|
||||||
def record_read(path: str | Path, offset: int = 1, limit: int | None = None) -> None:
|
def record_read(path: str | Path, offset: int = 1, limit: int | None = None) -> None:
|
||||||
"""Record that a file was read (called after successful read)."""
|
_default.record_read(path, offset=offset, limit=limit)
|
||||||
p = str(Path(path).resolve())
|
|
||||||
try:
|
|
||||||
mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
_state[p] = ReadState(
|
|
||||||
mtime=mtime,
|
|
||||||
offset=offset,
|
|
||||||
limit=limit,
|
|
||||||
content_hash=_hash_file(p),
|
|
||||||
can_dedup=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def record_write(path: str | Path) -> None:
|
def record_write(path: str | Path) -> None:
|
||||||
"""Record that a file was written (updates mtime in state)."""
|
_default.record_write(path)
|
||||||
p = str(Path(path).resolve())
|
|
||||||
try:
|
|
||||||
mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
_state.pop(p, None)
|
|
||||||
return
|
|
||||||
_state[p] = ReadState(
|
|
||||||
mtime=mtime,
|
|
||||||
offset=1,
|
|
||||||
limit=None,
|
|
||||||
content_hash=_hash_file(p),
|
|
||||||
can_dedup=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_read(path: str | Path) -> str | None:
|
def check_read(path: str | Path) -> str | None:
|
||||||
"""Check if a file has been read and is fresh.
|
return _default.check_read(path)
|
||||||
|
|
||||||
Returns None if OK, or a warning string.
|
|
||||||
When mtime changed but file content is identical (e.g. touch, editor save),
|
|
||||||
the check passes to avoid false-positive staleness warnings.
|
|
||||||
"""
|
|
||||||
p = str(Path(path).resolve())
|
|
||||||
entry = _state.get(p)
|
|
||||||
if entry is None:
|
|
||||||
return "Warning: file has not been read yet. Read it first to verify content before editing."
|
|
||||||
try:
|
|
||||||
current_mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
if current_mtime != entry.mtime:
|
|
||||||
if entry.content_hash and _hash_file(p) == entry.content_hash:
|
|
||||||
entry.mtime = current_mtime
|
|
||||||
return None
|
|
||||||
return "Warning: file has been modified since last read. Re-read to verify content before editing."
|
|
||||||
# mtime unchanged - still check content hash to detect quick modifications
|
|
||||||
if entry.content_hash and _hash_file(p) != entry.content_hash:
|
|
||||||
return "Warning: file has been modified since last read. Re-read to verify content before editing."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
|
def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
|
||||||
"""Return True if file was previously read with same params and content is unchanged."""
|
return _default.is_unchanged(path, offset=offset, limit=limit)
|
||||||
p = str(Path(path).resolve())
|
|
||||||
entry = _state.get(p)
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
if not entry.can_dedup:
|
|
||||||
return False
|
|
||||||
if entry.offset != offset or entry.limit != limit:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
current_mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
if current_mtime != entry.mtime:
|
|
||||||
# mtime changed - check if content also changed
|
|
||||||
current_hash = _hash_file(p)
|
|
||||||
if current_hash != entry.content_hash:
|
|
||||||
# Content actually changed - don't dedup
|
|
||||||
entry.can_dedup = False
|
|
||||||
return False
|
|
||||||
# Content identical despite mtime change (e.g. touch) - mark as not dedupable to force full read next time
|
|
||||||
entry.can_dedup = False
|
|
||||||
return True
|
|
||||||
# mtime unchanged - content must be identical
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def clear() -> None:
|
def clear() -> None:
|
||||||
"""Clear all tracked state (useful for testing)."""
|
_default.clear()
|
||||||
_state.clear()
|
|
||||||
|
|
||||||
|
# Legacy attribute for callers that reached into the module-level dict
|
||||||
|
# directly (filesystem.py used to do this). Kept as a property-like accessor
|
||||||
|
# so existing imports keep working.
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if name == "_state":
|
||||||
|
return _default._state
|
||||||
|
raise AttributeError(name)
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ from typing import Any
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
from nanobot.agent.tools import file_state
|
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
||||||
from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
|
from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
|
|
||||||
|
|
||||||
|
_FS_WORKSPACE_BOUNDARY_NOTE = (
|
||||||
|
" (this is a hard policy boundary, not a transient failure; "
|
||||||
|
"do not retry with shell tricks or alternative tools, and ask "
|
||||||
|
"the user how to proceed if the resource is genuinely required)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_path(
|
def _resolve_path(
|
||||||
path: str,
|
path: str,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
@@ -29,7 +36,10 @@ def _resolve_path(
|
|||||||
media_path = get_media_dir().resolve()
|
media_path = get_media_dir().resolve()
|
||||||
all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
|
all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
|
||||||
if not any(_is_under(resolved, d) for d in all_dirs):
|
if not any(_is_under(resolved, d) for d in all_dirs):
|
||||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
raise PermissionError(
|
||||||
|
f"Path {path} is outside allowed directory {allowed_dir}"
|
||||||
|
+ _FS_WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
@@ -49,10 +59,22 @@ class _FsTool(Tool):
|
|||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
allowed_dir: Path | None = None,
|
allowed_dir: Path | None = None,
|
||||||
extra_allowed_dirs: list[Path] | None = None,
|
extra_allowed_dirs: list[Path] | None = None,
|
||||||
|
file_states: FileStates | None = None,
|
||||||
):
|
):
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
self._allowed_dir = allowed_dir
|
self._allowed_dir = allowed_dir
|
||||||
self._extra_allowed_dirs = extra_allowed_dirs
|
self._extra_allowed_dirs = extra_allowed_dirs
|
||||||
|
# Explicit state is used by isolated runners like Dream/subagents.
|
||||||
|
# Main AgentLoop tools leave this unset and resolve state from the
|
||||||
|
# current async task, which keeps shared tool instances session-safe.
|
||||||
|
self._explicit_file_states = file_states
|
||||||
|
self._fallback_file_states = FileStates()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _file_states(self) -> FileStates:
|
||||||
|
if self._explicit_file_states is not None:
|
||||||
|
return self._explicit_file_states
|
||||||
|
return current_file_states(self._fallback_file_states)
|
||||||
|
|
||||||
def _resolve(self, path: str) -> Path:
|
def _resolve(self, path: str) -> Path:
|
||||||
return _resolve_path(path, self._workspace, self._allowed_dir, self._extra_allowed_dirs)
|
return _resolve_path(path, self._workspace, self._allowed_dir, self._extra_allowed_dirs)
|
||||||
@@ -184,7 +206,7 @@ class ReadFileTool(_FsTool):
|
|||||||
|
|
||||||
# Read dedup: same path + offset + limit + unchanged mtime → stub
|
# Read dedup: same path + offset + limit + unchanged mtime → stub
|
||||||
# Always check for external modifications before dedup
|
# Always check for external modifications before dedup
|
||||||
entry = file_state._state.get(str(fp.resolve()))
|
entry = self._file_states.get(fp)
|
||||||
try:
|
try:
|
||||||
current_mtime = os.path.getmtime(fp)
|
current_mtime = os.path.getmtime(fp)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -193,21 +215,21 @@ class ReadFileTool(_FsTool):
|
|||||||
if current_mtime != entry.mtime:
|
if current_mtime != entry.mtime:
|
||||||
# File was modified externally - force full read and mark as not dedupable
|
# File was modified externally - force full read and mark as not dedupable
|
||||||
entry.can_dedup = False
|
entry.can_dedup = False
|
||||||
file_state.record_read(fp, offset=offset, limit=limit) # Update state with new mtime
|
self._file_states.record_read(fp, offset=offset, limit=limit) # Update state with new mtime
|
||||||
# Continue to read full content (don't return dedup message)
|
# Continue to read full content (don't return dedup message)
|
||||||
else:
|
else:
|
||||||
# File unchanged - return dedup message
|
# File unchanged - return dedup message
|
||||||
# But only if content is actually unchanged (not just mtime)
|
# But only if content is actually unchanged (not just mtime)
|
||||||
current_hash = file_state._hash_file(str(fp))
|
current_hash = _hash_file(str(fp))
|
||||||
if current_hash == entry.content_hash:
|
if current_hash == entry.content_hash:
|
||||||
return f"[File unchanged since last read: {path}]"
|
return f"[File unchanged since last read: {path}]"
|
||||||
else:
|
else:
|
||||||
# Content changed despite same mtime - force full read
|
# Content changed despite same mtime - force full read
|
||||||
entry.can_dedup = False
|
entry.can_dedup = False
|
||||||
file_state.record_read(fp, offset=offset, limit=limit)
|
self._file_states.record_read(fp, offset=offset, limit=limit)
|
||||||
else:
|
else:
|
||||||
# No previous state or marked as not dedupable - read full content
|
# No previous state or marked as not dedupable - read full content
|
||||||
file_state.record_read(fp, offset=offset, limit=limit)
|
self._file_states.record_read(fp, offset=offset, limit=limit)
|
||||||
# Force full read by setting can_dedup to False for this read
|
# Force full read by setting can_dedup to False for this read
|
||||||
if entry:
|
if entry:
|
||||||
entry.can_dedup = False
|
entry.can_dedup = False
|
||||||
@@ -256,7 +278,7 @@ class ReadFileTool(_FsTool):
|
|||||||
result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
|
result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
|
||||||
else:
|
else:
|
||||||
result += f"\n\n(End of file — {total} lines total)"
|
result += f"\n\n(End of file — {total} lines total)"
|
||||||
file_state.record_read(fp, offset=offset, limit=limit)
|
self._file_states.record_read(fp, offset=offset, limit=limit)
|
||||||
return result
|
return result
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
@@ -365,7 +387,7 @@ class WriteFileTool(_FsTool):
|
|||||||
fp = self._resolve(path)
|
fp = self._resolve(path)
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fp.write_text(content, encoding="utf-8")
|
fp.write_text(content, encoding="utf-8")
|
||||||
file_state.record_write(fp)
|
self._file_states.record_write(fp)
|
||||||
return f"Successfully wrote {len(content)} characters to {fp}"
|
return f"Successfully wrote {len(content)} characters to {fp}"
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
@@ -699,7 +721,7 @@ class EditFileTool(_FsTool):
|
|||||||
if old_text == "":
|
if old_text == "":
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
fp.write_text(new_text, encoding="utf-8")
|
||||||
file_state.record_write(fp)
|
self._file_states.record_write(fp)
|
||||||
return f"Successfully created {fp}"
|
return f"Successfully created {fp}"
|
||||||
return self._file_not_found_msg(path, fp)
|
return self._file_not_found_msg(path, fp)
|
||||||
|
|
||||||
@@ -718,11 +740,11 @@ class EditFileTool(_FsTool):
|
|||||||
if content.strip():
|
if content.strip():
|
||||||
return f"Error: Cannot create file — {path} already exists and is not empty."
|
return f"Error: Cannot create file — {path} already exists and is not empty."
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
fp.write_text(new_text, encoding="utf-8")
|
||||||
file_state.record_write(fp)
|
self._file_states.record_write(fp)
|
||||||
return f"Successfully edited {fp}"
|
return f"Successfully edited {fp}"
|
||||||
|
|
||||||
# Read-before-edit check
|
# Read-before-edit check
|
||||||
warning = file_state.check_read(fp)
|
warning = self._file_states.check_read(fp)
|
||||||
|
|
||||||
raw = fp.read_bytes()
|
raw = fp.read_bytes()
|
||||||
uses_crlf = b"\r\n" in raw
|
uses_crlf = b"\r\n" in raw
|
||||||
@@ -767,7 +789,7 @@ class EditFileTool(_FsTool):
|
|||||||
new_content = new_content.replace("\n", "\r\n")
|
new_content = new_content.replace("\n", "\r\n")
|
||||||
|
|
||||||
fp.write_bytes(new_content.encode("utf-8"))
|
fp.write_bytes(new_content.encode("utf-8"))
|
||||||
file_state.record_write(fp)
|
self._file_states.record_write(fp)
|
||||||
msg = f"Successfully edited {fp}"
|
msg = f"Successfully edited {fp}"
|
||||||
if warning:
|
if warning:
|
||||||
msg = f"{warning}\n{msg}"
|
msg = f"{warning}\n{msg}"
|
||||||
|
|||||||
+18
-29
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack, suppress
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -198,11 +198,10 @@ class MCPToolWrapper(Tool):
|
|||||||
await asyncio.sleep(1) # Brief backoff before retry
|
await asyncio.sleep(1) # Brief backoff before retry
|
||||||
continue
|
continue
|
||||||
# Second transient failure — give up with retry-specific message
|
# Second transient failure — give up with retry-specific message
|
||||||
logger.error(
|
logger.exception(
|
||||||
"MCP tool '{}' failed after retry: {}: {}",
|
"MCP tool '{}' failed after retry: {}",
|
||||||
self._name,
|
self._name,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
return f"(MCP tool call failed after retry: {type(exc).__name__})"
|
return f"(MCP tool call failed after retry: {type(exc).__name__})"
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -287,11 +286,10 @@ class MCPResourceWrapper(Tool):
|
|||||||
)
|
)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
logger.error(
|
logger.exception(
|
||||||
"MCP resource '{}' failed after retry: {}: {}",
|
"MCP resource '{}' failed after retry: {}",
|
||||||
self._name,
|
self._name,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
return f"(MCP resource read failed after retry: {type(exc).__name__})"
|
return f"(MCP resource read failed after retry: {type(exc).__name__})"
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -383,7 +381,7 @@ class MCPPromptWrapper(Tool):
|
|||||||
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
||||||
return "(MCP prompt call was cancelled)"
|
return "(MCP prompt call was cancelled)"
|
||||||
except McpError as exc:
|
except McpError as exc:
|
||||||
logger.error(
|
logger.exception(
|
||||||
"MCP prompt '{}' failed: code={} message={}",
|
"MCP prompt '{}' failed: code={} message={}",
|
||||||
self._name,
|
self._name,
|
||||||
exc.error.code,
|
exc.error.code,
|
||||||
@@ -400,11 +398,10 @@ class MCPPromptWrapper(Tool):
|
|||||||
)
|
)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
logger.error(
|
logger.exception(
|
||||||
"MCP prompt '{}' failed after retry: {}: {}",
|
"MCP prompt '{}' failed after retry: {}",
|
||||||
self._name,
|
self._name,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
return f"(MCP prompt call failed after retry: {type(exc).__name__})"
|
return f"(MCP prompt call failed after retry: {type(exc).__name__})"
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -439,8 +436,8 @@ async def connect_mcp_servers(
|
|||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||||
|
|
||||||
Returns a dict mapping server name -> its dedicated AsyncExitStack.
|
Returns a dict mapping server name -> its dedicated AsyncExitStack.
|
||||||
Each server gets its own stack and runs in its own task to prevent
|
Each server gets its own stack to prevent cancel scope conflicts
|
||||||
cancel scope conflicts when multiple MCP servers are configured.
|
when multiple MCP servers are configured.
|
||||||
"""
|
"""
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
@@ -608,28 +605,20 @@ async def connect_mcp_servers(
|
|||||||
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
||||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||||
)
|
)
|
||||||
logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint)
|
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||||
try:
|
with suppress(Exception):
|
||||||
await server_stack.aclose()
|
await server_stack.aclose()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return name, None
|
return name, None
|
||||||
|
|
||||||
server_stacks: dict[str, AsyncExitStack] = {}
|
server_stacks: dict[str, AsyncExitStack] = {}
|
||||||
|
|
||||||
tasks: list[asyncio.Task] = []
|
|
||||||
for name, cfg in mcp_servers.items():
|
for name, cfg in mcp_servers.items():
|
||||||
task = asyncio.create_task(connect_single_server(name, cfg))
|
try:
|
||||||
tasks.append(task)
|
result = await connect_single_server(name, cfg)
|
||||||
|
except Exception as e:
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
logger.error("MCP server '{}' connection failed: {}", name, e)
|
||||||
|
continue
|
||||||
for i, result in enumerate(results):
|
if result is not None and result[1] is not None:
|
||||||
name = list(mcp_servers.keys())[i]
|
|
||||||
if isinstance(result, BaseException):
|
|
||||||
if not isinstance(result, asyncio.CancelledError):
|
|
||||||
logger.error("MCP server '{}' connection task failed: {}", name, result)
|
|
||||||
elif result is not None and result[1] is not None:
|
|
||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
|
|
||||||
return server_stacks
|
return server_stacks
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Iterable, TypeVar
|
from typing import Any, Iterable, TypeVar
|
||||||
|
|
||||||
@@ -92,10 +93,8 @@ class _SearchTool(_FsTool):
|
|||||||
|
|
||||||
def _display_path(self, target: Path, root: Path) -> str:
|
def _display_path(self, target: Path, root: Path) -> str:
|
||||||
if self._workspace:
|
if self._workspace:
|
||||||
try:
|
with suppress(ValueError):
|
||||||
return target.relative_to(self._workspace).as_posix()
|
return target.relative_to(self._workspace).as_posix()
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return target.relative_to(root).as_posix()
|
return target.relative_to(root).as_posix()
|
||||||
|
|
||||||
def _iter_files(self, root: Path) -> Iterable[Path]:
|
def _iter_files(self, root: Path) -> Iterable[Path]:
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
|
||||||
"model": {"type": str, "min_len": 1},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_MAX_RUNTIME_KEYS = 64
|
_MAX_RUNTIME_KEYS = 64
|
||||||
@@ -118,13 +116,14 @@ class MyTool(Tool):
|
|||||||
"Scratchpad keys persist across turns but not restarts.\n"
|
"Scratchpad keys persist across turns but not restarts.\n"
|
||||||
"Key values: _current_iteration (current progress), "
|
"Key values: _current_iteration (current progress), "
|
||||||
"max_iterations - _current_iteration = remaining iterations.\n"
|
"max_iterations - _current_iteration = remaining iterations.\n"
|
||||||
|
"Use 'model_preset' to switch the active model preset.\n"
|
||||||
"Note: web_config and exec_config are readable but read-only.\n"
|
"Note: web_config and exec_config are readable but read-only.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"When to use:\n"
|
"When to use:\n"
|
||||||
"- User asks about your model, settings, or token usage → check that key.\n"
|
"- User asks about your model, settings, or token usage → check that key.\n"
|
||||||
"- A tool fails or behaves unexpectedly → check the related config to diagnose.\n"
|
"- A tool fails or behaves unexpectedly → check the related config to diagnose.\n"
|
||||||
"- User asks you to remember a preference for this session → set to store it in your scratchpad.\n"
|
"- User asks you to remember a preference for this session → set to store it in your scratchpad.\n"
|
||||||
"- About to start a large task → check context_window_tokens and max_iterations first."
|
"- About to start a large task → check max_iterations and model_preset first."
|
||||||
)
|
)
|
||||||
if not self._modify_allowed:
|
if not self._modify_allowed:
|
||||||
base += "\nREAD-ONLY MODE: set is disabled."
|
base += "\nREAD-ONLY MODE: set is disabled."
|
||||||
@@ -132,7 +131,7 @@ class MyTool(Tool):
|
|||||||
base += (
|
base += (
|
||||||
"\nIMPORTANT: Before setting state, predict the potential impact. "
|
"\nIMPORTANT: Before setting state, predict the potential impact. "
|
||||||
"If the operation could cause crashes or instability "
|
"If the operation could cause crashes or instability "
|
||||||
"(e.g. changing model), warn the user first."
|
"(e.g. changing model_preset), warn the user first."
|
||||||
)
|
)
|
||||||
return base
|
return base
|
||||||
|
|
||||||
@@ -148,7 +147,7 @@ class MyTool(Tool):
|
|||||||
},
|
},
|
||||||
"key": {
|
"key": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. "
|
"description": "Dot-path for check/set. Examples: 'max_iterations', 'model_preset', 'provider_retry_mode'. "
|
||||||
"For check without key, shows all config values.",
|
"For check without key, shows all config values.",
|
||||||
},
|
},
|
||||||
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."},
|
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."},
|
||||||
@@ -330,6 +329,8 @@ class MyTool(Tool):
|
|||||||
# RESTRICTED keys
|
# RESTRICTED keys
|
||||||
for k in self.RESTRICTED:
|
for k in self.RESTRICTED:
|
||||||
parts.append(self._format_value(getattr(loop, k, None), k))
|
parts.append(self._format_value(getattr(loop, k, None), k))
|
||||||
|
# model_preset (property on AgentLoop)
|
||||||
|
parts.append(self._format_value(loop.model_preset, "model_preset"))
|
||||||
# Other useful top-level keys shown in description
|
# Other useful top-level keys shown in description
|
||||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
|
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
|
||||||
if _has_real_attr(loop, k):
|
if _has_real_attr(loop, k):
|
||||||
@@ -386,6 +387,8 @@ class MyTool(Tool):
|
|||||||
value = expected(value)
|
value = expected(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
||||||
|
|
||||||
|
# --- existing restricted key logic ---
|
||||||
old = getattr(self._loop, key)
|
old = getattr(self._loop, key)
|
||||||
if "min" in spec and value < spec["min"]:
|
if "min" in spec and value < spec["min"]:
|
||||||
return f"Error: '{key}' must be >= {spec['min']}"
|
return f"Error: '{key}' must be >= {spec['min']}"
|
||||||
@@ -394,6 +397,8 @@ class MyTool(Tool):
|
|||||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||||
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
||||||
setattr(self._loop, key, value)
|
setattr(self._loop, key, value)
|
||||||
|
if key == "max_iterations" and hasattr(self._loop, "_sync_subagent_runtime_limits"):
|
||||||
|
self._loop._sync_subagent_runtime_limits()
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
@@ -410,7 +415,14 @@ class MyTool(Tool):
|
|||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||||
)
|
)
|
||||||
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||||
setattr(self._loop, key, value)
|
# When a model-specific field is set directly, it no longer matches any preset
|
||||||
|
if key in ("model", "context_window_tokens"):
|
||||||
|
self._loop._active_preset = None
|
||||||
|
try:
|
||||||
|
setattr(self._loop, key, value)
|
||||||
|
except (AttributeError, TypeError, ValueError, KeyError) as e:
|
||||||
|
self._audit("modify", f"REJECTED {key}: {e}")
|
||||||
|
return f"Error: {e}"
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
if callable(value):
|
if callable(value):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,6 +19,16 @@ from nanobot.config.paths import get_media_dir
|
|||||||
_IS_WINDOWS = sys.platform == "win32"
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
# Policy note appended to recoverable workspace-boundary guard errors.
|
||||||
|
_WORKSPACE_BOUNDARY_NOTE = (
|
||||||
|
"\n\nNote: this is a hard policy boundary, not a transient failure. "
|
||||||
|
"Do NOT retry with shell tricks (symlinks, base64 piping, alternative "
|
||||||
|
"tools, working_dir overrides). If the user genuinely needs this "
|
||||||
|
"resource, tell them you cannot reach it under the current "
|
||||||
|
"restrict_to_workspace policy and ask how to proceed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
command=StringSchema("The shell command to execute"),
|
command=StringSchema("The shell command to execute"),
|
||||||
@@ -51,7 +62,7 @@ class ExecTool(Tool):
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
self.sandbox = sandbox
|
self.sandbox = sandbox
|
||||||
self.deny_patterns = deny_patterns or [
|
self.deny_patterns = (deny_patterns or []) + [
|
||||||
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||||
r"\brmdir\s+/s\b", # rmdir /s
|
r"\brmdir\s+/s\b", # rmdir /s
|
||||||
@@ -82,6 +93,19 @@ class ExecTool(Tool):
|
|||||||
_MAX_TIMEOUT = 600
|
_MAX_TIMEOUT = 600
|
||||||
_MAX_OUTPUT = 10_000
|
_MAX_OUTPUT = 10_000
|
||||||
|
|
||||||
|
# Kernel device files safe as stdio redirect targets (#3599).
|
||||||
|
_BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({
|
||||||
|
"/dev/null",
|
||||||
|
"/dev/zero",
|
||||||
|
"/dev/full",
|
||||||
|
"/dev/random",
|
||||||
|
"/dev/urandom",
|
||||||
|
"/dev/stdin",
|
||||||
|
"/dev/stdout",
|
||||||
|
"/dev/stderr",
|
||||||
|
"/dev/tty",
|
||||||
|
})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -112,9 +136,15 @@ class ExecTool(Tool):
|
|||||||
requested = Path(cwd).expanduser().resolve()
|
requested = Path(cwd).expanduser().resolve()
|
||||||
workspace_root = Path(self.working_dir).expanduser().resolve()
|
workspace_root = Path(self.working_dir).expanduser().resolve()
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Error: working_dir could not be resolved"
|
return (
|
||||||
|
"Error: working_dir could not be resolved"
|
||||||
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
if requested != workspace_root and workspace_root not in requested.parents:
|
if requested != workspace_root and workspace_root not in requested.parents:
|
||||||
return "Error: working_dir is outside the configured workspace"
|
return (
|
||||||
|
"Error: working_dir is outside the configured workspace"
|
||||||
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
|
|
||||||
guard_error = self._guard_command(command, cwd)
|
guard_error = self._guard_command(command, cwd)
|
||||||
if guard_error:
|
if guard_error:
|
||||||
@@ -190,9 +220,12 @@ class ExecTool(Tool):
|
|||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Launch *command* in a platform-appropriate shell."""
|
"""Launch *command* in a platform-appropriate shell."""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
comspec = env.get("COMSPEC", os.environ.get("COMSPEC", "cmd.exe"))
|
# create_subprocess_exec re-quotes args via list2cmdline, which
|
||||||
return await asyncio.create_subprocess_exec(
|
# breaks commands containing paths with spaces (e.g. "D:\Program
|
||||||
comspec, "/c", command,
|
# Files\python.exe" "script.py"). create_subprocess_shell passes
|
||||||
|
# the raw command string to COMSPEC without re-quoting.
|
||||||
|
return await asyncio.create_subprocess_shell(
|
||||||
|
command,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
@@ -212,9 +245,8 @@ class ExecTool(Tool):
|
|||||||
"""Kill a subprocess and reap it to prevent zombies."""
|
"""Kill a subprocess and reap it to prevent zombies."""
|
||||||
process.kill()
|
process.kill()
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
with suppress(asyncio.TimeoutError):
|
||||||
except asyncio.TimeoutError:
|
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||||
pass
|
|
||||||
finally:
|
finally:
|
||||||
if not _IS_WINDOWS:
|
if not _IS_WINDOWS:
|
||||||
try:
|
try:
|
||||||
@@ -273,31 +305,49 @@ class ExecTool(Tool):
|
|||||||
cmd = command.strip()
|
cmd = command.strip()
|
||||||
lower = cmd.lower()
|
lower = cmd.lower()
|
||||||
|
|
||||||
for pattern in self.deny_patterns:
|
# allow_patterns take priority over deny_patterns so that users can
|
||||||
if re.search(pattern, lower):
|
# exempt specific commands (e.g. "rm -rf" inside a build directory)
|
||||||
return "Error: Command blocked by safety guard (dangerous pattern detected)"
|
# from the hardcoded deny list via configuration.
|
||||||
|
explicitly_allowed = bool(self.allow_patterns) and any(
|
||||||
|
re.search(p, lower) for p in self.allow_patterns
|
||||||
|
)
|
||||||
|
if not explicitly_allowed:
|
||||||
|
for pattern in self.deny_patterns:
|
||||||
|
if re.search(pattern, lower):
|
||||||
|
return "Error: Command blocked by deny pattern filter"
|
||||||
|
|
||||||
if self.allow_patterns:
|
if self.allow_patterns:
|
||||||
if not any(re.search(p, lower) for p in self.allow_patterns):
|
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
||||||
return "Error: Command blocked by safety guard (not in allowlist)"
|
|
||||||
|
|
||||||
from nanobot.security.network import contains_internal_url
|
from nanobot.security.network import contains_internal_url
|
||||||
if contains_internal_url(cmd):
|
if contains_internal_url(cmd):
|
||||||
|
# The runner turns this marker into a non-retryable security hint.
|
||||||
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
|
|
||||||
if self.restrict_to_workspace:
|
if self.restrict_to_workspace:
|
||||||
if "..\\" in cmd or "../" in cmd:
|
if "..\\" in cmd or "../" in cmd:
|
||||||
return "Error: Command blocked by safety guard (path traversal detected)"
|
return (
|
||||||
|
"Error: Command blocked by safety guard (path traversal detected)"
|
||||||
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
|
|
||||||
cwd_path = Path(cwd).resolve()
|
cwd_path = Path(cwd).resolve()
|
||||||
|
|
||||||
for raw in self._extract_absolute_paths(cmd):
|
for raw in self._extract_absolute_paths(cmd):
|
||||||
try:
|
try:
|
||||||
expanded = os.path.expandvars(raw.strip())
|
expanded = os.path.expandvars(raw.strip())
|
||||||
|
# Match against the un-resolved path first. On Linux,
|
||||||
|
# /dev/stderr is a symlink to /proc/self/fd/2 and
|
||||||
|
# ``Path.resolve()`` would mask the device-file intent.
|
||||||
|
if self._is_benign_device_path(expanded):
|
||||||
|
continue
|
||||||
p = Path(expanded).expanduser().resolve()
|
p = Path(expanded).expanduser().resolve()
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if self._is_benign_device_path(str(p)):
|
||||||
|
continue
|
||||||
|
|
||||||
media_path = get_media_dir().resolve()
|
media_path = get_media_dir().resolve()
|
||||||
if (p.is_absolute()
|
if (p.is_absolute()
|
||||||
and cwd_path not in p.parents
|
and cwd_path not in p.parents
|
||||||
@@ -305,15 +355,25 @@ class ExecTool(Tool):
|
|||||||
and media_path not in p.parents
|
and media_path not in p.parents
|
||||||
and p != media_path
|
and p != media_path
|
||||||
):
|
):
|
||||||
return "Error: Command blocked by safety guard (path outside working dir)"
|
return (
|
||||||
|
"Error: Command blocked by safety guard (path outside working dir)"
|
||||||
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_benign_device_path(cls, path: str) -> bool:
|
||||||
|
"""Return True for kernel device files that should never be workspace-blocked."""
|
||||||
|
if path in cls._BENIGN_DEVICE_PATHS:
|
||||||
|
return True
|
||||||
|
return path.startswith("/dev/fd/")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_absolute_paths(command: str) -> list[str]:
|
def _extract_absolute_paths(command: str) -> list[str]:
|
||||||
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`
|
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`
|
||||||
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
|
||||||
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
|
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
|
||||||
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||||
home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
|
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
|
||||||
return win_paths + posix_paths + home_paths
|
return win_paths + posix_paths + home_paths
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class SpawnTool(Tool):
|
|||||||
self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli")
|
self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli")
|
||||||
self._origin_chat_id: ContextVar[str] = ContextVar("spawn_origin_chat_id", default="direct")
|
self._origin_chat_id: ContextVar[str] = ContextVar("spawn_origin_chat_id", default="direct")
|
||||||
self._session_key: ContextVar[str] = ContextVar("spawn_session_key", default="cli:direct")
|
self._session_key: ContextVar[str] = ContextVar("spawn_session_key", default="cli:direct")
|
||||||
|
self._origin_message_id: ContextVar[str | None] = ContextVar(
|
||||||
|
"spawn_origin_message_id",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
def set_context(self, channel: str, chat_id: str, effective_key: str | None = None) -> None:
|
def set_context(self, channel: str, chat_id: str, effective_key: str | None = None) -> None:
|
||||||
"""Set the origin context for subagent announcements."""
|
"""Set the origin context for subagent announcements."""
|
||||||
@@ -32,6 +36,10 @@ class SpawnTool(Tool):
|
|||||||
self._origin_chat_id.set(chat_id)
|
self._origin_chat_id.set(chat_id)
|
||||||
self._session_key.set(effective_key or f"{channel}:{chat_id}")
|
self._session_key.set(effective_key or f"{channel}:{chat_id}")
|
||||||
|
|
||||||
|
def set_origin_message_id(self, message_id: str | None) -> None:
|
||||||
|
"""Set the source message id for downstream deduplication."""
|
||||||
|
self._origin_message_id.set(message_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "spawn"
|
return "spawn"
|
||||||
@@ -48,10 +56,19 @@ class SpawnTool(Tool):
|
|||||||
|
|
||||||
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
||||||
"""Spawn a subagent to execute the given task."""
|
"""Spawn a subagent to execute the given task."""
|
||||||
|
running = self._manager.get_running_count()
|
||||||
|
limit = self._manager.max_concurrent_subagents
|
||||||
|
if running >= limit:
|
||||||
|
return (
|
||||||
|
f"Cannot spawn subagent: concurrency limit reached "
|
||||||
|
f"({running}/{limit} running). Wait for a running subagent "
|
||||||
|
f"to complete before spawning a new one."
|
||||||
|
)
|
||||||
return await self._manager.spawn(
|
return await self._manager.spawn(
|
||||||
task=task,
|
task=task,
|
||||||
label=label,
|
label=label,
|
||||||
origin_channel=self._origin_channel.get(),
|
origin_channel=self._origin_channel.get(),
|
||||||
origin_chat_id=self._origin_chat_id.get(),
|
origin_chat_id=self._origin_chat_id.get(),
|
||||||
session_key=self._session_key.get(),
|
session_key=self._session_key.get(),
|
||||||
|
origin_message_id=self._origin_message_id.get(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ class WebFetchTool(Tool):
|
|||||||
max_chars: int | None = None,
|
max_chars: int | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
url = url.strip(" \t\r\n`\"'")
|
||||||
extract_mode = kwargs.pop("extractMode", extract_mode)
|
extract_mode = kwargs.pop("extractMode", extract_mode)
|
||||||
max_chars = kwargs.pop("maxChars", max_chars) or self.max_chars
|
max_chars = kwargs.pop("maxChars", max_chars) or self.max_chars
|
||||||
is_valid, error_msg = _validate_url_safe(url)
|
is_valid, error_msg = _validate_url_safe(url)
|
||||||
@@ -499,10 +500,10 @@ class WebFetchTool(Tool):
|
|||||||
"untrusted": True, "text": text,
|
"untrusted": True, "text": text,
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
except httpx.ProxyError as e:
|
except httpx.ProxyError as e:
|
||||||
logger.error("WebFetch proxy error for {}: {}", url, e)
|
logger.exception("WebFetch proxy error for {}", url)
|
||||||
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
|
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("WebFetch error for {}: {}", url, e)
|
logger.exception("WebFetch error for {}", url)
|
||||||
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
||||||
|
|
||||||
def _to_markdown(self, html_content: str) -> str:
|
def _to_markdown(self, html_content: str) -> str:
|
||||||
|
|||||||
+26
-6
@@ -7,6 +7,7 @@ All requests route to a single persistent API session.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json as _json
|
import json as _json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -18,8 +19,12 @@ from loguru import logger
|
|||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.utils.helpers import safe_filename
|
from nanobot.utils.helpers import safe_filename
|
||||||
from nanobot.utils.media_decode import (
|
from nanobot.utils.media_decode import (
|
||||||
FileSizeExceeded as _FileSizeExceeded,
|
|
||||||
MAX_FILE_SIZE,
|
MAX_FILE_SIZE,
|
||||||
|
)
|
||||||
|
from nanobot.utils.media_decode import (
|
||||||
|
FileSizeExceeded as _FileSizeExceeded,
|
||||||
|
)
|
||||||
|
from nanobot.utils.media_decode import (
|
||||||
save_base64_data_url as _save_base64_data_url,
|
save_base64_data_url as _save_base64_data_url,
|
||||||
)
|
)
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
@@ -240,18 +245,25 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
stream_failed = False
|
stream_failed = False
|
||||||
|
emitted_content = False
|
||||||
|
|
||||||
async def _on_stream(token: str) -> None:
|
async def _on_stream(token: str) -> None:
|
||||||
|
nonlocal emitted_content
|
||||||
|
if token:
|
||||||
|
emitted_content = True
|
||||||
await queue.put(token)
|
await queue.put(token)
|
||||||
|
|
||||||
async def _on_stream_end(*_a: Any, **_kw: Any) -> None:
|
async def _on_stream_end(*_a: Any, **_kw: Any) -> None:
|
||||||
await queue.put(None)
|
# Agent stream-end callbacks mark generation segment boundaries.
|
||||||
|
# Tool-backed requests may continue after a segment ends, so the
|
||||||
|
# HTTP SSE stream is closed only when process_direct returns.
|
||||||
|
return None
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
nonlocal stream_failed
|
nonlocal stream_failed
|
||||||
try:
|
try:
|
||||||
async with session_lock:
|
async with session_lock:
|
||||||
await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
agent_loop.process_direct(
|
agent_loop.process_direct(
|
||||||
content=text,
|
content=text,
|
||||||
media=media_paths if media_paths else None,
|
media=media_paths if media_paths else None,
|
||||||
@@ -263,9 +275,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
),
|
),
|
||||||
timeout=timeout_s,
|
timeout=timeout_s,
|
||||||
)
|
)
|
||||||
|
if not emitted_content:
|
||||||
|
response_text = _response_text(response)
|
||||||
|
if response_text.strip():
|
||||||
|
await queue.put(response_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
stream_failed = True
|
stream_failed = True
|
||||||
logger.exception("Streaming error for session {}", session_key)
|
logger.exception("Streaming error for session {}", session_key)
|
||||||
|
finally:
|
||||||
await queue.put(None)
|
await queue.put(None)
|
||||||
|
|
||||||
task = asyncio.create_task(_run())
|
task = asyncio.create_task(_run())
|
||||||
@@ -276,7 +293,10 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
break
|
break
|
||||||
await resp.write(_sse_chunk(token, model_name, chunk_id))
|
await resp.write(_sse_chunk(token, model_name, chunk_id))
|
||||||
finally:
|
finally:
|
||||||
task.cancel()
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
if not stream_failed:
|
if not stream_failed:
|
||||||
await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop"))
|
await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop"))
|
||||||
@@ -284,7 +304,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
# -- non-streaming path (original logic) --
|
# -- non-streaming path (original logic) --
|
||||||
_FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE
|
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session_lock:
|
async with session_lock:
|
||||||
@@ -316,7 +336,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
response_text = _response_text(retry_response)
|
response_text = _response_text(retry_response)
|
||||||
if not response_text or not response_text.strip():
|
if not response_text or not response_text.strip():
|
||||||
logger.warning("Empty response after retry, using fallback")
|
logger.warning("Empty response after retry, using fallback")
|
||||||
response_text = _FALLBACK
|
response_text = fallback
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return _error_json(504, f"Request timed out after {timeout_s}s")
|
return _error_json(504, f"Request timed out after {timeout_s}s")
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class BaseChannel(ABC):
|
|||||||
bus: The message bus for communication.
|
bus: The message bus for communication.
|
||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.logger = logger.bind(channel=self.name)
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
@@ -61,8 +62,8 @@ class BaseChannel(ABC):
|
|||||||
language=self.transcription_language or None,
|
language=self.transcription_language or None,
|
||||||
)
|
)
|
||||||
return await provider.transcribe(file_path)
|
return await provider.transcribe(file_path)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning("{}: audio transcription failed: {}", self.name, e)
|
self.logger.exception("Audio transcription failed")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
async def login(self, force: bool = False) -> bool:
|
async def login(self, force: bool = False) -> bool:
|
||||||
@@ -136,7 +137,7 @@ class BaseChannel(ABC):
|
|||||||
else:
|
else:
|
||||||
allow_list = getattr(self.config, "allow_from", [])
|
allow_list = getattr(self.config, "allow_from", [])
|
||||||
if not allow_list:
|
if not allow_list:
|
||||||
logger.warning("{}: allow_from is empty — all access denied", self.name)
|
self.logger.warning("allow_from is empty — all access denied")
|
||||||
return False
|
return False
|
||||||
if "*" in allow_list:
|
if "*" in allow_list:
|
||||||
return True
|
return True
|
||||||
@@ -165,10 +166,10 @@ class BaseChannel(ABC):
|
|||||||
session_key: Optional session key override (e.g. thread-scoped sessions).
|
session_key: Optional session key override (e.g. thread-scoped sessions).
|
||||||
"""
|
"""
|
||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Access denied for sender {} on channel {}. "
|
"Access denied for sender {}. "
|
||||||
"Add them to allowFrom list in config to grant access.",
|
"Add them to allowFrom list in config to grant access.",
|
||||||
sender_id, self.name,
|
sender_id,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+213
-77
@@ -9,16 +9,19 @@ import zipfile
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
from nanobot.security.network import validate_resolved_url, validate_url_target
|
||||||
|
|
||||||
|
DINGTALK_MAX_REMOTE_MEDIA_BYTES = 20 * 1024 * 1024
|
||||||
|
DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS = 3
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dingtalk_stream import (
|
from dingtalk_stream import (
|
||||||
@@ -109,7 +112,7 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
content = content + "\n\nReceived files:\n" + file_list
|
content = content + "\n\nReceived files:\n" + file_list
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
logger.warning(
|
self.channel.logger.warning(
|
||||||
"Received empty or unsupported message type: {}",
|
"Received empty or unsupported message type: {}",
|
||||||
chatbot_msg.message_type,
|
chatbot_msg.message_type,
|
||||||
)
|
)
|
||||||
@@ -124,7 +127,7 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
or message.data.get("openConversationId")
|
or message.data.get("openConversationId")
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
|
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
|
||||||
|
|
||||||
# Forward to Nanobot via _on_message (non-blocking).
|
# Forward to Nanobot via _on_message (non-blocking).
|
||||||
# Store reference to prevent GC before task completes.
|
# Store reference to prevent GC before task completes.
|
||||||
@@ -142,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
|
|
||||||
return AckMessage.STATUS_OK, "OK"
|
return AckMessage.STATUS_OK, "OK"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error processing DingTalk message: {}", e)
|
self.channel.logger.exception("Error processing message")
|
||||||
# Return OK to avoid retry loop from DingTalk server
|
# Return OK to avoid retry loop from DingTalk server
|
||||||
return AckMessage.STATUS_OK, "Error"
|
return AckMessage.STATUS_OK, "Error"
|
||||||
|
|
||||||
@@ -155,6 +158,8 @@ class DingTalkConfig(Base):
|
|||||||
client_id: str = ""
|
client_id: str = ""
|
||||||
client_secret: str = ""
|
client_secret: str = ""
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
allow_remote_media_redirects: bool = False
|
||||||
|
remote_media_redirect_allowed_hosts: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class DingTalkChannel(BaseChannel):
|
class DingTalkChannel(BaseChannel):
|
||||||
@@ -198,20 +203,20 @@ class DingTalkChannel(BaseChannel):
|
|||||||
"""Start the DingTalk bot with Stream Mode."""
|
"""Start the DingTalk bot with Stream Mode."""
|
||||||
try:
|
try:
|
||||||
if not DINGTALK_AVAILABLE:
|
if not DINGTALK_AVAILABLE:
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"DingTalk Stream SDK not installed. Run: pip install dingtalk-stream"
|
"Stream SDK not installed. Run: pip install dingtalk-stream"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.client_id or not self.config.client_secret:
|
if not self.config.client_id or not self.config.client_secret:
|
||||||
logger.error("DingTalk client_id and client_secret not configured")
|
self.logger.error("client_id and client_secret not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._http = httpx.AsyncClient()
|
self._http = httpx.AsyncClient()
|
||||||
|
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"Initializing DingTalk Stream Client with Client ID: {}...",
|
"Initializing Stream Client with Client ID: {}...",
|
||||||
self.config.client_id,
|
self.config.client_id,
|
||||||
)
|
)
|
||||||
credential = Credential(self.config.client_id, self.config.client_secret)
|
credential = Credential(self.config.client_id, self.config.client_secret)
|
||||||
@@ -221,20 +226,20 @@ class DingTalkChannel(BaseChannel):
|
|||||||
handler = NanobotDingTalkHandler(self)
|
handler = NanobotDingTalkHandler(self)
|
||||||
self._client.register_callback_handler(ChatbotMessage.TOPIC, handler)
|
self._client.register_callback_handler(ChatbotMessage.TOPIC, handler)
|
||||||
|
|
||||||
logger.info("DingTalk bot started with Stream Mode")
|
self.logger.info("bot started with Stream Mode")
|
||||||
|
|
||||||
# Reconnect loop: restart stream if SDK exits or crashes
|
# Reconnect loop: restart stream if SDK exits or crashes
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self._client.start()
|
await self._client.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("DingTalk stream error: {}", e)
|
self.logger.warning("stream error: {}", e)
|
||||||
if self._running:
|
if self._running:
|
||||||
logger.info("Reconnecting DingTalk stream in 5 seconds...")
|
self.logger.info("Reconnecting stream in 5 seconds...")
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception("Failed to start DingTalk channel: {}", e)
|
self.logger.exception("Failed to start channel")
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the DingTalk bot."""
|
"""Stop the DingTalk bot."""
|
||||||
@@ -260,7 +265,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not self._http:
|
if not self._http:
|
||||||
logger.warning("DingTalk HTTP client not initialized, cannot refresh token")
|
self.logger.warning("HTTP client not initialized, cannot refresh token")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -271,8 +276,8 @@ class DingTalkChannel(BaseChannel):
|
|||||||
# Expire 60s early to be safe
|
# Expire 60s early to be safe
|
||||||
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
|
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
|
||||||
return self._access_token
|
return self._access_token
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Failed to get DingTalk access token: {}", e)
|
self.logger.exception("Failed to get access token")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -281,9 +286,12 @@ class DingTalkChannel(BaseChannel):
|
|||||||
|
|
||||||
def _guess_upload_type(self, media_ref: str) -> str:
|
def _guess_upload_type(self, media_ref: str) -> str:
|
||||||
ext = Path(urlparse(media_ref).path).suffix.lower()
|
ext = Path(urlparse(media_ref).path).suffix.lower()
|
||||||
if ext in self._IMAGE_EXTS: return "image"
|
if ext in self._IMAGE_EXTS:
|
||||||
if ext in self._AUDIO_EXTS: return "voice"
|
return "image"
|
||||||
if ext in self._VIDEO_EXTS: return "video"
|
if ext in self._AUDIO_EXTS:
|
||||||
|
return "voice"
|
||||||
|
if ext in self._VIDEO_EXTS:
|
||||||
|
return "video"
|
||||||
return "file"
|
return "file"
|
||||||
|
|
||||||
def _guess_filename(self, media_ref: str, upload_type: str) -> str:
|
def _guess_filename(self, media_ref: str, upload_type: str) -> str:
|
||||||
@@ -308,13 +316,153 @@ class DingTalkChannel(BaseChannel):
|
|||||||
) -> tuple[bytes, str, str | None]:
|
) -> tuple[bytes, str, str | None]:
|
||||||
ext = Path(filename).suffix.lower()
|
ext = Path(filename).suffix.lower()
|
||||||
if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html":
|
if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html":
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"DingTalk does not accept raw HTML attachments, zipping {} before upload",
|
"does not accept raw HTML attachments, zipping {} before upload",
|
||||||
filename,
|
filename,
|
||||||
)
|
)
|
||||||
return self._zip_bytes(filename, data)
|
return self._zip_bytes(filename, data)
|
||||||
return data, filename, content_type
|
return data, filename, content_type
|
||||||
|
|
||||||
|
def _validate_remote_media_url(self, media_ref: str) -> bool:
|
||||||
|
ok, err = validate_url_target(media_ref)
|
||||||
|
if not ok:
|
||||||
|
self.logger.warning("remote media URL blocked ref={} reason={}", media_ref, err)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _redirect_host_allowed(self, current_url: str, next_url: str) -> bool:
|
||||||
|
current_host = (urlparse(current_url).hostname or "").lower()
|
||||||
|
next_host = (urlparse(next_url).hostname or "").lower()
|
||||||
|
if not next_host:
|
||||||
|
return False
|
||||||
|
if next_host == current_host:
|
||||||
|
return True
|
||||||
|
allowed_hosts = {host.lower() for host in self.config.remote_media_redirect_allowed_hosts}
|
||||||
|
return next_host in allowed_hosts
|
||||||
|
|
||||||
|
def _next_remote_media_url(self, current_url: str, location: str | None) -> str | None:
|
||||||
|
if not self.config.allow_remote_media_redirects:
|
||||||
|
self.logger.warning("media download redirect refused ref={}", current_url)
|
||||||
|
return None
|
||||||
|
if not location:
|
||||||
|
self.logger.warning("media download redirect without Location ref={}", current_url)
|
||||||
|
return None
|
||||||
|
next_url = urljoin(current_url, location)
|
||||||
|
if not self._redirect_host_allowed(current_url, next_url):
|
||||||
|
self.logger.warning(
|
||||||
|
"media download cross-host redirect refused ref={} next={}",
|
||||||
|
current_url,
|
||||||
|
next_url,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not self._validate_remote_media_url(next_url):
|
||||||
|
return None
|
||||||
|
return next_url
|
||||||
|
|
||||||
|
async def _fetch_remote_media_bytes(
|
||||||
|
self,
|
||||||
|
media_ref: str,
|
||||||
|
) -> tuple[bytes | None, str | None]:
|
||||||
|
"""Fetch a remote media URL with SSRF, redirect, and size checks."""
|
||||||
|
if not self._http:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if not self._validate_remote_media_url(media_ref):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prefer streaming with a running byte cap so large responses are not
|
||||||
|
# materialized before the limit is enforced. Test fakes may only
|
||||||
|
# implement get(), so keep a small compatibility fallback below.
|
||||||
|
stream = getattr(self._http, "stream", None)
|
||||||
|
if stream is not None:
|
||||||
|
current_url = media_ref
|
||||||
|
for _ in range(DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS + 1):
|
||||||
|
async with stream("GET", current_url, follow_redirects=False) as resp:
|
||||||
|
final_ok, final_err = validate_resolved_url(str(resp.url))
|
||||||
|
if not final_ok:
|
||||||
|
self.logger.warning(
|
||||||
|
"remote media redirect blocked ref={} final={} reason={}",
|
||||||
|
media_ref,
|
||||||
|
resp.url,
|
||||||
|
final_err,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
if 300 <= resp.status_code < 400:
|
||||||
|
next_url = self._next_remote_media_url(
|
||||||
|
str(resp.url), resp.headers.get("location")
|
||||||
|
)
|
||||||
|
if not next_url:
|
||||||
|
return None, None
|
||||||
|
current_url = next_url
|
||||||
|
continue
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
self.logger.warning(
|
||||||
|
"media download failed status={} ref={}",
|
||||||
|
resp.status_code,
|
||||||
|
current_url,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
async for chunk in resp.aiter_bytes():
|
||||||
|
total += len(chunk)
|
||||||
|
if total > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
|
||||||
|
self.logger.warning(
|
||||||
|
"media download too large ref={} bytes>{}",
|
||||||
|
current_url,
|
||||||
|
DINGTALK_MAX_REMOTE_MEDIA_BYTES,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks), (resp.headers.get("content-type") or "")
|
||||||
|
self.logger.warning("media download exceeded redirect limit ref={}", media_ref)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
current_url = media_ref
|
||||||
|
for _ in range(DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS + 1):
|
||||||
|
resp = await self._http.get(current_url, follow_redirects=False)
|
||||||
|
final_ok, final_err = validate_resolved_url(str(getattr(resp, "url", current_url)))
|
||||||
|
if not final_ok:
|
||||||
|
self.logger.warning(
|
||||||
|
"remote media redirect blocked ref={} final={} reason={}",
|
||||||
|
media_ref,
|
||||||
|
getattr(resp, "url", current_url),
|
||||||
|
final_err,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
if 300 <= resp.status_code < 400:
|
||||||
|
next_url = self._next_remote_media_url(
|
||||||
|
str(getattr(resp, "url", current_url)), resp.headers.get("location")
|
||||||
|
)
|
||||||
|
if not next_url:
|
||||||
|
return None, None
|
||||||
|
current_url = next_url
|
||||||
|
continue
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
self.logger.warning(
|
||||||
|
"media download failed status={} ref={}",
|
||||||
|
resp.status_code,
|
||||||
|
current_url,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
if len(resp.content) > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
|
||||||
|
self.logger.warning(
|
||||||
|
"media download too large ref={} bytes>{}",
|
||||||
|
current_url,
|
||||||
|
DINGTALK_MAX_REMOTE_MEDIA_BYTES,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
return resp.content, (resp.headers.get("content-type") or "")
|
||||||
|
self.logger.warning("media download exceeded redirect limit ref={}", media_ref)
|
||||||
|
return None, None
|
||||||
|
except httpx.TransportError:
|
||||||
|
self.logger.exception("media download network error ref={}", media_ref)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("media download error ref={}", media_ref)
|
||||||
|
return None, None
|
||||||
|
|
||||||
async def _read_media_bytes(
|
async def _read_media_bytes(
|
||||||
self,
|
self,
|
||||||
media_ref: str,
|
media_ref: str,
|
||||||
@@ -323,26 +471,12 @@ class DingTalkChannel(BaseChannel):
|
|||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
if self._is_http_url(media_ref):
|
if self._is_http_url(media_ref):
|
||||||
if not self._http:
|
data, raw_content_type = await self._fetch_remote_media_bytes(media_ref)
|
||||||
return None, None, None
|
if data is None:
|
||||||
try:
|
|
||||||
resp = await self._http.get(media_ref, follow_redirects=True)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
logger.warning(
|
|
||||||
"DingTalk media download failed status={} ref={}",
|
|
||||||
resp.status_code,
|
|
||||||
media_ref,
|
|
||||||
)
|
|
||||||
return None, None, None
|
|
||||||
content_type = (resp.headers.get("content-type") or "").split(";")[0].strip()
|
|
||||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
|
||||||
return resp.content, filename, content_type or None
|
|
||||||
except httpx.TransportError as e:
|
|
||||||
logger.error("DingTalk media download network error ref={} err={}", media_ref, e)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("DingTalk media download error ref={} err={}", media_ref, e)
|
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
content_type = (raw_content_type or "").split(";")[0].strip()
|
||||||
|
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||||
|
return data, filename, content_type or None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if media_ref.startswith("file://"):
|
if media_ref.startswith("file://"):
|
||||||
@@ -351,13 +485,13 @@ class DingTalkChannel(BaseChannel):
|
|||||||
else:
|
else:
|
||||||
local_path = Path(os.path.expanduser(media_ref))
|
local_path = Path(os.path.expanduser(media_ref))
|
||||||
if not local_path.is_file():
|
if not local_path.is_file():
|
||||||
logger.warning("DingTalk media file not found: {}", local_path)
|
self.logger.warning("media file not found: {}", local_path)
|
||||||
return None, None, None
|
return None, None, None
|
||||||
data = await asyncio.to_thread(local_path.read_bytes)
|
data = await asyncio.to_thread(local_path.read_bytes)
|
||||||
content_type = mimetypes.guess_type(local_path.name)[0]
|
content_type = mimetypes.guess_type(local_path.name)[0]
|
||||||
return data, local_path.name, content_type
|
return data, local_path.name, content_type
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("DingTalk media read error ref={} err={}", media_ref, e)
|
self.logger.exception("media read error ref={}", media_ref)
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
async def _upload_media(
|
async def _upload_media(
|
||||||
@@ -379,23 +513,23 @@ class DingTalkChannel(BaseChannel):
|
|||||||
text = resp.text
|
text = resp.text
|
||||||
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
logger.error("DingTalk media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
|
self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
|
||||||
return None
|
return None
|
||||||
errcode = result.get("errcode", 0)
|
errcode = result.get("errcode", 0)
|
||||||
if errcode != 0:
|
if errcode != 0:
|
||||||
logger.error("DingTalk media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
|
self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
|
||||||
return None
|
return None
|
||||||
sub = result.get("result") or {}
|
sub = result.get("result") or {}
|
||||||
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
|
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
|
||||||
if not media_id:
|
if not media_id:
|
||||||
logger.error("DingTalk media upload missing media_id body={}", text[:500])
|
self.logger.error("media upload missing media_id body={}", text[:500])
|
||||||
return None
|
return None
|
||||||
return str(media_id)
|
return str(media_id)
|
||||||
except httpx.TransportError as e:
|
except httpx.TransportError:
|
||||||
logger.error("DingTalk media upload network error type={} err={}", media_type, e)
|
self.logger.exception("media upload network error type={}", media_type)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("DingTalk media upload error type={} err={}", media_type, e)
|
self.logger.exception("media upload error type={}", media_type)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _send_batch_message(
|
async def _send_batch_message(
|
||||||
@@ -406,7 +540,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
msg_param: dict[str, Any],
|
msg_param: dict[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self._http:
|
if not self._http:
|
||||||
logger.warning("DingTalk HTTP client not initialized, cannot send")
|
self.logger.warning("HTTP client not initialized, cannot send")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
headers = {"x-acs-dingtalk-access-token": token}
|
headers = {"x-acs-dingtalk-access-token": token}
|
||||||
@@ -433,21 +567,23 @@ class DingTalkChannel(BaseChannel):
|
|||||||
resp = await self._http.post(url, json=payload, headers=headers)
|
resp = await self._http.post(url, json=payload, headers=headers)
|
||||||
body = resp.text
|
body = resp.text
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
|
self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
|
||||||
return False
|
return False
|
||||||
try: result = resp.json()
|
try:
|
||||||
except Exception: result = {}
|
result = resp.json()
|
||||||
|
except Exception:
|
||||||
|
result = {}
|
||||||
errcode = result.get("errcode")
|
errcode = result.get("errcode")
|
||||||
if errcode not in (None, 0):
|
if errcode not in (None, 0):
|
||||||
logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500])
|
self.logger.error("send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500])
|
||||||
return False
|
return False
|
||||||
logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key)
|
self.logger.debug("message sent to {} with msgKey={}", chat_id, msg_key)
|
||||||
return True
|
return True
|
||||||
except httpx.TransportError as e:
|
except httpx.TransportError:
|
||||||
logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e)
|
self.logger.exception("network error sending message msgKey={}", msg_key)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e)
|
self.logger.exception("Error sending message msgKey={}", msg_key)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool:
|
async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool:
|
||||||
@@ -473,11 +609,11 @@ class DingTalkChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
return True
|
return True
|
||||||
logger.warning("DingTalk image url send failed, trying upload fallback: {}", media_ref)
|
self.logger.warning("image url send failed, trying upload fallback: {}", media_ref)
|
||||||
|
|
||||||
data, filename, content_type = await self._read_media_bytes(media_ref)
|
data, filename, content_type = await self._read_media_bytes(media_ref)
|
||||||
if not data:
|
if not data:
|
||||||
logger.error("DingTalk media read failed: {}", media_ref)
|
self.logger.error("media read failed: {}", media_ref)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
filename = filename or self._guess_filename(media_ref, upload_type)
|
filename = filename or self._guess_filename(media_ref, upload_type)
|
||||||
@@ -509,7 +645,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
return True
|
return True
|
||||||
logger.warning("DingTalk image media_id send failed, falling back to file: {}", media_ref)
|
self.logger.warning("image media_id send failed, falling back to file: {}", media_ref)
|
||||||
|
|
||||||
return await self._send_batch_message(
|
return await self._send_batch_message(
|
||||||
token,
|
token,
|
||||||
@@ -531,7 +667,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
|
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
|
||||||
if ok:
|
if ok:
|
||||||
continue
|
continue
|
||||||
logger.error("DingTalk media send failed for {}", media_ref)
|
self.logger.error("media send failed for {}", media_ref)
|
||||||
# Send visible fallback so failures are observable by the user.
|
# Send visible fallback so failures are observable by the user.
|
||||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||||
await self._send_markdown_text(
|
await self._send_markdown_text(
|
||||||
@@ -554,7 +690,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
permission checks before publishing to the bus.
|
permission checks before publishing to the bus.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("DingTalk inbound: {} from {}", content, sender_name)
|
self.logger.info("inbound: {} from {}", content, sender_name)
|
||||||
is_group = conversation_type == "2" and conversation_id
|
is_group = conversation_type == "2" and conversation_id
|
||||||
chat_id = f"group:{conversation_id}" if is_group else sender_id
|
chat_id = f"group:{conversation_id}" if is_group else sender_id
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
@@ -567,8 +703,8 @@ class DingTalkChannel(BaseChannel):
|
|||||||
"conversation_type": conversation_type,
|
"conversation_type": conversation_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error publishing DingTalk message: {}", e)
|
self.logger.exception("Error publishing message")
|
||||||
|
|
||||||
async def _download_dingtalk_file(
|
async def _download_dingtalk_file(
|
||||||
self,
|
self,
|
||||||
@@ -582,7 +718,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
token = await self._get_access_token()
|
token = await self._get_access_token()
|
||||||
if not token or not self._http:
|
if not token or not self._http:
|
||||||
logger.error("DingTalk file download: no token or http client")
|
self.logger.error("file download: no token or http client")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Step 1: Exchange downloadCode for a temporary download URL
|
# Step 1: Exchange downloadCode for a temporary download URL
|
||||||
@@ -591,19 +727,19 @@ class DingTalkChannel(BaseChannel):
|
|||||||
payload = {"downloadCode": download_code, "robotCode": self.config.client_id}
|
payload = {"downloadCode": download_code, "robotCode": self.config.client_id}
|
||||||
resp = await self._http.post(api_url, json=payload, headers=headers)
|
resp = await self._http.post(api_url, json=payload, headers=headers)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.error("DingTalk get download URL failed: status={}, body={}", resp.status_code, resp.text)
|
self.logger.error("get download URL failed: status={}, body={}", resp.status_code, resp.text)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
download_url = result.get("downloadUrl")
|
download_url = result.get("downloadUrl")
|
||||||
if not download_url:
|
if not download_url:
|
||||||
logger.error("DingTalk download URL not found in response: {}", result)
|
self.logger.error("download URL not found in response: {}", result)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Step 2: Download the file content
|
# Step 2: Download the file content
|
||||||
file_resp = await self._http.get(download_url, follow_redirects=True)
|
file_resp = await self._http.get(download_url, follow_redirects=True)
|
||||||
if file_resp.status_code != 200:
|
if file_resp.status_code != 200:
|
||||||
logger.error("DingTalk file download failed: status={}", file_resp.status_code)
|
self.logger.error("file download failed: status={}", file_resp.status_code)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Save to media directory (accessible under workspace)
|
# Save to media directory (accessible under workspace)
|
||||||
@@ -611,8 +747,8 @@ class DingTalkChannel(BaseChannel):
|
|||||||
download_dir.mkdir(parents=True, exist_ok=True)
|
download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
file_path = download_dir / filename
|
file_path = download_dir / filename
|
||||||
await asyncio.to_thread(file_path.write_bytes, file_resp.content)
|
await asyncio.to_thread(file_path.write_bytes, file_resp.content)
|
||||||
logger.info("DingTalk file saved: {}", file_path)
|
self.logger.info("file saved: {}", file_path)
|
||||||
return str(file_path)
|
return str(file_path)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("DingTalk file download error: {}", e)
|
self.logger.exception("file download error")
|
||||||
return None
|
return None
|
||||||
|
|||||||
+45
-51
@@ -5,11 +5,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -85,12 +85,12 @@ if DISCORD_AVAILABLE:
|
|||||||
|
|
||||||
async def on_ready(self) -> None:
|
async def on_ready(self) -> None:
|
||||||
self._channel._bot_user_id = str(self.user.id) if self.user else None
|
self._channel._bot_user_id = str(self.user.id) if self.user else None
|
||||||
logger.info("Discord bot connected as user {}", self._channel._bot_user_id)
|
self._channel.logger.info("bot connected as user {}", self._channel._bot_user_id)
|
||||||
try:
|
try:
|
||||||
synced = await self.tree.sync()
|
synced = await self.tree.sync()
|
||||||
logger.info("Discord app commands synced: {}", len(synced))
|
self._channel.logger.info("app commands synced: {}", len(synced))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord app command sync failed: {}", e)
|
self._channel.logger.warning("app command sync failed: {}", e)
|
||||||
|
|
||||||
async def on_message(self, message: discord.Message) -> None:
|
async def on_message(self, message: discord.Message) -> None:
|
||||||
await self._channel._handle_discord_message(message)
|
await self._channel._handle_discord_message(message)
|
||||||
@@ -110,7 +110,7 @@ if DISCORD_AVAILABLE:
|
|||||||
await interaction.response.send_message(text, ephemeral=True)
|
await interaction.response.send_message(text, ephemeral=True)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord interaction response failed: {}", e)
|
self._channel.logger.warning("interaction response failed: {}", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _resolve_interaction_channel(
|
async def _resolve_interaction_channel(
|
||||||
@@ -125,7 +125,7 @@ if DISCORD_AVAILABLE:
|
|||||||
try:
|
try:
|
||||||
channel = await self.fetch_channel(channel_id)
|
channel = await self.fetch_channel(channel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord interaction channel {} unavailable: {}", channel_id, e)
|
self._channel.logger.warning("interaction channel {} unavailable: {}", channel_id, e)
|
||||||
return None
|
return None
|
||||||
self._channel._remember_channel(channel)
|
self._channel._remember_channel(channel)
|
||||||
return channel
|
return channel
|
||||||
@@ -153,7 +153,7 @@ if DISCORD_AVAILABLE:
|
|||||||
channel_id = interaction.channel_id
|
channel_id = interaction.channel_id
|
||||||
|
|
||||||
if channel_id is None:
|
if channel_id is None:
|
||||||
logger.warning("Discord slash command missing channel_id: {}", command_text)
|
self._channel.logger.warning("slash command missing channel_id: {}", command_text)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._channel.is_allowed(sender_id):
|
if not self._channel.is_allowed(sender_id):
|
||||||
@@ -225,8 +225,8 @@ if DISCORD_AVAILABLE:
|
|||||||
error: app_commands.AppCommandError,
|
error: app_commands.AppCommandError,
|
||||||
) -> None:
|
) -> None:
|
||||||
command_name = interaction.command.qualified_name if interaction.command else "?"
|
command_name = interaction.command.qualified_name if interaction.command else "?"
|
||||||
logger.warning(
|
self._channel.logger.warning(
|
||||||
"Discord app command failed user={} channel={} cmd={} error={}",
|
"app command failed user={} channel={} cmd={} error={}",
|
||||||
interaction.user.id,
|
interaction.user.id,
|
||||||
interaction.channel_id,
|
interaction.channel_id,
|
||||||
command_name,
|
command_name,
|
||||||
@@ -242,7 +242,7 @@ if DISCORD_AVAILABLE:
|
|||||||
try:
|
try:
|
||||||
channel = await self.fetch_channel(channel_id)
|
channel = await self.fetch_channel(channel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e)
|
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
|
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
|
||||||
@@ -280,11 +280,11 @@ if DISCORD_AVAILABLE:
|
|||||||
"""Send a file attachment via discord.py."""
|
"""Send a file attachment via discord.py."""
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
logger.warning("Discord file not found, skipping: {}", file_path)
|
self._channel.logger.warning("file not found, skipping: {}", file_path)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if path.stat().st_size > MAX_ATTACHMENT_BYTES:
|
if path.stat().st_size > MAX_ATTACHMENT_BYTES:
|
||||||
logger.warning("Discord file too large (>20MB), skipping: {}", path.name)
|
self._channel.logger.warning("file too large (>20MB), skipping: {}", path.name)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -293,10 +293,10 @@ if DISCORD_AVAILABLE:
|
|||||||
kwargs["reference"] = reference
|
kwargs["reference"] = reference
|
||||||
kwargs["allowed_mentions"] = mention_settings
|
kwargs["allowed_mentions"] = mention_settings
|
||||||
await channel.send(**kwargs)
|
await channel.send(**kwargs)
|
||||||
logger.info("Discord file sent: {}", path.name)
|
self._channel.logger.info("file sent: {}", path.name)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending Discord file {}: {}", path.name, e)
|
self._channel.logger.exception("Error sending file {}", path.name)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -320,7 +320,7 @@ if DISCORD_AVAILABLE:
|
|||||||
try:
|
try:
|
||||||
message_id = int(reply_to)
|
message_id = int(reply_to)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Invalid Discord reply target: {}", reply_to)
|
self._channel.logger.warning("Invalid reply target: {}", reply_to)
|
||||||
return None, mention_settings
|
return None, mention_settings
|
||||||
|
|
||||||
return channel.get_partial_message(message_id), mention_settings
|
return channel.get_partial_message(message_id), mention_settings
|
||||||
@@ -384,11 +384,11 @@ class DiscordChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Discord client."""
|
"""Start the Discord client."""
|
||||||
if not DISCORD_AVAILABLE:
|
if not DISCORD_AVAILABLE:
|
||||||
logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
|
self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.token:
|
if not self.config.token:
|
||||||
logger.error("Discord bot token not configured")
|
self.logger.error("bot token not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -406,8 +406,8 @@ class DiscordChannel(BaseChannel):
|
|||||||
password=self.config.proxy_password,
|
password=self.config.proxy_password,
|
||||||
)
|
)
|
||||||
elif has_user != has_pass:
|
elif has_user != has_pass:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Discord proxy auth incomplete: both proxy_username and "
|
"proxy auth incomplete: both proxy_username and "
|
||||||
"proxy_password must be set; ignoring partial credentials",
|
"proxy_password must be set; ignoring partial credentials",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -417,21 +417,21 @@ class DiscordChannel(BaseChannel):
|
|||||||
proxy=self.config.proxy,
|
proxy=self.config.proxy,
|
||||||
proxy_auth=proxy_auth,
|
proxy_auth=proxy_auth,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Failed to initialize Discord client: {}", e)
|
self.logger.exception("Failed to initialize client")
|
||||||
self._client = None
|
self._client = None
|
||||||
self._running = False
|
self._running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
logger.info("Starting Discord client via discord.py...")
|
self.logger.info("Starting client via discord.py...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._client.start(self.config.token)
|
await self._client.start(self.config.token)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Discord client startup failed: {}", e)
|
self.logger.exception("client startup failed")
|
||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
await self._reset_runtime_state(close_client=True)
|
await self._reset_runtime_state(close_client=True)
|
||||||
@@ -445,15 +445,15 @@ class DiscordChannel(BaseChannel):
|
|||||||
"""Send a message through Discord using discord.py."""
|
"""Send a message through Discord using discord.py."""
|
||||||
client = self._client
|
client = self._client
|
||||||
if client is None or not client.is_ready():
|
if client is None or not client.is_ready():
|
||||||
logger.warning("Discord client not ready; dropping outbound message")
|
self.logger.warning("client not ready; dropping outbound message")
|
||||||
return
|
return
|
||||||
|
|
||||||
is_progress = bool((msg.metadata or {}).get("_progress"))
|
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.send_outbound(msg)
|
await client.send_outbound(msg)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending Discord message: {}", e)
|
self.logger.exception("Error sending message")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if not is_progress:
|
if not is_progress:
|
||||||
@@ -466,7 +466,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
"""Progressive Discord delivery: send once, then edit until the stream ends."""
|
"""Progressive Discord delivery: send once, then edit until the stream ends."""
|
||||||
client = self._client
|
client = self._client
|
||||||
if client is None or not client.is_ready():
|
if client is None or not client.is_ready():
|
||||||
logger.warning("Discord client not ready; dropping stream delta")
|
self.logger.warning("client not ready; dropping stream delta")
|
||||||
return
|
return
|
||||||
|
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
@@ -496,7 +496,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
|
|
||||||
target = await self._resolve_channel(chat_id)
|
target = await self._resolve_channel(chat_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
logger.warning("Discord stream target {} unavailable", chat_id)
|
self.logger.warning("stream target {} unavailable", chat_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -505,7 +505,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
buf.message = await target.send(content=buf.text)
|
buf.message = await target.send(content=buf.text)
|
||||||
buf.last_edit = now
|
buf.last_edit = now
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord stream initial send failed: {}", e)
|
self.logger.warning("stream initial send failed: {}", e)
|
||||||
raise
|
raise
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -516,7 +516,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
|
await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
|
||||||
buf.last_edit = now
|
buf.last_edit = now
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord stream edit failed: {}", e)
|
self.logger.warning("stream edit failed: {}", e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _handle_discord_message(self, message: discord.Message) -> None:
|
async def _handle_discord_message(self, message: discord.Message) -> None:
|
||||||
@@ -559,15 +559,13 @@ class DiscordChannel(BaseChannel):
|
|||||||
await message.add_reaction(self.config.read_receipt_emoji)
|
await message.add_reaction(self.config.read_receipt_emoji)
|
||||||
self._pending_reactions[channel_id] = message
|
self._pending_reactions[channel_id] = message
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to add read receipt reaction: {}", e)
|
self.logger.debug("Failed to add read receipt reaction: {}", e)
|
||||||
|
|
||||||
# Delayed working indicator (cosmetic — not tied to subagent lifecycle)
|
# Delayed working indicator (cosmetic — not tied to subagent lifecycle)
|
||||||
async def _delayed_working_emoji() -> None:
|
async def _delayed_working_emoji() -> None:
|
||||||
await asyncio.sleep(self.config.working_emoji_delay)
|
await asyncio.sleep(self.config.working_emoji_delay)
|
||||||
try:
|
with suppress(Exception):
|
||||||
await message.add_reaction(self.config.working_emoji)
|
await message.add_reaction(self.config.working_emoji)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji())
|
self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji())
|
||||||
|
|
||||||
@@ -604,7 +602,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
return await client.fetch_channel(channel_id)
|
return await client.fetch_channel(channel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord channel {} unavailable: {}", chat_id, e)
|
self.logger.warning("channel {} unavailable: {}", chat_id, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
|
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
|
||||||
@@ -617,12 +615,12 @@ class DiscordChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await buf.message.edit(content=chunks[0])
|
await buf.message.edit(content=chunks[0])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord final stream edit failed: {}", e)
|
self.logger.warning("final stream edit failed: {}", e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
|
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
logger.warning("Discord stream follow-up target {} unavailable", chat_id)
|
self.logger.warning("stream follow-up target {} unavailable", chat_id)
|
||||||
self._stream_bufs.pop(chat_id, None)
|
self._stream_bufs.pop(chat_id, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -674,7 +672,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
media_paths.append(str(file_path))
|
media_paths.append(str(file_path))
|
||||||
markers.append(f"[attachment: {file_path.name}]")
|
markers.append(f"[attachment: {file_path.name}]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to download Discord attachment: {}", e)
|
self.logger.warning("Failed to download attachment: {}", e)
|
||||||
markers.append(f"[attachment: {filename} - download failed]")
|
markers.append(f"[attachment: {filename} - download failed]")
|
||||||
|
|
||||||
return media_paths, markers
|
return media_paths, markers
|
||||||
@@ -716,8 +714,8 @@ class DiscordChannel(BaseChannel):
|
|||||||
if bot_user_id is None and self._client and self._client.user:
|
if bot_user_id is None and self._client and self._client.user:
|
||||||
bot_user_id = str(self._client.user.id)
|
bot_user_id = str(self._client.user.id)
|
||||||
if bot_user_id is None:
|
if bot_user_id is None:
|
||||||
logger.debug(
|
self.logger.debug(
|
||||||
"Discord message in {} ignored (bot identity unavailable)", message.channel.id
|
"message in {} ignored (bot identity unavailable)", message.channel.id
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -730,7 +728,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
if self._references_bot_message(message, bot_user_id):
|
if self._references_bot_message(message, bot_user_id):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id)
|
self.logger.debug("message in {} ignored (bot not mentioned)", message.channel.id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -760,7 +758,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
|
self.logger.debug("typing indicator failed for {}: {}", channel_id, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
|
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
|
||||||
@@ -771,10 +769,8 @@ class DiscordChannel(BaseChannel):
|
|||||||
if task is None:
|
if task is None:
|
||||||
return
|
return
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await task
|
await task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _clear_reactions(self, chat_id: str) -> None:
|
async def _clear_reactions(self, chat_id: str) -> None:
|
||||||
"""Remove all pending reactions after bot replies."""
|
"""Remove all pending reactions after bot replies."""
|
||||||
@@ -788,10 +784,8 @@ class DiscordChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
bot_user = self._client.user if self._client else None
|
bot_user = self._client.user if self._client else None
|
||||||
for emoji in (self.config.read_receipt_emoji, self.config.working_emoji):
|
for emoji in (self.config.read_receipt_emoji, self.config.working_emoji):
|
||||||
try:
|
with suppress(Exception):
|
||||||
await msg_obj.remove_reaction(emoji, bot_user)
|
await msg_obj.remove_reaction(emoji, bot_user)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _cancel_all_typing(self) -> None:
|
async def _cancel_all_typing(self) -> None:
|
||||||
"""Stop all typing tasks."""
|
"""Stop all typing tasks."""
|
||||||
@@ -808,6 +802,6 @@ class DiscordChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Discord client close failed: {}", e)
|
self.logger.warning("client close failed: {}", e)
|
||||||
self._client = None
|
self._client = None
|
||||||
self._bot_user_id = None
|
self._bot_user_id = None
|
||||||
|
|||||||
+33
-28
@@ -6,6 +6,7 @@ import imaplib
|
|||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
|
from contextlib import suppress
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.header import decode_header, make_header
|
from email.header import decode_header, make_header
|
||||||
@@ -127,7 +128,7 @@ class EmailChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start polling IMAP for inbound emails."""
|
"""Start polling IMAP for inbound emails."""
|
||||||
if not self.config.consent_granted:
|
if not self.config.consent_granted:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Email channel disabled: consent_granted is false. "
|
"Email channel disabled: consent_granted is false. "
|
||||||
"Set channels.email.consentGranted=true after explicit user permission."
|
"Set channels.email.consentGranted=true after explicit user permission."
|
||||||
)
|
)
|
||||||
@@ -138,12 +139,12 @@ class EmailChannel(BaseChannel):
|
|||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
if not self.config.verify_dkim and not self.config.verify_spf:
|
if not self.config.verify_dkim and not self.config.verify_spf:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Email channel: DKIM and SPF verification are both DISABLED. "
|
"DKIM and SPF verification are both DISABLED. "
|
||||||
"Emails with spoofed From headers will be accepted. "
|
"Emails with spoofed From headers will be accepted. "
|
||||||
"Set verify_dkim=true and verify_spf=true for anti-spoofing protection."
|
"Set verify_dkim=true and verify_spf=true for anti-spoofing protection."
|
||||||
)
|
)
|
||||||
logger.info("Starting Email channel (IMAP polling mode)...")
|
self.logger.info("Starting Email channel (IMAP polling mode)...")
|
||||||
|
|
||||||
poll_seconds = max(5, int(self.config.poll_interval_seconds))
|
poll_seconds = max(5, int(self.config.poll_interval_seconds))
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -166,8 +167,8 @@ class EmailChannel(BaseChannel):
|
|||||||
media=item.get("media") or None,
|
media=item.get("media") or None,
|
||||||
metadata=item.get("metadata", {}),
|
metadata=item.get("metadata", {}),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Email polling error: {}", e)
|
self.logger.exception("Polling error")
|
||||||
|
|
||||||
await asyncio.sleep(poll_seconds)
|
await asyncio.sleep(poll_seconds)
|
||||||
|
|
||||||
@@ -178,16 +179,16 @@ class EmailChannel(BaseChannel):
|
|||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send email via SMTP."""
|
"""Send email via SMTP."""
|
||||||
if not self.config.consent_granted:
|
if not self.config.consent_granted:
|
||||||
logger.warning("Skip email send: consent_granted is false")
|
self.logger.warning("Skip email send: consent_granted is false")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.smtp_host:
|
if not self.config.smtp_host:
|
||||||
logger.warning("Email channel SMTP host not configured")
|
self.logger.warning("SMTP host not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
to_addr = msg.chat_id.strip()
|
to_addr = msg.chat_id.strip()
|
||||||
if not to_addr:
|
if not to_addr:
|
||||||
logger.warning("Email channel missing recipient address")
|
self.logger.warning("Missing recipient address")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine if this is a reply (recipient has sent us an email before)
|
# Determine if this is a reply (recipient has sent us an email before)
|
||||||
@@ -196,7 +197,7 @@ class EmailChannel(BaseChannel):
|
|||||||
|
|
||||||
# autoReplyEnabled only controls automatic replies, not proactive sends
|
# autoReplyEnabled only controls automatic replies, not proactive sends
|
||||||
if is_reply and not self.config.auto_reply_enabled and not force_send:
|
if is_reply and not self.config.auto_reply_enabled and not force_send:
|
||||||
logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr)
|
self.logger.info("Skip automatic reply to {}: auto_reply_enabled is false", to_addr)
|
||||||
return
|
return
|
||||||
|
|
||||||
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
|
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
|
||||||
@@ -219,8 +220,8 @@ class EmailChannel(BaseChannel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self._smtp_send, email_msg)
|
await asyncio.to_thread(self._smtp_send, email_msg)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending email to {}: {}", to_addr, e)
|
self.logger.exception("Error sending to {}", to_addr)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _validate_config(self) -> bool:
|
def _validate_config(self) -> bool:
|
||||||
@@ -239,7 +240,7 @@ class EmailChannel(BaseChannel):
|
|||||||
missing.append("smtp_password")
|
missing.append("smtp_password")
|
||||||
|
|
||||||
if missing:
|
if missing:
|
||||||
logger.error("Email channel not configured, missing: {}", ', '.join(missing))
|
self.logger.error("Channel not configured, missing: {}", ', '.join(missing))
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -320,7 +321,7 @@ class EmailChannel(BaseChannel):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if attempt == 1 or not self._is_stale_imap_error(exc):
|
if attempt == 1 or not self._is_stale_imap_error(exc):
|
||||||
raise
|
raise
|
||||||
logger.warning("Email IMAP connection went stale, retrying once: {}", exc)
|
self.logger.warning("IMAP connection went stale, retrying once: {}", exc)
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
@@ -347,11 +348,11 @@ class EmailChannel(BaseChannel):
|
|||||||
status, _ = client.select(mailbox)
|
status, _ = client.select(mailbox)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if self._is_missing_mailbox_error(exc):
|
if self._is_missing_mailbox_error(exc):
|
||||||
logger.warning("Email mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
|
self.logger.warning("Mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
|
||||||
return messages
|
return messages
|
||||||
raise
|
raise
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
logger.warning("Email mailbox select returned {}, skipping poll for {}", status, mailbox)
|
self.logger.warning("Mailbox select returned {}, skipping poll for {}", status, mailbox)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
status, data = client.search(None, *search_criteria)
|
status, data = client.search(None, *search_criteria)
|
||||||
@@ -381,7 +382,7 @@ class EmailChannel(BaseChannel):
|
|||||||
if not sender:
|
if not sender:
|
||||||
continue
|
continue
|
||||||
if self._is_self_address(sender):
|
if self._is_self_address(sender):
|
||||||
logger.info("Email from {} ignored: matches bot-owned address", sender)
|
self.logger.info("From {} ignored: matches bot-owned address", sender)
|
||||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||||
if mark_seen:
|
if mark_seen:
|
||||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||||
@@ -390,22 +391,28 @@ class EmailChannel(BaseChannel):
|
|||||||
# --- Anti-spoofing: verify Authentication-Results ---
|
# --- Anti-spoofing: verify Authentication-Results ---
|
||||||
spf_pass, dkim_pass = self._check_authentication_results(parsed)
|
spf_pass, dkim_pass = self._check_authentication_results(parsed)
|
||||||
if self.config.verify_spf and not spf_pass:
|
if self.config.verify_spf and not spf_pass:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Email from {} rejected: SPF verification failed "
|
"From {} rejected: SPF verification failed "
|
||||||
"(no 'spf=pass' in Authentication-Results header)",
|
"(no 'spf=pass' in Authentication-Results header)",
|
||||||
sender,
|
sender,
|
||||||
)
|
)
|
||||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||||
continue
|
continue
|
||||||
if self.config.verify_dkim and not dkim_pass:
|
if self.config.verify_dkim and not dkim_pass:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Email from {} rejected: DKIM verification failed "
|
"From {} rejected: DKIM verification failed "
|
||||||
"(no 'dkim=pass' in Authentication-Results header)",
|
"(no 'dkim=pass' in Authentication-Results header)",
|
||||||
sender,
|
sender,
|
||||||
)
|
)
|
||||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not self.is_allowed(sender):
|
||||||
|
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||||
|
if mark_seen:
|
||||||
|
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||||
|
continue
|
||||||
|
|
||||||
subject = self._decode_header_value(parsed.get("Subject", ""))
|
subject = self._decode_header_value(parsed.get("Subject", ""))
|
||||||
date_value = parsed.get("Date", "")
|
date_value = parsed.get("Date", "")
|
||||||
message_id = parsed.get("Message-ID", "").strip()
|
message_id = parsed.get("Message-ID", "").strip()
|
||||||
@@ -460,10 +467,8 @@ class EmailChannel(BaseChannel):
|
|||||||
if mark_seen:
|
if mark_seen:
|
||||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with suppress(Exception):
|
||||||
client.logout()
|
client.logout()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _collect_self_addresses(self) -> set[str]:
|
def _collect_self_addresses(self) -> set[str]:
|
||||||
"""Return normalized email addresses owned by this channel instance."""
|
"""Return normalized email addresses owned by this channel instance."""
|
||||||
@@ -636,7 +641,7 @@ class EmailChannel(BaseChannel):
|
|||||||
|
|
||||||
content_type = part.get_content_type()
|
content_type = part.get_content_type()
|
||||||
if not any(fnmatch(content_type, pat) for pat in allowed_types):
|
if not any(fnmatch(content_type, pat) for pat in allowed_types):
|
||||||
logger.debug("Email attachment skipped (type {}): not in allowed list", content_type)
|
logger.debug("Attachment skipped (type {}): not in allowed list", content_type)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
payload = part.get_payload(decode=True)
|
payload = part.get_payload(decode=True)
|
||||||
@@ -644,7 +649,7 @@ class EmailChannel(BaseChannel):
|
|||||||
continue
|
continue
|
||||||
if len(payload) > max_size:
|
if len(payload) > max_size:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Email attachment skipped: size {} exceeds limit {}",
|
"Attachment skipped: size {} exceeds limit {}",
|
||||||
len(payload),
|
len(payload),
|
||||||
max_size,
|
max_size,
|
||||||
)
|
)
|
||||||
@@ -657,9 +662,9 @@ class EmailChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
dest.write_bytes(payload)
|
dest.write_bytes(payload)
|
||||||
saved.append(dest)
|
saved.append(dest)
|
||||||
logger.info("Email attachment saved: {}", dest)
|
logger.info("Attachment saved: {}", dest)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to save email attachment {}: {}", dest, exc)
|
logger.warning("Failed to save attachment {}: {}", dest, exc)
|
||||||
|
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
|||||||
+131
-110
@@ -9,12 +9,12 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
|
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
|
||||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||||
from loguru import logger
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -22,6 +22,7 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||||
|
|
||||||
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||||
|
|
||||||
@@ -319,15 +320,17 @@ class FeishuChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Feishu bot with WebSocket long connection."""
|
"""Start the Feishu bot with WebSocket long connection."""
|
||||||
if not FEISHU_AVAILABLE:
|
if not FEISHU_AVAILABLE:
|
||||||
logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
|
self.logger.error("SDK not installed. Run: pip install lark-oapi")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.app_id or not self.config.app_secret:
|
if not self.config.app_id or not self.config.app_secret:
|
||||||
logger.error("Feishu app_id and app_secret not configured")
|
self.logger.error("app_id and app_secret not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
import lark_oapi as lark
|
import lark_oapi as lark
|
||||||
|
|
||||||
|
redirect_lib_logging("Lark")
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
@@ -389,7 +392,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
self._ws_client.start()
|
self._ws_client.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Feishu WebSocket error: {}", e)
|
self.logger.warning("WebSocket error: {}", e)
|
||||||
if self._running:
|
if self._running:
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
finally:
|
finally:
|
||||||
@@ -403,12 +406,12 @@ class FeishuChannel(BaseChannel):
|
|||||||
None, self._fetch_bot_open_id
|
None, self._fetch_bot_open_id
|
||||||
)
|
)
|
||||||
if self._bot_open_id:
|
if self._bot_open_id:
|
||||||
logger.info("Feishu bot open_id: {}", self._bot_open_id)
|
self.logger.info("bot open_id: {}", self._bot_open_id)
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate")
|
self.logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate")
|
||||||
|
|
||||||
logger.info("Feishu bot started with WebSocket long connection")
|
self.logger.info("bot started with WebSocket long connection")
|
||||||
logger.info("No public IP required - using WebSocket to receive events")
|
self.logger.info("No public IP required - using WebSocket to receive events")
|
||||||
|
|
||||||
# Keep running until stopped
|
# Keep running until stopped
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -423,7 +426,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
|
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
|
||||||
"""
|
"""
|
||||||
self._running = False
|
self._running = False
|
||||||
logger.info("Feishu bot stopped")
|
self.logger.info("bot stopped")
|
||||||
|
|
||||||
def _fetch_bot_open_id(self) -> str | None:
|
def _fetch_bot_open_id(self) -> str | None:
|
||||||
"""Fetch the bot's own open_id via GET /open-apis/bot/v3/info."""
|
"""Fetch the bot's own open_id via GET /open-apis/bot/v3/info."""
|
||||||
@@ -444,10 +447,10 @@ class FeishuChannel(BaseChannel):
|
|||||||
data = json.loads(response.raw.content)
|
data = json.loads(response.raw.content)
|
||||||
bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
|
bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
|
||||||
return bot.get("open_id")
|
return bot.get("open_id")
|
||||||
logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
|
self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error fetching bot info: {}", e)
|
self.logger.warning("Error fetching bot info: {}", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -538,15 +541,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
response = self._client.im.v1.message_reaction.create(request)
|
response = self._client.im.v1.message_reaction.create(request)
|
||||||
|
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Failed to add reaction: code={}, msg={}", response.code, response.msg
|
"Failed to add reaction: code={}, msg={}", response.code, response.msg
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
logger.debug("Added {} reaction to message {}", emoji_type, message_id)
|
self.logger.debug("Added {} reaction to message {}", emoji_type, message_id)
|
||||||
return response.data.reaction_id if response.data else None
|
return response.data.reaction_id if response.data else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error adding reaction: {}", e)
|
self.logger.warning("Error adding reaction: {}", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
|
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
|
||||||
@@ -578,13 +581,13 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
response = self._client.im.v1.message_reaction.delete(request)
|
response = self._client.im.v1.message_reaction.delete(request)
|
||||||
if response.success():
|
if response.success():
|
||||||
logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
|
self.logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
self.logger.debug(
|
||||||
"Failed to remove reaction: code={}, msg={}", response.code, response.msg
|
"Failed to remove reaction: code={}, msg={}", response.code, response.msg
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Error removing reaction: {}", e)
|
self.logger.debug("Error removing reaction: {}", e)
|
||||||
|
|
||||||
async def _remove_reaction(self, message_id: str, reaction_id: str) -> None:
|
async def _remove_reaction(self, message_id: str, reaction_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -606,18 +609,17 @@ class FeishuChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
task.result()
|
task.result()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Background task failed: {}", exc)
|
self.logger.warning("Background task failed: {}", exc)
|
||||||
|
|
||||||
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
|
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
|
||||||
"""Callback: store reaction_id after background add-reaction completes."""
|
"""Callback: store reaction_id after background add-reaction completes."""
|
||||||
if task.cancelled():
|
if task.cancelled():
|
||||||
return
|
return
|
||||||
try:
|
# Failures already logged by _on_background_task_done.
|
||||||
|
with suppress(Exception):
|
||||||
reaction_id = task.result()
|
reaction_id = task.result()
|
||||||
if reaction_id:
|
if reaction_id:
|
||||||
self._reaction_ids[message_id] = reaction_id
|
self._reaction_ids[message_id] = reaction_id
|
||||||
except Exception:
|
|
||||||
pass # already logged by _on_background_task_done
|
|
||||||
# Trim cache to prevent unbounded growth
|
# Trim cache to prevent unbounded growth
|
||||||
if len(self._reaction_ids) > 500:
|
if len(self._reaction_ids) > 500:
|
||||||
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
||||||
@@ -917,15 +919,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
response = self._client.im.v1.image.create(request)
|
response = self._client.im.v1.image.create(request)
|
||||||
if response.success():
|
if response.success():
|
||||||
image_key = response.data.image_key
|
image_key = response.data.image_key
|
||||||
logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
|
self.logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
|
||||||
return image_key
|
return image_key
|
||||||
else:
|
else:
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"Failed to upload image: code={}, msg={}", response.code, response.msg
|
"Failed to upload image: code={}, msg={}", response.code, response.msg
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error uploading image {}: {}", file_path, e)
|
self.logger.exception("Error uploading image {}", file_path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _upload_file_sync(self, file_path: str) -> str | None:
|
def _upload_file_sync(self, file_path: str) -> str | None:
|
||||||
@@ -951,15 +953,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
response = self._client.im.v1.file.create(request)
|
response = self._client.im.v1.file.create(request)
|
||||||
if response.success():
|
if response.success():
|
||||||
file_key = response.data.file_key
|
file_key = response.data.file_key
|
||||||
logger.debug("Uploaded file {}: {}", file_name, file_key)
|
self.logger.debug("Uploaded file {}: {}", file_name, file_key)
|
||||||
return file_key
|
return file_key
|
||||||
else:
|
else:
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"Failed to upload file: code={}, msg={}", response.code, response.msg
|
"Failed to upload file: code={}, msg={}", response.code, response.msg
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error uploading file {}: {}", file_path, e)
|
self.logger.exception("Error uploading file {}", file_path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _download_image_sync(
|
def _download_image_sync(
|
||||||
@@ -984,12 +986,12 @@ class FeishuChannel(BaseChannel):
|
|||||||
file_data = file_data.read()
|
file_data = file_data.read()
|
||||||
return file_data, response.file_name
|
return file_data, response.file_name
|
||||||
else:
|
else:
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"Failed to download image: code={}, msg={}", response.code, response.msg
|
"Failed to download image: code={}, msg={}", response.code, response.msg
|
||||||
)
|
)
|
||||||
return None, None
|
return None, None
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error downloading image {}: {}", image_key, e)
|
self.logger.exception("Error downloading image {}", image_key)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def _download_file_sync(
|
def _download_file_sync(
|
||||||
@@ -1018,7 +1020,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
file_data = file_data.read()
|
file_data = file_data.read()
|
||||||
return file_data, response.file_name
|
return file_data, response.file_name
|
||||||
else:
|
else:
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"Failed to download {}: code={}, msg={}",
|
"Failed to download {}: code={}, msg={}",
|
||||||
resource_type,
|
resource_type,
|
||||||
response.code,
|
response.code,
|
||||||
@@ -1026,7 +1028,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return None, None
|
return None, None
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error downloading {} {}", resource_type, file_key)
|
self.logger.exception("Error downloading {} {}", resource_type, file_key)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def _download_and_save_media(
|
async def _download_and_save_media(
|
||||||
@@ -1055,10 +1057,10 @@ class FeishuChannel(BaseChannel):
|
|||||||
elif msg_type in ("audio", "file", "media"):
|
elif msg_type in ("audio", "file", "media"):
|
||||||
file_key = content_json.get("file_key")
|
file_key = content_json.get("file_key")
|
||||||
if not file_key:
|
if not file_key:
|
||||||
logger.warning("Feishu {} message missing file_key: {}", msg_type, content_json)
|
self.logger.warning("{} message missing file_key: {}", msg_type, content_json)
|
||||||
return None, f"[{msg_type}: missing file_key]"
|
return None, f"[{msg_type}: missing file_key]"
|
||||||
if not message_id:
|
if not message_id:
|
||||||
logger.warning("Feishu {} message missing message_id", msg_type)
|
self.logger.warning("{} message missing message_id", msg_type)
|
||||||
return None, f"[{msg_type}: missing message_id]"
|
return None, f"[{msg_type}: missing message_id]"
|
||||||
|
|
||||||
data, filename = await loop.run_in_executor(
|
data, filename = await loop.run_in_executor(
|
||||||
@@ -1066,7 +1068,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
logger.warning("Feishu {} download failed: file_key={}", msg_type, file_key)
|
self.logger.warning("{} download failed: file_key={}", msg_type, file_key)
|
||||||
return None, f"[{msg_type}: download failed]"
|
return None, f"[{msg_type}: download failed]"
|
||||||
|
|
||||||
if not filename:
|
if not filename:
|
||||||
@@ -1081,8 +1083,9 @@ class FeishuChannel(BaseChannel):
|
|||||||
if data and filename:
|
if data and filename:
|
||||||
file_path = media_dir / filename
|
file_path = media_dir / filename
|
||||||
file_path.write_bytes(data)
|
file_path.write_bytes(data)
|
||||||
logger.debug("Downloaded {} to {}", msg_type, file_path)
|
path_str = str(file_path)
|
||||||
return str(file_path), f"[{msg_type}: {filename}]"
|
self.logger.debug("Downloaded {} to {}", msg_type, path_str)
|
||||||
|
return path_str, f"[{msg_type}: {path_str}]"
|
||||||
|
|
||||||
return None, f"[{msg_type}: download failed]"
|
return None, f"[{msg_type}: download failed]"
|
||||||
|
|
||||||
@@ -1099,8 +1102,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
request = GetMessageRequest.builder().message_id(message_id).build()
|
request = GetMessageRequest.builder().message_id(message_id).build()
|
||||||
response = self._client.im.v1.message.get(request)
|
response = self._client.im.v1.message.get(request)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.debug(
|
self.logger.debug(
|
||||||
"Feishu: could not fetch parent message {}: code={}, msg={}",
|
"could not fetch parent message {}: code={}, msg={}",
|
||||||
message_id,
|
message_id,
|
||||||
response.code,
|
response.code,
|
||||||
response.msg,
|
response.msg,
|
||||||
@@ -1132,7 +1135,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
|
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
|
||||||
return f"[Reply to: {text}]"
|
return f"[Reply to: {text}]"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
|
self.logger.debug("error fetching parent message {}: {}", message_id, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
|
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
|
||||||
@@ -1156,20 +1159,35 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
response = self._client.im.v1.message.reply(request)
|
response = self._client.im.v1.message.reply(request)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"Failed to reply to Feishu message {}: code={}, msg={}, log_id={}",
|
"Failed to reply to message {}: code={}, msg={}, log_id={}",
|
||||||
parent_message_id,
|
parent_message_id,
|
||||||
response.code,
|
response.code,
|
||||||
response.msg,
|
response.msg,
|
||||||
response.get_log_id(),
|
response.get_log_id(),
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
logger.debug("Feishu reply sent to message {}", parent_message_id)
|
self.logger.debug("reply sent to message {}", parent_message_id)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error replying to Feishu message {}: {}", parent_message_id, e)
|
self.logger.exception("Error replying to message {}", parent_message_id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether a group reply should create a Feishu thread/topic."""
|
||||||
|
return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message
|
||||||
|
|
||||||
|
def _thread_reply_target(self, metadata: dict[str, Any]) -> str | None:
|
||||||
|
"""Return the message_id that should receive a Reply API response."""
|
||||||
|
if metadata.get("chat_type", "group") != "group":
|
||||||
|
return None
|
||||||
|
message_id = metadata.get("message_id")
|
||||||
|
if not message_id:
|
||||||
|
return None
|
||||||
|
if metadata.get("thread_id") or self.config.reply_to_message:
|
||||||
|
return message_id
|
||||||
|
return None
|
||||||
|
|
||||||
def _send_message_sync(
|
def _send_message_sync(
|
||||||
self, receive_id_type: str, receive_id: str, msg_type: str, content: str
|
self, receive_id_type: str, receive_id: str, msg_type: str, content: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -1191,8 +1209,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
response = self._client.im.v1.message.create(request)
|
response = self._client.im.v1.message.create(request)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"Failed to send Feishu {} message: code={}, msg={}, log_id={}",
|
"Failed to send {} message: code={}, msg={}, log_id={}",
|
||||||
msg_type,
|
msg_type,
|
||||||
response.code,
|
response.code,
|
||||||
response.msg,
|
response.msg,
|
||||||
@@ -1200,10 +1218,10 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
msg_id = getattr(response.data, "message_id", None)
|
msg_id = getattr(response.data, "message_id", None)
|
||||||
logger.debug("Feishu {} message sent to {}: {}", msg_type, receive_id, msg_id)
|
self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id)
|
||||||
return msg_id
|
return msg_id
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending Feishu {} message: {}", msg_type, e)
|
self.logger.exception("Error sending {} message", msg_type)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _create_streaming_card_sync(
|
def _create_streaming_card_sync(
|
||||||
@@ -1211,13 +1229,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
receive_id_type: str,
|
receive_id_type: str,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
reply_message_id: str | None = None,
|
reply_message_id: str | None = None,
|
||||||
|
*,
|
||||||
|
reply_in_thread: bool = False,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Create a CardKit streaming card, send it to chat, return card_id.
|
"""Create a CardKit streaming card, send it to chat, return card_id.
|
||||||
|
|
||||||
When *reply_message_id* is provided the card is delivered via the
|
When *reply_message_id* is provided the card is delivered via the
|
||||||
reply API (with reply_in_thread=True) so it lands inside the
|
reply API. *reply_in_thread* controls whether Feishu creates a
|
||||||
originating thread / topic. Otherwise the plain create-message
|
thread/topic for that reply. Otherwise the plain create-message API is
|
||||||
API is used.
|
used.
|
||||||
"""
|
"""
|
||||||
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
||||||
|
|
||||||
@@ -1241,7 +1261,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
response = self._client.cardkit.v1.card.create(request)
|
response = self._client.cardkit.v1.card.create(request)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Failed to create streaming card: code={}, msg={}", response.code, response.msg
|
"Failed to create streaming card: code={}, msg={}", response.code, response.msg
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -1253,7 +1273,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
if reply_message_id:
|
if reply_message_id:
|
||||||
sent = self._reply_message_sync(
|
sent = self._reply_message_sync(
|
||||||
reply_message_id, "interactive", card_content,
|
reply_message_id, "interactive", card_content,
|
||||||
reply_in_thread=True,
|
reply_in_thread=reply_in_thread,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sent = self._send_message_sync(
|
sent = self._send_message_sync(
|
||||||
@@ -1261,12 +1281,12 @@ class FeishuChannel(BaseChannel):
|
|||||||
) is not None
|
) is not None
|
||||||
if sent:
|
if sent:
|
||||||
return card_id
|
return card_id
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Created streaming card {} but failed to send it to {}", card_id, chat_id
|
"Created streaming card {} but failed to send it to {}", card_id, chat_id
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error creating streaming card: {}", e)
|
self.logger.warning("Error creating streaming card: {}", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool:
|
def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool:
|
||||||
@@ -1291,7 +1311,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
response = self._client.cardkit.v1.card_element.content(request)
|
response = self._client.cardkit.v1.card_element.content(request)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Failed to stream-update card {}: code={}, msg={}",
|
"Failed to stream-update card {}: code={}, msg={}",
|
||||||
card_id,
|
card_id,
|
||||||
response.code,
|
response.code,
|
||||||
@@ -1300,7 +1320,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error stream-updating card {}: {}", card_id, e)
|
self.logger.warning("Error stream-updating card {}: {}", card_id, e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
|
def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
|
||||||
@@ -1328,7 +1348,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
response = self._client.cardkit.v1.card.settings(request)
|
response = self._client.cardkit.v1.card.settings(request)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Failed to close streaming on card {}: code={}, msg={}",
|
"Failed to close streaming on card {}: code={}, msg={}",
|
||||||
card_id,
|
card_id,
|
||||||
response.code,
|
response.code,
|
||||||
@@ -1337,7 +1357,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error closing streaming on card {}: {}", card_id, e)
|
self.logger.warning("Error closing streaming on card {}: {}", card_id, e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(
|
||||||
@@ -1398,7 +1418,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
buf.sequence,
|
buf.sequence,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Streaming card {} final update failed, falling back to regular card",
|
"Streaming card {} final update failed, falling back to regular card",
|
||||||
buf.card_id,
|
buf.card_id,
|
||||||
)
|
)
|
||||||
@@ -1409,16 +1429,14 @@ class FeishuChannel(BaseChannel):
|
|||||||
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
# Fallback: reply via the Reply API for group chats.
|
# Fallback replies stay in existing topics, but only create a
|
||||||
# Target message_id — the Feishu API keeps the reply in
|
# new topic when reply-to-message is enabled.
|
||||||
# the same topic automatically.
|
fallback_msg_id = self._thread_reply_target(meta)
|
||||||
_f_msg = meta.get("message_id")
|
|
||||||
fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None
|
|
||||||
if fallback_msg_id:
|
if fallback_msg_id:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, lambda: self._reply_message_sync(
|
None, lambda: self._reply_message_sync(
|
||||||
fallback_msg_id, "interactive", card,
|
fallback_msg_id, "interactive", card,
|
||||||
reply_in_thread=True,
|
reply_in_thread=self._should_use_reply_in_thread(meta),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1438,16 +1456,18 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if buf.card_id is None:
|
if buf.card_id is None:
|
||||||
# Send the streaming card as a reply for group chats so it
|
# Use the Reply API for existing topics, and only create new topics
|
||||||
# lands inside the originating topic/thread. Always target
|
# when reply-to-message is enabled.
|
||||||
# message_id (the actual inbound message) — the Feishu Reply
|
use_reply_in_thread = self._should_use_reply_in_thread(meta)
|
||||||
# API keeps the response in the same topic automatically.
|
reply_msg_id = self._thread_reply_target(meta)
|
||||||
is_group = meta.get("chat_type", "group") == "group"
|
|
||||||
reply_msg_id = meta.get("message_id") if is_group else None
|
|
||||||
card_id = await loop.run_in_executor(
|
card_id = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
self._create_streaming_card_sync,
|
lambda: self._create_streaming_card_sync(
|
||||||
rid_type, chat_id, reply_msg_id,
|
rid_type,
|
||||||
|
chat_id,
|
||||||
|
reply_msg_id,
|
||||||
|
reply_in_thread=use_reply_in_thread,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if card_id:
|
if card_id:
|
||||||
buf.card_id = card_id
|
buf.card_id = card_id
|
||||||
@@ -1466,7 +1486,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through Feishu, including media (images/files) if present."""
|
"""Send a message through Feishu, including media (images/files) if present."""
|
||||||
if not self._client:
|
if not self._client:
|
||||||
logger.warning("Feishu client not initialized")
|
self.logger.warning("client not initialized")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1489,22 +1509,21 @@ class FeishuChannel(BaseChannel):
|
|||||||
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# No active streaming card — send as a regular
|
# No active streaming card — send as a regular interactive card
|
||||||
# interactive card with the same 🔧 prefix style.
|
# with the same 🔧 prefix style. Existing topics stay threaded;
|
||||||
# Use reply API for group chats so the hint stays in topic.
|
# new topics are created only when reply-to-message is enabled.
|
||||||
card = json.dumps(
|
card = json.dumps(
|
||||||
{"config": {"wide_screen_mode": True}, "elements": [
|
{"config": {"wide_screen_mode": True}, "elements": [
|
||||||
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
||||||
]},
|
]},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
_th_msg_id = msg.metadata.get("message_id")
|
_th_msg_id = self._thread_reply_target(msg.metadata)
|
||||||
_th_chat_type = msg.metadata.get("chat_type", "group")
|
if _th_msg_id:
|
||||||
if _th_msg_id and _th_chat_type == "group":
|
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, lambda: self._reply_message_sync(
|
None, lambda: self._reply_message_sync(
|
||||||
_th_msg_id, "interactive", card,
|
_th_msg_id, "interactive", card,
|
||||||
reply_in_thread=True,
|
reply_in_thread=self._should_use_reply_in_thread(msg.metadata),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1531,18 +1550,16 @@ class FeishuChannel(BaseChannel):
|
|||||||
def _do_send(m_type: str, content: str) -> None:
|
def _do_send(m_type: str, content: str) -> None:
|
||||||
"""Send via reply (first message) or create (subsequent).
|
"""Send via reply (first message) or create (subsequent).
|
||||||
|
|
||||||
For group chats the reply API always uses reply_in_thread=True.
|
Group chats only set reply_in_thread=True when
|
||||||
The Feishu API automatically keeps replies inside existing
|
reply_to_message is enabled; otherwise a Reply API call for an
|
||||||
topics — reply_in_thread only creates a *new* topic when the
|
existing topic must not create a new topic.
|
||||||
target message is a plain (non-topic) message.
|
|
||||||
"""
|
"""
|
||||||
nonlocal first_send
|
nonlocal first_send
|
||||||
if reply_message_id and first_send:
|
if reply_message_id and first_send:
|
||||||
first_send = False
|
first_send = False
|
||||||
chat_type = msg.metadata.get("chat_type", "group")
|
|
||||||
ok = self._reply_message_sync(
|
ok = self._reply_message_sync(
|
||||||
reply_message_id, m_type, content,
|
reply_message_id, m_type, content,
|
||||||
reply_in_thread=chat_type == "group",
|
reply_in_thread=self._should_use_reply_in_thread(msg.metadata),
|
||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
return
|
return
|
||||||
@@ -1551,7 +1568,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
for file_path in msg.media:
|
for file_path in msg.media:
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
logger.warning("Media file not found: {}", file_path)
|
self.logger.warning("Media file not found: {}", file_path)
|
||||||
continue
|
continue
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
if ext in self._IMAGE_EXTS:
|
if ext in self._IMAGE_EXTS:
|
||||||
@@ -1607,8 +1624,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
json.dumps(card, ensure_ascii=False),
|
json.dumps(card, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending Feishu message: {}", e)
|
self.logger.exception("Error sending message")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _on_message_sync(self, data: Any) -> None:
|
def _on_message_sync(self, data: Any) -> None:
|
||||||
@@ -1626,18 +1643,10 @@ class FeishuChannel(BaseChannel):
|
|||||||
message = event.message
|
message = event.message
|
||||||
sender = event.sender
|
sender = event.sender
|
||||||
|
|
||||||
logger.debug("Feishu raw message: {}", message.content)
|
self.logger.debug("raw message: {}", message.content)
|
||||||
logger.debug("Feishu mentions: {}", getattr(message, "mentions", None))
|
self.logger.debug("mentions: {}", getattr(message, "mentions", None))
|
||||||
|
|
||||||
# Deduplication check
|
|
||||||
message_id = message.message_id
|
message_id = message.message_id
|
||||||
if message_id in self._processed_message_ids:
|
|
||||||
return
|
|
||||||
self._processed_message_ids[message_id] = None
|
|
||||||
|
|
||||||
# Trim cache
|
|
||||||
while len(self._processed_message_ids) > 1000:
|
|
||||||
self._processed_message_ids.popitem(last=False)
|
|
||||||
|
|
||||||
# Skip bot messages
|
# Skip bot messages
|
||||||
if sender.sender_type == "bot":
|
if sender.sender_type == "bot":
|
||||||
@@ -1648,10 +1657,22 @@ class FeishuChannel(BaseChannel):
|
|||||||
chat_type = message.chat_type
|
chat_type = message.chat_type
|
||||||
msg_type = message.message_type
|
msg_type = message.message_type
|
||||||
|
|
||||||
if chat_type == "group" and not self._is_group_message_for_bot(message):
|
if not self.is_allowed(sender_id):
|
||||||
logger.debug("Feishu: skipping group message (not mentioned)")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if chat_type == "group" and not self._is_group_message_for_bot(message):
|
||||||
|
self.logger.debug("skipping group message (not mentioned)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Deduplication check
|
||||||
|
if message_id in self._processed_message_ids:
|
||||||
|
return
|
||||||
|
self._processed_message_ids[message_id] = None
|
||||||
|
|
||||||
|
# Trim cache
|
||||||
|
while len(self._processed_message_ids) > 1000:
|
||||||
|
self._processed_message_ids.popitem(last=False)
|
||||||
|
|
||||||
# Add reaction (non-blocking — tracked background task)
|
# Add reaction (non-blocking — tracked background task)
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
self._add_reaction(message_id, self.config.react_emoji)
|
self._add_reaction(message_id, self.config.react_emoji)
|
||||||
@@ -1765,8 +1786,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error processing Feishu message: {}", e)
|
self.logger.exception("Error processing message")
|
||||||
|
|
||||||
def _on_reaction_created(self, data: Any) -> None:
|
def _on_reaction_created(self, data: Any) -> None:
|
||||||
"""Ignore reaction events so they do not generate SDK noise."""
|
"""Ignore reaction events so they do not generate SDK noise."""
|
||||||
@@ -1782,7 +1803,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
def _on_bot_p2p_chat_entered(self, data: Any) -> None:
|
def _on_bot_p2p_chat_entered(self, data: Any) -> None:
|
||||||
"""Ignore p2p-enter events when a user opens a bot chat."""
|
"""Ignore p2p-enter events when a user opens a bot chat."""
|
||||||
logger.debug("Bot entered p2p chat (user opened chat window)")
|
self.logger.debug("Bot entered p2p chat (user opened chat window)")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
+48
-11
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -36,7 +38,6 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = {
|
|||||||
"send_tool_hints": "sendToolHints",
|
"send_tool_hints": "sendToolHints",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ChannelManager:
|
class ChannelManager:
|
||||||
"""
|
"""
|
||||||
Manages chat channels and coordinates message routing.
|
Manages chat channels and coordinates message routing.
|
||||||
@@ -59,6 +60,7 @@ class ChannelManager:
|
|||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._dispatch_task: asyncio.Task | None = None
|
self._dispatch_task: asyncio.Task | None = None
|
||||||
|
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
||||||
|
|
||||||
self._init_channels()
|
self._init_channels()
|
||||||
|
|
||||||
@@ -172,8 +174,8 @@ class ChannelManager:
|
|||||||
"""Start a channel and log any exceptions."""
|
"""Start a channel and log any exceptions."""
|
||||||
try:
|
try:
|
||||||
await channel.start()
|
await channel.start()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Failed to start channel {}: {}", name, e)
|
logger.exception("Failed to start channel {}", name)
|
||||||
|
|
||||||
async def start_all(self) -> None:
|
async def start_all(self) -> None:
|
||||||
"""Start all channels and the outbound dispatcher."""
|
"""Start all channels and the outbound dispatcher."""
|
||||||
@@ -220,18 +222,43 @@ class ChannelManager:
|
|||||||
# Stop dispatcher
|
# Stop dispatcher
|
||||||
if self._dispatch_task:
|
if self._dispatch_task:
|
||||||
self._dispatch_task.cancel()
|
self._dispatch_task.cancel()
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await self._dispatch_task
|
await self._dispatch_task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Stop all channels
|
# Stop all channels
|
||||||
for name, channel in self.channels.items():
|
for name, channel in self.channels.items():
|
||||||
try:
|
try:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
logger.info("Stopped {} channel", name)
|
logger.info("Stopped {} channel", name)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error stopping {}: {}", name, e)
|
logger.exception("Error stopping {}", name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fingerprint_content(content: str) -> str:
|
||||||
|
normalized = " ".join(content.split())
|
||||||
|
return hashlib.sha1(normalized.encode("utf-8")).hexdigest() if normalized else ""
|
||||||
|
|
||||||
|
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
|
||||||
|
metadata = msg.metadata or {}
|
||||||
|
if metadata.get("_progress"):
|
||||||
|
return False
|
||||||
|
fingerprint = self._fingerprint_content(msg.content)
|
||||||
|
if not fingerprint:
|
||||||
|
return False
|
||||||
|
|
||||||
|
origin_message_id = metadata.get("origin_message_id")
|
||||||
|
if isinstance(origin_message_id, str) and origin_message_id:
|
||||||
|
key = (msg.channel, msg.chat_id, origin_message_id)
|
||||||
|
if self._origin_reply_fingerprints.get(key) == fingerprint:
|
||||||
|
return True
|
||||||
|
self._origin_reply_fingerprints[key] = fingerprint
|
||||||
|
|
||||||
|
message_id = metadata.get("message_id")
|
||||||
|
if isinstance(message_id, str) and message_id:
|
||||||
|
key = (msg.channel, msg.chat_id, message_id)
|
||||||
|
self._origin_reply_fingerprints[key] = fingerprint
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def _dispatch_outbound(self) -> None:
|
async def _dispatch_outbound(self) -> None:
|
||||||
"""Dispatch outbound messages to the appropriate channel."""
|
"""Dispatch outbound messages to the appropriate channel."""
|
||||||
@@ -273,6 +300,16 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel = self.channels.get(msg.channel)
|
channel = self.channels.get(msg.channel)
|
||||||
if channel:
|
if channel:
|
||||||
|
# Duplicate suppression is scoped to a known source message
|
||||||
|
# so repeated content from separate turns is still delivered.
|
||||||
|
if (
|
||||||
|
not msg.metadata.get("_stream_delta")
|
||||||
|
and not msg.metadata.get("_stream_end")
|
||||||
|
and not msg.metadata.get("_streamed")
|
||||||
|
):
|
||||||
|
if self._should_suppress_outbound(msg):
|
||||||
|
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
|
||||||
|
continue
|
||||||
await self._send_with_retry(channel, msg)
|
await self._send_with_retry(channel, msg)
|
||||||
else:
|
else:
|
||||||
logger.warning("Unknown channel: {}", msg.channel)
|
logger.warning("Unknown channel: {}", msg.channel)
|
||||||
@@ -355,9 +392,9 @@ class ChannelManager:
|
|||||||
raise # Propagate cancellation for graceful shutdown
|
raise # Propagate cancellation for graceful shutdown
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.error(
|
logger.exception(
|
||||||
"Failed to send to {} after {} attempts: {} - {}",
|
"Failed to send to {} after {} attempts",
|
||||||
msg.channel, max_attempts, type(e).__name__, e
|
msg.channel, max_attempts
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)]
|
delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)]
|
||||||
|
|||||||
+69
-63
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, TypeAlias
|
from typing import Any, Literal, TypeAlias
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -46,6 +45,7 @@ from nanobot.channels.base import BaseChannel
|
|||||||
from nanobot.config.paths import get_data_dir, get_media_dir
|
from nanobot.config.paths import get_data_dir, get_media_dir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.utils.helpers import safe_filename
|
from nanobot.utils.helpers import safe_filename
|
||||||
|
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||||
|
|
||||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||||
# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing.
|
# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing.
|
||||||
@@ -177,28 +177,6 @@ def _build_matrix_text_content(
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
class _NioLoguruHandler(logging.Handler):
|
|
||||||
"""Route matrix-nio stdlib logs into Loguru."""
|
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
|
||||||
try:
|
|
||||||
level = logger.level(record.levelname).name
|
|
||||||
except ValueError:
|
|
||||||
level = record.levelno
|
|
||||||
frame, depth = logging.currentframe(), 2
|
|
||||||
while frame and frame.f_code.co_filename == logging.__file__:
|
|
||||||
frame, depth = frame.f_back, depth + 1
|
|
||||||
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_nio_logging_bridge() -> None:
|
|
||||||
"""Bridge matrix-nio logs to Loguru (idempotent)."""
|
|
||||||
nio_logger = logging.getLogger("nio")
|
|
||||||
if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers):
|
|
||||||
nio_logger.handlers = [_NioLoguruHandler()]
|
|
||||||
nio_logger.propagate = False
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixConfig(Base):
|
class MatrixConfig(Base):
|
||||||
"""Matrix (Element) channel configuration."""
|
"""Matrix (Element) channel configuration."""
|
||||||
|
|
||||||
@@ -214,7 +192,7 @@ class MatrixConfig(Base):
|
|||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||||
group_allow_from: list[str] = Field(default_factory=list)
|
group_allow_from: list[str] = Field(default_factory=list)
|
||||||
allow_room_mentions: bool = False,
|
allow_room_mentions: bool = False
|
||||||
streaming: bool = False
|
streaming: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -251,12 +229,14 @@ class MatrixChannel(BaseChannel):
|
|||||||
self._server_upload_limit_bytes: int | None = None
|
self._server_upload_limit_bytes: int | None = None
|
||||||
self._server_upload_limit_checked = False
|
self._server_upload_limit_checked = False
|
||||||
self._stream_bufs: dict[str, _StreamBuf] = {}
|
self._stream_bufs: dict[str, _StreamBuf] = {}
|
||||||
|
self._started_at_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start Matrix client and begin sync loop."""
|
"""Start Matrix client and begin sync loop."""
|
||||||
self._running = True
|
self._running = True
|
||||||
_configure_nio_logging_bridge()
|
self._started_at_ms = int(time.time() * 1000)
|
||||||
|
redirect_lib_logging("nio", level="WARNING")
|
||||||
|
|
||||||
self.store_path = get_data_dir() / "matrix-store"
|
self.store_path = get_data_dir() / "matrix-store"
|
||||||
self.store_path.mkdir(parents=True, exist_ok=True)
|
self.store_path.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -280,15 +260,15 @@ class MatrixChannel(BaseChannel):
|
|||||||
self._register_response_callbacks()
|
self._register_response_callbacks()
|
||||||
|
|
||||||
if not self.config.e2ee_enabled:
|
if not self.config.e2ee_enabled:
|
||||||
logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.")
|
self.logger.warning("E2EE disabled; encrypted rooms may be undecryptable.")
|
||||||
|
|
||||||
if self.config.password:
|
if self.config.password:
|
||||||
if self.config.access_token or self.config.device_id:
|
if self.config.access_token or self.config.device_id:
|
||||||
logger.warning("Password-based Matrix login active; access_token and device_id fields will be ignored.")
|
self.logger.warning("Password-based login active; access_token and device_id fields will be ignored.")
|
||||||
|
|
||||||
create_new_session = True
|
create_new_session = True
|
||||||
if self.session_path.exists():
|
if self.session_path.exists():
|
||||||
logger.info("Found session.json at {}; attempting to use existing session...", self.session_path)
|
self.logger.info("Found session.json at {}; attempting to use existing session...", self.session_path)
|
||||||
try:
|
try:
|
||||||
with open(self.session_path, "r", encoding="utf-8") as f:
|
with open(self.session_path, "r", encoding="utf-8") as f:
|
||||||
session = json.load(f)
|
session = json.load(f)
|
||||||
@@ -296,20 +276,20 @@ class MatrixChannel(BaseChannel):
|
|||||||
self.client.access_token = session["access_token"]
|
self.client.access_token = session["access_token"]
|
||||||
self.client.device_id = session["device_id"]
|
self.client.device_id = session["device_id"]
|
||||||
self.client.load_store()
|
self.client.load_store()
|
||||||
logger.info("Successfully loaded from existing session")
|
self.logger.info("Successfully loaded from existing session")
|
||||||
create_new_session = False
|
create_new_session = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to load from existing session: {}", e)
|
self.logger.warning("Failed to load from existing session: {}", e)
|
||||||
logger.info("Falling back to password login...")
|
self.logger.info("Falling back to password login...")
|
||||||
|
|
||||||
if create_new_session:
|
if create_new_session:
|
||||||
logger.info("Using password login...")
|
self.logger.info("Using password login...")
|
||||||
resp = await self.client.login(self.config.password)
|
resp = await self.client.login(self.config.password)
|
||||||
if isinstance(resp, LoginResponse):
|
if isinstance(resp, LoginResponse):
|
||||||
logger.info("Logged in using a password; saving details to disk")
|
self.logger.info("Logged in using a password; saving details to disk")
|
||||||
self._write_session_to_disk(resp)
|
self._write_session_to_disk(resp)
|
||||||
else:
|
else:
|
||||||
logger.error("Failed to log in: {}", resp)
|
self.logger.error("Failed to log in: {}", resp)
|
||||||
return
|
return
|
||||||
|
|
||||||
elif self.config.access_token and self.config.device_id:
|
elif self.config.access_token and self.config.device_id:
|
||||||
@@ -318,12 +298,12 @@ class MatrixChannel(BaseChannel):
|
|||||||
self.client.access_token = self.config.access_token
|
self.client.access_token = self.config.access_token
|
||||||
self.client.device_id = self.config.device_id
|
self.client.device_id = self.config.device_id
|
||||||
self.client.load_store()
|
self.client.load_store()
|
||||||
logger.info("Successfully loaded from existing session")
|
self.logger.info("Successfully loaded from existing session")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to load from existing session: {}", e)
|
self.logger.warning("Failed to load from existing session: {}", e)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id; encryption may not work")
|
self.logger.warning("Unable to load a session due to missing password, access_token, or device_id; encryption may not work")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||||
@@ -341,10 +321,8 @@ class MatrixChannel(BaseChannel):
|
|||||||
timeout=self.config.sync_stop_grace_seconds)
|
timeout=self.config.sync_stop_grace_seconds)
|
||||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||||
self._sync_task.cancel()
|
self._sync_task.cancel()
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await self._sync_task
|
await self._sync_task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
if self.client:
|
if self.client:
|
||||||
await self.client.close()
|
await self.client.close()
|
||||||
|
|
||||||
@@ -357,9 +335,9 @@ class MatrixChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
with open(self.session_path, "w", encoding="utf-8") as f:
|
with open(self.session_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(session, f, indent=2)
|
json.dump(session, f, indent=2)
|
||||||
logger.info("Session saved to {}", self.session_path)
|
self.logger.info("Session saved to {}", self.session_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to save session: {}", e)
|
self.logger.warning("Failed to save session: {}", e)
|
||||||
|
|
||||||
def _is_workspace_path_allowed(self, path: Path) -> bool:
|
def _is_workspace_path_allowed(self, path: Path) -> bool:
|
||||||
"""Check path is inside workspace (when restriction enabled)."""
|
"""Check path is inside workspace (when restriction enabled)."""
|
||||||
@@ -523,7 +501,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
failures.append(fail)
|
failures.append(fail)
|
||||||
if failures:
|
if failures:
|
||||||
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures)
|
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures)
|
||||||
if text or not candidates:
|
if text.strip():
|
||||||
content = _build_matrix_text_content(text)
|
content = _build_matrix_text_content(text)
|
||||||
if relates_to:
|
if relates_to:
|
||||||
content["m.relates_to"] = relates_to
|
content["m.relates_to"] = relates_to
|
||||||
@@ -589,15 +567,26 @@ class MatrixChannel(BaseChannel):
|
|||||||
self.client.add_response_callback(self._on_join_error, JoinError)
|
self.client.add_response_callback(self._on_join_error, JoinError)
|
||||||
self.client.add_response_callback(self._on_send_error, RoomSendError)
|
self.client.add_response_callback(self._on_send_error, RoomSendError)
|
||||||
|
|
||||||
def _log_response_error(self, label: str, response: Any) -> None:
|
def _is_fatal_auth_response(self, response: Any) -> bool:
|
||||||
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
|
|
||||||
code = getattr(response, "status_code", None)
|
code = getattr(response, "status_code", None)
|
||||||
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
|
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
|
||||||
is_fatal = is_auth or getattr(response, "soft_logout", False)
|
return is_auth or bool(getattr(response, "soft_logout", False))
|
||||||
(logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response)
|
|
||||||
|
def _log_response_error(self, label: str, response: Any) -> None:
|
||||||
|
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
|
||||||
|
is_fatal = self._is_fatal_auth_response(response)
|
||||||
|
(self.logger.error if is_fatal else self.logger.warning)("{} failed: {}", label, response)
|
||||||
|
|
||||||
async def _on_sync_error(self, response: SyncError) -> None:
|
async def _on_sync_error(self, response: SyncError) -> None:
|
||||||
self._log_response_error("sync", response)
|
self._log_response_error("sync", response)
|
||||||
|
if self._is_fatal_auth_response(response):
|
||||||
|
# Auth errors won't recover by retry; stop the sync loop instead of
|
||||||
|
# spamming the homeserver every 2s (#1851).
|
||||||
|
self.logger.error("Authentication failed irrecoverably; stopping sync loop")
|
||||||
|
self._running = False
|
||||||
|
if self.client:
|
||||||
|
with suppress(Exception):
|
||||||
|
self.client.stop_sync_forever()
|
||||||
|
|
||||||
async def _on_join_error(self, response: JoinError) -> None:
|
async def _on_join_error(self, response: JoinError) -> None:
|
||||||
self._log_response_error("join", response)
|
self._log_response_error("join", response)
|
||||||
@@ -609,13 +598,11 @@ class MatrixChannel(BaseChannel):
|
|||||||
"""Best-effort typing indicator update."""
|
"""Best-effort typing indicator update."""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return
|
return
|
||||||
try:
|
with suppress(Exception):
|
||||||
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
|
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
|
||||||
timeout=TYPING_NOTICE_TIMEOUT_MS)
|
timeout=TYPING_NOTICE_TIMEOUT_MS)
|
||||||
if isinstance(response, RoomTypingError):
|
if isinstance(response, RoomTypingError):
|
||||||
logger.debug("Matrix typing failed for {}: {}", room_id, response)
|
self.logger.debug("typing failed for {}: {}", room_id, response)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _start_typing_keepalive(self, room_id: str) -> None:
|
async def _start_typing_keepalive(self, room_id: str) -> None:
|
||||||
"""Start periodic typing refresh (spec-recommended keepalive)."""
|
"""Start periodic typing refresh (spec-recommended keepalive)."""
|
||||||
@@ -625,33 +612,34 @@ class MatrixChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
async def loop() -> None:
|
async def loop() -> None:
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
while self._running:
|
while self._running:
|
||||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000)
|
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000)
|
||||||
await self._set_typing(room_id, True)
|
await self._set_typing(room_id, True)
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._typing_tasks[room_id] = asyncio.create_task(loop())
|
self._typing_tasks[room_id] = asyncio.create_task(loop())
|
||||||
|
|
||||||
async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None:
|
async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None:
|
||||||
if task := self._typing_tasks.pop(room_id, None):
|
if task := self._typing_tasks.pop(room_id, None):
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await task
|
await task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
if clear_typing:
|
if clear_typing:
|
||||||
await self._set_typing(room_id, False)
|
await self._set_typing(room_id, False)
|
||||||
|
|
||||||
async def _sync_loop(self) -> None:
|
async def _sync_loop(self) -> None:
|
||||||
|
backoff = 2.0
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
await self.client.sync_forever(timeout=30000, full_state=True)
|
||||||
|
backoff = 2.0
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
await asyncio.sleep(2)
|
if not self._running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
backoff = min(backoff * 2, 60.0)
|
||||||
|
|
||||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||||
if self.is_allowed(event.sender):
|
if self.is_allowed(event.sender):
|
||||||
@@ -674,6 +662,16 @@ class MatrixChannel(BaseChannel):
|
|||||||
return True
|
return True
|
||||||
return bool(self.config.allow_room_mentions and mentions.get("room") is True)
|
return bool(self.config.allow_room_mentions and mentions.get("room") is True)
|
||||||
|
|
||||||
|
def _is_pre_startup_event(self, event: RoomMessage) -> bool:
|
||||||
|
"""Skip events that landed in the timeline before this process started.
|
||||||
|
|
||||||
|
Matrix sync replays the room timeline on each startup/restart; without
|
||||||
|
this filter old messages would be re-handled as if they were fresh
|
||||||
|
(#3553).
|
||||||
|
"""
|
||||||
|
ts = getattr(event, "server_timestamp", None)
|
||||||
|
return isinstance(ts, int) and ts < self._started_at_ms
|
||||||
|
|
||||||
def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool:
|
def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool:
|
||||||
"""Apply sender and room policy checks."""
|
"""Apply sender and room policy checks."""
|
||||||
if not self.is_allowed(event.sender):
|
if not self.is_allowed(event.sender):
|
||||||
@@ -775,7 +773,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
response = await self.client.download(mxc=mxc_url)
|
response = await self.client.download(mxc=mxc_url)
|
||||||
if isinstance(response, DownloadError):
|
if isinstance(response, DownloadError):
|
||||||
logger.warning("Matrix download failed for {}: {}", mxc_url, response)
|
self.logger.warning("download failed for {}: {}", mxc_url, response)
|
||||||
return None
|
return None
|
||||||
body = getattr(response, "body", None)
|
body = getattr(response, "body", None)
|
||||||
if isinstance(body, (bytes, bytearray)):
|
if isinstance(body, (bytes, bytearray)):
|
||||||
@@ -800,7 +798,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
return decrypt_attachment(ciphertext, key, sha256, iv)
|
return decrypt_attachment(ciphertext, key, sha256, iv)
|
||||||
except (EncryptionError, ValueError, TypeError):
|
except (EncryptionError, ValueError, TypeError):
|
||||||
logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", ""))
|
self.logger.warning("decrypt failed for event {}", getattr(event, "event_id", ""))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _fetch_media_attachment(
|
async def _fetch_media_attachment(
|
||||||
@@ -858,7 +856,11 @@ class MatrixChannel(BaseChannel):
|
|||||||
return meta
|
return meta
|
||||||
|
|
||||||
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
||||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
if (
|
||||||
|
event.sender == self.config.user_id
|
||||||
|
or self._is_pre_startup_event(event)
|
||||||
|
or not self._should_process_message(room, event)
|
||||||
|
):
|
||||||
return
|
return
|
||||||
await self._start_typing_keepalive(room.room_id)
|
await self._start_typing_keepalive(room.room_id)
|
||||||
try:
|
try:
|
||||||
@@ -871,7 +873,11 @@ class MatrixChannel(BaseChannel):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None:
|
async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None:
|
||||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
if (
|
||||||
|
event.sender == self.config.user_id
|
||||||
|
or self._is_pre_startup_event(event)
|
||||||
|
or not self._should_process_message(room, event)
|
||||||
|
):
|
||||||
return
|
return
|
||||||
attachment, marker = await self._fetch_media_attachment(room, event)
|
attachment, marker = await self._fetch_media_attachment(room, event)
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|||||||
+24
-28
@@ -5,12 +5,12 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -302,7 +302,7 @@ class MochatChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start Mochat channel workers and websocket connection."""
|
"""Start Mochat channel workers and websocket connection."""
|
||||||
if not self.config.claw_token:
|
if not self.config.claw_token:
|
||||||
logger.error("Mochat claw_token not configured")
|
self.logger.error("claw_token not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -330,10 +330,8 @@ class MochatChannel(BaseChannel):
|
|||||||
await self._cancel_delay_timers()
|
await self._cancel_delay_timers()
|
||||||
|
|
||||||
if self._socket:
|
if self._socket:
|
||||||
try:
|
with suppress(Exception):
|
||||||
await self._socket.disconnect()
|
await self._socket.disconnect()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._socket = None
|
self._socket = None
|
||||||
|
|
||||||
if self._cursor_save_task:
|
if self._cursor_save_task:
|
||||||
@@ -349,7 +347,7 @@ class MochatChannel(BaseChannel):
|
|||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send outbound message to session or panel."""
|
"""Send outbound message to session or panel."""
|
||||||
if not self.config.claw_token:
|
if not self.config.claw_token:
|
||||||
logger.warning("Mochat claw_token missing, skip send")
|
self.logger.warning("claw_token missing, skip send")
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
|
parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
|
||||||
@@ -361,7 +359,7 @@ class MochatChannel(BaseChannel):
|
|||||||
|
|
||||||
target = resolve_mochat_target(msg.chat_id)
|
target = resolve_mochat_target(msg.chat_id)
|
||||||
if not target.id:
|
if not target.id:
|
||||||
logger.warning("Mochat outbound target is empty")
|
self.logger.warning("outbound target is empty")
|
||||||
return
|
return
|
||||||
|
|
||||||
is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_")
|
is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_")
|
||||||
@@ -372,8 +370,8 @@ class MochatChannel(BaseChannel):
|
|||||||
else:
|
else:
|
||||||
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
|
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
|
||||||
content, msg.reply_to)
|
content, msg.reply_to)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Failed to send Mochat message: {}", e)
|
self.logger.exception("Failed to send message")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# ---- config / init helpers ---------------------------------------------
|
# ---- config / init helpers ---------------------------------------------
|
||||||
@@ -396,7 +394,7 @@ class MochatChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _start_socket_client(self) -> bool:
|
async def _start_socket_client(self) -> bool:
|
||||||
if not SOCKETIO_AVAILABLE:
|
if not SOCKETIO_AVAILABLE:
|
||||||
logger.warning("python-socketio not installed, Mochat using polling fallback")
|
self.logger.warning("python-socketio not installed, using polling fallback")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
serializer = "default"
|
serializer = "default"
|
||||||
@@ -404,7 +402,7 @@ class MochatChannel(BaseChannel):
|
|||||||
if MSGPACK_AVAILABLE:
|
if MSGPACK_AVAILABLE:
|
||||||
serializer = "msgpack"
|
serializer = "msgpack"
|
||||||
else:
|
else:
|
||||||
logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
|
self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
|
||||||
|
|
||||||
client = socketio.AsyncClient(
|
client = socketio.AsyncClient(
|
||||||
reconnection=True,
|
reconnection=True,
|
||||||
@@ -417,7 +415,7 @@ class MochatChannel(BaseChannel):
|
|||||||
@client.event
|
@client.event
|
||||||
async def connect() -> None:
|
async def connect() -> None:
|
||||||
self._ws_connected, self._ws_ready = True, False
|
self._ws_connected, self._ws_ready = True, False
|
||||||
logger.info("Mochat websocket connected")
|
self.logger.info("websocket connected")
|
||||||
subscribed = await self._subscribe_all()
|
subscribed = await self._subscribe_all()
|
||||||
self._ws_ready = subscribed
|
self._ws_ready = subscribed
|
||||||
await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
|
await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
|
||||||
@@ -427,12 +425,12 @@ class MochatChannel(BaseChannel):
|
|||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
self._ws_connected = self._ws_ready = False
|
self._ws_connected = self._ws_ready = False
|
||||||
logger.warning("Mochat websocket disconnected")
|
self.logger.warning("websocket disconnected")
|
||||||
await self._ensure_fallback_workers()
|
await self._ensure_fallback_workers()
|
||||||
|
|
||||||
@client.event
|
@client.event
|
||||||
async def connect_error(data: Any) -> None:
|
async def connect_error(data: Any) -> None:
|
||||||
logger.error("Mochat websocket connect error: {}", data)
|
self.logger.error("websocket connect error: {}", data)
|
||||||
|
|
||||||
@client.on("claw.session.events")
|
@client.on("claw.session.events")
|
||||||
async def on_session_events(payload: dict[str, Any]) -> None:
|
async def on_session_events(payload: dict[str, Any]) -> None:
|
||||||
@@ -458,12 +456,10 @@ class MochatChannel(BaseChannel):
|
|||||||
wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0),
|
wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Failed to connect Mochat websocket: {}", e)
|
self.logger.exception("Failed to connect websocket")
|
||||||
try:
|
with suppress(Exception):
|
||||||
await client.disconnect()
|
await client.disconnect()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._socket = None
|
self._socket = None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -496,7 +492,7 @@ class MochatChannel(BaseChannel):
|
|||||||
"limit": self.config.watch_limit,
|
"limit": self.config.watch_limit,
|
||||||
})
|
})
|
||||||
if not ack.get("result"):
|
if not ack.get("result"):
|
||||||
logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error'))
|
self.logger.error("subscribeSessions failed: {}", ack.get('message', 'unknown error'))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
data = ack.get("data")
|
data = ack.get("data")
|
||||||
@@ -518,7 +514,7 @@ class MochatChannel(BaseChannel):
|
|||||||
return True
|
return True
|
||||||
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
|
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
|
||||||
if not ack.get("result"):
|
if not ack.get("result"):
|
||||||
logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error'))
|
self.logger.error("subscribePanels failed: {}", ack.get('message', 'unknown error'))
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -540,7 +536,7 @@ class MochatChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._refresh_targets(subscribe_new=self._ws_ready)
|
await self._refresh_targets(subscribe_new=self._ws_ready)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Mochat refresh failed: {}", e)
|
self.logger.warning("refresh failed: {}", e)
|
||||||
if self._fallback_mode:
|
if self._fallback_mode:
|
||||||
await self._ensure_fallback_workers()
|
await self._ensure_fallback_workers()
|
||||||
|
|
||||||
@@ -554,7 +550,7 @@ class MochatChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
response = await self._post_json("/api/claw/sessions/list", {})
|
response = await self._post_json("/api/claw/sessions/list", {})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Mochat listSessions failed: {}", e)
|
self.logger.warning("listSessions failed: {}", e)
|
||||||
return
|
return
|
||||||
|
|
||||||
sessions = response.get("sessions")
|
sessions = response.get("sessions")
|
||||||
@@ -588,7 +584,7 @@ class MochatChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
response = await self._post_json("/api/claw/groups/get", {})
|
response = await self._post_json("/api/claw/groups/get", {})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Mochat getWorkspaceGroup failed: {}", e)
|
self.logger.warning("getWorkspaceGroup failed: {}", e)
|
||||||
return
|
return
|
||||||
|
|
||||||
raw_panels = response.get("panels")
|
raw_panels = response.get("panels")
|
||||||
@@ -650,7 +646,7 @@ class MochatChannel(BaseChannel):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Mochat watch fallback error ({}): {}", session_id, e)
|
self.logger.warning("watch fallback error ({}): {}", session_id, e)
|
||||||
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
|
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
|
||||||
|
|
||||||
async def _panel_poll_worker(self, panel_id: str) -> None:
|
async def _panel_poll_worker(self, panel_id: str) -> None:
|
||||||
@@ -677,7 +673,7 @@ class MochatChannel(BaseChannel):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Mochat panel polling error ({}): {}", panel_id, e)
|
self.logger.warning("panel polling error ({}): {}", panel_id, e)
|
||||||
await asyncio.sleep(sleep_s)
|
await asyncio.sleep(sleep_s)
|
||||||
|
|
||||||
# ---- inbound event processing ------------------------------------------
|
# ---- inbound event processing ------------------------------------------
|
||||||
@@ -888,7 +884,7 @@ class MochatChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
data = json.loads(self._cursor_path.read_text("utf-8"))
|
data = json.loads(self._cursor_path.read_text("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to read Mochat cursor file: {}", e)
|
self.logger.warning("Failed to read cursor file: {}", e)
|
||||||
return
|
return
|
||||||
cursors = data.get("cursors") if isinstance(data, dict) else None
|
cursors = data.get("cursors") if isinstance(data, dict) else None
|
||||||
if isinstance(cursors, dict):
|
if isinstance(cursors, dict):
|
||||||
@@ -904,7 +900,7 @@ class MochatChannel(BaseChannel):
|
|||||||
"cursors": self._session_cursor,
|
"cursors": self._session_cursor,
|
||||||
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to save Mochat cursor file: {}", e)
|
self.logger.warning("Failed to save cursor file: {}", e)
|
||||||
|
|
||||||
# ---- HTTP helpers ------------------------------------------------------
|
# ---- HTTP helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
+22
-25
@@ -20,7 +20,7 @@ import re
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager, suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -32,7 +32,6 @@ except ImportError: # pragma: no cover
|
|||||||
fcntl = None
|
fcntl = None
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -134,16 +133,16 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Teams webhook listener."""
|
"""Start the Teams webhook listener."""
|
||||||
if not MSTEAMS_AVAILABLE:
|
if not MSTEAMS_AVAILABLE:
|
||||||
logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.app_id or not self.config.app_password:
|
if not self.config.app_id or not self.config.app_password:
|
||||||
logger.error("MSTeams app_id/app_password not configured")
|
self.logger.error("app_id/app_password not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.validate_inbound_auth:
|
if not self.config.validate_inbound_auth:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"MSTeams inbound auth validation was explicitly DISABLED in config. "
|
"Inbound auth validation was explicitly DISABLED in config. "
|
||||||
"Anyone who knows the webhook URL can send messages as any user. "
|
"Anyone who knows the webhook URL can send messages as any user. "
|
||||||
"Only disable this for local development or controlled testing."
|
"Only disable this for local development or controlled testing."
|
||||||
)
|
)
|
||||||
@@ -166,7 +165,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
raw = self.rfile.read(length) if length > 0 else b"{}"
|
raw = self.rfile.read(length) if length > 0 else b"{}"
|
||||||
payload = json.loads(raw.decode("utf-8"))
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("MSTeams invalid request body: {}", e)
|
channel.logger.warning("Invalid request body: {}", e)
|
||||||
self.send_response(400)
|
self.send_response(400)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
@@ -180,7 +179,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
fut.result(timeout=15)
|
fut.result(timeout=15)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("MSTeams inbound auth validation failed: {}", e)
|
channel.logger.warning("Inbound auth validation failed: {}", e)
|
||||||
self.send_response(401)
|
self.send_response(401)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -193,7 +192,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
fut.result(timeout=15)
|
fut.result(timeout=15)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("MSTeams activity handling failed: {}", e)
|
channel.logger.warning("Activity handling failed: {}", e)
|
||||||
|
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
@@ -211,8 +210,8 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
self._server_thread.start()
|
self._server_thread.start()
|
||||||
|
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"MSTeams webhook listening on http://{}:{}{}",
|
"Webhook listening on http://{}:{}{}",
|
||||||
self.config.host,
|
self.config.host,
|
||||||
self.config.port,
|
self.config.port,
|
||||||
self.config.path,
|
self.config.path,
|
||||||
@@ -261,10 +260,10 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
resp = await self._http.post(base_url, headers=headers, json=payload)
|
resp = await self._http.post(base_url, headers=headers, json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
logger.info("MSTeams message sent to {}", ref.conversation_id)
|
self.logger.info("Message sent to {}", ref.conversation_id)
|
||||||
self._touch_conversation_ref(str(msg.chat_id), persist=True)
|
self._touch_conversation_ref(str(msg.chat_id), persist=True)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("MSTeams send failed: {}", e)
|
self.logger.exception("Send failed")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _handle_activity(self, activity: dict[str, Any]) -> None:
|
async def _handle_activity(self, activity: dict[str, Any]) -> None:
|
||||||
@@ -291,18 +290,18 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
|
|
||||||
# DM-only MVP: ignore group/channel traffic for now
|
# DM-only MVP: ignore group/channel traffic for now
|
||||||
if conversation_type and conversation_type not in ("personal", ""):
|
if conversation_type and conversation_type not in ("personal", ""):
|
||||||
logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type)
|
self.logger.debug("Ignoring non-DM conversation {}", conversation_type)
|
||||||
return
|
return
|
||||||
|
|
||||||
text = self._sanitize_inbound_text(activity)
|
text = self._sanitize_inbound_text(activity)
|
||||||
if not text:
|
if not text:
|
||||||
text = self.config.mention_only_response.strip()
|
text = self.config.mention_only_response.strip()
|
||||||
if not text:
|
if not text:
|
||||||
logger.debug("MSTeams ignoring empty message after Teams text sanitization")
|
self.logger.debug("Ignoring empty message after Teams text sanitization")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Access denied for sender {} on channel {}. "
|
"Access denied for sender {} on channel {}. "
|
||||||
"Add them to allowFrom list in config to grant access.",
|
"Add them to allowFrom list in config to grant access.",
|
||||||
sender_id, self.name,
|
sender_id, self.name,
|
||||||
@@ -554,7 +553,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
if isinstance(loaded, dict):
|
if isinstance(loaded, dict):
|
||||||
main_data = loaded
|
main_data = loaded
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to load MSTeams conversation refs: {}", e)
|
self.logger.warning("Failed to load conversation refs: {}", e)
|
||||||
|
|
||||||
if meta_exists:
|
if meta_exists:
|
||||||
try:
|
try:
|
||||||
@@ -562,7 +561,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
if isinstance(loaded_meta, dict):
|
if isinstance(loaded_meta, dict):
|
||||||
meta_data = loaded_meta
|
meta_data = loaded_meta
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to load MSTeams conversation refs metadata: {}", e)
|
self.logger.warning("Failed to load conversation refs metadata: {}", e)
|
||||||
|
|
||||||
return main_data, meta_data, meta_exists
|
return main_data, meta_data, meta_exists
|
||||||
|
|
||||||
@@ -660,8 +659,8 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
|
|
||||||
for key in keys_to_drop:
|
for key in keys_to_drop:
|
||||||
self._conversation_refs.pop(key, None)
|
self._conversation_refs.pop(key, None)
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"MSTeams pruned {} stale/unsupported conversation refs (ttl={} days)",
|
"Pruned {} stale/unsupported conversation refs (ttl={} days)",
|
||||||
len(keys_to_drop),
|
len(keys_to_drop),
|
||||||
ttl_days,
|
ttl_days,
|
||||||
)
|
)
|
||||||
@@ -712,10 +711,8 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
os.replace(tmp_path, path)
|
os.replace(tmp_path, path)
|
||||||
finally:
|
finally:
|
||||||
if tmp_path and os.path.exists(tmp_path):
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
try:
|
with suppress(OSError):
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _save_refs_locked(self, *, prune: bool = True) -> None:
|
def _save_refs_locked(self, *, prune: bool = True) -> None:
|
||||||
"""Persist conversation references (caller must hold _refs_guard)."""
|
"""Persist conversation references (caller must hold _refs_guard)."""
|
||||||
@@ -744,7 +741,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
self._write_json_atomically(self._refs_path, refs_data)
|
self._write_json_atomically(self._refs_path, refs_data)
|
||||||
self._write_json_atomically(self._refs_meta_path, refs_meta)
|
self._write_json_atomically(self._refs_meta_path, refs_meta)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to save MSTeams conversation refs: {}", e)
|
self.logger.warning("Failed to save conversation refs: {}", e)
|
||||||
|
|
||||||
def _save_refs(self, *, prune: bool = True) -> None:
|
def _save_refs(self, *, prune: bool = True) -> None:
|
||||||
"""Persist conversation references."""
|
"""Persist conversation references."""
|
||||||
|
|||||||
+45
-45
@@ -25,6 +25,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
@@ -37,7 +38,7 @@ from nanobot.bus.events import OutboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.security.network import validate_url_target
|
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
@@ -186,24 +187,25 @@ class QQChannel(BaseChannel):
|
|||||||
root = Path.home() / ".nanobot" / "media" / "qq"
|
root = Path.home() / ".nanobot" / "media" / "qq"
|
||||||
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
logger.info("QQ media directory: {}", str(root))
|
self.logger.info("media directory: {}", str(root))
|
||||||
return root
|
return root
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the QQ bot with auto-reconnect loop."""
|
"""Start the QQ bot with auto-reconnect loop."""
|
||||||
|
redirect_lib_logging("botpy", level="WARNING")
|
||||||
if not QQ_AVAILABLE:
|
if not QQ_AVAILABLE:
|
||||||
logger.error("QQ SDK not installed. Run: pip install qq-botpy")
|
self.logger.error("SDK not installed. Run: pip install qq-botpy")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.app_id or not self.config.secret:
|
if not self.config.app_id or not self.config.secret:
|
||||||
logger.error("QQ app_id and secret not configured")
|
self.logger.error("app_id and secret not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
|
self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
|
||||||
|
|
||||||
self._client = _make_bot_class(self)()
|
self._client = _make_bot_class(self)()
|
||||||
logger.info("QQ bot started (C2C & Group supported)")
|
self.logger.info("bot started (C2C & Group supported)")
|
||||||
await self._run_bot()
|
await self._run_bot()
|
||||||
|
|
||||||
async def _run_bot(self) -> None:
|
async def _run_bot(self) -> None:
|
||||||
@@ -212,29 +214,25 @@ class QQChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("QQ bot error: {}", e)
|
self.logger.warning("bot error: {}", e)
|
||||||
if self._running:
|
if self._running:
|
||||||
logger.info("Reconnecting QQ bot in 5 seconds...")
|
self.logger.info("Reconnecting bot in 5 seconds...")
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop bot and cleanup resources."""
|
"""Stop bot and cleanup resources."""
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
if self._client:
|
||||||
try:
|
with suppress(Exception):
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
if self._http:
|
if self._http:
|
||||||
try:
|
with suppress(Exception):
|
||||||
await self._http.close()
|
await self._http.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._http = None
|
self._http = None
|
||||||
|
|
||||||
logger.info("QQ bot stopped")
|
self.logger.info("bot stopped")
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Outbound (send)
|
# Outbound (send)
|
||||||
@@ -244,7 +242,7 @@ class QQChannel(BaseChannel):
|
|||||||
"""Send attachments first, then text."""
|
"""Send attachments first, then text."""
|
||||||
try:
|
try:
|
||||||
if not self._client:
|
if not self._client:
|
||||||
logger.warning("QQ client not initialized")
|
self.logger.warning("client not initialized")
|
||||||
return
|
return
|
||||||
|
|
||||||
msg_id = msg.metadata.get("message_id")
|
msg_id = msg.metadata.get("message_id")
|
||||||
@@ -284,7 +282,7 @@ class QQChannel(BaseChannel):
|
|||||||
# Network / transport errors — propagate so ChannelManager can retry
|
# Network / transport errors — propagate so ChannelManager can retry
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error sending QQ message to chat_id={}", msg.chat_id)
|
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
|
||||||
|
|
||||||
async def _send_text_only(
|
async def _send_text_only(
|
||||||
self,
|
self,
|
||||||
@@ -342,7 +340,7 @@ class QQChannel(BaseChannel):
|
|||||||
srv_send_msg=False,
|
srv_send_msg=False,
|
||||||
)
|
)
|
||||||
if not media_obj:
|
if not media_obj:
|
||||||
logger.error("QQ media upload failed: empty response")
|
self.logger.error("media upload failed: empty response")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._msg_seq += 1
|
self._msg_seq += 1
|
||||||
@@ -363,15 +361,15 @@ class QQChannel(BaseChannel):
|
|||||||
media=media_obj,
|
media=media_obj,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("QQ media sent: {}", filename)
|
self.logger.info("media sent: {}", filename)
|
||||||
return True
|
return True
|
||||||
except (aiohttp.ClientError, OSError) as e:
|
except (aiohttp.ClientError, OSError) as e:
|
||||||
# Network / transport errors — propagate for retry by caller
|
# Network / transport errors — propagate for retry by caller
|
||||||
logger.warning("QQ send media network error filename={} err={}", filename, e)
|
self.logger.warning("send media network error filename={} err={}", filename, e)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# API-level or other non-network errors — return False so send() can fallback
|
# API-level or other non-network errors — return False so send() can fallback
|
||||||
logger.error("QQ send media failed filename={} err={}", filename, e)
|
self.logger.exception("send media failed filename={}", filename)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]:
|
async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]:
|
||||||
@@ -392,19 +390,19 @@ class QQChannel(BaseChannel):
|
|||||||
local_path = Path(os.path.expanduser(media_ref))
|
local_path = Path(os.path.expanduser(media_ref))
|
||||||
|
|
||||||
if not local_path.is_file():
|
if not local_path.is_file():
|
||||||
logger.warning("QQ outbound media file not found: {}", str(local_path))
|
self.logger.warning("outbound media file not found: {}", str(local_path))
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
data = await asyncio.to_thread(local_path.read_bytes)
|
data = await asyncio.to_thread(local_path.read_bytes)
|
||||||
return data, local_path.name
|
return data, local_path.name
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("QQ outbound media read error ref={} err={}", media_ref, e)
|
self.logger.warning("outbound media read error ref={} err={}", media_ref, e)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Remote URL
|
# Remote URL
|
||||||
ok, err = validate_url_target(media_ref)
|
ok, err = validate_url_target(media_ref)
|
||||||
if not ok:
|
if not ok:
|
||||||
logger.warning("QQ outbound media URL validation failed url={} err={}", media_ref, err)
|
self.logger.warning("outbound media URL validation failed url={} err={}", media_ref, err)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
if not self._http:
|
if not self._http:
|
||||||
@@ -412,8 +410,8 @@ class QQChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
async with self._http.get(media_ref, allow_redirects=True) as resp:
|
async with self._http.get(media_ref, allow_redirects=True) as resp:
|
||||||
if resp.status >= 400:
|
if resp.status >= 400:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"QQ outbound media download failed status={} url={}",
|
"outbound media download failed status={} url={}",
|
||||||
resp.status,
|
resp.status,
|
||||||
media_ref,
|
media_ref,
|
||||||
)
|
)
|
||||||
@@ -424,7 +422,7 @@ class QQChannel(BaseChannel):
|
|||||||
filename = os.path.basename(urlparse(media_ref).path) or "file.bin"
|
filename = os.path.basename(urlparse(media_ref).path) or "file.bin"
|
||||||
return data, filename
|
return data, filename
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("QQ outbound media download error url={} err={}", media_ref, e)
|
self.logger.warning("outbound media download error url={} err={}", media_ref, e)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# https://github.com/tencent-connect/botpy/issues/198
|
# https://github.com/tencent-connect/botpy/issues/198
|
||||||
@@ -477,24 +475,28 @@ class QQChannel(BaseChannel):
|
|||||||
async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None:
|
async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None:
|
||||||
"""Parse inbound message, download attachments, and publish to the bus."""
|
"""Parse inbound message, download attachments, and publish to the bus."""
|
||||||
try:
|
try:
|
||||||
if data.id in self._processed_ids:
|
|
||||||
return
|
|
||||||
self._processed_ids.append(data.id)
|
|
||||||
|
|
||||||
if is_group:
|
if is_group:
|
||||||
chat_id = data.group_openid
|
chat_id = data.group_openid
|
||||||
user_id = data.author.member_openid
|
user_id = data.author.member_openid
|
||||||
self._chat_type_cache[chat_id] = "group"
|
chat_type = "group"
|
||||||
else:
|
else:
|
||||||
chat_id = str(
|
chat_id = str(
|
||||||
getattr(data.author, "id", None)
|
getattr(data.author, "id", None)
|
||||||
or getattr(data.author, "user_openid", "unknown")
|
or getattr(data.author, "user_openid", "unknown")
|
||||||
)
|
)
|
||||||
user_id = chat_id
|
user_id = chat_id
|
||||||
self._chat_type_cache[chat_id] = "c2c"
|
chat_type = "c2c"
|
||||||
|
|
||||||
content = (data.content or "").strip()
|
content = (data.content or "").strip()
|
||||||
|
|
||||||
|
if not self.is_allowed(user_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
if data.id in self._processed_ids:
|
||||||
|
return
|
||||||
|
self._processed_ids.append(data.id)
|
||||||
|
self._chat_type_cache[chat_id] = chat_type
|
||||||
|
|
||||||
# the data used by tests don't contain attachments property
|
# the data used by tests don't contain attachments property
|
||||||
# so we use getattr with a default of [] to avoid AttributeError in tests
|
# so we use getattr with a default of [] to avoid AttributeError in tests
|
||||||
attachments = getattr(data, "attachments", None) or []
|
attachments = getattr(data, "attachments", None) or []
|
||||||
@@ -524,7 +526,7 @@ class QQChannel(BaseChannel):
|
|||||||
content=self.config.ack_message,
|
content=self.config.ack_message,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("QQ ack message failed for chat_id={}", chat_id)
|
self.logger.debug("ack message failed for chat_id={}", chat_id)
|
||||||
|
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=user_id,
|
sender_id=user_id,
|
||||||
@@ -537,7 +539,7 @@ class QQChannel(BaseChannel):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error handling QQ inbound message id={}", getattr(data, "id", "?"))
|
self.logger.exception("Error handling inbound message id={}", getattr(data, "id", "?"))
|
||||||
|
|
||||||
async def _handle_attachments(
|
async def _handle_attachments(
|
||||||
self,
|
self,
|
||||||
@@ -556,7 +558,7 @@ class QQChannel(BaseChannel):
|
|||||||
filename = getattr(att, "filename", None) or ""
|
filename = getattr(att, "filename", None) or ""
|
||||||
ctype = getattr(att, "content_type", None) or ""
|
ctype = getattr(att, "content_type", None) or ""
|
||||||
|
|
||||||
logger.info("Downloading file from QQ: {}", filename or url)
|
self.logger.info("Downloading file: {}", filename or url)
|
||||||
local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename)
|
local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename)
|
||||||
|
|
||||||
att_meta.append(
|
att_meta.append(
|
||||||
@@ -607,7 +609,7 @@ class QQChannel(BaseChannel):
|
|||||||
allow_redirects=True,
|
allow_redirects=True,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
logger.warning("QQ download failed: status={} url={}", resp.status, url)
|
self.logger.warning("download failed: status={} url={}", resp.status, url)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ctype = (resp.headers.get("Content-Type") or "").lower()
|
ctype = (resp.headers.get("Content-Type") or "").lower()
|
||||||
@@ -661,8 +663,8 @@ class QQChannel(BaseChannel):
|
|||||||
continue
|
continue
|
||||||
downloaded += len(chunk)
|
downloaded += len(chunk)
|
||||||
if downloaded > max_bytes:
|
if downloaded > max_bytes:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"QQ download exceeded max_bytes={} url={} -> abort",
|
"download exceeded max_bytes={} url={} -> abort",
|
||||||
max_bytes,
|
max_bytes,
|
||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
@@ -674,16 +676,14 @@ class QQChannel(BaseChannel):
|
|||||||
# Atomic rename
|
# Atomic rename
|
||||||
await asyncio.to_thread(os.replace, tmp_path, target)
|
await asyncio.to_thread(os.replace, tmp_path, target)
|
||||||
tmp_path = None # mark as moved
|
tmp_path = None # mark as moved
|
||||||
logger.info("QQ file saved: {}", str(target))
|
self.logger.info("file saved: {}", str(target))
|
||||||
return str(target)
|
return str(target)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("QQ download error: {}", e)
|
self.logger.exception("download error")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
# Cleanup partial file
|
# Cleanup partial file
|
||||||
if tmp_path is not None:
|
if tmp_path is not None:
|
||||||
try:
|
with suppress(Exception):
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
+30
-24
@@ -6,7 +6,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||||
@@ -84,10 +83,10 @@ class SlackChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Slack Socket Mode client."""
|
"""Start the Slack Socket Mode client."""
|
||||||
if not self.config.bot_token or not self.config.app_token:
|
if not self.config.bot_token or not self.config.app_token:
|
||||||
logger.error("Slack bot/app token not configured")
|
self.logger.error("bot/app token not configured")
|
||||||
return
|
return
|
||||||
if self.config.mode != "socket":
|
if self.config.mode != "socket":
|
||||||
logger.error("Unsupported Slack mode: {}", self.config.mode)
|
self.logger.error("Unsupported mode: {}", self.config.mode)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -104,11 +103,11 @@ class SlackChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
auth = await self._web_client.auth_test()
|
auth = await self._web_client.auth_test()
|
||||||
self._bot_user_id = auth.get("user_id")
|
self._bot_user_id = auth.get("user_id")
|
||||||
logger.info("Slack bot connected as {}", self._bot_user_id)
|
self.logger.info("bot connected as {}", self._bot_user_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Slack auth_test failed: {}", e)
|
self.logger.warning("auth_test failed: {}", e)
|
||||||
|
|
||||||
logger.info("Starting Slack Socket Mode client...")
|
self.logger.info("Starting Socket Mode client...")
|
||||||
await self._socket_client.connect()
|
await self._socket_client.connect()
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -121,13 +120,13 @@ class SlackChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._socket_client.close()
|
await self._socket_client.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Slack socket close failed: {}", e)
|
self.logger.warning("socket close failed: {}", e)
|
||||||
self._socket_client = None
|
self._socket_client = None
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through Slack."""
|
"""Send a message through Slack."""
|
||||||
if not self._web_client:
|
if not self._web_client:
|
||||||
logger.warning("Slack client not running")
|
self.logger.warning("client not running")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
|
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
|
||||||
@@ -162,16 +161,16 @@ class SlackChannel(BaseChannel):
|
|||||||
file=media_path,
|
file=media_path,
|
||||||
thread_ts=thread_ts_param,
|
thread_ts=thread_ts_param,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Failed to upload file {}: {}", media_path, e)
|
self.logger.exception("Failed to upload file {}", media_path)
|
||||||
|
|
||||||
# Update reaction emoji when the final (non-progress) response is sent
|
# Update reaction emoji when the final (non-progress) response is sent
|
||||||
if not (msg.metadata or {}).get("_progress"):
|
if not (msg.metadata or {}).get("_progress"):
|
||||||
event = slack_meta.get("event", {})
|
event = slack_meta.get("event", {})
|
||||||
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending Slack message: {}", e)
|
self.logger.exception("Error sending message")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _resolve_target_chat_id(self, target: str) -> str:
|
async def _resolve_target_chat_id(self, target: str) -> str:
|
||||||
@@ -328,8 +327,8 @@ class SlackChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Debug: log basic event shape
|
# Debug: log basic event shape
|
||||||
logger.debug(
|
self.logger.debug(
|
||||||
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
|
"event: type={} subtype={} user={} channel={} channel_type={} text={}",
|
||||||
event_type,
|
event_type,
|
||||||
subtype,
|
subtype,
|
||||||
sender_id,
|
sender_id,
|
||||||
@@ -371,7 +370,7 @@ class SlackChannel(BaseChannel):
|
|||||||
timestamp=event.get("ts"),
|
timestamp=event.get("ts"),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Slack reactions_add failed: {}", e)
|
self.logger.debug("reactions_add failed: {}", e)
|
||||||
|
|
||||||
# Thread-scoped session key whenever the user is in a real thread
|
# Thread-scoped session key whenever the user is in a real thread
|
||||||
# (raw_thread_ts is set). DM threads get their own session, separate
|
# (raw_thread_ts is set). DM threads get their own session, separate
|
||||||
@@ -420,7 +419,7 @@ class SlackChannel(BaseChannel):
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error handling Slack message from {}", sender_id)
|
self.logger.exception("Error handling message from {}", sender_id)
|
||||||
|
|
||||||
async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]:
|
async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]:
|
||||||
"""Download a Slack private file to the local media directory."""
|
"""Download a Slack private file to the local media directory."""
|
||||||
@@ -435,9 +434,9 @@ class SlackChannel(BaseChannel):
|
|||||||
marker = f"[{marker_type}: {name}]"
|
marker = f"[{marker_type}: {name}]"
|
||||||
url = str(file_info.get("url_private_download") or file_info.get("url_private") or "")
|
url = str(file_info.get("url_private_download") or file_info.get("url_private") or "")
|
||||||
if not url:
|
if not url:
|
||||||
return None, f"[{marker_type}: {name}: missing download url]"
|
return None, self._download_failure_marker(marker_type, name, "missing download url")
|
||||||
if not self.config.bot_token:
|
if not self.config.bot_token:
|
||||||
return None, f"[{marker_type}: {name}: missing bot token]"
|
return None, self._download_failure_marker(marker_type, name, "missing bot token")
|
||||||
|
|
||||||
filename = safe_filename(f"{file_id}_{name}")
|
filename = safe_filename(f"{file_id}_{name}")
|
||||||
path = Path(get_media_dir("slack")) / filename
|
path = Path(get_media_dir("slack")) / filename
|
||||||
@@ -453,8 +452,15 @@ class SlackChannel(BaseChannel):
|
|||||||
path.write_bytes(response.content)
|
path.write_bytes(response.content)
|
||||||
return str(path), marker
|
return str(path), marker
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to download Slack file {}: {}", file_id, e)
|
self.logger.warning("Failed to download file {}: {}", file_id, e)
|
||||||
return None, f"[{marker_type}: {name}: download failed]"
|
return None, self._download_failure_marker(marker_type, name, "download failed")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _download_failure_marker(marker_type: str, name: str, reason: str) -> str:
|
||||||
|
return (
|
||||||
|
f"[{marker_type}: {name}: {reason}; not available to nanobot. "
|
||||||
|
"Check Slack files:read scope, reinstall the Slack app, and ensure the bot can access the file.]"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _looks_like_html_download(response: httpx.Response) -> bool:
|
def _looks_like_html_download(response: httpx.Response) -> bool:
|
||||||
@@ -493,7 +499,7 @@ class SlackChannel(BaseChannel):
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error handling Slack button click from {}", sender_id)
|
self.logger.exception("Error handling button click from {}", sender_id)
|
||||||
|
|
||||||
async def _with_thread_context(
|
async def _with_thread_context(
|
||||||
self,
|
self,
|
||||||
@@ -530,7 +536,7 @@ class SlackChannel(BaseChannel):
|
|||||||
limit=max(1, self.config.thread_context_limit),
|
limit=max(1, self.config.thread_context_limit),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Slack thread context unavailable for {}: {}", key, e)
|
self.logger.warning("thread context unavailable for {}: {}", key, e)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
lines = self._format_thread_context(
|
lines = self._format_thread_context(
|
||||||
@@ -590,7 +596,7 @@ class SlackChannel(BaseChannel):
|
|||||||
timestamp=ts,
|
timestamp=ts,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Slack reactions_remove failed: {}", e)
|
self.logger.debug("reactions_remove failed: {}", e)
|
||||||
if self.config.done_emoji:
|
if self.config.done_emoji:
|
||||||
try:
|
try:
|
||||||
await self._web_client.reactions_add(
|
await self._web_client.reactions_add(
|
||||||
@@ -599,7 +605,7 @@ class SlackChannel(BaseChannel):
|
|||||||
timestamp=ts,
|
timestamp=ts,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Slack done reaction failed: {}", e)
|
self.logger.debug("done reaction failed: {}", e)
|
||||||
|
|
||||||
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
|
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
|
||||||
if channel_type == "im":
|
if channel_type == "im":
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from telegram import (
|
from telegram import (
|
||||||
BotCommand,
|
BotCommand,
|
||||||
@@ -319,7 +319,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Telegram bot with long polling."""
|
"""Start the Telegram bot with long polling."""
|
||||||
if not self.config.token:
|
if not self.config.token:
|
||||||
logger.error("Telegram bot token not configured")
|
self.logger.error("bot token not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -381,11 +381,11 @@ class TelegramChannel(BaseChannel):
|
|||||||
if self.config.inline_keyboards:
|
if self.config.inline_keyboards:
|
||||||
self._app.add_handler(CallbackQueryHandler(self._on_callback_query))
|
self._app.add_handler(CallbackQueryHandler(self._on_callback_query))
|
||||||
allowed_updates = ["message", "callback_query"]
|
allowed_updates = ["message", "callback_query"]
|
||||||
logger.debug("Telegram inline keyboards enabled")
|
self.logger.debug("inline keyboards enabled")
|
||||||
else:
|
else:
|
||||||
allowed_updates = ["message"]
|
allowed_updates = ["message"]
|
||||||
|
|
||||||
logger.info("Starting Telegram bot (polling mode)...")
|
self.logger.info("Starting bot (polling mode)...")
|
||||||
|
|
||||||
# Initialize and start polling
|
# Initialize and start polling
|
||||||
await self._app.initialize()
|
await self._app.initialize()
|
||||||
@@ -395,13 +395,13 @@ class TelegramChannel(BaseChannel):
|
|||||||
bot_info = await self._app.bot.get_me()
|
bot_info = await self._app.bot.get_me()
|
||||||
self._bot_user_id = getattr(bot_info, "id", None)
|
self._bot_user_id = getattr(bot_info, "id", None)
|
||||||
self._bot_username = getattr(bot_info, "username", None)
|
self._bot_username = getattr(bot_info, "username", None)
|
||||||
logger.info("Telegram bot @{} connected", bot_info.username)
|
self.logger.info("bot @{} connected", bot_info.username)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._app.bot.set_my_commands(self.BOT_COMMANDS)
|
await self._app.bot.set_my_commands(self.BOT_COMMANDS)
|
||||||
logger.debug("Telegram bot commands registered")
|
self.logger.debug("bot commands registered")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to register bot commands: {}", e)
|
self.logger.warning("Failed to register bot commands: {}", e)
|
||||||
|
|
||||||
# Start polling (this runs until stopped)
|
# Start polling (this runs until stopped)
|
||||||
await self._app.updater.start_polling(
|
await self._app.updater.start_polling(
|
||||||
@@ -428,7 +428,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._media_group_buffers.clear()
|
self._media_group_buffers.clear()
|
||||||
|
|
||||||
if self._app:
|
if self._app:
|
||||||
logger.info("Stopping Telegram bot...")
|
self.logger.info("Stopping bot...")
|
||||||
await self._app.updater.stop()
|
await self._app.updater.stop()
|
||||||
await self._app.stop()
|
await self._app.stop()
|
||||||
await self._app.shutdown()
|
await self._app.shutdown()
|
||||||
@@ -455,22 +455,20 @@ class TelegramChannel(BaseChannel):
|
|||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through Telegram."""
|
"""Send a message through Telegram."""
|
||||||
if not self._app:
|
if not self._app:
|
||||||
logger.warning("Telegram bot not running")
|
self.logger.warning("bot not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only stop typing indicator and remove reaction for final responses
|
# Only stop typing indicator and remove reaction for final responses
|
||||||
if not msg.metadata.get("_progress", False):
|
if not msg.metadata.get("_progress", False):
|
||||||
self._stop_typing(msg.chat_id)
|
self._stop_typing(msg.chat_id)
|
||||||
if reply_to_message_id := msg.metadata.get("message_id"):
|
if reply_to_message_id := msg.metadata.get("message_id"):
|
||||||
try:
|
with suppress(ValueError):
|
||||||
await self._remove_reaction(msg.chat_id, int(reply_to_message_id))
|
await self._remove_reaction(msg.chat_id, int(reply_to_message_id))
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chat_id = int(msg.chat_id)
|
chat_id = int(msg.chat_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("Invalid chat_id: {}", msg.chat_id)
|
self.logger.exception("Invalid chat_id: {}", msg.chat_id)
|
||||||
return
|
return
|
||||||
reply_to_message_id = msg.metadata.get("message_id")
|
reply_to_message_id = msg.metadata.get("message_id")
|
||||||
message_thread_id = msg.metadata.get("message_thread_id")
|
message_thread_id = msg.metadata.get("message_thread_id")
|
||||||
@@ -534,9 +532,9 @@ class TelegramChannel(BaseChannel):
|
|||||||
**extra,
|
**extra,
|
||||||
**send_kwargs,
|
**send_kwargs,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
filename = media_path.rsplit("/", 1)[-1]
|
filename = media_path.rsplit("/", 1)[-1]
|
||||||
logger.error("Failed to send media {}: {}", media_path, e)
|
self.logger.exception("Failed to send media {}", media_path)
|
||||||
await self._app.bot.send_message(
|
await self._app.bot.send_message(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
text=f"[Failed to send: {filename}]",
|
text=f"[Failed to send: {filename}]",
|
||||||
@@ -573,8 +571,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
if attempt == _SEND_MAX_RETRIES:
|
if attempt == _SEND_MAX_RETRIES:
|
||||||
raise
|
raise
|
||||||
delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Telegram timeout (attempt {}/{}), retrying in {:.1f}s",
|
"timeout (attempt {}/{}), retrying in {:.1f}s",
|
||||||
attempt, _SEND_MAX_RETRIES, delay,
|
attempt, _SEND_MAX_RETRIES, delay,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
@@ -582,8 +580,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
if attempt == _SEND_MAX_RETRIES:
|
if attempt == _SEND_MAX_RETRIES:
|
||||||
raise
|
raise
|
||||||
delay = float(e.retry_after)
|
delay = float(e.retry_after)
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s",
|
"Flood Control (attempt {}/{}), retrying in {:.1f}s",
|
||||||
attempt, _SEND_MAX_RETRIES, delay,
|
attempt, _SEND_MAX_RETRIES, delay,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
@@ -608,7 +606,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
**(thread_kwargs or {}),
|
**(thread_kwargs or {}),
|
||||||
)
|
)
|
||||||
except BadRequest as e:
|
except BadRequest as e:
|
||||||
logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
self.logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
||||||
try:
|
try:
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
self._app.bot.send_message,
|
self._app.bot.send_message,
|
||||||
@@ -618,8 +616,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
reply_markup=reply_markup,
|
reply_markup=reply_markup,
|
||||||
**(thread_kwargs or {}),
|
**(thread_kwargs or {}),
|
||||||
)
|
)
|
||||||
except Exception as e2:
|
except Exception:
|
||||||
logger.error("Error sending Telegram message: {}", e2)
|
self.logger.exception("Error sending message")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -642,10 +640,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
self._stop_typing(chat_id)
|
self._stop_typing(chat_id)
|
||||||
if reply_to_message_id := meta.get("message_id"):
|
if reply_to_message_id := meta.get("message_id"):
|
||||||
try:
|
with suppress(ValueError):
|
||||||
await self._remove_reaction(chat_id, int(reply_to_message_id))
|
await self._remove_reaction(chat_id, int(reply_to_message_id))
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
thread_kwargs = {}
|
thread_kwargs = {}
|
||||||
if message_thread_id := meta.get("message_thread_id"):
|
if message_thread_id := meta.get("message_thread_id"):
|
||||||
thread_kwargs["message_thread_id"] = message_thread_id
|
thread_kwargs["message_thread_id"] = message_thread_id
|
||||||
@@ -669,10 +665,10 @@ class TelegramChannel(BaseChannel):
|
|||||||
# Network errors (TimedOut, NetworkError) should propagate immediately
|
# Network errors (TimedOut, NetworkError) should propagate immediately
|
||||||
# to avoid doubling connection demand during pool exhaustion.
|
# to avoid doubling connection demand during pool exhaustion.
|
||||||
if self._is_not_modified_error(e):
|
if self._is_not_modified_error(e):
|
||||||
logger.debug("Final stream edit already applied for {}", chat_id)
|
self.logger.debug("Final stream edit already applied for {}", chat_id)
|
||||||
self._stream_bufs.pop(chat_id, None)
|
self._stream_bufs.pop(chat_id, None)
|
||||||
return
|
return
|
||||||
logger.debug("Final stream edit failed (HTML), trying plain: {}", e)
|
self.logger.debug("Final stream edit failed (HTML), trying plain: {}", e)
|
||||||
# Fall back to raw markdown (not HTML) so users don't see raw tags.
|
# Fall back to raw markdown (not HTML) so users don't see raw tags.
|
||||||
primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text
|
primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text
|
||||||
try:
|
try:
|
||||||
@@ -683,9 +679,9 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
if self._is_not_modified_error(e2):
|
if self._is_not_modified_error(e2):
|
||||||
logger.debug("Final stream plain edit already applied for {}", chat_id)
|
self.logger.debug("Final stream plain edit already applied for {}", chat_id)
|
||||||
else:
|
else:
|
||||||
logger.warning("Final stream edit failed: {}", e2)
|
self.logger.warning("Final stream edit failed: {}", e2)
|
||||||
raise # Let ChannelManager handle retry
|
raise # Let ChannelManager handle retry
|
||||||
for extra_html_chunk in extra_html_chunks:
|
for extra_html_chunk in extra_html_chunks:
|
||||||
try:
|
try:
|
||||||
@@ -727,7 +723,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
buf.message_id = sent.message_id
|
buf.message_id = sent.message_id
|
||||||
buf.last_edit = now
|
buf.last_edit = now
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Stream initial send failed: {}", e)
|
self.logger.warning("Stream initial send failed: {}", e)
|
||||||
raise # Let ChannelManager handle retry
|
raise # Let ChannelManager handle retry
|
||||||
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
|
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
|
||||||
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
|
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
|
||||||
@@ -746,7 +742,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
if self._is_not_modified_error(e):
|
if self._is_not_modified_error(e):
|
||||||
buf.last_edit = now
|
buf.last_edit = now
|
||||||
return
|
return
|
||||||
logger.warning("Stream edit failed: {}", e)
|
self.logger.warning("Stream edit failed: {}", e)
|
||||||
raise # Let ChannelManager handle retry
|
raise # Let ChannelManager handle retry
|
||||||
|
|
||||||
async def _flush_stream_overflow(
|
async def _flush_stream_overflow(
|
||||||
@@ -772,7 +768,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not self._is_not_modified_error(e):
|
if not self._is_not_modified_error(e):
|
||||||
logger.warning("Stream overflow edit failed: {}", e)
|
self.logger.warning("Stream overflow edit failed: {}", e)
|
||||||
raise
|
raise
|
||||||
for chunk in chunks[1:-1]:
|
for chunk in chunks[1:-1]:
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
@@ -793,6 +789,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
if not self.is_allowed(self._sender_id(user)):
|
||||||
|
return
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
|
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
|
||||||
"Send me a message and I'll respond!\n"
|
"Send me a message and I'll respond!\n"
|
||||||
@@ -800,8 +798,10 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Handle /help command, bypassing ACL so all users can access it."""
|
"""Handle /help command for allowed users only."""
|
||||||
if not update.message:
|
if not update.message or not update.effective_user:
|
||||||
|
return
|
||||||
|
if not self.is_allowed(self._sender_id(update.effective_user)):
|
||||||
return
|
return
|
||||||
await update.message.reply_text(build_help_text())
|
await update.message.reply_text(build_help_text())
|
||||||
|
|
||||||
@@ -902,12 +902,12 @@ class TelegramChannel(BaseChannel):
|
|||||||
if media_type in ("voice", "audio"):
|
if media_type in ("voice", "audio"):
|
||||||
transcription = await self.transcribe_audio(file_path)
|
transcription = await self.transcribe_audio(file_path)
|
||||||
if transcription:
|
if transcription:
|
||||||
logger.info("Transcribed {}: {}...", media_type, transcription[:50])
|
self.logger.info("Transcribed {}: {}...", media_type, transcription[:50])
|
||||||
return [path_str], [f"[transcription: {transcription}]"]
|
return [path_str], [f"[transcription: {transcription}]"]
|
||||||
return [path_str], [f"[{media_type}: {path_str}]"]
|
return [path_str], [f"[{media_type}: {path_str}]"]
|
||||||
return [path_str], [f"[{media_type}: {path_str}]"]
|
return [path_str], [f"[{media_type}: {path_str}]"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to download message media: {}", e)
|
self.logger.warning("Failed to download message media: {}", e)
|
||||||
if add_failure_content:
|
if add_failure_content:
|
||||||
return [], [f"[{media_type}: download failed]"]
|
return [], [f"[{media_type}: download failed]"]
|
||||||
return [], []
|
return [], []
|
||||||
@@ -992,6 +992,9 @@ class TelegramChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
message = update.message
|
message = update.message
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
sender_id = self._sender_id(user)
|
||||||
|
if not self.is_allowed(sender_id):
|
||||||
|
return
|
||||||
self._remember_thread_context(message)
|
self._remember_thread_context(message)
|
||||||
|
|
||||||
# Strip @bot_username suffix if present
|
# Strip @bot_username suffix if present
|
||||||
@@ -1003,7 +1006,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
content = self._normalize_telegram_command(content)
|
content = self._normalize_telegram_command(content)
|
||||||
|
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=self._sender_id(user),
|
sender_id=sender_id,
|
||||||
chat_id=str(message.chat_id),
|
chat_id=str(message.chat_id),
|
||||||
content=content,
|
content=content,
|
||||||
metadata=self._build_message_metadata(message, user),
|
metadata=self._build_message_metadata(message, user),
|
||||||
@@ -1019,6 +1022,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
chat_id = message.chat_id
|
chat_id = message.chat_id
|
||||||
sender_id = self._sender_id(user)
|
sender_id = self._sender_id(user)
|
||||||
|
if not self.is_allowed(sender_id):
|
||||||
|
return
|
||||||
self._remember_thread_context(message)
|
self._remember_thread_context(message)
|
||||||
|
|
||||||
# Store chat_id for replies
|
# Store chat_id for replies
|
||||||
@@ -1050,7 +1055,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
media_paths.extend(current_media_paths)
|
media_paths.extend(current_media_paths)
|
||||||
content_parts.extend(current_media_parts)
|
content_parts.extend(current_media_parts)
|
||||||
if current_media_paths:
|
if current_media_paths:
|
||||||
logger.debug("Downloaded message media to {}", current_media_paths[0])
|
self.logger.debug("Downloaded message media to {}", current_media_paths[0])
|
||||||
|
|
||||||
# Reply context: text and/or media from the replied-to message
|
# Reply context: text and/or media from the replied-to message
|
||||||
reply = getattr(message, "reply_to_message", None)
|
reply = getattr(message, "reply_to_message", None)
|
||||||
@@ -1059,13 +1064,13 @@ class TelegramChannel(BaseChannel):
|
|||||||
reply_media, reply_media_parts = await self._download_message_media(reply)
|
reply_media, reply_media_parts = await self._download_message_media(reply)
|
||||||
if reply_media:
|
if reply_media:
|
||||||
media_paths = reply_media + media_paths
|
media_paths = reply_media + media_paths
|
||||||
logger.debug("Attached replied-to media: {}", reply_media[0])
|
self.logger.debug("Attached replied-to media: {}", reply_media[0])
|
||||||
tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None)
|
tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None)
|
||||||
if tag:
|
if tag:
|
||||||
content_parts.insert(0, tag)
|
content_parts.insert(0, tag)
|
||||||
content = "\n".join(content_parts) if content_parts else "[empty message]"
|
content = "\n".join(content_parts) if content_parts else "[empty message]"
|
||||||
|
|
||||||
logger.debug("Telegram message from {}: {}...", sender_id, content[:50])
|
self.logger.debug("message from {}: {}...", sender_id, content[:50])
|
||||||
|
|
||||||
str_chat_id = str(chat_id)
|
str_chat_id = str(chat_id)
|
||||||
metadata = self._build_message_metadata(message, user)
|
metadata = self._build_message_metadata(message, user)
|
||||||
@@ -1144,7 +1149,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
reaction=[ReactionTypeEmoji(emoji=emoji)],
|
reaction=[ReactionTypeEmoji(emoji=emoji)],
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Telegram reaction failed: {}", e)
|
self.logger.debug("reaction failed: {}", e)
|
||||||
|
|
||||||
async def _remove_reaction(self, chat_id: str, message_id: int) -> None:
|
async def _remove_reaction(self, chat_id: str, message_id: int) -> None:
|
||||||
"""Remove emoji reaction from a message (best-effort, non-blocking)."""
|
"""Remove emoji reaction from a message (best-effort, non-blocking)."""
|
||||||
@@ -1157,18 +1162,17 @@ class TelegramChannel(BaseChannel):
|
|||||||
reaction=[],
|
reaction=[],
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Telegram reaction removal failed: {}", e)
|
self.logger.debug("reaction removal failed: {}", e)
|
||||||
|
|
||||||
async def _typing_loop(self, chat_id: str) -> None:
|
async def _typing_loop(self, chat_id: str) -> None:
|
||||||
"""Repeatedly send 'typing' action until cancelled."""
|
"""Repeatedly send 'typing' action until cancelled."""
|
||||||
try:
|
try:
|
||||||
while self._app:
|
with suppress(asyncio.CancelledError):
|
||||||
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
|
while self._app:
|
||||||
await asyncio.sleep(4)
|
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
|
||||||
except asyncio.CancelledError:
|
await asyncio.sleep(4)
|
||||||
pass
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_telegram_error(exc: Exception) -> str:
|
def _format_telegram_error(exc: Exception) -> str:
|
||||||
@@ -1188,18 +1192,18 @@ class TelegramChannel(BaseChannel):
|
|||||||
"""Keep long-polling network failures to a single readable line."""
|
"""Keep long-polling network failures to a single readable line."""
|
||||||
summary = self._format_telegram_error(exc)
|
summary = self._format_telegram_error(exc)
|
||||||
if isinstance(exc, (NetworkError, TimedOut)):
|
if isinstance(exc, (NetworkError, TimedOut)):
|
||||||
logger.warning("Telegram polling network issue: {}", summary)
|
self.logger.warning("polling network issue: {}", summary)
|
||||||
else:
|
else:
|
||||||
logger.error("Telegram polling error: {}", summary)
|
self.logger.error("polling error: {}", summary)
|
||||||
|
|
||||||
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Log polling / handler errors instead of silently swallowing them."""
|
"""Log polling / handler errors instead of silently swallowing them."""
|
||||||
summary = self._format_telegram_error(context.error)
|
summary = self._format_telegram_error(context.error)
|
||||||
|
|
||||||
if isinstance(context.error, (NetworkError, TimedOut)):
|
if isinstance(context.error, (NetworkError, TimedOut)):
|
||||||
logger.warning("Telegram network issue: {}", summary)
|
self.logger.warning("network issue: {}", summary)
|
||||||
else:
|
else:
|
||||||
logger.error("Telegram error: {}", summary)
|
self.logger.error("error: {}", summary)
|
||||||
|
|
||||||
def _get_extension(
|
def _get_extension(
|
||||||
self,
|
self,
|
||||||
@@ -1260,16 +1264,16 @@ class TelegramChannel(BaseChannel):
|
|||||||
chat_id = query.message.chat_id if query.message else None
|
chat_id = query.message.chat_id if query.message else None
|
||||||
sender_id = self._sender_id(user)
|
sender_id = self._sender_id(user)
|
||||||
if not chat_id:
|
if not chat_id:
|
||||||
logger.warning("Callback query without chat_id")
|
self.logger.warning("Callback query without chat_id")
|
||||||
|
return
|
||||||
|
if not self.is_allowed(sender_id):
|
||||||
return
|
return
|
||||||
button_label = query.data or ""
|
button_label = query.data or ""
|
||||||
await query.answer()
|
await query.answer()
|
||||||
if query.message:
|
if query.message:
|
||||||
try:
|
with suppress(Exception):
|
||||||
await query.message.edit_reply_markup(reply_markup=None)
|
await query.message.edit_reply_markup(reply_markup=None)
|
||||||
except Exception:
|
self.logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
||||||
pass
|
|
||||||
logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
|
||||||
self._start_typing(str(chat_id))
|
self._start_typing(str(chat_id))
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from websockets.http11 import Response
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
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
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.utils.helpers import safe_filename
|
from nanobot.utils.helpers import safe_filename
|
||||||
@@ -128,6 +129,17 @@ class WebSocketConfig(Base):
|
|||||||
raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
|
raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def wildcard_host_requires_auth(self) -> Self:
|
||||||
|
if self.host not in ("0.0.0.0", "::"):
|
||||||
|
return self
|
||||||
|
if self.token.strip() or self.token_issue_secret.strip():
|
||||||
|
return self
|
||||||
|
raise ValueError(
|
||||||
|
"host is 0.0.0.0 (all interfaces) but neither token nor "
|
||||||
|
"token_issue_secret is set — set one to prevent unauthenticated access"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
|
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
@@ -148,7 +160,7 @@ def _read_webui_model_name() -> str | None:
|
|||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
model = load_config().agents.defaults.model.strip()
|
model = load_config().resolve_preset().model.strip()
|
||||||
return model or None
|
return model or None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("webui bootstrap could not load model name: {}", e)
|
logger.debug("webui bootstrap could not load model name: {}", e)
|
||||||
@@ -448,7 +460,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("websocket: failed to send {} event: {}", event, e)
|
self.logger.warning("failed to send {} event: {}", event, e)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
@@ -464,7 +476,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
if not cert or not key:
|
if not cert or not key:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"websocket: ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
|
"ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
|
||||||
)
|
)
|
||||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
@@ -501,14 +513,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _issue_route_secret_matches(request.headers, secret):
|
if not _issue_route_secret_matches(request.headers, secret):
|
||||||
return connection.respond(401, "Unauthorized")
|
return connection.respond(401, "Unauthorized")
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"websocket: token_issue_path is set but token_issue_secret is empty; "
|
"token_issue_path is set but token_issue_secret is empty; "
|
||||||
"any client can obtain connection tokens — set token_issue_secret for production."
|
"any client can obtain connection tokens — set token_issue_secret for production."
|
||||||
)
|
)
|
||||||
self._purge_expired_issued_tokens()
|
self._purge_expired_issued_tokens()
|
||||||
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
|
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
|
||||||
logger.error(
|
self.logger.error(
|
||||||
"websocket: too many outstanding issued tokens ({}), rejecting issuance",
|
"too many outstanding issued tokens ({}), rejecting issuance",
|
||||||
len(self._issued_tokens),
|
len(self._issued_tokens),
|
||||||
)
|
)
|
||||||
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
|
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
|
||||||
@@ -531,9 +543,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == issue_expected:
|
if got == issue_expected:
|
||||||
return self._handle_token_issue_http(connection, request)
|
return self._handle_token_issue_http(connection, request)
|
||||||
|
|
||||||
# 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI.
|
# 2. WebUI bootstrap: mints tokens for the embedded UI.
|
||||||
if got == "/webui/bootstrap":
|
if got == "/webui/bootstrap":
|
||||||
return self._handle_webui_bootstrap(connection)
|
return self._handle_webui_bootstrap(connection, request)
|
||||||
|
|
||||||
# 3. REST surface for the embedded UI.
|
# 3. REST surface for the embedded UI.
|
||||||
if got == "/api/sessions":
|
if got == "/api/sessions":
|
||||||
@@ -542,6 +554,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == "/api/settings":
|
if got == "/api/settings":
|
||||||
return self._handle_settings(request)
|
return self._handle_settings(request)
|
||||||
|
|
||||||
|
if got == "/api/commands":
|
||||||
|
return self._handle_commands(request)
|
||||||
|
|
||||||
if got == "/api/settings/update":
|
if got == "/api/settings/update":
|
||||||
return self._handle_settings_update(request)
|
return self._handle_settings_update(request)
|
||||||
|
|
||||||
@@ -606,8 +621,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if now > expiry:
|
if now > expiry:
|
||||||
self._api_tokens.pop(token_key, None)
|
self._api_tokens.pop(token_key, None)
|
||||||
|
|
||||||
def _handle_webui_bootstrap(self, connection: Any) -> Response:
|
def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||||
if not _is_localhost(connection):
|
# When a secret is configured (token_issue_secret or static token),
|
||||||
|
# validate it regardless of source IP. This secures deployments
|
||||||
|
# behind a reverse proxy where all connections appear as localhost.
|
||||||
|
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||||
|
if secret:
|
||||||
|
if not _issue_route_secret_matches(request.headers, secret):
|
||||||
|
return _http_error(401, "Unauthorized")
|
||||||
|
elif not _is_localhost(connection):
|
||||||
|
# No secret configured: only allow localhost (local dev mode).
|
||||||
return _http_error(403, "webui bootstrap is localhost-only")
|
return _http_error(403, "webui bootstrap is localhost-only")
|
||||||
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
|
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
|
||||||
self._purge_expired_issued_tokens()
|
self._purge_expired_issued_tokens()
|
||||||
@@ -689,6 +712,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
return _http_json_response(self._settings_payload())
|
return _http_json_response(self._settings_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_settings_update(self, request: WsRequest) -> Response:
|
def _handle_settings_update(self, request: WsRequest) -> Response:
|
||||||
if not self._check_api_token(request):
|
if not self._check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
@@ -821,7 +849,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
|
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
|
||||||
shutil.copyfile(path, staged)
|
shutil.copyfile(path, staged)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning("websocket: failed to stage outbound media {}: {}", path, exc)
|
self.logger.warning("failed to stage outbound media {}: {}", path, exc)
|
||||||
return None
|
return None
|
||||||
signed = self._sign_media_path(staged)
|
signed = self._sign_media_path(staged)
|
||||||
if signed is None:
|
if signed is None:
|
||||||
@@ -917,7 +945,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
body = candidate.read_bytes()
|
body = candidate.read_bytes()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("websocket static: failed to read {}: {}", candidate, e)
|
self.logger.warning("static: failed to read {}: {}", candidate, e)
|
||||||
return _http_error(500, "Internal Server Error")
|
return _http_error(500, "Internal Server Error")
|
||||||
ctype, _ = mimetypes.guess_type(candidate.name)
|
ctype, _ = mimetypes.guess_type(candidate.name)
|
||||||
if ctype is None:
|
if ctype is None:
|
||||||
@@ -972,7 +1000,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
async def handler(connection: ServerConnection) -> None:
|
async def handler(connection: ServerConnection) -> None:
|
||||||
await self._connection_loop(connection)
|
await self._connection_loop(connection)
|
||||||
|
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"WebSocket server listening on {}://{}:{}{}",
|
"WebSocket server listening on {}://{}:{}{}",
|
||||||
scheme,
|
scheme,
|
||||||
self.config.host,
|
self.config.host,
|
||||||
@@ -980,7 +1008,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self.config.path,
|
self.config.path,
|
||||||
)
|
)
|
||||||
if self.config.token_issue_path:
|
if self.config.token_issue_path:
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"WebSocket token issue route: {}://{}:{}{}",
|
"WebSocket token issue route: {}://{}:{}{}",
|
||||||
scheme,
|
scheme,
|
||||||
self.config.host,
|
self.config.host,
|
||||||
@@ -1014,7 +1042,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not client_id:
|
if not client_id:
|
||||||
client_id = f"anon-{uuid.uuid4().hex[:12]}"
|
client_id = f"anon-{uuid.uuid4().hex[:12]}"
|
||||||
elif len(client_id) > 128:
|
elif len(client_id) > 128:
|
||||||
logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id))
|
self.logger.warning("client_id too long ({} chars), truncating", len(client_id))
|
||||||
client_id = client_id[:128]
|
client_id = client_id[:128]
|
||||||
|
|
||||||
default_chat_id = str(uuid.uuid4())
|
default_chat_id = str(uuid.uuid4())
|
||||||
@@ -1039,7 +1067,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
raw = raw.decode("utf-8")
|
raw = raw.decode("utf-8")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
logger.warning("websocket: ignoring non-utf8 binary frame")
|
self.logger.warning("ignoring non-utf8 binary frame")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
envelope = _parse_envelope(raw)
|
envelope = _parse_envelope(raw)
|
||||||
@@ -1057,7 +1085,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("websocket connection ended: {}", e)
|
self.logger.debug("connection ended: {}", e)
|
||||||
finally:
|
finally:
|
||||||
self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
|
|
||||||
@@ -1097,8 +1125,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
Path(p).unlink(missing_ok=True)
|
Path(p).unlink(missing_ok=True)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"websocket: failed to unlink partial media {}: {}", p, exc
|
"failed to unlink partial media {}: {}", p, exc
|
||||||
)
|
)
|
||||||
return [], reason
|
return [], reason
|
||||||
|
|
||||||
@@ -1122,7 +1150,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except FileSizeExceeded:
|
except FileSizeExceeded:
|
||||||
return _abort("size")
|
return _abort("size")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("websocket: media decode failed: {}", exc)
|
self.logger.warning("media decode failed: {}", exc)
|
||||||
return _abort("decode")
|
return _abort("decode")
|
||||||
if saved is None:
|
if saved is None:
|
||||||
return _abort("decode")
|
return _abort("decode")
|
||||||
@@ -1184,12 +1212,15 @@ class WebSocketChannel(BaseChannel):
|
|||||||
|
|
||||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
|
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||||
|
if envelope.get("webui") is True:
|
||||||
|
metadata["webui"] = True
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=client_id,
|
sender_id=client_id,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
content=content,
|
content=content,
|
||||||
media=media_paths or None,
|
media=media_paths or None,
|
||||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||||
@@ -1204,7 +1235,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._server_task
|
await self._server_task
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("websocket: server task error during shutdown: {}", e)
|
self.logger.warning("server task error during shutdown: {}", e)
|
||||||
self._server_task = None
|
self._server_task = None
|
||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
@@ -1218,16 +1249,23 @@ class WebSocketChannel(BaseChannel):
|
|||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
logger.warning("websocket{}connection gone", label)
|
self.logger.warning("connection gone{}", label)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("websocket{}send failed: {}", label, e)
|
self.logger.exception("send failed{}", label)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
|
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
|
||||||
conns = list(self._subs.get(msg.chat_id, ()))
|
conns = list(self._subs.get(msg.chat_id, ()))
|
||||||
if not conns:
|
if not conns:
|
||||||
logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
|
return
|
||||||
|
# Signal that the agent has fully finished processing the current turn.
|
||||||
|
if msg.metadata.get("_turn_end"):
|
||||||
|
await self.send_turn_end(msg.chat_id)
|
||||||
|
return
|
||||||
|
if msg.metadata.get("_session_updated"):
|
||||||
|
await self.send_session_updated(msg.chat_id)
|
||||||
return
|
return
|
||||||
text = msg.content
|
text = msg.content
|
||||||
if msg.buttons:
|
if msg.buttons:
|
||||||
@@ -1285,3 +1323,23 @@ class WebSocketChannel(BaseChannel):
|
|||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" stream ")
|
await self._safe_send_to(connection, raw, label=" stream ")
|
||||||
|
|
||||||
|
async def send_turn_end(self, chat_id: str) -> None:
|
||||||
|
"""Signal that the agent has fully finished processing the current turn."""
|
||||||
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
|
if not conns:
|
||||||
|
return
|
||||||
|
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
|
||||||
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
|
for connection in conns:
|
||||||
|
await self._safe_send_to(connection, raw, label=" turn_end ")
|
||||||
|
|
||||||
|
async def send_session_updated(self, chat_id: str) -> None:
|
||||||
|
"""Notify clients that session metadata changed outside the main turn."""
|
||||||
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
|
if not conns:
|
||||||
|
return
|
||||||
|
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
||||||
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
|
for connection in conns:
|
||||||
|
await self._safe_send_to(connection, raw, label=" session_updated ")
|
||||||
|
|||||||
+48
-44
@@ -10,14 +10,13 @@ from collections import OrderedDict
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
|
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
|
||||||
|
|
||||||
@@ -103,11 +102,11 @@ class WecomChannel(BaseChannel):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the WeCom bot with WebSocket long connection."""
|
"""Start the WeCom bot with WebSocket long connection."""
|
||||||
if not WECOM_AVAILABLE:
|
if not WECOM_AVAILABLE:
|
||||||
logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]")
|
self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.bot_id or not self.config.secret:
|
if not self.config.bot_id or not self.config.secret:
|
||||||
logger.error("WeCom bot_id and secret not configured")
|
self.logger.error("bot_id and secret not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
from wecom_aibot_sdk import WSClient, generate_req_id
|
from wecom_aibot_sdk import WSClient, generate_req_id
|
||||||
@@ -137,8 +136,8 @@ class WecomChannel(BaseChannel):
|
|||||||
self._client.on("message.mixed", self._on_mixed_message)
|
self._client.on("message.mixed", self._on_mixed_message)
|
||||||
self._client.on("event.enter_chat", self._on_enter_chat)
|
self._client.on("event.enter_chat", self._on_enter_chat)
|
||||||
|
|
||||||
logger.info("WeCom bot starting with WebSocket long connection")
|
self.logger.info("bot starting with WebSocket long connection")
|
||||||
logger.info("No public IP required - using WebSocket to receive events")
|
self.logger.info("No public IP required - using WebSocket to receive events")
|
||||||
|
|
||||||
# Connect
|
# Connect
|
||||||
await self._client.connect_async()
|
await self._client.connect_async()
|
||||||
@@ -152,24 +151,24 @@ class WecomChannel(BaseChannel):
|
|||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
if self._client:
|
||||||
await self._client.disconnect()
|
await self._client.disconnect()
|
||||||
logger.info("WeCom bot stopped")
|
self.logger.info("bot stopped")
|
||||||
|
|
||||||
async def _on_connected(self, frame: Any) -> None:
|
async def _on_connected(self, frame: Any) -> None:
|
||||||
"""Handle WebSocket connected event."""
|
"""Handle WebSocket connected event."""
|
||||||
logger.info("WeCom WebSocket connected")
|
self.logger.info("WebSocket connected")
|
||||||
|
|
||||||
async def _on_authenticated(self, frame: Any) -> None:
|
async def _on_authenticated(self, frame: Any) -> None:
|
||||||
"""Handle authentication success event."""
|
"""Handle authentication success event."""
|
||||||
logger.info("WeCom authenticated successfully")
|
self.logger.info("authenticated successfully")
|
||||||
|
|
||||||
async def _on_disconnected(self, frame: Any) -> None:
|
async def _on_disconnected(self, frame: Any) -> None:
|
||||||
"""Handle WebSocket disconnected event."""
|
"""Handle WebSocket disconnected event."""
|
||||||
reason = frame.body if hasattr(frame, 'body') else str(frame)
|
reason = frame.body if hasattr(frame, 'body') else str(frame)
|
||||||
logger.warning("WeCom WebSocket disconnected: {}", reason)
|
self.logger.warning("WebSocket disconnected: {}", reason)
|
||||||
|
|
||||||
async def _on_error(self, frame: Any) -> None:
|
async def _on_error(self, frame: Any) -> None:
|
||||||
"""Handle error event."""
|
"""Handle error event."""
|
||||||
logger.error("WeCom error: {}", frame)
|
self.logger.error("error: {}", frame)
|
||||||
|
|
||||||
async def _on_text_message(self, frame: Any) -> None:
|
async def _on_text_message(self, frame: Any) -> None:
|
||||||
"""Handle text message."""
|
"""Handle text message."""
|
||||||
@@ -204,13 +203,16 @@ class WecomChannel(BaseChannel):
|
|||||||
|
|
||||||
chat_id = body.get("chatid", "") if isinstance(body, dict) else ""
|
chat_id = body.get("chatid", "") if isinstance(body, dict) else ""
|
||||||
|
|
||||||
|
if chat_id and not self.is_allowed(chat_id):
|
||||||
|
return
|
||||||
|
|
||||||
if chat_id and self.config.welcome_message:
|
if chat_id and self.config.welcome_message:
|
||||||
await self._client.reply_welcome(frame, {
|
await self._client.reply_welcome(frame, {
|
||||||
"msgtype": "text",
|
"msgtype": "text",
|
||||||
"text": {"content": self.config.welcome_message},
|
"text": {"content": self.config.welcome_message},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error handling enter_chat: {}", e)
|
self.logger.exception("Error handling enter_chat")
|
||||||
|
|
||||||
async def _process_message(self, frame: Any, msg_type: str) -> None:
|
async def _process_message(self, frame: Any, msg_type: str) -> None:
|
||||||
"""Process incoming message and forward to bus."""
|
"""Process incoming message and forward to bus."""
|
||||||
@@ -225,7 +227,7 @@ class WecomChannel(BaseChannel):
|
|||||||
|
|
||||||
# Ensure body is a dict
|
# Ensure body is a dict
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
logger.warning("Invalid body type: {}", type(body))
|
self.logger.warning("Invalid body type: {}", type(body))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Extract message info
|
# Extract message info
|
||||||
@@ -233,6 +235,12 @@ class WecomChannel(BaseChannel):
|
|||||||
if not msg_id:
|
if not msg_id:
|
||||||
msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
|
msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
|
||||||
|
|
||||||
|
# Extract sender info from "from" field (SDK format)
|
||||||
|
from_info = body.get("from", {})
|
||||||
|
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
|
||||||
|
if not self.is_allowed(sender_id):
|
||||||
|
return
|
||||||
|
|
||||||
# Deduplication check
|
# Deduplication check
|
||||||
if msg_id in self._processed_message_ids:
|
if msg_id in self._processed_message_ids:
|
||||||
return
|
return
|
||||||
@@ -242,10 +250,6 @@ class WecomChannel(BaseChannel):
|
|||||||
while len(self._processed_message_ids) > 1000:
|
while len(self._processed_message_ids) > 1000:
|
||||||
self._processed_message_ids.popitem(last=False)
|
self._processed_message_ids.popitem(last=False)
|
||||||
|
|
||||||
# Extract sender info from "from" field (SDK format)
|
|
||||||
from_info = body.get("from", {})
|
|
||||||
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
|
|
||||||
|
|
||||||
# For single chat, chatid is the sender's userid
|
# For single chat, chatid is the sender's userid
|
||||||
# For group chat, chatid is provided in body
|
# For group chat, chatid is provided in body
|
||||||
chat_type = body.get("chattype", "single")
|
chat_type = body.get("chattype", "single")
|
||||||
@@ -345,8 +349,8 @@ class WecomChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error processing WeCom message: {}", e)
|
self.logger.exception("Error processing message")
|
||||||
|
|
||||||
async def _download_and_save_media(
|
async def _download_and_save_media(
|
||||||
self,
|
self,
|
||||||
@@ -365,12 +369,12 @@ class WecomChannel(BaseChannel):
|
|||||||
data, fname = await self._client.download_file(file_url, aes_key)
|
data, fname = await self._client.download_file(file_url, aes_key)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
logger.warning("Failed to download media from WeCom")
|
self.logger.warning("Failed to download media")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(data) > WECOM_UPLOAD_MAX_BYTES:
|
if len(data) > WECOM_UPLOAD_MAX_BYTES:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"WeCom inbound media too large: {} bytes (max {})",
|
"inbound media too large: {} bytes (max {})",
|
||||||
len(data),
|
len(data),
|
||||||
WECOM_UPLOAD_MAX_BYTES,
|
WECOM_UPLOAD_MAX_BYTES,
|
||||||
)
|
)
|
||||||
@@ -383,11 +387,11 @@ class WecomChannel(BaseChannel):
|
|||||||
|
|
||||||
file_path = media_dir / filename
|
file_path = media_dir / filename
|
||||||
await asyncio.to_thread(file_path.write_bytes, data)
|
await asyncio.to_thread(file_path.write_bytes, data)
|
||||||
logger.debug("Downloaded {} to {}", media_type, file_path)
|
self.logger.debug("Downloaded {} to {}", media_type, file_path)
|
||||||
return str(file_path)
|
return str(file_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error downloading media: {}", e)
|
self.logger.exception("Error downloading media")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _upload_media_ws(
|
async def _upload_media_ws(
|
||||||
@@ -424,9 +428,9 @@ class WecomChannel(BaseChannel):
|
|||||||
# MD5 is used for file integrity only, not cryptographic security
|
# MD5 is used for file integrity only, not cryptographic security
|
||||||
md5_hash = hashlib.md5(data).hexdigest()
|
md5_hash = hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
CHUNK_SIZE = 512 * 1024 # 512 KB raw (before base64)
|
chunk_size = 512 * 1024 # 512 KB raw (before base64)
|
||||||
mv = memoryview(data)
|
mv = memoryview(data)
|
||||||
chunk_list = [bytes(mv[i : i + CHUNK_SIZE]) for i in range(0, file_size, CHUNK_SIZE)]
|
chunk_list = [bytes(mv[i : i + chunk_size]) for i in range(0, file_size, chunk_size)]
|
||||||
n_chunks = len(chunk_list)
|
n_chunks = len(chunk_list)
|
||||||
del mv, data
|
del mv, data
|
||||||
|
|
||||||
@@ -440,11 +444,11 @@ class WecomChannel(BaseChannel):
|
|||||||
"md5": md5_hash,
|
"md5": md5_hash,
|
||||||
}, "aibot_upload_media_init")
|
}, "aibot_upload_media_init")
|
||||||
if resp.errcode != 0:
|
if resp.errcode != 0:
|
||||||
logger.warning("WeCom upload init failed ({}): {}", resp.errcode, resp.errmsg)
|
self.logger.warning("upload init failed ({}): {}", resp.errcode, resp.errmsg)
|
||||||
return None, None
|
return None, None
|
||||||
upload_id = resp.body.get("upload_id") if resp.body else None
|
upload_id = resp.body.get("upload_id") if resp.body else None
|
||||||
if not upload_id:
|
if not upload_id:
|
||||||
logger.warning("WeCom upload init: no upload_id in response")
|
self.logger.warning("upload init: no upload_id in response")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Step 2: send chunks
|
# Step 2: send chunks
|
||||||
@@ -456,7 +460,7 @@ class WecomChannel(BaseChannel):
|
|||||||
"base64_data": base64.b64encode(chunk).decode(),
|
"base64_data": base64.b64encode(chunk).decode(),
|
||||||
}, "aibot_upload_media_chunk")
|
}, "aibot_upload_media_chunk")
|
||||||
if resp.errcode != 0:
|
if resp.errcode != 0:
|
||||||
logger.warning("WeCom upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg)
|
self.logger.warning("upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Step 3: finish
|
# Step 3: finish
|
||||||
@@ -465,29 +469,29 @@ class WecomChannel(BaseChannel):
|
|||||||
"upload_id": upload_id,
|
"upload_id": upload_id,
|
||||||
}, "aibot_upload_media_finish")
|
}, "aibot_upload_media_finish")
|
||||||
if resp.errcode != 0:
|
if resp.errcode != 0:
|
||||||
logger.warning("WeCom upload finish failed ({}): {}", resp.errcode, resp.errmsg)
|
self.logger.warning("upload finish failed ({}): {}", resp.errcode, resp.errmsg)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
media_id = resp.body.get("media_id") if resp.body else None
|
media_id = resp.body.get("media_id") if resp.body else None
|
||||||
if not media_id:
|
if not media_id:
|
||||||
logger.warning("WeCom upload finish: no media_id in response body={}", resp.body)
|
self.logger.warning("upload finish: no media_id in response body={}", resp.body)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
suffix = "..." if len(media_id) > 16 else ""
|
suffix = "..." if len(media_id) > 16 else ""
|
||||||
logger.debug("WeCom uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix)
|
self.logger.debug("uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix)
|
||||||
return media_id, media_type
|
return media_id, media_type
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning("WeCom upload skipped for {}: {}", file_path, e)
|
self.logger.warning("upload skipped for {}: {}", file_path, e)
|
||||||
return None, None
|
return None, None
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e)
|
self.logger.exception("_upload_media_ws error for {}", file_path)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through WeCom."""
|
"""Send a message through WeCom."""
|
||||||
if not self._client:
|
if not self._client:
|
||||||
logger.warning("WeCom client not initialized")
|
self.logger.warning("client not initialized")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -500,7 +504,7 @@ class WecomChannel(BaseChannel):
|
|||||||
# Send media files via WebSocket upload
|
# Send media files via WebSocket upload
|
||||||
for file_path in msg.media or []:
|
for file_path in msg.media or []:
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
logger.warning("WeCom media file not found: {}", file_path)
|
self.logger.warning("media file not found: {}", file_path)
|
||||||
continue
|
continue
|
||||||
media_id, media_type = await self._upload_media_ws(self._client, file_path)
|
media_id, media_type = await self._upload_media_ws(self._client, file_path)
|
||||||
if media_id:
|
if media_id:
|
||||||
@@ -514,7 +518,7 @@ class WecomChannel(BaseChannel):
|
|||||||
"msgtype": media_type,
|
"msgtype": media_type,
|
||||||
media_type: {"media_id": media_id},
|
media_type: {"media_id": media_id},
|
||||||
})
|
})
|
||||||
logger.debug("WeCom sent {} → {}", media_type, msg.chat_id)
|
self.logger.debug("sent {} → {}", media_type, msg.chat_id)
|
||||||
else:
|
else:
|
||||||
content += f"\n[file upload failed: {os.path.basename(file_path)}]"
|
content += f"\n[file upload failed: {os.path.basename(file_path)}]"
|
||||||
|
|
||||||
@@ -532,8 +536,8 @@ class WecomChannel(BaseChannel):
|
|||||||
content,
|
content,
|
||||||
finish=not is_progress,
|
finish=not is_progress,
|
||||||
)
|
)
|
||||||
logger.debug(
|
self.logger.debug(
|
||||||
"WeCom {} sent to {}",
|
"{} sent to {}",
|
||||||
"progress" if is_progress else "message",
|
"progress" if is_progress else "message",
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
)
|
)
|
||||||
@@ -543,7 +547,7 @@ class WecomChannel(BaseChannel):
|
|||||||
"msgtype": "markdown",
|
"msgtype": "markdown",
|
||||||
"markdown": {"content": content},
|
"markdown": {"content": content},
|
||||||
})
|
})
|
||||||
logger.info("WeCom proactive send to {}", msg.chat_id)
|
self.logger.info("proactive send to {}", msg.chat_id)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error sending WeCom message to chat_id={}", msg.chat_id)
|
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
|
||||||
|
|||||||
+178
-88
@@ -11,14 +11,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -53,7 +54,7 @@ MESSAGE_TYPE_BOT = 2
|
|||||||
MESSAGE_STATE_FINISH = 2
|
MESSAGE_STATE_FINISH = 2
|
||||||
|
|
||||||
WEIXIN_MAX_MESSAGE_LEN = 4000
|
WEIXIN_MAX_MESSAGE_LEN = 4000
|
||||||
WEIXIN_CHANNEL_VERSION = "2.1.1"
|
WEIXIN_CHANNEL_VERSION = "2.1.7"
|
||||||
ILINK_APP_ID = "bot"
|
ILINK_APP_ID = "bot"
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +80,36 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
|
|||||||
ERRCODE_SESSION_EXPIRED = -14
|
ERRCODE_SESSION_EXPIRED = -14
|
||||||
SESSION_PAUSE_DURATION_S = 60 * 60
|
SESSION_PAUSE_DURATION_S = 60 * 60
|
||||||
|
|
||||||
|
# iLink rate-limit / stale-session errcode
|
||||||
|
RATE_LIMIT_ERRCODE = -2
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stale_session_ret(
|
||||||
|
ret: int | None,
|
||||||
|
errcode: int | None,
|
||||||
|
errmsg: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when iLink returns ret=-2 / errcode=-2 that is likely a stale
|
||||||
|
context_token rather than a genuine rate limit.
|
||||||
|
|
||||||
|
Empirically iLink signals these two scenarios weakly:
|
||||||
|
- stale session: ret=-2, errmsg="unknown error" OR errmsg empty/None
|
||||||
|
- genuine rate limit: ret=-2 with a populated errmsg such as
|
||||||
|
"frequency limit" / "too frequently" / similar
|
||||||
|
|
||||||
|
Treating "unknown error" and empty/None errmsg as stale-session signals
|
||||||
|
lets the caller attempt one tokenless retry. A true rate limit still
|
||||||
|
falls through to the existing retry/backoff path if the tokenless
|
||||||
|
attempt also fails.
|
||||||
|
"""
|
||||||
|
if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
|
||||||
|
return False
|
||||||
|
msg = (errmsg or "").strip().lower()
|
||||||
|
if not msg:
|
||||||
|
return True
|
||||||
|
return msg == "unknown error"
|
||||||
|
|
||||||
|
|
||||||
# Retry constants (matching the reference plugin's monitor.ts)
|
# Retry constants (matching the reference plugin's monitor.ts)
|
||||||
MAX_CONSECUTIVE_FAILURES = 3
|
MAX_CONSECUTIVE_FAILURES = 3
|
||||||
BACKOFF_DELAY_S = 30
|
BACKOFF_DELAY_S = 30
|
||||||
@@ -211,7 +242,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
|
|
||||||
def _save_state(self) -> None:
|
def _save_state(self) -> None:
|
||||||
state_file = self._get_state_dir() / "account.json"
|
state_file = self._get_state_dir() / "account.json"
|
||||||
try:
|
with suppress(Exception):
|
||||||
data = {
|
data = {
|
||||||
"token": self._token,
|
"token": self._token,
|
||||||
"get_updates_buf": self._get_updates_buf,
|
"get_updates_buf": self._get_updates_buf,
|
||||||
@@ -220,8 +251,6 @@ class WeixinChannel(BaseChannel):
|
|||||||
"base_url": self.config.base_url,
|
"base_url": self.config.base_url,
|
||||||
}
|
}
|
||||||
state_file.write_text(json.dumps(data, ensure_ascii=False))
|
state_file.write_text(json.dumps(data, ensure_ascii=False))
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# HTTP helpers (matches api.ts buildHeaders / apiFetch)
|
# HTTP helpers (matches api.ts buildHeaders / apiFetch)
|
||||||
@@ -367,14 +396,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
if base_url:
|
if base_url:
|
||||||
self.config.base_url = base_url
|
self.config.base_url = base_url
|
||||||
self._save_state()
|
self._save_state()
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"WeChat login successful! bot_id={} user_id={}",
|
"login successful! bot_id={} user_id={}",
|
||||||
bot_id,
|
bot_id,
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error("Login confirmed but no bot_token in response")
|
self.logger.error("Login confirmed but no bot_token in response")
|
||||||
return False
|
return False
|
||||||
elif status == "scaned_but_redirect":
|
elif status == "scaned_but_redirect":
|
||||||
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
|
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
|
||||||
@@ -388,7 +417,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
elif status == "expired":
|
elif status == "expired":
|
||||||
refresh_count += 1
|
refresh_count += 1
|
||||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"QR code expired too many times ({}/{}), giving up.",
|
"QR code expired too many times ({}/{}), giving up.",
|
||||||
refresh_count - 1,
|
refresh_count - 1,
|
||||||
MAX_QR_REFRESH_COUNT,
|
MAX_QR_REFRESH_COUNT,
|
||||||
@@ -402,8 +431,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("WeChat QR login failed: {}", e)
|
self.logger.exception("QR login failed")
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -470,11 +499,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._token = self.config.token
|
self._token = self.config.token
|
||||||
elif not self._load_state():
|
elif not self._load_state():
|
||||||
if not await self._qr_login():
|
if not await self._qr_login():
|
||||||
logger.error("WeChat login failed. Run 'nanobot channels login weixin' to authenticate.")
|
self.logger.error("login failed. Run 'nanobot channels login weixin' to authenticate.")
|
||||||
self._running = False
|
self._running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("WeChat channel starting with long-poll...")
|
self.logger.info("channel starting with long-poll...")
|
||||||
|
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -487,6 +516,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
break
|
break
|
||||||
|
self.logger.exception("WeChat poll loop error")
|
||||||
consecutive_failures += 1
|
consecutive_failures += 1
|
||||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
@@ -526,6 +556,22 @@ class WeixinChannel(BaseChannel):
|
|||||||
f"WeChat session paused, {remaining_min} min remaining (errcode {ERRCODE_SESSION_EXPIRED})"
|
f"WeChat session paused, {remaining_min} min remaining (errcode {ERRCODE_SESSION_EXPIRED})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_response_error(self, data: dict, operation: str, *, body: dict | None = None) -> None:
|
||||||
|
"""Check both ``ret`` and ``errcode`` like the reference TS code.
|
||||||
|
|
||||||
|
The iLink API may signal failure through either field (or both).
|
||||||
|
``_poll_once`` already checks both; outbound send helpers must do
|
||||||
|
the same to avoid silent drops.
|
||||||
|
"""
|
||||||
|
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 not is_error:
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"WeChat {operation} error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _poll_once(self) -> None:
|
async def _poll_once(self) -> None:
|
||||||
remaining = self._session_pause_remaining_s()
|
remaining = self._session_pause_remaining_s()
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
@@ -552,8 +598,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED:
|
if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED:
|
||||||
self._pause_session()
|
self._pause_session()
|
||||||
remaining = self._session_pause_remaining_s()
|
remaining = self._session_pause_remaining_s()
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"WeChat session expired (errcode {}). Pausing {} min.",
|
"session expired (errcode {}). Pausing {} min.",
|
||||||
errcode,
|
errcode,
|
||||||
max((remaining + 59) // 60, 1),
|
max((remaining + 59) // 60, 1),
|
||||||
)
|
)
|
||||||
@@ -579,7 +625,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._process_message(msg)
|
await self._process_message(msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
self.logger.exception("Failed to process WeChat message")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||||
@@ -591,20 +637,24 @@ class WeixinChannel(BaseChannel):
|
|||||||
if msg.get("message_type") == MESSAGE_TYPE_BOT:
|
if msg.get("message_type") == MESSAGE_TYPE_BOT:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Deduplication by message_id
|
|
||||||
msg_id = str(msg.get("message_id", "") or msg.get("seq", ""))
|
msg_id = str(msg.get("message_id", "") or msg.get("seq", ""))
|
||||||
if not msg_id:
|
if not msg_id:
|
||||||
msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}"
|
msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}"
|
||||||
|
|
||||||
|
from_user_id = msg.get("from_user_id", "") or ""
|
||||||
|
if not from_user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.is_allowed(from_user_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Deduplication by message_id
|
||||||
if msg_id in self._processed_ids:
|
if msg_id in self._processed_ids:
|
||||||
return
|
return
|
||||||
self._processed_ids[msg_id] = None
|
self._processed_ids[msg_id] = None
|
||||||
while len(self._processed_ids) > 1000:
|
while len(self._processed_ids) > 1000:
|
||||||
self._processed_ids.popitem(last=False)
|
self._processed_ids.popitem(last=False)
|
||||||
|
|
||||||
from_user_id = msg.get("from_user_id", "") or ""
|
|
||||||
if not from_user_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Cache context_token (required for all replies — inbound.ts:23-27)
|
# Cache context_token (required for all replies — inbound.ts:23-27)
|
||||||
ctx_token = msg.get("context_token", "")
|
ctx_token = msg.get("context_token", "")
|
||||||
if ctx_token:
|
if ctx_token:
|
||||||
@@ -758,8 +808,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
self.logger.info(
|
||||||
"WeChat inbound: from={} items={} bodyLen={}",
|
"inbound: from={} items={} bodyLen={}",
|
||||||
from_user_id,
|
from_user_id,
|
||||||
",".join(str(i.get("type", 0)) for i in item_list),
|
",".join(str(i.get("type", 0)) for i in item_list),
|
||||||
len(content),
|
len(content),
|
||||||
@@ -842,8 +892,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
and self._is_retryable_media_download_error(e)
|
and self._is_retryable_media_download_error(e)
|
||||||
)
|
)
|
||||||
if should_fallback:
|
if should_fallback:
|
||||||
logger.warning(
|
self.logger.warning(
|
||||||
"WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
|
"media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
|
||||||
media_type,
|
media_type,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
@@ -868,8 +918,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
file_path.write_bytes(data)
|
file_path.write_bytes(data)
|
||||||
return str(file_path)
|
return str(file_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error downloading WeChat media: {}", e)
|
self.logger.exception("Error downloading media")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -932,21 +982,15 @@ class WeixinChannel(BaseChannel):
|
|||||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
||||||
if stop_event.is_set():
|
if stop_event.is_set():
|
||||||
break
|
break
|
||||||
try:
|
with suppress(Exception):
|
||||||
await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING)
|
await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
finally:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
if not self._client or not self._token:
|
if not self._client or not self._token:
|
||||||
logger.warning("WeChat client not initialized or not authenticated")
|
raise RuntimeError("WeChat client not initialized or not authenticated")
|
||||||
return
|
self._assert_session_active()
|
||||||
try:
|
|
||||||
self._assert_session_active()
|
|
||||||
except RuntimeError:
|
|
||||||
return
|
|
||||||
|
|
||||||
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
||||||
if not is_progress:
|
if not is_progress:
|
||||||
@@ -955,23 +999,17 @@ class WeixinChannel(BaseChannel):
|
|||||||
content = msg.content.strip()
|
content = msg.content.strip()
|
||||||
ctx_token = self._context_tokens.get(msg.chat_id, "")
|
ctx_token = self._context_tokens.get(msg.chat_id, "")
|
||||||
if not ctx_token:
|
if not ctx_token:
|
||||||
logger.warning(
|
raise RuntimeError(
|
||||||
"WeChat: no context_token for chat_id={}, cannot send",
|
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
||||||
msg.chat_id,
|
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
typing_ticket = ""
|
typing_ticket = ""
|
||||||
try:
|
with suppress(Exception):
|
||||||
typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token)
|
typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token)
|
||||||
except Exception:
|
|
||||||
typing_ticket = ""
|
|
||||||
|
|
||||||
if typing_ticket:
|
if typing_ticket:
|
||||||
try:
|
with suppress(Exception):
|
||||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
|
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
typing_keepalive_stop = asyncio.Event()
|
typing_keepalive_stop = asyncio.Event()
|
||||||
typing_keepalive_task: asyncio.Task | None = None
|
typing_keepalive_task: asyncio.Task | None = None
|
||||||
@@ -985,14 +1023,13 @@ class WeixinChannel(BaseChannel):
|
|||||||
for media_path in (msg.media or []):
|
for media_path in (msg.media or []):
|
||||||
try:
|
try:
|
||||||
await self._send_media_file(msg.chat_id, media_path, ctx_token)
|
await self._send_media_file(msg.chat_id, media_path, ctx_token)
|
||||||
except (httpx.TimeoutException, httpx.TransportError) as net_err:
|
except (httpx.TimeoutException, httpx.TransportError):
|
||||||
# Network/transport errors: do NOT fall back to text —
|
# Network/transport errors: do NOT fall back to text —
|
||||||
# the text send would also likely fail, and the outer
|
# the text send would also likely fail, and the outer
|
||||||
# except will re-raise so ChannelManager retries properly.
|
# except will re-raise so ChannelManager retries properly.
|
||||||
logger.error(
|
self.logger.opt(exception=True).warning(
|
||||||
"Network error sending WeChat media {}: {}",
|
"Network error sending media {}",
|
||||||
media_path,
|
media_path,
|
||||||
net_err,
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except httpx.HTTPStatusError as http_err:
|
except httpx.HTTPStatusError as http_err:
|
||||||
@@ -1003,27 +1040,26 @@ class WeixinChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
if status_code >= 500:
|
if status_code >= 500:
|
||||||
# Server-side / retryable HTTP error — same as network.
|
# Server-side / retryable HTTP error — same as network.
|
||||||
logger.error(
|
self.logger.exception(
|
||||||
"Server error ({} {}) sending WeChat media {}: {}",
|
"Server error ({} {}) sending media {}",
|
||||||
status_code,
|
status_code,
|
||||||
http_err.response.reason_phrase
|
http_err.response.reason_phrase
|
||||||
if http_err.response is not None
|
if http_err.response is not None
|
||||||
else "",
|
else "",
|
||||||
media_path,
|
media_path,
|
||||||
http_err,
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
# 4xx client errors are NOT retryable — fall back to text.
|
# 4xx client errors are NOT retryable — fall back to text.
|
||||||
filename = Path(media_path).name
|
filename = Path(media_path).name
|
||||||
logger.error("Failed to send WeChat media {}: {}", media_path, http_err)
|
self.logger.exception("Failed to send media {}", media_path)
|
||||||
await self._send_text(
|
await self._send_text(
|
||||||
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
|
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# Non-network errors (format, file-not-found, etc.):
|
# Non-network errors (format, file-not-found, etc.):
|
||||||
# notify the user via text fallback.
|
# notify the user via text fallback.
|
||||||
filename = Path(media_path).name
|
filename = Path(media_path).name
|
||||||
logger.error("Failed to send WeChat media {}: {}", media_path, e)
|
self.logger.exception("Failed to send media {}", media_path)
|
||||||
# Notify user about failure via text
|
# Notify user about failure via text
|
||||||
await self._send_text(
|
await self._send_text(
|
||||||
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
|
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
|
||||||
@@ -1036,23 +1072,19 @@ class WeixinChannel(BaseChannel):
|
|||||||
chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN)
|
chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN)
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
await self._send_text(msg.chat_id, chunk, ctx_token)
|
await self._send_text(msg.chat_id, chunk, ctx_token)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending WeChat message: {}", e)
|
self.logger.exception("Error sending message")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if typing_keepalive_task:
|
if typing_keepalive_task:
|
||||||
typing_keepalive_stop.set()
|
typing_keepalive_stop.set()
|
||||||
typing_keepalive_task.cancel()
|
typing_keepalive_task.cancel()
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await typing_keepalive_task
|
await typing_keepalive_task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if typing_ticket and not is_progress:
|
if typing_ticket and not is_progress:
|
||||||
try:
|
with suppress(Exception):
|
||||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
||||||
"""Start typing indicator immediately when a message is received."""
|
"""Start typing indicator immediately when a message is received."""
|
||||||
@@ -1065,7 +1097,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
|
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("WeChat typing indicator start failed for {}: {}", chat_id, e)
|
self.logger.debug("typing indicator start failed for {}: {}", chat_id, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
@@ -1076,10 +1108,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
||||||
if stop_event.is_set():
|
if stop_event.is_set():
|
||||||
break
|
break
|
||||||
try:
|
with suppress(Exception):
|
||||||
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
|
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
finally:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1095,10 +1125,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
if stop_event:
|
if stop_event:
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
with suppress(asyncio.CancelledError):
|
||||||
await task
|
await task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
if not clear_remote:
|
if not clear_remote:
|
||||||
return
|
return
|
||||||
entry = self._typing_tickets.get(chat_id)
|
entry = self._typing_tickets.get(chat_id)
|
||||||
@@ -1108,7 +1136,15 @@ class WeixinChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL)
|
await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("WeChat typing clear failed for {}: {}", chat_id, e)
|
self.logger.debug("typing clear failed for {}: {}", chat_id, e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_client_id() -> str:
|
||||||
|
"""Generate a client_id matching the reference plugin format.
|
||||||
|
|
||||||
|
openclaw-weixin uses ``{prefix}:{timestamp}-{8-char hex}``.
|
||||||
|
"""
|
||||||
|
return f"nanobot:{int(time.time() * 1000)}-{os.urandom(4).hex()}"
|
||||||
|
|
||||||
async def _send_text(
|
async def _send_text(
|
||||||
self,
|
self,
|
||||||
@@ -1117,7 +1153,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
context_token: str,
|
context_token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a text message matching the exact protocol from send.ts."""
|
"""Send a text message matching the exact protocol from send.ts."""
|
||||||
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
client_id = self._generate_client_id()
|
||||||
|
|
||||||
item_list: list[dict] = []
|
item_list: list[dict] = []
|
||||||
if text:
|
if text:
|
||||||
@@ -1141,13 +1177,47 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
errmsg = data.get("errmsg", "")
|
||||||
logger.warning(
|
|
||||||
"WeChat send error (code {}): {}",
|
# The iLink sendmessage API may return ret=-2 / errcode=-2 for two
|
||||||
errcode,
|
# different reasons:
|
||||||
data.get("errmsg", ""),
|
# - stale context_token: errmsg is empty/None or "unknown error"
|
||||||
|
# - genuine rate limit: errmsg is populated (e.g. "frequency limit")
|
||||||
|
# Per hermes-agent#17228 / #18100, the empty/None variant is a stale
|
||||||
|
# session signal. Retry once without context_token (iLink accepts
|
||||||
|
# tokenless sends as a degraded fallback). If the tokenless attempt
|
||||||
|
# also fails, let _check_response_error raise so ChannelManager can
|
||||||
|
# retry with backoff — do NOT swallow the error.
|
||||||
|
if _is_stale_session_ret(ret, errcode, errmsg) and context_token:
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send text returned stale-session signal for {} (client_id={}); "
|
||||||
|
"retrying without context_token",
|
||||||
|
to_user_id,
|
||||||
|
client_id,
|
||||||
)
|
)
|
||||||
|
body_no_ctx = copy.deepcopy(body)
|
||||||
|
body_no_ctx["msg"].pop("context_token", None)
|
||||||
|
data = await self._api_post("ilink/bot/sendmessage", body_no_ctx)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
|
errcode = data.get("errcode", 0)
|
||||||
|
errmsg = data.get("errmsg", "")
|
||||||
|
if ret == 0 and (errcode == 0 or errcode is None):
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send text succeeded WITHOUT context_token for {}; "
|
||||||
|
"clearing expired token from cache",
|
||||||
|
to_user_id,
|
||||||
|
)
|
||||||
|
self._context_tokens.pop(to_user_id, None)
|
||||||
|
self._save_state()
|
||||||
|
self.logger.debug(
|
||||||
|
"WeChat text sent to {} (client_id={})", to_user_id, client_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._check_response_error(data, "send text", body=body)
|
||||||
|
self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id)
|
||||||
|
|
||||||
async def _send_media_file(
|
async def _send_media_file(
|
||||||
self,
|
self,
|
||||||
@@ -1273,7 +1343,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
media_item["len"] = str(raw_size)
|
media_item["len"] = str(raw_size)
|
||||||
|
|
||||||
# Send each media item as its own message (matching reference plugin)
|
# Send each media item as its own message (matching reference plugin)
|
||||||
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
client_id = self._generate_client_id()
|
||||||
item_list: list[dict] = [{"type": item_type, item_key: media_item}]
|
item_list: list[dict] = [{"type": item_type, item_key: media_item}]
|
||||||
|
|
||||||
weixin_msg: dict[str, Any] = {
|
weixin_msg: dict[str, Any] = {
|
||||||
@@ -1293,11 +1363,35 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
errmsg = data.get("errmsg", "")
|
||||||
raise RuntimeError(
|
|
||||||
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
|
# Same stale-session handling as _send_text (hermes-agent#17228 / #18100).
|
||||||
|
if _is_stale_session_ret(ret, errcode, errmsg) and context_token:
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send media returned stale-session signal for {} (client_id={}); "
|
||||||
|
"retrying without context_token",
|
||||||
|
to_user_id,
|
||||||
|
client_id,
|
||||||
)
|
)
|
||||||
|
body_no_ctx = copy.deepcopy(body)
|
||||||
|
body_no_ctx["msg"].pop("context_token", None)
|
||||||
|
data = await self._api_post("ilink/bot/sendmessage", body_no_ctx)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
|
errcode = data.get("errcode", 0)
|
||||||
|
errmsg = data.get("errmsg", "")
|
||||||
|
if ret == 0 and (errcode == 0 or errcode is None):
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send media succeeded WITHOUT context_token for {}; "
|
||||||
|
"clearing expired token from cache",
|
||||||
|
to_user_id,
|
||||||
|
)
|
||||||
|
self._context_tokens.pop(to_user_id, None)
|
||||||
|
self._save_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._check_response_error(data, "send media", body=body)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1339,13 +1433,11 @@ def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
|
|||||||
pad_len = 16 - len(data) % 16
|
pad_len = 16 - len(data) % 16
|
||||||
padded = data + bytes([pad_len] * pad_len)
|
padded = data + bytes([pad_len] * pad_len)
|
||||||
|
|
||||||
try:
|
with suppress(ImportError):
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
cipher = AES.new(key, AES.MODE_ECB)
|
cipher = AES.new(key, AES.MODE_ECB)
|
||||||
return cipher.encrypt(padded)
|
return cipher.encrypt(padded)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
@@ -1371,13 +1463,11 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
|
|||||||
|
|
||||||
decrypted: bytes | None = None
|
decrypted: bytes | None = None
|
||||||
|
|
||||||
try:
|
with suppress(ImportError):
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
cipher = AES.new(key, AES.MODE_ECB)
|
cipher = AES.new(key, AES.MODE_ECB)
|
||||||
decrypted = cipher.decrypt(data)
|
decrypted = cipher.decrypt(data)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if decrypted is None:
|
if decrypted is None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""WhatsApp channel implementation using Node.js bridge."""
|
"""WhatsApp channel implementation using Node.js bridge."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@@ -8,6 +9,7 @@ import secrets
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
@@ -46,10 +48,8 @@ def _load_or_create_bridge_token(path: Path) -> str:
|
|||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
path.write_text(token, encoding="utf-8")
|
path.write_text(token, encoding="utf-8")
|
||||||
try:
|
with suppress(OSError):
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
@@ -99,15 +99,15 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
bridge_dir = _ensure_bridge_setup()
|
bridge_dir = _ensure_bridge_setup()
|
||||||
except RuntimeError as e:
|
except RuntimeError:
|
||||||
logger.error("{}", e)
|
self.logger.exception("bridge setup failed")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
env = {**os.environ}
|
env = {**os.environ}
|
||||||
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
|
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
|
||||||
env["AUTH_DIR"] = str(_bridge_token_path().parent)
|
env["AUTH_DIR"] = str(_bridge_token_path().parent)
|
||||||
|
|
||||||
logger.info("Starting WhatsApp bridge for QR login...")
|
self.logger.info("Starting WhatsApp bridge for QR login...")
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
|
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
|
||||||
@@ -123,7 +123,7 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
|
|
||||||
bridge_url = self.config.bridge_url
|
bridge_url = self.config.bridge_url
|
||||||
|
|
||||||
logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
|
self.logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
@@ -135,24 +135,24 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
|
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
|
||||||
)
|
)
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.info("Connected to WhatsApp bridge")
|
self.logger.info("Connected to WhatsApp bridge")
|
||||||
|
|
||||||
# Listen for messages
|
# Listen for messages
|
||||||
async for message in ws:
|
async for message in ws:
|
||||||
try:
|
try:
|
||||||
await self._handle_bridge_message(message)
|
await self._handle_bridge_message(message)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error handling bridge message: {}", e)
|
self.logger.exception("Error handling bridge message")
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._ws = None
|
self._ws = None
|
||||||
logger.warning("WhatsApp bridge connection error: {}", e)
|
self.logger.warning("WhatsApp bridge connection error: {}", e)
|
||||||
|
|
||||||
if self._running:
|
if self._running:
|
||||||
logger.info("Reconnecting in 5 seconds...")
|
self.logger.info("Reconnecting in 5 seconds...")
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
@@ -167,7 +167,7 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through WhatsApp."""
|
"""Send a message through WhatsApp."""
|
||||||
if not self._ws or not self._connected:
|
if not self._ws or not self._connected:
|
||||||
logger.warning("WhatsApp bridge not connected")
|
self.logger.warning("WhatsApp bridge not connected")
|
||||||
return
|
return
|
||||||
|
|
||||||
chat_id = msg.chat_id
|
chat_id = msg.chat_id
|
||||||
@@ -176,8 +176,8 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
payload = {"type": "send", "to": chat_id, "text": msg.content}
|
payload = {"type": "send", "to": chat_id, "text": msg.content}
|
||||||
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending WhatsApp message: {}", e)
|
self.logger.exception("Error sending message")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
for media_path in msg.media or []:
|
for media_path in msg.media or []:
|
||||||
@@ -191,8 +191,8 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
"fileName": media_path.rsplit("/", 1)[-1],
|
"fileName": media_path.rsplit("/", 1)[-1],
|
||||||
}
|
}
|
||||||
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error sending WhatsApp media {}: {}", media_path, e)
|
self.logger.exception("Error sending media {}", media_path)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _handle_bridge_message(self, raw: str) -> None:
|
async def _handle_bridge_message(self, raw: str) -> None:
|
||||||
@@ -200,7 +200,7 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning("Invalid JSON from bridge: {}", raw[:100])
|
self.logger.warning("Invalid JSON from bridge: {}", raw[:100])
|
||||||
return
|
return
|
||||||
|
|
||||||
msg_type = data.get("type")
|
msg_type = data.get("type")
|
||||||
@@ -214,13 +214,6 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
content = data.get("content", "")
|
content = data.get("content", "")
|
||||||
message_id = data.get("id", "")
|
message_id = data.get("id", "")
|
||||||
|
|
||||||
if message_id:
|
|
||||||
if message_id in self._processed_message_ids:
|
|
||||||
return
|
|
||||||
self._processed_message_ids[message_id] = None
|
|
||||||
while len(self._processed_message_ids) > 1000:
|
|
||||||
self._processed_message_ids.popitem(last=False)
|
|
||||||
|
|
||||||
# Extract just the phone number or lid as chat_id
|
# Extract just the phone number or lid as chat_id
|
||||||
is_group = data.get("isGroup", False)
|
is_group = data.get("isGroup", False)
|
||||||
was_mentioned = data.get("wasMentioned", False)
|
was_mentioned = data.get("wasMentioned", False)
|
||||||
@@ -246,11 +239,21 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
elif extracted and not phone_id:
|
elif extracted and not phone_id:
|
||||||
phone_id = extracted # best guess for bare values
|
phone_id = extracted # best guess for bare values
|
||||||
|
|
||||||
|
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
|
||||||
|
if not self.is_allowed(sender_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
if message_id:
|
||||||
|
if message_id in self._processed_message_ids:
|
||||||
|
return
|
||||||
|
self._processed_message_ids[message_id] = None
|
||||||
|
while len(self._processed_message_ids) > 1000:
|
||||||
|
self._processed_message_ids.popitem(last=False)
|
||||||
|
|
||||||
if phone_id and lid_id:
|
if phone_id and lid_id:
|
||||||
self._lid_to_phone[lid_id] = phone_id
|
self._lid_to_phone[lid_id] = phone_id
|
||||||
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
|
|
||||||
|
|
||||||
logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id)
|
self.logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id)
|
||||||
|
|
||||||
# Extract media paths (images/documents/videos downloaded by the bridge)
|
# Extract media paths (images/documents/videos downloaded by the bridge)
|
||||||
media_paths = data.get("media") or []
|
media_paths = data.get("media") or []
|
||||||
@@ -258,11 +261,11 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
# Handle voice transcription if it's a voice message
|
# Handle voice transcription if it's a voice message
|
||||||
if content == "[Voice Message]":
|
if content == "[Voice Message]":
|
||||||
if media_paths:
|
if media_paths:
|
||||||
logger.info("Transcribing voice message from {}...", sender_id)
|
self.logger.info("Transcribing voice message from {}...", sender_id)
|
||||||
transcription = await self.transcribe_audio(media_paths[0])
|
transcription = await self.transcribe_audio(media_paths[0])
|
||||||
if transcription:
|
if transcription:
|
||||||
content = transcription
|
content = transcription
|
||||||
logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
|
self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
|
||||||
else:
|
else:
|
||||||
content = "[Voice Message: Transcription failed]"
|
content = "[Voice Message: Transcription failed]"
|
||||||
else:
|
else:
|
||||||
@@ -291,7 +294,7 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
elif msg_type == "status":
|
elif msg_type == "status":
|
||||||
# Connection status update
|
# Connection status update
|
||||||
status = data.get("status")
|
status = data.get("status")
|
||||||
logger.info("WhatsApp status: {}", status)
|
self.logger.info("Status: {}", status)
|
||||||
|
|
||||||
if status == "connected":
|
if status == "connected":
|
||||||
self._connected = True
|
self._connected = True
|
||||||
@@ -300,10 +303,10 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
|
|
||||||
elif msg_type == "qr":
|
elif msg_type == "qr":
|
||||||
# QR code for authentication
|
# QR code for authentication
|
||||||
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
|
self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
|
||||||
|
|
||||||
elif msg_type == "error":
|
elif msg_type == "error":
|
||||||
logger.error("WhatsApp bridge error: {}", data.get("error"))
|
self.logger.error("Bridge error: {}", data.get("error"))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_bridge_setup() -> Path:
|
def _ensure_bridge_setup() -> Path:
|
||||||
@@ -316,13 +319,7 @@ def _ensure_bridge_setup() -> Path:
|
|||||||
from nanobot.config.paths import get_bridge_install_dir
|
from nanobot.config.paths import get_bridge_install_dir
|
||||||
|
|
||||||
user_bridge = get_bridge_install_dir()
|
user_bridge = get_bridge_install_dir()
|
||||||
|
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
|
||||||
if (user_bridge / "dist" / "index.js").exists():
|
|
||||||
return user_bridge
|
|
||||||
|
|
||||||
npm_path = shutil.which("npm")
|
|
||||||
if not npm_path:
|
|
||||||
raise RuntimeError("npm not found. Please install Node.js >= 18.")
|
|
||||||
|
|
||||||
# Find source bridge
|
# Find source bridge
|
||||||
current_file = Path(__file__)
|
current_file = Path(__file__)
|
||||||
@@ -341,6 +338,33 @@ def _ensure_bridge_setup() -> Path:
|
|||||||
"Try reinstalling: pip install --force-reinstall nanobot"
|
"Try reinstalling: pip install --force-reinstall nanobot"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def source_hash(root: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(root)
|
||||||
|
if rel.parts and rel.parts[0] in {"node_modules", "dist"}:
|
||||||
|
continue
|
||||||
|
digest.update(rel.as_posix().encode("utf-8"))
|
||||||
|
digest.update(b"\0")
|
||||||
|
digest.update(path.read_bytes())
|
||||||
|
digest.update(b"\0")
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
expected_hash = source_hash(source)
|
||||||
|
current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None
|
||||||
|
|
||||||
|
if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash:
|
||||||
|
return user_bridge
|
||||||
|
|
||||||
|
if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash:
|
||||||
|
logger.info("WhatsApp bridge source changed; rebuilding bridge...")
|
||||||
|
|
||||||
|
npm_path = shutil.which("npm")
|
||||||
|
if not npm_path:
|
||||||
|
raise RuntimeError("npm not found. Please install Node.js >= 18.")
|
||||||
|
|
||||||
logger.info("Setting up WhatsApp bridge...")
|
logger.info("Setting up WhatsApp bridge...")
|
||||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if user_bridge.exists():
|
if user_bridge.exists():
|
||||||
@@ -352,6 +376,7 @@ def _ensure_bridge_setup() -> Path:
|
|||||||
|
|
||||||
logger.info(" Building...")
|
logger.info(" Building...")
|
||||||
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
||||||
|
stamp_file.write_text(expected_hash + "\n")
|
||||||
|
|
||||||
logger.info("Bridge ready")
|
logger.info("Bridge ready")
|
||||||
return user_bridge
|
return user_bridge
|
||||||
|
|||||||
+216
-143
@@ -5,7 +5,8 @@ import os
|
|||||||
import select
|
import select
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from contextlib import nullcontext
|
from collections.abc import Callable
|
||||||
|
from contextlib import nullcontext, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -14,14 +15,28 @@ if sys.platform == "win32":
|
|||||||
if sys.stdout.encoding != "utf-8":
|
if sys.stdout.encoding != "utf-8":
|
||||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||||
# Re-open stdout/stderr with UTF-8 encoding
|
# Re-open stdout/stderr with UTF-8 encoding
|
||||||
try:
|
with suppress(Exception):
|
||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
# Remove default handler and re-add with unified nanobot format
|
||||||
|
logger.remove()
|
||||||
|
_log_handler_id = logger.add(
|
||||||
|
sys.stderr,
|
||||||
|
format=(
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||||
|
"<level>{level: <5}</level> | "
|
||||||
|
"<cyan>{extra[channel]}</cyan> | "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
),
|
||||||
|
level="INFO",
|
||||||
|
colorize=None,
|
||||||
|
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
|
||||||
|
)
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession, print_formatted_text
|
from prompt_toolkit import PromptSession, print_formatted_text
|
||||||
from prompt_toolkit.application import run_in_terminal
|
from prompt_toolkit.application import run_in_terminal
|
||||||
from prompt_toolkit.formatted_text import ANSI, HTML
|
from prompt_toolkit.formatted_text import ANSI, HTML
|
||||||
@@ -33,6 +48,7 @@ from rich.table import Table
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from nanobot import __logo__, __version__
|
from nanobot import __logo__, __version__
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
|
||||||
|
|
||||||
class SafeFileHistory(FileHistory):
|
class SafeFileHistory(FileHistory):
|
||||||
@@ -83,35 +99,29 @@ def _flush_pending_tty_input() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
with suppress(Exception):
|
||||||
import termios
|
import termios
|
||||||
|
|
||||||
termios.tcflush(fd, termios.TCIFLUSH)
|
termios.tcflush(fd, termios.TCIFLUSH)
|
||||||
return
|
return
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
with suppress(Exception):
|
||||||
while True:
|
while True:
|
||||||
ready, _, _ = select.select([fd], [], [], 0)
|
ready, _, _ = select.select([fd], [], [], 0)
|
||||||
if not ready:
|
if not ready:
|
||||||
break
|
break
|
||||||
if not os.read(fd, 4096):
|
if not os.read(fd, 4096):
|
||||||
break
|
break
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def _restore_terminal() -> None:
|
def _restore_terminal() -> None:
|
||||||
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
||||||
if _SAVED_TERM_ATTRS is None:
|
if _SAVED_TERM_ATTRS is None:
|
||||||
return
|
return
|
||||||
try:
|
with suppress(Exception):
|
||||||
import termios
|
import termios
|
||||||
|
|
||||||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
|
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _init_prompt_session() -> None:
|
def _init_prompt_session() -> None:
|
||||||
@@ -119,12 +129,10 @@ def _init_prompt_session() -> None:
|
|||||||
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
|
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
|
||||||
|
|
||||||
# Save terminal state so we can restore it on exit
|
# Save terminal state so we can restore it on exit
|
||||||
try:
|
with suppress(Exception):
|
||||||
import termios
|
import termios
|
||||||
|
|
||||||
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
|
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
from nanobot.config.paths import get_cli_history_path
|
from nanobot.config.paths import get_cli_history_path
|
||||||
|
|
||||||
@@ -226,6 +234,29 @@ async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner
|
|||||||
await _print_interactive_line(text)
|
await _print_interactive_line(text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_print_interactive_progress(
|
||||||
|
msg: Any,
|
||||||
|
thinking: ThinkingSpinner | None,
|
||||||
|
channels_config: Any,
|
||||||
|
) -> bool:
|
||||||
|
metadata = msg.metadata or {}
|
||||||
|
if metadata.get("_retry_wait"):
|
||||||
|
await _print_interactive_progress_line(msg.content, thinking)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not metadata.get("_progress"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_tool_hint = metadata.get("_tool_hint", False)
|
||||||
|
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
||||||
|
return True
|
||||||
|
if channels_config and not is_tool_hint and not channels_config.send_progress:
|
||||||
|
return True
|
||||||
|
|
||||||
|
await _print_interactive_progress_line(msg.content, thinking)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _is_exit_command(command: str) -> bool:
|
def _is_exit_command(command: str) -> bool:
|
||||||
"""Return True when input should end interactive chat."""
|
"""Return True when input should end interactive chat."""
|
||||||
return command.lower() in EXIT_COMMANDS
|
return command.lower() in EXIT_COMMANDS
|
||||||
@@ -407,20 +438,6 @@ def _onboard_plugins(config_path: Path) -> None:
|
|||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Config):
|
|
||||||
"""Create the appropriate LLM provider from config.
|
|
||||||
|
|
||||||
Routing is driven by ``ProviderSpec.backend`` in the registry.
|
|
||||||
"""
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
try:
|
|
||||||
return make_provider(config)
|
|
||||||
except ValueError as exc:
|
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
|
||||||
raise typer.Exit(1) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||||
"""Load config and optionally override the active workspace."""
|
"""Load config and optionally override the active workspace."""
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
||||||
@@ -498,7 +515,6 @@ def serve(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.api.server import create_app
|
from nanobot.api.server import create_app
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -515,37 +531,20 @@ def serve(
|
|||||||
timeout = timeout if timeout is not None else api_cfg.timeout
|
timeout = timeout if timeout is not None else api_cfg.timeout
|
||||||
sync_workspace_templates(runtime_config.workspace_path)
|
sync_workspace_templates(runtime_config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(runtime_config)
|
defaults = runtime_config.agents.defaults
|
||||||
session_manager = SessionManager(runtime_config.workspace_path)
|
session_manager = SessionManager(runtime_config.workspace_path)
|
||||||
agent_loop = AgentLoop(
|
resolved_preset = runtime_config.resolve_preset()
|
||||||
bus=bus,
|
agent_loop = AgentLoop.from_config(
|
||||||
provider=provider,
|
runtime_config, bus,
|
||||||
workspace=runtime_config.workspace_path,
|
|
||||||
model=runtime_config.agents.defaults.model,
|
|
||||||
max_iterations=runtime_config.agents.defaults.max_tool_iterations,
|
|
||||||
context_window_tokens=runtime_config.agents.defaults.context_window_tokens,
|
|
||||||
context_block_limit=runtime_config.agents.defaults.context_block_limit,
|
|
||||||
max_tool_result_chars=runtime_config.agents.defaults.max_tool_result_chars,
|
|
||||||
provider_retry_mode=runtime_config.agents.defaults.provider_retry_mode,
|
|
||||||
web_config=runtime_config.tools.web,
|
|
||||||
exec_config=runtime_config.tools.exec,
|
|
||||||
restrict_to_workspace=runtime_config.tools.restrict_to_workspace,
|
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
mcp_servers=runtime_config.tools.mcp_servers,
|
|
||||||
channels_config=runtime_config.channels,
|
|
||||||
timezone=runtime_config.agents.defaults.timezone,
|
|
||||||
unified_session=runtime_config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=runtime_config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=runtime_config.agents.defaults.consolidation_ratio,
|
|
||||||
max_messages=runtime_config.agents.defaults.max_messages,
|
|
||||||
tools_config=runtime_config.tools,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
model_name = runtime_config.agents.defaults.model
|
model_name = resolved_preset.model
|
||||||
|
preset_name = defaults.model_preset
|
||||||
|
preset_tag = f" (preset: {preset_name})" if preset_name else ""
|
||||||
console.print(f"{__logo__} Starting OpenAI-compatible API server")
|
console.print(f"{__logo__} Starting OpenAI-compatible API server")
|
||||||
console.print(f" [cyan]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions")
|
console.print(f" [cyan]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions")
|
||||||
console.print(f" [cyan]Model[/cyan] : {model_name}")
|
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
|
||||||
console.print(" [cyan]Session[/cyan] : api:default")
|
console.print(" [cyan]Session[/cyan] : api:default")
|
||||||
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||||
if host in {"0.0.0.0", "::"}:
|
if host in {"0.0.0.0", "::"}:
|
||||||
@@ -583,9 +582,19 @@ def gateway(
|
|||||||
):
|
):
|
||||||
"""Start the nanobot gateway."""
|
"""Start the nanobot gateway."""
|
||||||
if verbose:
|
if verbose:
|
||||||
import logging
|
logger.remove(_log_handler_id)
|
||||||
|
logger.add(
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
sys.stderr,
|
||||||
|
format=(
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||||
|
"<level>{level: <5}</level> | "
|
||||||
|
"<cyan>{extra[channel]}</cyan> | "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
),
|
||||||
|
level="DEBUG",
|
||||||
|
colorize=None,
|
||||||
|
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
|
||||||
|
)
|
||||||
cfg = _load_runtime_config(config, workspace)
|
cfg = _load_runtime_config(config, workspace)
|
||||||
_run_gateway(cfg, port=port)
|
_run_gateway(cfg, port=port)
|
||||||
|
|
||||||
@@ -597,7 +606,6 @@ def _run_gateway(
|
|||||||
open_browser_url: str | None = None,
|
open_browser_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -618,7 +626,6 @@ def _run_gateway(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
provider = provider_snapshot.provider
|
|
||||||
session_manager = SessionManager(config.workspace_path)
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
@@ -630,30 +637,10 @@ def _run_gateway(
|
|||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop(
|
agent = AgentLoop.from_config(
|
||||||
bus=bus,
|
config, bus,
|
||||||
provider=provider,
|
|
||||||
workspace=config.workspace_path,
|
|
||||||
model=provider_snapshot.model,
|
|
||||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
|
||||||
context_window_tokens=provider_snapshot.context_window_tokens,
|
|
||||||
web_config=config.tools.web,
|
|
||||||
context_block_limit=config.agents.defaults.context_block_limit,
|
|
||||||
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
|
|
||||||
provider_retry_mode=config.agents.defaults.provider_retry_mode,
|
|
||||||
exec_config=config.tools.exec,
|
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
|
||||||
channels_config=config.channels,
|
|
||||||
timezone=config.agents.defaults.timezone,
|
|
||||||
unified_session=config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
|
||||||
max_messages=config.agents.defaults.max_messages,
|
|
||||||
tools_config=config.tools,
|
|
||||||
provider_snapshot_loader=load_provider_snapshot,
|
provider_snapshot_loader=load_provider_snapshot,
|
||||||
provider_signature=provider_snapshot.signature,
|
provider_signature=provider_snapshot.signature,
|
||||||
)
|
)
|
||||||
@@ -756,7 +743,7 @@ def _run_gateway(
|
|||||||
|
|
||||||
if job.payload.deliver and job.payload.to and response:
|
if job.payload.deliver and job.payload.to and response:
|
||||||
should_notify = await evaluate_response(
|
should_notify = await evaluate_response(
|
||||||
response, reminder_note, provider, agent.model,
|
response, reminder_note, agent.provider, agent.model,
|
||||||
)
|
)
|
||||||
if should_notify:
|
if should_notify:
|
||||||
await _deliver_to_channel(
|
await _deliver_to_channel(
|
||||||
@@ -846,7 +833,7 @@ def _run_gateway(
|
|||||||
hb_cfg = config.gateway.heartbeat
|
hb_cfg = config.gateway.heartbeat
|
||||||
heartbeat = HeartbeatService(
|
heartbeat = HeartbeatService(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
provider=provider,
|
provider=agent.provider,
|
||||||
model=agent.model,
|
model=agent.model,
|
||||||
on_execute=on_heartbeat_execute,
|
on_execute=on_heartbeat_execute,
|
||||||
on_notify=on_heartbeat_notify,
|
on_notify=on_heartbeat_notify,
|
||||||
@@ -936,10 +923,8 @@ def _run_gateway(
|
|||||||
config.gateway.host or "127.0.0.1", port
|
config.gateway.host or "127.0.0.1", port
|
||||||
)
|
)
|
||||||
writer.close()
|
writer.close()
|
||||||
try:
|
with suppress(Exception):
|
||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
break
|
break
|
||||||
except OSError:
|
except OSError:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
@@ -1001,7 +986,6 @@ def agent(
|
|||||||
"""Interact with the agent directly."""
|
"""Interact with the agent directly."""
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
@@ -1009,8 +993,6 @@ def agent(
|
|||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
|
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
|
||||||
|
|
||||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
if is_default_workspace(config.workspace_path):
|
if is_default_workspace(config.workspace_path):
|
||||||
_migrate_cron_store(config)
|
_migrate_cron_store(config)
|
||||||
@@ -1024,29 +1006,10 @@ def agent(
|
|||||||
else:
|
else:
|
||||||
logger.disable("nanobot")
|
logger.disable("nanobot")
|
||||||
|
|
||||||
agent_loop = AgentLoop(
|
resolved_preset = config.resolve_preset()
|
||||||
bus=bus,
|
agent_loop = AgentLoop.from_config(
|
||||||
provider=provider,
|
config, bus,
|
||||||
workspace=config.workspace_path,
|
|
||||||
model=config.agents.defaults.model,
|
|
||||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
|
||||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
|
||||||
web_config=config.tools.web,
|
|
||||||
context_block_limit=config.agents.defaults.context_block_limit,
|
|
||||||
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
|
|
||||||
provider_retry_mode=config.agents.defaults.provider_retry_mode,
|
|
||||||
exec_config=config.tools.exec,
|
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
|
||||||
mcp_servers=config.tools.mcp_servers,
|
|
||||||
channels_config=config.channels,
|
|
||||||
timezone=config.agents.defaults.timezone,
|
|
||||||
unified_session=config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
|
||||||
max_messages=config.agents.defaults.max_messages,
|
|
||||||
tools_config=config.tools,
|
|
||||||
)
|
)
|
||||||
restart_notice = consume_restart_notice_from_env()
|
restart_notice = consume_restart_notice_from_env()
|
||||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||||
@@ -1090,7 +1053,7 @@ def agent(
|
|||||||
# Interactive mode — route through bus like other channels
|
# Interactive mode — route through bus like other channels
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
_init_prompt_session()
|
_init_prompt_session()
|
||||||
console.print(f"{__logo__} Interactive mode [bold blue]({config.agents.defaults.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
console.print(f"{__logo__} Interactive mode [bold blue]({resolved_preset.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
||||||
|
|
||||||
if ":" in session_id:
|
if ":" in session_id:
|
||||||
cli_channel, cli_chat_id = session_id.split(":", 1)
|
cli_channel, cli_chat_id = session_id.split(":", 1)
|
||||||
@@ -1139,15 +1102,11 @@ def agent(
|
|||||||
turn_done.set()
|
turn_done.set()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if msg.metadata.get("_progress"):
|
if await _maybe_print_interactive_progress(
|
||||||
is_tool_hint = msg.metadata.get("_tool_hint", False)
|
msg,
|
||||||
ch = agent_loop.channels_config
|
_thinking,
|
||||||
if ch and is_tool_hint and not ch.send_tool_hints:
|
agent_loop.channels_config,
|
||||||
pass
|
):
|
||||||
elif ch and not is_tool_hint and not ch.send_progress:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
await _print_interactive_progress_line(msg.content, _thinking)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not turn_done.is_set():
|
if not turn_done.is_set():
|
||||||
@@ -1271,6 +1230,7 @@ def channels_status(
|
|||||||
|
|
||||||
def _get_bridge_dir() -> Path:
|
def _get_bridge_dir() -> Path:
|
||||||
"""Get the bridge directory, setting it up if needed."""
|
"""Get the bridge directory, setting it up if needed."""
|
||||||
|
import hashlib
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -1278,16 +1238,7 @@ def _get_bridge_dir() -> Path:
|
|||||||
from nanobot.config.paths import get_bridge_install_dir
|
from nanobot.config.paths import get_bridge_install_dir
|
||||||
|
|
||||||
user_bridge = get_bridge_install_dir()
|
user_bridge = get_bridge_install_dir()
|
||||||
|
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
|
||||||
# Check if already built
|
|
||||||
if (user_bridge / "dist" / "index.js").exists():
|
|
||||||
return user_bridge
|
|
||||||
|
|
||||||
# Check for npm
|
|
||||||
npm_path = shutil.which("npm")
|
|
||||||
if not npm_path:
|
|
||||||
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Find source bridge: first check package data, then source dir
|
# Find source bridge: first check package data, then source dir
|
||||||
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
||||||
@@ -1304,6 +1255,36 @@ def _get_bridge_dir() -> Path:
|
|||||||
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
def source_hash(root: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(root)
|
||||||
|
if rel.parts and rel.parts[0] in {"node_modules", "dist"}:
|
||||||
|
continue
|
||||||
|
digest.update(rel.as_posix().encode("utf-8"))
|
||||||
|
digest.update(b"\0")
|
||||||
|
digest.update(path.read_bytes())
|
||||||
|
digest.update(b"\0")
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
expected_hash = source_hash(source)
|
||||||
|
current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None
|
||||||
|
|
||||||
|
# Reuse only a bridge built from the currently installed source.
|
||||||
|
if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash:
|
||||||
|
return user_bridge
|
||||||
|
|
||||||
|
if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash:
|
||||||
|
console.print(f"{__logo__} WhatsApp bridge source changed; rebuilding bridge...")
|
||||||
|
|
||||||
|
# Check for npm
|
||||||
|
npm_path = shutil.which("npm")
|
||||||
|
if not npm_path:
|
||||||
|
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
console.print(f"{__logo__} Setting up bridge...")
|
console.print(f"{__logo__} Setting up bridge...")
|
||||||
|
|
||||||
# Copy to user directory
|
# Copy to user directory
|
||||||
@@ -1319,6 +1300,7 @@ def _get_bridge_dir() -> Path:
|
|||||||
|
|
||||||
console.print(" Building...")
|
console.print(" Building...")
|
||||||
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
||||||
|
stamp_file.write_text(expected_hash + "\n")
|
||||||
|
|
||||||
console.print("[green]✓[/green] Bridge ready\n")
|
console.print("[green]✓[/green] Bridge ready\n")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
@@ -1429,7 +1411,10 @@ def status():
|
|||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
console.print(f"Model: {config.agents.defaults.model}")
|
resolved_preset = config.resolve_preset()
|
||||||
|
preset = config.agents.defaults.model_preset
|
||||||
|
preset_tag = f" (preset: {preset})" if preset else ""
|
||||||
|
console.print(f"Model: {resolved_preset.model}{preset_tag}")
|
||||||
|
|
||||||
# Check API keys from registry
|
# Check API keys from registry
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
@@ -1457,10 +1442,17 @@ provider_app = typer.Typer(help="Manage providers")
|
|||||||
app.add_typer(provider_app, name="provider")
|
app.add_typer(provider_app, name="provider")
|
||||||
|
|
||||||
|
|
||||||
_LOGIN_HANDLERS: dict[str, callable] = {}
|
_LOGIN_HANDLERS: dict[str, Callable[[], None]] = {}
|
||||||
|
_LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {}
|
||||||
|
|
||||||
|
_PROVIDER_DISPLAY: dict[str, str] = {
|
||||||
|
"openai_codex": "OpenAI Codex",
|
||||||
|
"github_copilot": "GitHub Copilot",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _register_login(name: str):
|
def _register_login(name: str):
|
||||||
|
"""Register an OAuth login handler."""
|
||||||
def decorator(fn):
|
def decorator(fn):
|
||||||
_LOGIN_HANDLERS[name] = fn
|
_LOGIN_HANDLERS[name] = fn
|
||||||
return fn
|
return fn
|
||||||
@@ -1468,11 +1460,16 @@ def _register_login(name: str):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@provider_app.command("login")
|
def _register_logout(name: str):
|
||||||
def provider_login(
|
"""Register an OAuth logout handler."""
|
||||||
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
def decorator(fn):
|
||||||
):
|
_LOGOUT_HANDLERS[name] = fn
|
||||||
"""Authenticate with an OAuth provider."""
|
return fn
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_oauth_provider(provider: str):
|
||||||
|
"""Resolve and validate an OAuth provider configuration."""
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
key = provider.replace("-", "_")
|
key = provider.replace("-", "_")
|
||||||
@@ -1481,6 +1478,15 @@ def provider_login(
|
|||||||
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
|
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
|
||||||
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
|
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
@provider_app.command("login")
|
||||||
|
def provider_login(
|
||||||
|
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
||||||
|
):
|
||||||
|
"""Authenticate with an OAuth provider."""
|
||||||
|
spec = _resolve_oauth_provider(provider)
|
||||||
|
|
||||||
handler = _LOGIN_HANDLERS.get(spec.name)
|
handler = _LOGIN_HANDLERS.get(spec.name)
|
||||||
if not handler:
|
if not handler:
|
||||||
@@ -1491,16 +1497,30 @@ def provider_login(
|
|||||||
handler()
|
handler()
|
||||||
|
|
||||||
|
|
||||||
|
@provider_app.command("logout")
|
||||||
|
def provider_logout(
|
||||||
|
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
||||||
|
):
|
||||||
|
"""Log out from an OAuth provider."""
|
||||||
|
spec = _resolve_oauth_provider(provider)
|
||||||
|
|
||||||
|
handler = _LOGOUT_HANDLERS.get(spec.name)
|
||||||
|
if not handler:
|
||||||
|
console.print(f"[red]Logout not implemented for {spec.label}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"{__logo__} OAuth Logout - {spec.label}\n")
|
||||||
|
handler()
|
||||||
|
|
||||||
|
|
||||||
@_register_login("openai_codex")
|
@_register_login("openai_codex")
|
||||||
def _login_openai_codex() -> None:
|
def _login_openai_codex() -> None:
|
||||||
try:
|
try:
|
||||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||||
|
|
||||||
token = None
|
token = None
|
||||||
try:
|
with suppress(Exception):
|
||||||
token = get_token()
|
token = get_token()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||||
token = login_oauth_interactive(
|
token = login_oauth_interactive(
|
||||||
@@ -1516,6 +1536,59 @@ def _login_openai_codex() -> None:
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@_register_logout("openai_codex")
|
||||||
|
def _logout_openai_codex() -> None:
|
||||||
|
"""Clear local OAuth credentials for OpenAI Codex."""
|
||||||
|
try:
|
||||||
|
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
||||||
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
|
except ImportError:
|
||||||
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
|
||||||
|
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
|
||||||
|
|
||||||
|
|
||||||
|
@_register_logout("github_copilot")
|
||||||
|
def _logout_github_copilot() -> None:
|
||||||
|
"""Clear local OAuth credentials for GitHub Copilot."""
|
||||||
|
try:
|
||||||
|
from nanobot.providers.github_copilot_provider import get_storage
|
||||||
|
except ImportError:
|
||||||
|
console.print("[red]GitHub Copilot provider unavailable. Ensure oauth-cli-kit is installed.[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
storage = get_storage()
|
||||||
|
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_oauth_files(token_path: Path, provider_label: str) -> None:
|
||||||
|
"""Delete OAuth token and lock files, reporting the result."""
|
||||||
|
removed_paths: list[Path] = []
|
||||||
|
skipped: list[tuple[Path, OSError]] = []
|
||||||
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
skipped.append((path, exc))
|
||||||
|
continue
|
||||||
|
removed_paths.append(path)
|
||||||
|
|
||||||
|
if not removed_paths and not skipped:
|
||||||
|
console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if removed_paths:
|
||||||
|
console.print(f"[green]✓ Logged out from {provider_label}[/green]")
|
||||||
|
for path in removed_paths:
|
||||||
|
console.print(f"[dim]Removed: {path}[/dim]")
|
||||||
|
for path, exc in skipped:
|
||||||
|
console.print(f"[yellow]! Could not remove {path}: {exc}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
@_register_login("github_copilot")
|
@_register_login("github_copilot")
|
||||||
def _login_github_copilot() -> None:
|
def _login_github_copilot() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+271
-11
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
|
|||||||
get_model_suggestions,
|
get_model_suggestions,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import get_config_path, load_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -49,6 +49,16 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
|
|||||||
|
|
||||||
_BACK_PRESSED = object() # Sentinel value for back navigation
|
_BACK_PRESSED = object() # Sentinel value for back navigation
|
||||||
|
|
||||||
|
# Cache of model-preset names populated at runtime so that field handlers can
|
||||||
|
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
|
||||||
|
#
|
||||||
|
# Lifecycle: populated by _sync_preset_cache(config), which must be called
|
||||||
|
# after every config mutation that changes model_presets (add, delete, edit).
|
||||||
|
# Cleared between tests via _MODEL_PRESET_CACHE.clear(). In long-running
|
||||||
|
# processes (gateway) the cache is refreshed each time the preset management
|
||||||
|
# screen is entered, so staleness is bounded by user interaction.
|
||||||
|
_MODEL_PRESET_CACHE: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
def _get_questionary():
|
def _get_questionary():
|
||||||
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
||||||
@@ -191,13 +201,13 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
|||||||
origin = get_origin(annotation)
|
origin = get_origin(annotation)
|
||||||
args = get_args(annotation)
|
args = get_args(annotation)
|
||||||
|
|
||||||
_SIMPLE_TYPES: dict[type, str] = {bool: "bool", int: "int", float: "float"}
|
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
|
||||||
|
|
||||||
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
|
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
|
||||||
return FieldTypeInfo("list", args[0] if args else str)
|
return FieldTypeInfo("list", args[0] if args else str)
|
||||||
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
|
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
|
||||||
return FieldTypeInfo("dict", None)
|
return FieldTypeInfo("dict", None)
|
||||||
for py_type, name in _SIMPLE_TYPES.items():
|
for py_type, name in _simple_types.items():
|
||||||
if annotation is py_type:
|
if annotation is py_type:
|
||||||
return FieldTypeInfo(name, None)
|
return FieldTypeInfo(name, None)
|
||||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||||
@@ -403,7 +413,7 @@ def _input_text(display_name: str, current: Any, field_type: str, field_info=Non
|
|||||||
|
|
||||||
value = _get_questionary().text(f"{display_name}:", default=default).ask()
|
value = _get_questionary().text(f"{display_name}:", default=default).ask()
|
||||||
|
|
||||||
if value is None or value == "":
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if field_type == "int":
|
if field_type == "int":
|
||||||
@@ -507,7 +517,7 @@ def _input_model_with_autocomplete(
|
|||||||
qmark=">",
|
qmark=">",
|
||||||
).ask()
|
).ask()
|
||||||
|
|
||||||
return value if value else None
|
return value if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _input_context_window_with_recommendation(
|
def _input_context_window_with_recommendation(
|
||||||
@@ -588,12 +598,112 @@ def _handle_context_window_field(
|
|||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_model_preset_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'model_preset' field with a list of existing presets."""
|
||||||
|
# model_preset lives on AgentDefaults, but the preset list is on Config.
|
||||||
|
# We can't easily access Config here, so we read from the global config
|
||||||
|
# via a module-level cache set by _configure_model_presets / run_onboard.
|
||||||
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
choices = ["(clear/unset)"] + preset_names
|
||||||
|
default_choice = str(current_value) if current_value else "(clear/unset)"
|
||||||
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
|
if new_value is _BACK_PRESSED:
|
||||||
|
return
|
||||||
|
if new_value == "(clear/unset)":
|
||||||
|
setattr(working_model, field_name, None)
|
||||||
|
elif new_value is not None:
|
||||||
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_provider_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'provider' field with a list of registered providers."""
|
||||||
|
provider_names = sorted(_get_provider_names().keys())
|
||||||
|
choices = ["auto"] + provider_names
|
||||||
|
default_choice = str(current_value) if current_value else "auto"
|
||||||
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
|
if new_value is _BACK_PRESSED:
|
||||||
|
return
|
||||||
|
if new_value is not None:
|
||||||
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_fallback_presets_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'fallback_presets' field with preset-aware multi-select."""
|
||||||
|
items: list[str] = list(current_value) if isinstance(current_value, list) else []
|
||||||
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
console.clear()
|
||||||
|
console.print(f"[bold]{field_display}[/bold]")
|
||||||
|
if items:
|
||||||
|
for idx, item in enumerate(items, 1):
|
||||||
|
console.print(f" {idx}. {item}")
|
||||||
|
else:
|
||||||
|
console.print(" [dim](empty)[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
choices = ["[+] Add preset"]
|
||||||
|
if items:
|
||||||
|
choices.append("[-] Remove last")
|
||||||
|
choices.append("[X] Clear all")
|
||||||
|
choices.append("[Done]")
|
||||||
|
choices.append("<- Back")
|
||||||
|
|
||||||
|
answer = _get_questionary().select(
|
||||||
|
"Manage fallback chain:",
|
||||||
|
choices=choices,
|
||||||
|
qmark=">",
|
||||||
|
).ask()
|
||||||
|
|
||||||
|
if answer is None or answer == "<- Back":
|
||||||
|
return
|
||||||
|
if answer == "[Done]":
|
||||||
|
setattr(working_model, field_name, items)
|
||||||
|
return
|
||||||
|
if answer == "[+] Add preset":
|
||||||
|
if not preset_names:
|
||||||
|
console.print("[yellow]! No presets defined yet.[/yellow]")
|
||||||
|
_get_questionary().press_any_key_to_continue().ask()
|
||||||
|
continue
|
||||||
|
add_choices = [p for p in preset_names if p not in items]
|
||||||
|
if not add_choices:
|
||||||
|
console.print("[yellow]! All presets already added.[/yellow]")
|
||||||
|
_get_questionary().press_any_key_to_continue().ask()
|
||||||
|
continue
|
||||||
|
picked = _select_with_back("Select preset:", add_choices)
|
||||||
|
if picked is _BACK_PRESSED or picked is None:
|
||||||
|
continue
|
||||||
|
items.append(picked)
|
||||||
|
elif answer == "[-] Remove last" and items:
|
||||||
|
items.pop()
|
||||||
|
elif answer == "[X] Clear all" and items:
|
||||||
|
items.clear()
|
||||||
|
|
||||||
|
|
||||||
_FIELD_HANDLERS: dict[str, Any] = {
|
_FIELD_HANDLERS: dict[str, Any] = {
|
||||||
"model": _handle_model_field,
|
"model": _handle_model_field,
|
||||||
"context_window_tokens": _handle_context_window_field,
|
"context_window_tokens": _handle_context_window_field,
|
||||||
|
"model_preset": _handle_model_preset_field,
|
||||||
|
"provider": _handle_provider_field,
|
||||||
|
"fallback_presets": _handle_fallback_presets_field,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_str_or_none(annotation: Any) -> bool:
|
||||||
|
"""Check whether a field annotation is ``str | None`` (or ``Optional[str]``)."""
|
||||||
|
origin = get_origin(annotation)
|
||||||
|
if origin is None:
|
||||||
|
return False
|
||||||
|
args = get_args(annotation)
|
||||||
|
return str in args and type(None) in args
|
||||||
|
|
||||||
|
|
||||||
def _configure_pydantic_model(
|
def _configure_pydantic_model(
|
||||||
model: BaseModel,
|
model: BaseModel,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
@@ -626,11 +736,20 @@ def _configure_pydantic_model(
|
|||||||
items.append(f"{display}: {formatted}")
|
items.append(f"{display}: {formatted}")
|
||||||
return items + ["[Done]"]
|
return items + ["[Done]"]
|
||||||
|
|
||||||
|
last_field_name: str | None = None
|
||||||
while True:
|
while True:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_config_panel(display_name, working_model, fields)
|
_show_config_panel(display_name, working_model, fields)
|
||||||
choices = get_choices()
|
choices = get_choices()
|
||||||
answer = _select_with_back("Select field to configure:", choices)
|
default_choice = None
|
||||||
|
if last_field_name:
|
||||||
|
for idx, (fname, _) in enumerate(fields):
|
||||||
|
if fname == last_field_name:
|
||||||
|
default_choice = choices[idx]
|
||||||
|
break
|
||||||
|
answer = _select_with_back(
|
||||||
|
"Select field to configure:", choices, default=default_choice
|
||||||
|
)
|
||||||
|
|
||||||
if answer is _BACK_PRESSED or answer is None:
|
if answer is _BACK_PRESSED or answer is None:
|
||||||
return None
|
return None
|
||||||
@@ -641,6 +760,8 @@ def _configure_pydantic_model(
|
|||||||
if field_idx < 0 or field_idx >= len(fields):
|
if field_idx < 0 or field_idx >= len(fields):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
last_field_name = fields[field_idx][0]
|
||||||
|
|
||||||
field_name, field_info = fields[field_idx]
|
field_name, field_info = fields[field_idx]
|
||||||
current_value = getattr(working_model, field_name, None)
|
current_value = getattr(working_model, field_name, None)
|
||||||
ftype = _get_field_type_info(field_info)
|
ftype = _get_field_type_info(field_info)
|
||||||
@@ -697,6 +818,10 @@ def _configure_pydantic_model(
|
|||||||
else:
|
else:
|
||||||
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
|
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
|
||||||
if new_value is not None:
|
if new_value is not None:
|
||||||
|
# Normalize empty string to None for optional string fields so that
|
||||||
|
# clearing an api_key / api_base actually removes the value.
|
||||||
|
if new_value == "" and _is_str_or_none(field_info.annotation):
|
||||||
|
new_value = None
|
||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
@@ -733,6 +858,113 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None
|
|||||||
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
|
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Model Preset Configuration ---
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_preset_cache(config: Config) -> None:
|
||||||
|
"""Synchronise the module-level preset name cache from config."""
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_model_presets(config: Config) -> None:
|
||||||
|
"""Configure model presets (CRUD)."""
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
def get_preset_choices() -> list[str]:
|
||||||
|
choices: list[str] = []
|
||||||
|
for name, preset in config.model_presets.items():
|
||||||
|
choices.append(f"{name} ({preset.model})")
|
||||||
|
choices.append("[+] Add new preset")
|
||||||
|
choices.append("<- Back")
|
||||||
|
return choices
|
||||||
|
|
||||||
|
last_preset_name: str | None = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
console.clear()
|
||||||
|
_show_section_header(
|
||||||
|
"Model Presets",
|
||||||
|
"Create, edit or delete named model presets for quick switching",
|
||||||
|
)
|
||||||
|
choices = get_preset_choices()
|
||||||
|
default_choice = None
|
||||||
|
if last_preset_name:
|
||||||
|
for c in choices:
|
||||||
|
if c.startswith(last_preset_name + " ("):
|
||||||
|
default_choice = c
|
||||||
|
break
|
||||||
|
answer = _select_with_back(
|
||||||
|
"Select preset:", choices, default=default_choice
|
||||||
|
)
|
||||||
|
|
||||||
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
|
break
|
||||||
|
|
||||||
|
assert isinstance(answer, str)
|
||||||
|
|
||||||
|
if answer == "[+] Add new preset":
|
||||||
|
name_input = _get_questionary().text(
|
||||||
|
"Preset name:",
|
||||||
|
validate=lambda t: True if t and t.strip() else "Name cannot be empty",
|
||||||
|
).ask()
|
||||||
|
if not name_input:
|
||||||
|
continue
|
||||||
|
name = name_input.strip()
|
||||||
|
if name in config.model_presets:
|
||||||
|
console.print(f"[yellow]! Preset '{name}' already exists[/yellow]")
|
||||||
|
_pause()
|
||||||
|
continue
|
||||||
|
new_preset = ModelPresetConfig(model="")
|
||||||
|
updated = _configure_pydantic_model(new_preset, f"New Preset: {name}")
|
||||||
|
if updated is not None:
|
||||||
|
config.model_presets[name] = updated
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
last_preset_name = name
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Editing / deleting an existing preset
|
||||||
|
# Extract preset name from "name (model)" format
|
||||||
|
preset_name = answer.split(" (", 1)[0]
|
||||||
|
preset = config.model_presets.get(preset_name)
|
||||||
|
if preset is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_preset_name = preset_name
|
||||||
|
|
||||||
|
choices = ["Edit", "Cancel"]
|
||||||
|
if preset_name != "default":
|
||||||
|
choices.insert(1, "Delete")
|
||||||
|
action = _select_with_back(
|
||||||
|
f"Preset: {preset_name}",
|
||||||
|
choices,
|
||||||
|
default="Edit",
|
||||||
|
)
|
||||||
|
if action is _BACK_PRESSED or action == "Cancel" or action is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action == "Delete":
|
||||||
|
confirm = _get_questionary().confirm(
|
||||||
|
f"Delete preset '{preset_name}'?",
|
||||||
|
default=False,
|
||||||
|
).ask()
|
||||||
|
if confirm:
|
||||||
|
del config.model_presets[preset_name]
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
last_preset_name = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action == "Edit":
|
||||||
|
updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}")
|
||||||
|
if updated is not None:
|
||||||
|
config.model_presets[preset_name] = updated
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[dim]Returning to main menu...[/dim]")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
# --- Provider Configuration ---
|
# --- Provider Configuration ---
|
||||||
|
|
||||||
|
|
||||||
@@ -795,12 +1027,23 @@ def _configure_providers(config: Config) -> None:
|
|||||||
choices.append(display)
|
choices.append(display)
|
||||||
return choices + ["<- Back"]
|
return choices + ["<- Back"]
|
||||||
|
|
||||||
|
last_provider_key: str | None = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
|
_show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
|
||||||
choices = get_provider_choices()
|
choices = get_provider_choices()
|
||||||
answer = _select_with_back("Select provider:", choices)
|
default_choice = None
|
||||||
|
if last_provider_key:
|
||||||
|
display = _get_provider_names().get(last_provider_key)
|
||||||
|
if display:
|
||||||
|
for c in choices:
|
||||||
|
if c.replace(" *", "") == display:
|
||||||
|
default_choice = c
|
||||||
|
break
|
||||||
|
answer = _select_with_back(
|
||||||
|
"Select provider:", choices, default=default_choice
|
||||||
|
)
|
||||||
|
|
||||||
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
break
|
break
|
||||||
@@ -812,6 +1055,7 @@ def _configure_providers(config: Config) -> None:
|
|||||||
# Find the actual provider key from display names
|
# Find the actual provider key from display names
|
||||||
for name, display in _get_provider_names().items():
|
for name, display in _get_provider_names().items():
|
||||||
if display == provider_name:
|
if display == provider_name:
|
||||||
|
last_provider_key = name
|
||||||
_configure_provider(config, name)
|
_configure_provider(config, name)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -840,7 +1084,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:
|
|||||||
display_name = getattr(channel_cls, "display_name", name.capitalize())
|
display_name = getattr(channel_cls, "display_name", name.capitalize())
|
||||||
result[name] = (display_name, config_cls)
|
result[name] = (display_name, config_cls)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f"Failed to load channel module: {name}")
|
logger.warning("Failed to load channel module: {}", name)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -885,17 +1129,21 @@ def _configure_channels(config: Config) -> None:
|
|||||||
channel_names = list(_get_channel_names().keys())
|
channel_names = list(_get_channel_names().keys())
|
||||||
choices = channel_names + ["<- Back"]
|
choices = channel_names + ["<- Back"]
|
||||||
|
|
||||||
|
last_choice: str | None = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_section_header("Chat Channels", "Select a channel to configure connection settings")
|
_show_section_header("Chat Channels", "Select a channel to configure connection settings")
|
||||||
answer = _select_with_back("Select channel:", choices)
|
answer = _select_with_back(
|
||||||
|
"Select channel:", choices, default=last_choice
|
||||||
|
)
|
||||||
|
|
||||||
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
break
|
break
|
||||||
|
|
||||||
# Type guard: answer is now guaranteed to be a string
|
# Type guard: answer is now guaranteed to be a string
|
||||||
assert isinstance(answer, str)
|
assert isinstance(answer, str)
|
||||||
|
last_choice = answer
|
||||||
_configure_channel(config, answer)
|
_configure_channel(config, answer)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[dim]Returning to main menu...[/dim]")
|
console.print("\n[dim]Returning to main menu...[/dim]")
|
||||||
@@ -1003,6 +1251,12 @@ def _show_summary(config: Config) -> None:
|
|||||||
channel_rows.append((display, status))
|
channel_rows.append((display, status))
|
||||||
_print_summary_panel(channel_rows, "Chat Channels")
|
_print_summary_panel(channel_rows, "Chat Channels")
|
||||||
|
|
||||||
|
# Model Presets
|
||||||
|
preset_rows = []
|
||||||
|
for name, preset in config.model_presets.items():
|
||||||
|
preset_rows.append((name, f"{preset.model} (ctx={preset.context_window_tokens})"))
|
||||||
|
_print_summary_panel(preset_rows, "Model Presets")
|
||||||
|
|
||||||
# Settings sections
|
# Settings sections
|
||||||
for title, model in [
|
for title, model in [
|
||||||
("Agent Settings", config.agents.defaults),
|
("Agent Settings", config.agents.defaults),
|
||||||
@@ -1072,7 +1326,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
|
|
||||||
original_config = base_config.model_copy(deep=True)
|
original_config = base_config.model_copy(deep=True)
|
||||||
config = base_config.model_copy(deep=True)
|
config = base_config.model_copy(deep=True)
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
last_main_choice: str | None = None
|
||||||
while True:
|
while True:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_main_menu_header()
|
_show_main_menu_header()
|
||||||
@@ -1082,6 +1338,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
"What would you like to configure?",
|
"What would you like to configure?",
|
||||||
choices=[
|
choices=[
|
||||||
"[P] LLM Provider",
|
"[P] LLM Provider",
|
||||||
|
"[M] Model Presets",
|
||||||
"[C] Chat Channel",
|
"[C] Chat Channel",
|
||||||
"[H] Channel Common",
|
"[H] Channel Common",
|
||||||
"[A] Agent Settings",
|
"[A] Agent Settings",
|
||||||
@@ -1092,6 +1349,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
"[S] Save and Exit",
|
"[S] Save and Exit",
|
||||||
"[X] Exit Without Saving",
|
"[X] Exit Without Saving",
|
||||||
],
|
],
|
||||||
|
default=last_main_choice,
|
||||||
qmark=">",
|
qmark=">",
|
||||||
).ask()
|
).ask()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -1105,8 +1363,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
return OnboardResult(config=original_config, should_save=False)
|
return OnboardResult(config=original_config, should_save=False)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_MENU_DISPATCH = {
|
_menu_dispatch = {
|
||||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||||
|
"[M] Model Presets": lambda: _configure_model_presets(config),
|
||||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||||
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
||||||
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
||||||
@@ -1121,6 +1380,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
if answer == "[X] Exit Without Saving":
|
if answer == "[X] Exit Without Saving":
|
||||||
return OnboardResult(config=original_config, should_save=False)
|
return OnboardResult(config=original_config, should_save=False)
|
||||||
|
|
||||||
action_fn = _MENU_DISPATCH.get(answer)
|
action_fn = _menu_dispatch.get(answer)
|
||||||
if action_fn:
|
if action_fn:
|
||||||
|
last_main_choice = answer
|
||||||
action_fn()
|
action_fn()
|
||||||
|
|||||||
+94
-21
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -13,6 +15,88 @@ from nanobot.utils.helpers import build_status_content
|
|||||||
from nanobot.utils.restart import set_restart_notice_to_env
|
from nanobot.utils.restart import set_restart_notice_to_env
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuiltinCommandSpec:
|
||||||
|
command: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
icon: str
|
||||||
|
arg_hint: str = ""
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"command": self.command,
|
||||||
|
"title": self.title,
|
||||||
|
"description": self.description,
|
||||||
|
"icon": self.icon,
|
||||||
|
"arg_hint": self.arg_hint,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/new",
|
||||||
|
"New chat",
|
||||||
|
"Stop the current task and start a fresh conversation.",
|
||||||
|
"square-pen",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/stop",
|
||||||
|
"Stop current task",
|
||||||
|
"Cancel the active agent turn for this chat.",
|
||||||
|
"square",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/restart",
|
||||||
|
"Restart nanobot",
|
||||||
|
"Restart the bot process in place.",
|
||||||
|
"rotate-cw",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/status",
|
||||||
|
"Show status",
|
||||||
|
"Display runtime, provider, and channel status.",
|
||||||
|
"activity",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/history",
|
||||||
|
"Show conversation history",
|
||||||
|
"Print the last N persisted conversation messages.",
|
||||||
|
"history",
|
||||||
|
"[n]",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/dream",
|
||||||
|
"Run Dream",
|
||||||
|
"Manually trigger memory consolidation.",
|
||||||
|
"sparkles",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/dream-log",
|
||||||
|
"Show Dream log",
|
||||||
|
"Show what the last Dream consolidation changed.",
|
||||||
|
"book-open",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/dream-restore",
|
||||||
|
"Restore memory",
|
||||||
|
"Revert memory to a previous Dream snapshot.",
|
||||||
|
"undo-2",
|
||||||
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/help",
|
||||||
|
"Show help",
|
||||||
|
"List available slash commands.",
|
||||||
|
"circle-help",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def builtin_command_palette() -> list[dict[str, str]]:
|
||||||
|
"""Return structured command metadata for UI command palettes."""
|
||||||
|
return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS]
|
||||||
|
|
||||||
|
|
||||||
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Cancel all active tasks and subagents for the session."""
|
"""Cancel all active tasks and subagents for the session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
@@ -50,16 +134,15 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
ctx_est = 0
|
ctx_est = 0
|
||||||
try:
|
with suppress(Exception):
|
||||||
ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(session)
|
ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(session)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if ctx_est <= 0:
|
if ctx_est <= 0:
|
||||||
ctx_est = loop._last_usage.get("prompt_tokens", 0)
|
ctx_est = loop._last_usage.get("prompt_tokens", 0)
|
||||||
|
|
||||||
# Fetch web search provider usage (best-effort, never blocks the response)
|
# Fetch web search provider usage (best-effort, never blocks the response)
|
||||||
search_usage_text: str | None = None
|
search_usage_text: str | None = None
|
||||||
try:
|
# Never let usage fetch break /status
|
||||||
|
with suppress(Exception):
|
||||||
from nanobot.utils.searchusage import fetch_search_usage
|
from nanobot.utils.searchusage import fetch_search_usage
|
||||||
web_cfg = getattr(loop, "web_config", None)
|
web_cfg = getattr(loop, "web_config", None)
|
||||||
search_cfg = getattr(web_cfg, "search", None) if web_cfg else None
|
search_cfg = getattr(web_cfg, "search", None) if web_cfg else None
|
||||||
@@ -68,14 +151,10 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
api_key = getattr(search_cfg, "api_key", "") or None
|
api_key = getattr(search_cfg, "api_key", "") or None
|
||||||
usage = await fetch_search_usage(provider=provider, api_key=api_key)
|
usage = await fetch_search_usage(provider=provider, api_key=api_key)
|
||||||
search_usage_text = usage.format()
|
search_usage_text = usage.format()
|
||||||
except Exception:
|
|
||||||
pass # Never let usage fetch break /status
|
|
||||||
active_tasks = loop._active_tasks.get(ctx.key, [])
|
active_tasks = loop._active_tasks.get(ctx.key, [])
|
||||||
task_count = sum(1 for t in active_tasks if not t.done())
|
task_count = sum(1 for t in active_tasks if not t.done())
|
||||||
try:
|
with suppress(Exception):
|
||||||
task_count += loop.subagents.get_running_count_by_session(ctx.key)
|
task_count += loop.subagents.get_running_count_by_session(ctx.key)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=ctx.msg.channel,
|
channel=ctx.msg.channel,
|
||||||
chat_id=ctx.msg.chat_id,
|
chat_id=ctx.msg.chat_id,
|
||||||
@@ -382,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
def build_help_text() -> str:
|
def build_help_text() -> str:
|
||||||
"""Build canonical help text shared across channels."""
|
"""Build canonical help text shared across channels."""
|
||||||
lines = [
|
lines = ["🐈 nanobot commands:"]
|
||||||
"🐈 nanobot commands:",
|
for spec in BUILTIN_COMMAND_SPECS:
|
||||||
"/new — Stop current task and start a new conversation",
|
command = spec.command
|
||||||
"/stop — Stop the current task",
|
if spec.arg_hint:
|
||||||
"/restart — Restart the bot",
|
command = f"{command} {spec.arg_hint}"
|
||||||
"/status — Show bot status",
|
lines.append(f"{command} — {spec.description}")
|
||||||
"/history [n] — Show the last N conversation messages (default 10)",
|
|
||||||
"/dream — Manually trigger Dream consolidation",
|
|
||||||
"/dream-log — Show what the last Dream changed",
|
|
||||||
"/dream-restore — Revert memory to a previous state",
|
|
||||||
"/help — Show available commands",
|
|
||||||
]
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
data = _migrate_config(data)
|
data = _migrate_config(data)
|
||||||
config = Config.model_validate(data)
|
config = Config.model_validate(data)
|
||||||
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
||||||
logger.warning(f"Failed to load config from {path}: {e}")
|
logger.warning("Failed to load config from {}: {}", path, e)
|
||||||
logger.warning("Using default configuration.")
|
logger.warning("Using default configuration.")
|
||||||
|
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||||
from pydantic.alias_generators import to_camel
|
from pydantic.alias_generators import to_camel
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@@ -65,22 +65,48 @@ class DreamConfig(Base):
|
|||||||
return f"every {hours}h"
|
return f"every {hours}h"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPresetConfig(Base):
|
||||||
|
"""A named set of model + generation parameters for quick switching."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
provider: str = "auto"
|
||||||
|
max_tokens: int = 8192
|
||||||
|
context_window_tokens: int = 65_536
|
||||||
|
temperature: float = 0.1
|
||||||
|
reasoning_effort: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentDefaults(Base):
|
class AgentDefaults(Base):
|
||||||
"""Default agent configuration."""
|
"""Default agent configuration."""
|
||||||
|
|
||||||
workspace: str = "~/.nanobot/workspace"
|
workspace: str = "~/.nanobot/workspace"
|
||||||
|
model_preset: str | None = None # Active preset name — takes precedence over fields below
|
||||||
|
# Fallback fields (used when model_preset is not set):
|
||||||
model: str = "anthropic/claude-opus-4-5"
|
model: str = "anthropic/claude-opus-4-5"
|
||||||
provider: str = (
|
provider: str = (
|
||||||
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
||||||
)
|
)
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 8192
|
||||||
context_window_tokens: int = 65_536
|
context_window_tokens: int = 65_536
|
||||||
context_block_limit: int | None = None
|
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
|
reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode
|
||||||
|
# End fallback fields
|
||||||
|
|
||||||
|
context_block_limit: int | None = None
|
||||||
max_tool_iterations: int = 200
|
max_tool_iterations: int = 200
|
||||||
|
max_concurrent_subagents: int = Field(default=1, ge=1)
|
||||||
max_tool_result_chars: int = 16_000
|
max_tool_result_chars: int = 16_000
|
||||||
provider_retry_mode: Literal["standard", "persistent"] = "standard"
|
provider_retry_mode: Literal["standard", "persistent"] = "standard"
|
||||||
reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode
|
tool_hint_max_length: int = Field(
|
||||||
|
default=40,
|
||||||
|
ge=20,
|
||||||
|
le=500,
|
||||||
|
validation_alias=AliasChoices("toolHintMaxLength"),
|
||||||
|
serialization_alias="toolHintMaxLength",
|
||||||
|
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
||||||
|
fallback_presets: list[str] = Field(
|
||||||
|
default_factory=list
|
||||||
|
) # Ordered fallback chain. Each item must be a preset name defined in model_presets.
|
||||||
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
||||||
unified_session: bool = False # Share one session across all channels (single-user multi-device)
|
unified_session: bool = False # Share one session across all channels (single-user multi-device)
|
||||||
disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
|
disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
|
||||||
@@ -119,11 +145,19 @@ class ProviderConfig(Base):
|
|||||||
extra_body: dict[str, Any] | None = None # Extra fields merged into every request body
|
extra_body: dict[str, Any] | None = None # Extra fields merged into every request body
|
||||||
|
|
||||||
|
|
||||||
|
class BedrockProviderConfig(ProviderConfig):
|
||||||
|
"""AWS Bedrock Runtime provider configuration."""
|
||||||
|
|
||||||
|
region: str | None = None # AWS region, falls back to AWS_REGION/AWS_DEFAULT_REGION/profile
|
||||||
|
profile: str | None = None # Optional AWS shared config profile
|
||||||
|
|
||||||
|
|
||||||
class ProvidersConfig(Base):
|
class ProvidersConfig(Base):
|
||||||
"""Configuration for LLM providers."""
|
"""Configuration for LLM providers."""
|
||||||
|
|
||||||
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
|
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
|
||||||
azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name)
|
azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name)
|
||||||
|
bedrock: BedrockProviderConfig = Field(default_factory=BedrockProviderConfig) # AWS Bedrock Converse
|
||||||
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
|
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
@@ -143,6 +177,7 @@ class ProvidersConfig(Base):
|
|||||||
mistral: ProviderConfig = Field(default_factory=ProviderConfig)
|
mistral: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
||||||
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
|
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
|
||||||
|
longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat
|
||||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
||||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
||||||
@@ -214,6 +249,8 @@ class ExecToolConfig(Base):
|
|||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
|
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
|
||||||
allowed_env_keys: list[str] = Field(default_factory=list) # Env var names to pass through to subprocess (e.g. ["GOPATH", "JAVA_HOME"])
|
allowed_env_keys: list[str] = Field(default_factory=list) # Env var names to pass through to subprocess (e.g. ["GOPATH", "JAVA_HOME"])
|
||||||
|
allow_patterns: list[str] = Field(default_factory=list) # Regex patterns that bypass deny_patterns (e.g. [r"rm\s+-rf\s+/tmp/"])
|
||||||
|
deny_patterns: list[str] = Field(default_factory=list) # Extra regex patterns to block (appended to built-in list)
|
||||||
|
|
||||||
class MCPServerConfig(Base):
|
class MCPServerConfig(Base):
|
||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""MCP server connection configuration (stdio or HTTP)."""
|
||||||
@@ -254,6 +291,54 @@ class Config(BaseSettings):
|
|||||||
api: ApiConfig = Field(default_factory=ApiConfig)
|
api: ApiConfig = Field(default_factory=ApiConfig)
|
||||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||||
|
model_presets: dict[str, ModelPresetConfig] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _sync_and_validate_preset(self) -> "Config":
|
||||||
|
"""Expose agents.defaults model fields as the implicit 'default' preset
|
||||||
|
and validate the active preset reference.
|
||||||
|
|
||||||
|
This guarantees that ``model_presets`` is never empty and that legacy
|
||||||
|
configs (which only set ``agents.defaults.model`` etc.) continue to work
|
||||||
|
without explicitly declaring a preset.
|
||||||
|
"""
|
||||||
|
self._refresh_default_preset()
|
||||||
|
defaults = self.agents.defaults
|
||||||
|
if defaults.model_preset is None:
|
||||||
|
defaults.model_preset = "default"
|
||||||
|
if defaults.model_preset not in self.model_presets:
|
||||||
|
raise ValueError(f"model_preset {defaults.model_preset!r} not found in model_presets")
|
||||||
|
for fb in defaults.fallback_presets:
|
||||||
|
if fb not in self.model_presets:
|
||||||
|
raise ValueError(f"fallback_presets entry {fb!r} not found in model_presets")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _refresh_default_preset(self) -> None:
|
||||||
|
"""Rebuild the implicit 'default' preset from current agents.defaults.
|
||||||
|
|
||||||
|
Called inside ``_sync_and_validate_preset`` (model validator) and
|
||||||
|
``resolve_preset()`` so that runtime mutations (e.g. tests directly
|
||||||
|
setting ``defaults.model``) are reflected.
|
||||||
|
"""
|
||||||
|
d = self.agents.defaults
|
||||||
|
self.model_presets["default"] = ModelPresetConfig(
|
||||||
|
model=d.model,
|
||||||
|
provider=d.provider,
|
||||||
|
max_tokens=d.max_tokens,
|
||||||
|
context_window_tokens=d.context_window_tokens,
|
||||||
|
temperature=d.temperature,
|
||||||
|
reasoning_effort=d.reasoning_effort,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_preset(self) -> ModelPresetConfig:
|
||||||
|
"""Return the active preset.
|
||||||
|
|
||||||
|
The implicit ``"default"`` preset is rebuilt from current defaults every
|
||||||
|
time so that runtime mutations (e.g. tests setting ``defaults.model``)
|
||||||
|
are always reflected.
|
||||||
|
"""
|
||||||
|
self._refresh_default_preset()
|
||||||
|
return self.model_presets[self.agents.defaults.model_preset]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workspace_path(self) -> Path:
|
def workspace_path(self) -> Path:
|
||||||
@@ -266,15 +351,16 @@ class Config(BaseSettings):
|
|||||||
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
||||||
from nanobot.providers.registry import PROVIDERS, find_by_name
|
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||||
|
|
||||||
forced = self.agents.defaults.provider
|
resolved = self.resolve_preset()
|
||||||
|
forced = resolved.provider
|
||||||
if forced != "auto":
|
if forced != "auto":
|
||||||
spec = find_by_name(forced)
|
spec = find_by_name(forced)
|
||||||
if spec:
|
if spec:
|
||||||
p = getattr(self.providers, spec.name, None)
|
provider_cfg = getattr(self.providers, spec.name, None)
|
||||||
return (p, spec.name) if p else (None, None)
|
return (provider_cfg, spec.name) if provider_cfg else (None, None)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
model_lower = (model or self.agents.defaults.model).lower()
|
model_lower = (model or resolved.model).lower()
|
||||||
model_normalized = model_lower.replace("-", "_")
|
model_normalized = model_lower.replace("-", "_")
|
||||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||||
normalized_prefix = model_prefix.replace("-", "_")
|
normalized_prefix = model_prefix.replace("-", "_")
|
||||||
@@ -287,14 +373,14 @@ class Config(BaseSettings):
|
|||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
p = getattr(self.providers, spec.name, None)
|
p = getattr(self.providers, spec.name, None)
|
||||||
if p and model_prefix and normalized_prefix == spec.name:
|
if p and model_prefix and normalized_prefix == spec.name:
|
||||||
if spec.is_oauth or spec.is_local or p.api_key:
|
if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key:
|
||||||
return p, spec.name
|
return p, spec.name
|
||||||
|
|
||||||
# Match by keyword (order follows PROVIDERS registry)
|
# Match by keyword (order follows PROVIDERS registry)
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
p = getattr(self.providers, spec.name, None)
|
p = getattr(self.providers, spec.name, None)
|
||||||
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
||||||
if spec.is_oauth or spec.is_local or p.api_key:
|
if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key:
|
||||||
return p, spec.name
|
return p, spec.name
|
||||||
|
|
||||||
# Fallback: configured local providers can route models without
|
# Fallback: configured local providers can route models without
|
||||||
|
|||||||
+107
-12
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,7 +14,14 @@ from typing import Any, Callable, Coroutine, Literal
|
|||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore
|
from nanobot.cron.types import (
|
||||||
|
CronJob,
|
||||||
|
CronJobState,
|
||||||
|
CronPayload,
|
||||||
|
CronRunRecord,
|
||||||
|
CronSchedule,
|
||||||
|
CronStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _now_ms() -> int:
|
def _now_ms() -> int:
|
||||||
@@ -83,8 +92,20 @@ class CronService:
|
|||||||
self._timer_active = False
|
self._timer_active = False
|
||||||
self.max_sleep_ms = max_sleep_ms
|
self.max_sleep_ms = max_sleep_ms
|
||||||
|
|
||||||
def _load_jobs(self) -> tuple[list[CronJob], int]:
|
def _load_jobs(self) -> tuple[list[CronJob], int] | None:
|
||||||
jobs = []
|
"""Load jobs from disk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(jobs, version)`` tuple on success or when no store file exists
|
||||||
|
(in which case an empty list and version 1 are returned).
|
||||||
|
``None`` when the store file exists but cannot be parsed; the
|
||||||
|
corrupt file is preserved with a ``.corrupt-<ts>`` suffix so the
|
||||||
|
caller can decide whether to overwrite or bail out. Returning a
|
||||||
|
sentinel here is important: silently treating a parse error as an
|
||||||
|
empty job list would cause the next ``_save_store`` to wipe every
|
||||||
|
job from disk.
|
||||||
|
"""
|
||||||
|
jobs: list[CronJob] = []
|
||||||
version = 1
|
version = 1
|
||||||
if self.store_path.exists():
|
if self.store_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -135,8 +156,22 @@ class CronService:
|
|||||||
updated_at_ms=j.get("updatedAtMs", 0),
|
updated_at_ms=j.get("updatedAtMs", 0),
|
||||||
delete_after_run=j.get("deleteAfterRun", False),
|
delete_after_run=j.get("deleteAfterRun", False),
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning("Failed to load cron store: {}", e)
|
# Preserve the corrupt file for forensic recovery instead of
|
||||||
|
# letting the next save overwrite it with an empty job list.
|
||||||
|
backup = self.store_path.with_suffix(
|
||||||
|
self.store_path.suffix + f".corrupt-{int(time.time())}"
|
||||||
|
)
|
||||||
|
with suppress(OSError):
|
||||||
|
self.store_path.rename(backup)
|
||||||
|
logger.exception(
|
||||||
|
"Failed to load cron store at {}. "
|
||||||
|
"Corrupt file preserved at {}. "
|
||||||
|
"Refusing to overwrite to avoid data loss.",
|
||||||
|
self.store_path,
|
||||||
|
backup,
|
||||||
|
)
|
||||||
|
return None
|
||||||
return jobs, version
|
return jobs, version
|
||||||
|
|
||||||
def _merge_action(self):
|
def _merge_action(self):
|
||||||
@@ -166,8 +201,8 @@ class CronService:
|
|||||||
else:
|
else:
|
||||||
_update(action.get("params", {}))
|
_update(action.get("params", {}))
|
||||||
changed = True
|
changed = True
|
||||||
except Exception as exp:
|
except Exception:
|
||||||
logger.debug(f"load action line error: {exp}")
|
logger.exception("load action line error")
|
||||||
continue
|
continue
|
||||||
self._store.jobs = list(jobs_map.values())
|
self._store.jobs = list(jobs_map.values())
|
||||||
if self._running and changed:
|
if self._running and changed:
|
||||||
@@ -175,15 +210,28 @@ class CronService:
|
|||||||
self._save_store()
|
self._save_store()
|
||||||
return
|
return
|
||||||
|
|
||||||
def _load_store(self) -> CronStore:
|
def _load_store(self) -> CronStore | None:
|
||||||
"""Load jobs from disk. Reloads automatically if file was modified externally.
|
"""Load jobs from disk. Reloads automatically if file was modified externally.
|
||||||
- Reload every time because it needs to merge operations on the jobs object from other instances.
|
- Reload every time because it needs to merge operations on the jobs object from other instances.
|
||||||
- During _on_timer execution, return the existing store to prevent concurrent
|
- During _on_timer execution, return the existing store to prevent concurrent
|
||||||
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
|
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
|
||||||
|
- When the on-disk store exists but is unreadable: keep using the
|
||||||
|
previous in-memory ``self._store`` if we already have one (so a
|
||||||
|
transient corruption does not drop live jobs); only the very first
|
||||||
|
load (during ``start``) can return ``None`` to signal an unrecoverable
|
||||||
|
state to the caller.
|
||||||
"""
|
"""
|
||||||
if self._timer_active and self._store:
|
if self._timer_active and self._store:
|
||||||
return self._store
|
return self._store
|
||||||
jobs, version = self._load_jobs()
|
loaded = self._load_jobs()
|
||||||
|
if loaded is None:
|
||||||
|
# Corrupt store on disk. Prefer the last good in-memory snapshot
|
||||||
|
# over wiping live jobs; ``_load_jobs`` has already moved the
|
||||||
|
# corrupt file aside with a ``.corrupt-<ts>`` suffix.
|
||||||
|
if self._store is not None:
|
||||||
|
return self._store
|
||||||
|
return None
|
||||||
|
jobs, version = loaded
|
||||||
self._store = CronStore(version=version, jobs=jobs)
|
self._store = CronStore(version=version, jobs=jobs)
|
||||||
self._merge_action()
|
self._merge_action()
|
||||||
|
|
||||||
@@ -242,12 +290,56 @@ class CronService:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
self._atomic_write(self.store_path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _atomic_write(path: Path, content: str) -> None:
|
||||||
|
"""Write *content* to *path* atomically with fsync.
|
||||||
|
|
||||||
|
Uses a temp-file + ``os.replace`` + ``fsync`` pattern so a crash or
|
||||||
|
SIGKILL mid-write cannot leave the destination truncated or invalid.
|
||||||
|
Mirrors ``nanobot.session.manager.SessionManager.save`` (see
|
||||||
|
commit 512bf59, ``fix(session): fsync sessions on graceful shutdown
|
||||||
|
to prevent data loss``). Without this, ``jobs.json`` could be
|
||||||
|
corrupted on container shutdown and silently re-created empty on
|
||||||
|
next start, wiping every scheduled job.
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
# fsync the parent directory so the rename itself is durable.
|
||||||
|
# Skip on Windows where opening a directory raises PermissionError;
|
||||||
|
# NTFS journals metadata synchronously so this is a no-op there.
|
||||||
|
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
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the cron service."""
|
"""Start the cron service."""
|
||||||
self._running = True
|
self._running = True
|
||||||
self._load_store()
|
loaded = self._load_store()
|
||||||
|
if loaded is None:
|
||||||
|
# Store file existed but was corrupt and has been preserved with
|
||||||
|
# a ``.corrupt-<ts>`` suffix. Bail out instead of starting with
|
||||||
|
# an empty store; that would call ``_save_store`` and overwrite
|
||||||
|
# the now-renamed (but still recoverable) data with [].
|
||||||
|
self._running = False
|
||||||
|
raise RuntimeError(
|
||||||
|
f"cron store at {self.store_path} is corrupt and was preserved; "
|
||||||
|
"refusing to start with an empty job list. "
|
||||||
|
"Inspect the .corrupt-<ts> backup and restore manually."
|
||||||
|
)
|
||||||
self._recompute_next_runs()
|
self._recompute_next_runs()
|
||||||
self._save_store()
|
self._save_store()
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
@@ -302,6 +394,9 @@ class CronService:
|
|||||||
async def _on_timer(self) -> None:
|
async def _on_timer(self) -> None:
|
||||||
"""Handle timer tick - run due jobs."""
|
"""Handle timer tick - run due jobs."""
|
||||||
self._load_store()
|
self._load_store()
|
||||||
|
# If a hot reload found a corrupt store on disk, ``self._store`` may
|
||||||
|
# still hold the previous, known-good in-memory snapshot. Keep using
|
||||||
|
# it rather than crashing the timer or wiping live jobs.
|
||||||
if not self._store:
|
if not self._store:
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
return
|
return
|
||||||
@@ -338,7 +433,7 @@ class CronService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
job.state.last_status = "error"
|
job.state.last_status = "error"
|
||||||
job.state.last_error = str(e)
|
job.state.last_error = str(e)
|
||||||
logger.error("Cron: job '{}' failed: {}", job.name, e)
|
logger.exception("Cron: job '{}' failed", job.name)
|
||||||
|
|
||||||
end_ms = _now_ms()
|
end_ms = _now_ms()
|
||||||
job.state.last_run_at_ms = start_ms
|
job.state.last_run_at_ms = start_ms
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ class HeartbeatService:
|
|||||||
await self._tick()
|
await self._tick()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Heartbeat error: {}", e)
|
logger.exception("Heartbeat error")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_deliverable(response: str) -> bool:
|
def _is_deliverable(response: str) -> bool:
|
||||||
|
|||||||
+10
-35
@@ -6,9 +6,8 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook
|
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -62,31 +61,7 @@ class Nanobot:
|
|||||||
Path(workspace).expanduser().resolve()
|
Path(workspace).expanduser().resolve()
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = _make_provider(config)
|
loop = AgentLoop.from_config(config)
|
||||||
bus = MessageBus()
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=bus,
|
|
||||||
provider=provider,
|
|
||||||
workspace=config.workspace_path,
|
|
||||||
model=defaults.model,
|
|
||||||
max_iterations=defaults.max_tool_iterations,
|
|
||||||
context_window_tokens=defaults.context_window_tokens,
|
|
||||||
context_block_limit=defaults.context_block_limit,
|
|
||||||
max_tool_result_chars=defaults.max_tool_result_chars,
|
|
||||||
provider_retry_mode=defaults.provider_retry_mode,
|
|
||||||
web_config=config.tools.web,
|
|
||||||
exec_config=config.tools.exec,
|
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
|
||||||
mcp_servers=config.tools.mcp_servers,
|
|
||||||
timezone=defaults.timezone,
|
|
||||||
unified_session=defaults.unified_session,
|
|
||||||
disabled_skills=defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=defaults.consolidation_ratio,
|
|
||||||
tools_config=config.tools,
|
|
||||||
)
|
|
||||||
return cls(loop)
|
return cls(loop)
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
@@ -104,9 +79,10 @@ class Nanobot:
|
|||||||
Different keys get independent history.
|
Different keys get independent history.
|
||||||
hooks: Optional lifecycle hooks for this run.
|
hooks: Optional lifecycle hooks for this run.
|
||||||
"""
|
"""
|
||||||
|
capture = SDKCaptureHook()
|
||||||
prev = self._loop._extra_hooks
|
prev = self._loop._extra_hooks
|
||||||
if hooks is not None:
|
base_hooks = list(hooks) if hooks is not None else list(prev or [])
|
||||||
self._loop._extra_hooks = list(hooks)
|
self._loop._extra_hooks = [capture, *base_hooks]
|
||||||
try:
|
try:
|
||||||
response = await self._loop.process_direct(
|
response = await self._loop.process_direct(
|
||||||
message, session_key=session_key,
|
message, session_key=session_key,
|
||||||
@@ -115,11 +91,10 @@ class Nanobot:
|
|||||||
self._loop._extra_hooks = prev
|
self._loop._extra_hooks = prev
|
||||||
|
|
||||||
content = (response.content if response else None) or ""
|
content = (response.content if response else None) or ""
|
||||||
return RunResult(content=content, tools_used=[], messages=[])
|
return RunResult(
|
||||||
|
content=content,
|
||||||
|
tools_used=capture.tools_used,
|
||||||
|
messages=capture.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Any) -> Any:
|
|
||||||
"""Create the LLM provider from config (extracted from CLI)."""
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
return make_provider(config)
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ __all__ = [
|
|||||||
"OpenAICodexProvider",
|
"OpenAICodexProvider",
|
||||||
"GitHubCopilotProvider",
|
"GitHubCopilotProvider",
|
||||||
"AzureOpenAIProvider",
|
"AzureOpenAIProvider",
|
||||||
|
"BedrockProvider",
|
||||||
]
|
]
|
||||||
|
|
||||||
_LAZY_IMPORTS = {
|
_LAZY_IMPORTS = {
|
||||||
@@ -23,11 +24,13 @@ _LAZY_IMPORTS = {
|
|||||||
"OpenAICodexProvider": ".openai_codex_provider",
|
"OpenAICodexProvider": ".openai_codex_provider",
|
||||||
"GitHubCopilotProvider": ".github_copilot_provider",
|
"GitHubCopilotProvider": ".github_copilot_provider",
|
||||||
"AzureOpenAIProvider": ".azure_openai_provider",
|
"AzureOpenAIProvider": ".azure_openai_provider",
|
||||||
|
"BedrockProvider": ".bedrock_provider",
|
||||||
}
|
}
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
|
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
|
|||||||
@@ -537,6 +537,13 @@ class AnthropicProvider(LLMProvider):
|
|||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_streaming_required_error(e: Exception) -> bool:
|
||||||
|
"""Anthropic SDK rejects long non-stream requests with a ValueError
|
||||||
|
whose message starts with 'Streaming is required'. Match defensively
|
||||||
|
on substring so a future SDK message tweak doesn't break detection."""
|
||||||
|
return isinstance(e, ValueError) and "streaming is required" in str(e).lower()
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@@ -555,6 +562,21 @@ class AnthropicProvider(LLMProvider):
|
|||||||
response = await self._client.messages.create(**kwargs)
|
response = await self._client.messages.create(**kwargs)
|
||||||
return self._parse_response(response)
|
return self._parse_response(response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if self._is_streaming_required_error(e):
|
||||||
|
# Anthropic SDK refuses non-stream calls when max_tokens (plus
|
||||||
|
# extended thinking budget) could push the request past the
|
||||||
|
# 10-minute server-side timeout (#2709). Transparently retry
|
||||||
|
# via the streaming path so callers don't need to know the
|
||||||
|
# provider-specific limit.
|
||||||
|
return await self.chat_stream(
|
||||||
|
messages=messages,
|
||||||
|
tools=tools,
|
||||||
|
model=model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
tool_choice=tool_choice,
|
||||||
|
)
|
||||||
return self._handle_error(e)
|
return self._handle_error(e)
|
||||||
|
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from contextlib import suppress
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -136,7 +137,9 @@ class LLMProvider(ABC):
|
|||||||
"insufficient_quota",
|
"insufficient_quota",
|
||||||
"insufficient quota",
|
"insufficient quota",
|
||||||
"quota exceeded",
|
"quota exceeded",
|
||||||
|
"quota_exceeded",
|
||||||
"quota exhausted",
|
"quota exhausted",
|
||||||
|
"quota_exhausted",
|
||||||
"billing hard limit",
|
"billing hard limit",
|
||||||
"billing_hard_limit_reached",
|
"billing_hard_limit_reached",
|
||||||
"billing not active",
|
"billing not active",
|
||||||
@@ -643,14 +646,12 @@ class LLMProvider(ABC):
|
|||||||
return value
|
return value
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
with suppress(TypeError, ValueError):
|
||||||
retry_ms = _header_value("retry-after-ms")
|
retry_ms = _header_value("retry-after-ms")
|
||||||
if retry_ms is not None:
|
if retry_ms is not None:
|
||||||
value = float(retry_ms) / 1000.0
|
value = float(retry_ms) / 1000.0
|
||||||
if value > 0:
|
if value > 0:
|
||||||
return value
|
return value
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
retry_after = _header_value("retry-after")
|
retry_after = _header_value("retry-after")
|
||||||
if retry_after is None:
|
if retry_after is None:
|
||||||
|
|||||||
@@ -0,0 +1,730 @@
|
|||||||
|
"""AWS Bedrock Converse provider."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Awaitable, Callable, Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import json_repair
|
||||||
|
|
||||||
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
|
||||||
|
_IMAGE_DATA_URL = re.compile(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", re.DOTALL)
|
||||||
|
_TEXT_BLOCK_TYPES = {"text", "input_text", "output_text"}
|
||||||
|
_TEMPERATURE_UNSUPPORTED_MODEL_TOKENS = ("claude-opus-4-7",)
|
||||||
|
_ADAPTIVE_THINKING_ONLY_MODEL_TOKENS = ("claude-opus-4-7",)
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
merged = dict(base)
|
||||||
|
for key, value in override.items():
|
||||||
|
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||||
|
merged[key] = _deep_merge(merged[key], value)
|
||||||
|
else:
|
||||||
|
merged[key] = value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _next_or_none(iterator: Iterator[dict[str, Any]]) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
return next(iterator)
|
||||||
|
except StopIteration:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BedrockProvider(LLMProvider):
|
||||||
|
"""LLM provider using AWS Bedrock Runtime's Converse APIs."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_base: str | None = None,
|
||||||
|
default_model: str = "bedrock/global.anthropic.claude-opus-4-7",
|
||||||
|
*,
|
||||||
|
region: str | None = None,
|
||||||
|
profile: str | None = None,
|
||||||
|
extra_body: dict[str, Any] | None = None,
|
||||||
|
client: Any | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(api_key, api_base)
|
||||||
|
self.default_model = default_model
|
||||||
|
self.region = region or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
||||||
|
self.profile = profile
|
||||||
|
self._extra_body = extra_body or {}
|
||||||
|
self._client = client if client is not None else self._make_client()
|
||||||
|
|
||||||
|
def _make_client(self) -> Any:
|
||||||
|
if self.api_key:
|
||||||
|
os.environ["AWS_BEARER_TOKEN_BEDROCK"] = self.api_key
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
except ImportError as exc: # pragma: no cover - exercised only without boto3 installed
|
||||||
|
raise RuntimeError(
|
||||||
|
"AWS Bedrock provider requires boto3. Install it with `pip install boto3`."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
session_kwargs: dict[str, Any] = {}
|
||||||
|
if self.profile:
|
||||||
|
session_kwargs["profile_name"] = self.profile
|
||||||
|
session = boto3.Session(**session_kwargs)
|
||||||
|
|
||||||
|
client_kwargs: dict[str, Any] = {}
|
||||||
|
if self.region:
|
||||||
|
client_kwargs["region_name"] = self.region
|
||||||
|
if self.api_base:
|
||||||
|
client_kwargs["endpoint_url"] = self.api_base
|
||||||
|
return session.client("bedrock-runtime", **client_kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_prefix(model: str) -> str:
|
||||||
|
if model.startswith("bedrock/"):
|
||||||
|
return model[len("bedrock/"):]
|
||||||
|
return model
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matches_model_token(model: str, tokens: tuple[str, ...]) -> bool:
|
||||||
|
model_lower = model.lower()
|
||||||
|
return any(token in model_lower for token in tokens)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _supports_temperature(cls, model: str) -> bool:
|
||||||
|
return not cls._matches_model_token(model, _TEMPERATURE_UNSUPPORTED_MODEL_TOKENS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _uses_adaptive_thinking_only(cls, model: str) -> bool:
|
||||||
|
return cls._matches_model_token(model, _ADAPTIVE_THINKING_ONLY_MODEL_TOKENS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _image_url_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
url = (block.get("image_url") or {}).get("url", "")
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
return None
|
||||||
|
match = _IMAGE_DATA_URL.match(url)
|
||||||
|
if not match:
|
||||||
|
return {"text": f"(image URL: {url})"}
|
||||||
|
fmt = match.group(1).lower()
|
||||||
|
if fmt == "jpg":
|
||||||
|
fmt = "jpeg"
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(match.group(2), validate=False)
|
||||||
|
except Exception:
|
||||||
|
return {"text": "(invalid image data)"}
|
||||||
|
return {"image": {"format": fmt, "source": {"bytes": data}}}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _content_blocks(cls, content: Any, *, for_tool_result: bool = False) -> list[dict[str, Any]]:
|
||||||
|
if isinstance(content, str) or content is None:
|
||||||
|
return [{"text": content or "(empty)"}]
|
||||||
|
if not isinstance(content, list):
|
||||||
|
if for_tool_result and isinstance(content, dict):
|
||||||
|
return [{"json": content}]
|
||||||
|
return [{"text": str(content)}]
|
||||||
|
|
||||||
|
blocks: list[dict[str, Any]] = []
|
||||||
|
for item in content:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
blocks.append({"text": str(item)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_type = item.get("type")
|
||||||
|
if item_type in _TEXT_BLOCK_TYPES or "text" in item:
|
||||||
|
text = item.get("text")
|
||||||
|
if text:
|
||||||
|
blocks.append({"text": str(text)})
|
||||||
|
continue
|
||||||
|
if item_type == "image_url":
|
||||||
|
converted = cls._image_url_block(item)
|
||||||
|
if converted:
|
||||||
|
blocks.append(converted)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Preserve already-Bedrock-shaped content where possible.
|
||||||
|
for key in ("text", "image", "document", "video", "json", "searchResult"):
|
||||||
|
if key in item:
|
||||||
|
blocks.append({key: item[key]})
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
blocks.append({"json": item} if for_tool_result else {"text": json.dumps(item)})
|
||||||
|
|
||||||
|
return blocks or [{"text": "(empty)"}]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _system_blocks(cls, content: Any) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
block for block in cls._content_blocks(content)
|
||||||
|
if "text" in block or "cachePoint" in block or "guardContent" in block
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _tool_result_block(cls, msg: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"toolResult": {
|
||||||
|
"toolUseId": str(msg.get("tool_call_id") or ""),
|
||||||
|
"content": cls._content_blocks(msg.get("content"), for_tool_result=True),
|
||||||
|
"status": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tool_use_block(tool_call: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
function = tool_call.get("function")
|
||||||
|
if not isinstance(function, dict):
|
||||||
|
return None
|
||||||
|
args = function.get("arguments", {})
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json_repair.loads(args) if args.strip() else {}
|
||||||
|
except Exception:
|
||||||
|
args = {}
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
args = {}
|
||||||
|
return {
|
||||||
|
"toolUse": {
|
||||||
|
"toolUseId": str(tool_call.get("id") or ""),
|
||||||
|
"name": str(function.get("name") or ""),
|
||||||
|
"input": args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reasoning_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
if block.get("type") not in {"thinking", "reasoning", "redacted_thinking"}:
|
||||||
|
return None
|
||||||
|
text = block.get("thinking") or block.get("text")
|
||||||
|
signature = block.get("signature")
|
||||||
|
if text and signature:
|
||||||
|
return {
|
||||||
|
"reasoningContent": {
|
||||||
|
"reasoningText": {"text": str(text), "signature": str(signature)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redacted = block.get("redactedContent")
|
||||||
|
if redacted is None and isinstance(block.get("redactedContentBase64"), str):
|
||||||
|
try:
|
||||||
|
redacted = base64.b64decode(block["redactedContentBase64"])
|
||||||
|
except Exception:
|
||||||
|
redacted = None
|
||||||
|
if redacted is not None:
|
||||||
|
return {"reasoningContent": {"redactedContent": redacted}}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _assistant_blocks(cls, msg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
blocks: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for thinking in msg.get("thinking_blocks") or []:
|
||||||
|
if isinstance(thinking, dict):
|
||||||
|
reasoning = cls._reasoning_block(thinking)
|
||||||
|
if reasoning:
|
||||||
|
blocks.append(reasoning)
|
||||||
|
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, str) and content:
|
||||||
|
blocks.append({"text": content})
|
||||||
|
elif isinstance(content, list):
|
||||||
|
blocks.extend(block for block in cls._content_blocks(content) if "text" in block)
|
||||||
|
|
||||||
|
for tool_call in msg.get("tool_calls") or []:
|
||||||
|
if isinstance(tool_call, dict):
|
||||||
|
block = cls._tool_use_block(tool_call)
|
||||||
|
if block:
|
||||||
|
blocks.append(block)
|
||||||
|
|
||||||
|
return blocks or [{"text": ""}]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_tool_use(msg: dict[str, Any]) -> bool:
|
||||||
|
content = msg.get("content")
|
||||||
|
return isinstance(content, list) and any(
|
||||||
|
isinstance(block, dict) and "toolUse" in block for block in content
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_consecutive(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
merged: list[dict[str, Any]] = []
|
||||||
|
for msg in messages:
|
||||||
|
if merged and merged[-1].get("role") == msg.get("role"):
|
||||||
|
prev = merged[-1].setdefault("content", [])
|
||||||
|
cur = msg.get("content") or []
|
||||||
|
if not isinstance(prev, list):
|
||||||
|
prev = [{"text": str(prev)}]
|
||||||
|
merged[-1]["content"] = prev
|
||||||
|
if isinstance(cur, list):
|
||||||
|
prev.extend(cur)
|
||||||
|
else:
|
||||||
|
prev.append({"text": str(cur)})
|
||||||
|
else:
|
||||||
|
merged.append(msg)
|
||||||
|
|
||||||
|
last_popped: dict[str, Any] | None = None
|
||||||
|
while merged and merged[-1].get("role") == "assistant":
|
||||||
|
last_popped = merged.pop()
|
||||||
|
if not merged and last_popped is not None and not BedrockProvider._has_tool_use(last_popped):
|
||||||
|
merged.append({"role": "user", "content": last_popped.get("content") or [{"text": "(empty)"}]})
|
||||||
|
if merged and merged[0].get("role") == "assistant" and not BedrockProvider._has_tool_use(merged[0]):
|
||||||
|
merged.insert(0, {"role": "user", "content": [{"text": "(conversation continued)"}]})
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _convert_messages(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
system: list[dict[str, Any]] = []
|
||||||
|
converted: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content")
|
||||||
|
if role == "system":
|
||||||
|
system.extend(self._system_blocks(content))
|
||||||
|
continue
|
||||||
|
if role == "tool":
|
||||||
|
block = self._tool_result_block(msg)
|
||||||
|
if converted and converted[-1].get("role") == "user":
|
||||||
|
converted[-1].setdefault("content", []).append(block)
|
||||||
|
else:
|
||||||
|
converted.append({"role": "user", "content": [block]})
|
||||||
|
continue
|
||||||
|
if role == "assistant":
|
||||||
|
converted.append({"role": "assistant", "content": self._assistant_blocks(msg)})
|
||||||
|
continue
|
||||||
|
if role == "user":
|
||||||
|
converted.append({"role": "user", "content": self._content_blocks(content)})
|
||||||
|
|
||||||
|
return system, self._merge_consecutive(converted)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
||||||
|
if not tools:
|
||||||
|
return None
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for tool in tools:
|
||||||
|
func = tool.get("function") if isinstance(tool.get("function"), dict) else tool
|
||||||
|
if not isinstance(func, dict):
|
||||||
|
continue
|
||||||
|
name = str(func.get("name") or "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
spec: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"inputSchema": {
|
||||||
|
"json": func.get("parameters") or {"type": "object", "properties": {}}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
description = func.get("description")
|
||||||
|
if description:
|
||||||
|
spec["description"] = str(description)
|
||||||
|
strict = func.get("strict", tool.get("strict"))
|
||||||
|
if isinstance(strict, bool):
|
||||||
|
spec["strict"] = strict
|
||||||
|
result.append({"toolSpec": spec})
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_tool_choice(
|
||||||
|
tool_choice: str | dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if tool_choice is None or tool_choice == "auto":
|
||||||
|
return {"auto": {}}
|
||||||
|
if tool_choice == "required":
|
||||||
|
return {"any": {}}
|
||||||
|
if tool_choice == "none":
|
||||||
|
return None
|
||||||
|
if isinstance(tool_choice, dict):
|
||||||
|
name = tool_choice.get("function", {}).get("name")
|
||||||
|
if name:
|
||||||
|
return {"tool": {"name": str(name)}}
|
||||||
|
return {"auto": {}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _adaptive_thinking(reasoning_effort: str | None) -> dict[str, Any] | None:
|
||||||
|
if not reasoning_effort:
|
||||||
|
return None
|
||||||
|
effort = reasoning_effort.lower()
|
||||||
|
if effort == "none":
|
||||||
|
return None
|
||||||
|
thinking: dict[str, Any] = {"type": "adaptive"}
|
||||||
|
if effort != "adaptive":
|
||||||
|
thinking["effort"] = effort
|
||||||
|
return thinking
|
||||||
|
|
||||||
|
def _build_kwargs(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None,
|
||||||
|
model: str | None,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
reasoning_effort: str | None,
|
||||||
|
tool_choice: str | dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
model_id = self._strip_prefix(model or self.default_model)
|
||||||
|
system, bedrock_messages = self._convert_messages(self._sanitize_empty_content(messages))
|
||||||
|
if not bedrock_messages:
|
||||||
|
bedrock_messages = [{"role": "user", "content": [{"text": "(empty)"}]}]
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"modelId": model_id,
|
||||||
|
"messages": bedrock_messages,
|
||||||
|
"inferenceConfig": {"maxTokens": max(1, max_tokens)},
|
||||||
|
}
|
||||||
|
if system:
|
||||||
|
kwargs["system"] = system
|
||||||
|
if self._supports_temperature(model_id):
|
||||||
|
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||||
|
|
||||||
|
additional: dict[str, Any] = {}
|
||||||
|
if self._uses_adaptive_thinking_only(model_id):
|
||||||
|
thinking = self._adaptive_thinking(reasoning_effort)
|
||||||
|
if thinking:
|
||||||
|
additional["thinking"] = thinking
|
||||||
|
if self._extra_body:
|
||||||
|
additional = _deep_merge(additional, self._extra_body)
|
||||||
|
if additional:
|
||||||
|
kwargs["additionalModelRequestFields"] = additional
|
||||||
|
|
||||||
|
bedrock_tools = self._convert_tools(tools)
|
||||||
|
if bedrock_tools:
|
||||||
|
tool_config: dict[str, Any] = {"tools": bedrock_tools}
|
||||||
|
choice = self._convert_tool_choice(tool_choice)
|
||||||
|
if choice:
|
||||||
|
tool_config["toolChoice"] = choice
|
||||||
|
kwargs["toolConfig"] = tool_config
|
||||||
|
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _finish_reason(stop_reason: str | None) -> str:
|
||||||
|
return {
|
||||||
|
"end_turn": "stop",
|
||||||
|
"tool_use": "tool_calls",
|
||||||
|
"max_tokens": "length",
|
||||||
|
}.get(stop_reason or "", stop_reason or "stop")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _usage(usage: dict[str, Any] | None) -> dict[str, int]:
|
||||||
|
if not usage:
|
||||||
|
return {}
|
||||||
|
prompt = int(usage.get("inputTokens") or 0)
|
||||||
|
completion = int(usage.get("outputTokens") or 0)
|
||||||
|
total = int(usage.get("totalTokens") or prompt + completion)
|
||||||
|
result = {
|
||||||
|
"prompt_tokens": prompt,
|
||||||
|
"completion_tokens": completion,
|
||||||
|
"total_tokens": total,
|
||||||
|
}
|
||||||
|
cache_read = int(usage.get("cacheReadInputTokens") or 0)
|
||||||
|
cache_write = int(usage.get("cacheWriteInputTokens") or 0)
|
||||||
|
if cache_read:
|
||||||
|
result["cached_tokens"] = cache_read
|
||||||
|
result["cache_read_input_tokens"] = cache_read
|
||||||
|
if cache_write:
|
||||||
|
result["cache_creation_input_tokens"] = cache_write
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_reasoning(block: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]:
|
||||||
|
reasoning = block.get("reasoningContent")
|
||||||
|
if not isinstance(reasoning, dict):
|
||||||
|
return None, None
|
||||||
|
text_obj = reasoning.get("reasoningText")
|
||||||
|
if isinstance(text_obj, dict):
|
||||||
|
text = text_obj.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text, {
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": text,
|
||||||
|
"signature": text_obj.get("signature", ""),
|
||||||
|
}
|
||||||
|
redacted = reasoning.get("redactedContent")
|
||||||
|
if redacted is not None:
|
||||||
|
if isinstance(redacted, (bytes, bytearray)):
|
||||||
|
encoded = base64.b64encode(bytes(redacted)).decode("ascii")
|
||||||
|
return None, {"type": "redacted_thinking", "redactedContentBase64": encoded}
|
||||||
|
return None, {"type": "redacted_thinking", "redactedContent": redacted}
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_response(cls, response: dict[str, Any]) -> LLMResponse:
|
||||||
|
content_parts: list[str] = []
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
|
tool_calls: list[ToolCallRequest] = []
|
||||||
|
thinking_blocks: list[dict[str, Any]] = []
|
||||||
|
message = (response.get("output") or {}).get("message") or {}
|
||||||
|
|
||||||
|
for block in message.get("content") or []:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if isinstance(block.get("text"), str):
|
||||||
|
content_parts.append(block["text"])
|
||||||
|
tool_use = block.get("toolUse")
|
||||||
|
if isinstance(tool_use, dict):
|
||||||
|
arguments = tool_use.get("input") if isinstance(tool_use.get("input"), dict) else {}
|
||||||
|
tool_calls.append(ToolCallRequest(
|
||||||
|
id=str(tool_use.get("toolUseId") or ""),
|
||||||
|
name=str(tool_use.get("name") or ""),
|
||||||
|
arguments=arguments,
|
||||||
|
))
|
||||||
|
reasoning_text, thinking = cls._parse_reasoning(block)
|
||||||
|
if reasoning_text:
|
||||||
|
reasoning_parts.append(reasoning_text)
|
||||||
|
if thinking:
|
||||||
|
thinking_blocks.append(thinking)
|
||||||
|
|
||||||
|
return LLMResponse(
|
||||||
|
content="".join(content_parts) or None,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
finish_reason=cls._finish_reason(response.get("stopReason")),
|
||||||
|
usage=cls._usage(response.get("usage")),
|
||||||
|
reasoning_content="".join(reasoning_parts) or None,
|
||||||
|
thinking_blocks=thinking_blocks or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_stream_event(
|
||||||
|
cls,
|
||||||
|
event: dict[str, Any],
|
||||||
|
*,
|
||||||
|
content_parts: list[str],
|
||||||
|
reasoning_parts: list[str],
|
||||||
|
thinking_blocks: list[dict[str, Any]],
|
||||||
|
tool_buffers: dict[int, dict[str, Any]],
|
||||||
|
state: dict[str, Any],
|
||||||
|
) -> str | None:
|
||||||
|
if "contentBlockStart" in event:
|
||||||
|
data = event["contentBlockStart"]
|
||||||
|
idx = int(data.get("contentBlockIndex") or 0)
|
||||||
|
start = data.get("start") or {}
|
||||||
|
tool_use = start.get("toolUse")
|
||||||
|
if isinstance(tool_use, dict):
|
||||||
|
tool_buffers[idx] = {
|
||||||
|
"id": str(tool_use.get("toolUseId") or ""),
|
||||||
|
"name": str(tool_use.get("name") or ""),
|
||||||
|
"input": "",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "contentBlockDelta" in event:
|
||||||
|
data = event["contentBlockDelta"]
|
||||||
|
idx = int(data.get("contentBlockIndex") or 0)
|
||||||
|
delta = data.get("delta") or {}
|
||||||
|
text = delta.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
content_parts.append(text)
|
||||||
|
return text
|
||||||
|
tool_delta = delta.get("toolUse")
|
||||||
|
if isinstance(tool_delta, dict):
|
||||||
|
buf = tool_buffers.setdefault(idx, {"id": "", "name": "", "input": ""})
|
||||||
|
if isinstance(tool_delta.get("input"), str):
|
||||||
|
buf["input"] += tool_delta["input"]
|
||||||
|
reasoning = delta.get("reasoningContent")
|
||||||
|
if isinstance(reasoning, dict):
|
||||||
|
buf = state.setdefault("reasoning_buffers", {}).setdefault(
|
||||||
|
idx, {"text": "", "signature": "", "redactedContent": None}
|
||||||
|
)
|
||||||
|
if isinstance(reasoning.get("text"), str):
|
||||||
|
buf["text"] += reasoning["text"]
|
||||||
|
reasoning_parts.append(reasoning["text"])
|
||||||
|
if isinstance(reasoning.get("signature"), str):
|
||||||
|
buf["signature"] = reasoning["signature"]
|
||||||
|
if reasoning.get("redactedContent") is not None:
|
||||||
|
buf["redactedContent"] = reasoning["redactedContent"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "contentBlockStop" in event:
|
||||||
|
idx = int((event["contentBlockStop"] or {}).get("contentBlockIndex") or 0)
|
||||||
|
reasoning_buf = state.setdefault("reasoning_buffers", {}).pop(idx, None)
|
||||||
|
if reasoning_buf:
|
||||||
|
if reasoning_buf.get("text"):
|
||||||
|
thinking_blocks.append({
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": reasoning_buf["text"],
|
||||||
|
"signature": reasoning_buf.get("signature", ""),
|
||||||
|
})
|
||||||
|
elif reasoning_buf.get("redactedContent") is not None:
|
||||||
|
redacted = reasoning_buf["redactedContent"]
|
||||||
|
if isinstance(redacted, (bytes, bytearray)):
|
||||||
|
redacted_block = {
|
||||||
|
"type": "redacted_thinking",
|
||||||
|
"redactedContentBase64": base64.b64encode(bytes(redacted)).decode("ascii"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
redacted_block = {
|
||||||
|
"type": "redacted_thinking",
|
||||||
|
"redactedContent": redacted,
|
||||||
|
}
|
||||||
|
thinking_blocks.append({
|
||||||
|
**redacted_block,
|
||||||
|
})
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "messageStop" in event:
|
||||||
|
state["stop_reason"] = (event["messageStop"] or {}).get("stopReason")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "metadata" in event:
|
||||||
|
metadata = event["metadata"] or {}
|
||||||
|
if isinstance(metadata.get("usage"), dict):
|
||||||
|
state["usage"] = metadata["usage"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _stream_result(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
content_parts: list[str],
|
||||||
|
reasoning_parts: list[str],
|
||||||
|
thinking_blocks: list[dict[str, Any]],
|
||||||
|
tool_buffers: dict[int, dict[str, Any]],
|
||||||
|
state: dict[str, Any],
|
||||||
|
) -> LLMResponse:
|
||||||
|
tool_calls: list[ToolCallRequest] = []
|
||||||
|
for buf in tool_buffers.values():
|
||||||
|
args: Any = {}
|
||||||
|
if buf.get("input"):
|
||||||
|
try:
|
||||||
|
args = json_repair.loads(buf["input"])
|
||||||
|
except Exception:
|
||||||
|
args = {}
|
||||||
|
tool_calls.append(ToolCallRequest(
|
||||||
|
id=buf.get("id") or "",
|
||||||
|
name=buf.get("name") or "",
|
||||||
|
arguments=args if isinstance(args, dict) else {},
|
||||||
|
))
|
||||||
|
return LLMResponse(
|
||||||
|
content="".join(content_parts) or None,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
finish_reason=cls._finish_reason(state.get("stop_reason")),
|
||||||
|
usage=cls._usage(state.get("usage")),
|
||||||
|
reasoning_content="".join(reasoning_parts) or None,
|
||||||
|
thinking_blocks=thinking_blocks or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _handle_error(cls, e: Exception) -> LLMResponse:
|
||||||
|
response = getattr(e, "response", None)
|
||||||
|
metadata = response.get("ResponseMetadata", {}) if isinstance(response, dict) else {}
|
||||||
|
headers = metadata.get("HTTPHeaders") if isinstance(metadata, dict) else None
|
||||||
|
error_obj = response.get("Error", {}) if isinstance(response, dict) else {}
|
||||||
|
message = error_obj.get("Message") if isinstance(error_obj, dict) else None
|
||||||
|
code = error_obj.get("Code") if isinstance(error_obj, dict) else None
|
||||||
|
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
|
||||||
|
body = message or str(e)
|
||||||
|
retry_after = cls._extract_retry_after_from_headers(headers)
|
||||||
|
if retry_after is None:
|
||||||
|
retry_after = cls._extract_retry_after(body)
|
||||||
|
|
||||||
|
error_name = e.__class__.__name__.lower()
|
||||||
|
error_kind = None
|
||||||
|
if "timeout" in error_name:
|
||||||
|
error_kind = "timeout"
|
||||||
|
elif "connection" in error_name or "endpoint" in error_name:
|
||||||
|
error_kind = "connection"
|
||||||
|
|
||||||
|
code_text = str(code or "").lower()
|
||||||
|
should_retry = None
|
||||||
|
if status_code is not None:
|
||||||
|
should_retry = int(status_code) == 429 or int(status_code) >= 500
|
||||||
|
if any(token in code_text for token in ("throttl", "timeout", "unavailable", "modelnotready")):
|
||||||
|
should_retry = True
|
||||||
|
|
||||||
|
return LLMResponse(
|
||||||
|
content=f"Error: {str(body).strip()[:500]}",
|
||||||
|
finish_reason="error",
|
||||||
|
retry_after=retry_after,
|
||||||
|
error_status_code=int(status_code) if status_code is not None else None,
|
||||||
|
error_kind=error_kind,
|
||||||
|
error_type=code_text or None,
|
||||||
|
error_code=code_text or None,
|
||||||
|
error_retry_after_s=retry_after,
|
||||||
|
error_should_retry=should_retry,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
reasoning_effort: str | None = None,
|
||||||
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
try:
|
||||||
|
kwargs = self._build_kwargs(
|
||||||
|
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
|
||||||
|
)
|
||||||
|
response = await asyncio.to_thread(self._client.converse, **kwargs)
|
||||||
|
return self._parse_response(response)
|
||||||
|
except Exception as e:
|
||||||
|
return self._handle_error(e)
|
||||||
|
|
||||||
|
async def chat_stream(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
reasoning_effort: str | None = None,
|
||||||
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
|
content_parts: list[str] = []
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
|
thinking_blocks: list[dict[str, Any]] = []
|
||||||
|
tool_buffers: dict[int, dict[str, Any]] = {}
|
||||||
|
state: dict[str, Any] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
kwargs = self._build_kwargs(
|
||||||
|
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
|
||||||
|
)
|
||||||
|
response = await asyncio.to_thread(self._client.converse_stream, **kwargs)
|
||||||
|
stream = iter(response.get("stream") or [])
|
||||||
|
while True:
|
||||||
|
event = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(_next_or_none, stream),
|
||||||
|
timeout=idle_timeout_s,
|
||||||
|
)
|
||||||
|
if event is None:
|
||||||
|
break
|
||||||
|
delta = self._parse_stream_event(
|
||||||
|
event,
|
||||||
|
content_parts=content_parts,
|
||||||
|
reasoning_parts=reasoning_parts,
|
||||||
|
thinking_blocks=thinking_blocks,
|
||||||
|
tool_buffers=tool_buffers,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
if delta and on_content_delta:
|
||||||
|
await on_content_delta(delta)
|
||||||
|
return self._stream_result(
|
||||||
|
content_parts=content_parts,
|
||||||
|
reasoning_parts=reasoning_parts,
|
||||||
|
thinking_blocks=thinking_blocks,
|
||||||
|
tool_buffers=tool_buffers,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return LLMResponse(
|
||||||
|
content=(
|
||||||
|
f"Error calling LLM: stream stalled for more than "
|
||||||
|
f"{idle_timeout_s} seconds"
|
||||||
|
),
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="timeout",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self._handle_error(e)
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return self.default_model
|
||||||
+133
-39
@@ -4,11 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
||||||
|
from nanobot.providers.registry import ProviderSpec
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderSnapshot:
|
class ProviderSnapshot:
|
||||||
@@ -18,22 +23,62 @@ class ProviderSnapshot:
|
|||||||
signature: tuple[object, ...]
|
signature: tuple[object, ...]
|
||||||
|
|
||||||
|
|
||||||
def make_provider(config: Config) -> LLMProvider:
|
@dataclass(frozen=True)
|
||||||
"""Create the LLM provider implied by config."""
|
class _ProviderInfo:
|
||||||
model = config.agents.defaults.model
|
"""Resolved metadata needed to build and validate an LLM provider."""
|
||||||
provider_name = config.get_provider_name(model)
|
|
||||||
p = config.get_provider(model)
|
name: str | None
|
||||||
spec = find_by_name(provider_name) if provider_name else None
|
cfg: ProviderConfig | None
|
||||||
|
spec: ProviderSpec | None
|
||||||
|
api_base: str | None
|
||||||
|
backend: str
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_provider_info(
|
||||||
|
config: Config,
|
||||||
|
model: str,
|
||||||
|
preset: ModelPresetConfig,
|
||||||
|
) -> _ProviderInfo:
|
||||||
|
"""Derive provider name, config, spec and api_base from preset or auto-detection."""
|
||||||
|
if preset.provider != "auto":
|
||||||
|
name = preset.provider
|
||||||
|
cfg = getattr(config.providers, name, None)
|
||||||
|
spec = find_by_name(name)
|
||||||
|
api_base = (
|
||||||
|
cfg.api_base
|
||||||
|
if cfg and cfg.api_base
|
||||||
|
else (spec.default_api_base if spec and spec.default_api_base else None)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
name = config.get_provider_name(model)
|
||||||
|
cfg = config.get_provider(model)
|
||||||
|
spec = find_by_name(name) if name else None
|
||||||
|
api_base = config.get_api_base(model)
|
||||||
|
|
||||||
backend = spec.backend if spec else "openai_compat"
|
backend = spec.backend if spec else "openai_compat"
|
||||||
|
return _ProviderInfo(name=name, cfg=cfg, spec=spec, api_base=api_base, backend=backend)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provider(info: _ProviderInfo, model: str) -> None:
|
||||||
|
"""Ensure credentials / endpoints are present before instantiation."""
|
||||||
|
cfg = info.cfg
|
||||||
|
backend = info.backend
|
||||||
|
name = info.name
|
||||||
|
|
||||||
if backend == "azure_openai":
|
if backend == "azure_openai":
|
||||||
if not p or not p.api_key or not p.api_base:
|
if not cfg or not cfg.api_key or not cfg.api_base:
|
||||||
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
||||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||||
needs_key = not (p and p.api_key)
|
needs_key = not (cfg and cfg.api_key)
|
||||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
exempt = info.spec and (info.spec.is_oauth or info.spec.is_local or info.spec.is_direct)
|
||||||
if needs_key and not exempt:
|
if needs_key and not exempt:
|
||||||
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
raise ValueError(f"No API key configured for provider '{name}'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_provider(model: str, info: _ProviderInfo) -> LLMProvider:
|
||||||
|
"""Instantiate the concrete provider class for *backend*."""
|
||||||
|
cfg = info.cfg
|
||||||
|
backend = info.backend
|
||||||
|
|
||||||
if backend == "openai_codex":
|
if backend == "openai_codex":
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
@@ -43,8 +88,8 @@ def make_provider(config: Config) -> LLMProvider:
|
|||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
|
|
||||||
provider = AzureOpenAIProvider(
|
provider = AzureOpenAIProvider(
|
||||||
api_key=p.api_key,
|
api_key=cfg.api_key if cfg else None,
|
||||||
api_base=p.api_base,
|
api_base=info.api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
)
|
)
|
||||||
elif backend == "github_copilot":
|
elif backend == "github_copilot":
|
||||||
@@ -55,54 +100,103 @@ def make_provider(config: Config) -> LLMProvider:
|
|||||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||||
|
|
||||||
provider = AnthropicProvider(
|
provider = AnthropicProvider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=cfg.api_key if cfg else None,
|
||||||
api_base=config.get_api_base(model),
|
api_base=info.api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
extra_headers=p.extra_headers if p else None,
|
extra_headers=cfg.extra_headers if cfg else None,
|
||||||
|
)
|
||||||
|
elif backend == "bedrock":
|
||||||
|
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||||
|
|
||||||
|
provider = BedrockProvider(
|
||||||
|
api_key=cfg.api_key if cfg else None,
|
||||||
|
api_base=info.api_base if cfg else None,
|
||||||
|
default_model=model,
|
||||||
|
region=getattr(cfg, "region", None) if cfg else None,
|
||||||
|
profile=getattr(cfg, "profile", None) if cfg else None,
|
||||||
|
extra_body=cfg.extra_body if cfg else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
provider = OpenAICompatProvider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=cfg.api_key if cfg else None,
|
||||||
api_base=config.get_api_base(model),
|
api_base=info.api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
extra_headers=p.extra_headers if p else None,
|
extra_headers=cfg.extra_headers if cfg else None,
|
||||||
spec=spec,
|
spec=info.spec,
|
||||||
extra_body=p.extra_body if p else None,
|
extra_body=cfg.extra_body if cfg else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
provider.generation = GenerationSettings(
|
|
||||||
temperature=defaults.temperature,
|
|
||||||
max_tokens=defaults.max_tokens,
|
|
||||||
reasoning_effort=defaults.reasoning_effort,
|
|
||||||
)
|
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_generation(provider: LLMProvider, preset: ModelPresetConfig) -> None:
|
||||||
|
provider.generation = GenerationSettings(
|
||||||
|
temperature=preset.temperature,
|
||||||
|
max_tokens=preset.max_tokens,
|
||||||
|
reasoning_effort=preset.reasoning_effort,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider_for_preset(config: Config, preset: ModelPresetConfig) -> LLMProvider:
|
||||||
|
"""Create an LLM provider from a full *preset* (model + provider + generation)."""
|
||||||
|
info = _resolve_provider_info(config, preset.model, preset)
|
||||||
|
_validate_provider(info, preset.model)
|
||||||
|
provider = _create_provider(preset.model, info)
|
||||||
|
_apply_generation(provider, preset)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def make_provider(config: Config) -> LLMProvider:
|
||||||
|
"""Create the LLM provider implied by config (legacy entrypoint)."""
|
||||||
|
resolved = config.resolve_preset()
|
||||||
|
return build_provider_for_preset(config, resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def make_provider_factory(config: Config):
|
||||||
|
"""Build a cached factory that creates providers for preset names.
|
||||||
|
|
||||||
|
The factory looks up *preset_name* in ``config.model_presets`` and builds
|
||||||
|
the provider from the preset's full configuration.
|
||||||
|
"""
|
||||||
|
cache: dict[str, LLMProvider] = {}
|
||||||
|
presets = config.model_presets
|
||||||
|
|
||||||
|
def factory(preset_name: str) -> LLMProvider:
|
||||||
|
preset = presets.get(preset_name)
|
||||||
|
if preset is None:
|
||||||
|
raise ValueError(f"Preset {preset_name!r} not found in model_presets")
|
||||||
|
if preset_name not in cache:
|
||||||
|
cache[preset_name] = build_provider_for_preset(config, preset)
|
||||||
|
return cache[preset_name]
|
||||||
|
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
def provider_signature(config: Config) -> tuple[object, ...]:
|
def provider_signature(config: Config) -> tuple[object, ...]:
|
||||||
"""Return the config fields that affect the primary LLM provider."""
|
"""Return the config fields that affect the primary LLM provider."""
|
||||||
model = config.agents.defaults.model
|
resolved = config.resolve_preset()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
return (
|
return (
|
||||||
model,
|
resolved.model,
|
||||||
defaults.provider,
|
resolved.provider,
|
||||||
config.get_provider_name(model),
|
config.get_provider_name(resolved.model),
|
||||||
config.get_api_key(model),
|
config.get_api_key(resolved.model),
|
||||||
config.get_api_base(model),
|
config.get_api_base(resolved.model),
|
||||||
defaults.max_tokens,
|
resolved.max_tokens,
|
||||||
defaults.temperature,
|
resolved.temperature,
|
||||||
defaults.reasoning_effort,
|
resolved.reasoning_effort,
|
||||||
defaults.context_window_tokens,
|
resolved.context_window_tokens,
|
||||||
|
tuple(defaults.fallback_presets),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
|
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
|
||||||
|
resolved = config.resolve_preset()
|
||||||
return ProviderSnapshot(
|
return ProviderSnapshot(
|
||||||
provider=make_provider(config),
|
provider=make_provider(config),
|
||||||
model=config.agents.defaults.model,
|
model=resolved.model,
|
||||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
context_window_tokens=resolved.context_window_tokens,
|
||||||
signature=provider_signature(config),
|
signature=provider_signature(config),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Provider-like failover router used after provider-local retry is exhausted."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRouter(LLMProvider):
|
||||||
|
"""Try fallback model candidates for eligible transient final errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
primary_provider: LLMProvider,
|
||||||
|
primary_model: str,
|
||||||
|
fallback_presets: list[str],
|
||||||
|
provider_factory: Callable[[str], LLMProvider] | None = None,
|
||||||
|
per_candidate_timeout_s: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
api_key=getattr(primary_provider, "api_key", None),
|
||||||
|
api_base=getattr(primary_provider, "api_base", None),
|
||||||
|
)
|
||||||
|
self.primary_provider = primary_provider
|
||||||
|
self.primary_model = primary_model
|
||||||
|
self.fallback_presets = list(fallback_presets)
|
||||||
|
self._provider_factory = provider_factory
|
||||||
|
self._provider_cache: dict[str, LLMProvider] = {}
|
||||||
|
self.per_candidate_timeout_s = per_candidate_timeout_s
|
||||||
|
self.generation = getattr(primary_provider, "generation", GenerationSettings())
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return self.primary_model
|
||||||
|
|
||||||
|
async def chat(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
async def call(provider: LLMProvider, candidate_model: str, _unused_delta: Any) -> LLMResponse:
|
||||||
|
return await provider.chat(**{**kwargs, "model": candidate_model})
|
||||||
|
return await self._route(call)
|
||||||
|
|
||||||
|
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
async def call(provider: LLMProvider, candidate_model: str, content_delta: Any) -> LLMResponse:
|
||||||
|
return await provider.chat_stream(
|
||||||
|
**{**kwargs, "model": candidate_model, "on_content_delta": content_delta}
|
||||||
|
)
|
||||||
|
return await self._route(call, on_content_delta=kwargs.get("on_content_delta"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_progress_deltas(self) -> bool: # type: ignore[override]
|
||||||
|
return getattr(self.primary_provider, "supports_progress_deltas", False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_failover(cls, response: LLMResponse) -> bool:
|
||||||
|
if response.finish_reason != "error":
|
||||||
|
return False
|
||||||
|
if response.error_should_retry is False:
|
||||||
|
return False
|
||||||
|
if response.error_kind == "configuration":
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _resolve(self, model: str) -> tuple[LLMProvider, str]:
|
||||||
|
"""Return (provider, actual_model_name) for a preset name.
|
||||||
|
|
||||||
|
Caches results so factory is only invoked once per unique name.
|
||||||
|
"""
|
||||||
|
if model in self._provider_cache:
|
||||||
|
cached_provider = self._provider_cache[model]
|
||||||
|
return cached_provider, cached_provider.get_default_model()
|
||||||
|
if self._provider_factory is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot resolve fallback model {model!r}: no provider_factory configured"
|
||||||
|
)
|
||||||
|
provider = self._provider_factory(model)
|
||||||
|
self._provider_cache[model] = provider
|
||||||
|
return provider, provider.get_default_model()
|
||||||
|
|
||||||
|
async def _with_timeout(self, coro: Awaitable[LLMResponse]) -> LLMResponse:
|
||||||
|
timeout_s = self.per_candidate_timeout_s
|
||||||
|
if timeout_s is None:
|
||||||
|
return await coro
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(coro, timeout=timeout_s)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return LLMResponse(
|
||||||
|
content=f"Error calling LLM: timed out after {timeout_s:g}s",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolver_error(label: str, exc: Exception) -> LLMResponse:
|
||||||
|
logger.warning("Failed to resolve fallback model {}: {}", label, exc)
|
||||||
|
return LLMResponse(
|
||||||
|
content=f"Error configuring fallback model {label}: {exc}",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="configuration",
|
||||||
|
error_should_retry=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _route(
|
||||||
|
self,
|
||||||
|
call: Callable[[LLMProvider, str, Callable[[str], Awaitable[None]] | None], Awaitable[LLMResponse]],
|
||||||
|
*,
|
||||||
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
"""Try primary then each fallback candidate, lazily resolving providers."""
|
||||||
|
|
||||||
|
async def _try_one(label: str, provider: LLMProvider, model: str) -> LLMResponse:
|
||||||
|
try:
|
||||||
|
return await self._with_timeout(call(provider, model, on_content_delta))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
return self._resolver_error(label, exc)
|
||||||
|
|
||||||
|
# Primary
|
||||||
|
response = await _try_one("primary", self.primary_provider, self.primary_model)
|
||||||
|
if response.finish_reason != "error":
|
||||||
|
return response
|
||||||
|
if not self._should_failover(response):
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallbacks
|
||||||
|
for name in self.fallback_presets:
|
||||||
|
try:
|
||||||
|
provider, model = self._resolve(name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to resolve fallback model {}: {}", name, exc)
|
||||||
|
return self._resolver_error(name, exc)
|
||||||
|
|
||||||
|
response = await _try_one(name, provider, model)
|
||||||
|
if response.finish_reason != "error":
|
||||||
|
logger.info("LLM failover selected model={}", name)
|
||||||
|
return response
|
||||||
|
if not self._should_failover(response):
|
||||||
|
return response
|
||||||
|
|
||||||
|
logger.warning("LLM failover exhausted after all candidates")
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def chat_with_retry(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
async def call(
|
||||||
|
provider: LLMProvider, candidate_model: str, _unused_delta: Any
|
||||||
|
) -> LLMResponse:
|
||||||
|
return await provider.chat_with_retry(
|
||||||
|
**{**kwargs, "model": candidate_model}
|
||||||
|
)
|
||||||
|
return await self._route(call)
|
||||||
|
|
||||||
|
async def chat_stream_with_retry(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
on_content_delta = kwargs.pop("on_content_delta", None)
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
provider: LLMProvider,
|
||||||
|
candidate_model: str,
|
||||||
|
content_delta: Callable[[str], Awaitable[None]] | None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
buffered: list[str] = []
|
||||||
|
|
||||||
|
async def buffer_delta(delta: str) -> None:
|
||||||
|
buffered.append(delta)
|
||||||
|
|
||||||
|
kwargs["on_content_delta"] = buffer_delta if content_delta else None
|
||||||
|
response = await provider.chat_stream_with_retry(
|
||||||
|
**{**kwargs, "model": candidate_model}
|
||||||
|
)
|
||||||
|
if response.finish_reason != "error" and content_delta:
|
||||||
|
try:
|
||||||
|
for delta in buffered:
|
||||||
|
await content_delta(delta)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failover delta callback failed for model={}", candidate_model)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return await self._route(call, on_content_delta=on_content_delta)
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from oauth_cli_kit.models import OAuthToken
|
from oauth_cli_kit.models import OAuthToken
|
||||||
@@ -28,7 +29,7 @@ _EXPIRY_SKEW_SECONDS = 60
|
|||||||
_LONG_LIVED_TOKEN_SECONDS = 315360000
|
_LONG_LIVED_TOKEN_SECONDS = 315360000
|
||||||
|
|
||||||
|
|
||||||
def _storage() -> FileTokenStorage:
|
def get_storage() -> FileTokenStorage:
|
||||||
return FileTokenStorage(
|
return FileTokenStorage(
|
||||||
token_filename=TOKEN_FILENAME,
|
token_filename=TOKEN_FILENAME,
|
||||||
app_name=TOKEN_APP_NAME,
|
app_name=TOKEN_APP_NAME,
|
||||||
@@ -47,7 +48,7 @@ def _copilot_headers(token: str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _load_github_token() -> OAuthToken | None:
|
def _load_github_token() -> OAuthToken | None:
|
||||||
token = _storage().load()
|
token = get_storage().load()
|
||||||
if not token or not token.access:
|
if not token or not token.access:
|
||||||
return None
|
return None
|
||||||
return token
|
return token
|
||||||
@@ -86,10 +87,8 @@ def login_github_copilot(
|
|||||||
printer(f"Open: {verify_url}")
|
printer(f"Open: {verify_url}")
|
||||||
printer(f"Code: {user_code}")
|
printer(f"Code: {user_code}")
|
||||||
if verify_complete:
|
if verify_complete:
|
||||||
try:
|
with suppress(Exception):
|
||||||
webbrowser.open(verify_complete)
|
webbrowser.open(verify_complete)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
deadline = time.time() + expires_in
|
deadline = time.time() + expires_in
|
||||||
current_interval = interval
|
current_interval = interval
|
||||||
@@ -151,7 +150,7 @@ def login_github_copilot(
|
|||||||
expires=expires_ms,
|
expires=expires_ms,
|
||||||
account_id=str(account_id) if account_id else None,
|
account_id=str(account_id) if account_id else None,
|
||||||
)
|
)
|
||||||
_storage().save(token)
|
get_storage().save(token)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -449,47 +449,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
||||||
return self._enforce_role_alternation(sanitized)
|
return self._enforce_role_alternation(sanitized)
|
||||||
|
|
||||||
def _drop_deepseek_incomplete_reasoning_history(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
reasoning_effort: str | None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
if (
|
|
||||||
not self._spec
|
|
||||||
or self._spec.name != "deepseek"
|
|
||||||
or not reasoning_effort
|
|
||||||
or reasoning_effort.lower() == "none"
|
|
||||||
):
|
|
||||||
return messages
|
|
||||||
|
|
||||||
bad_idx = None
|
|
||||||
for idx, msg in enumerate(messages):
|
|
||||||
if (
|
|
||||||
msg.get("role") == "assistant"
|
|
||||||
and msg.get("tool_calls")
|
|
||||||
and not msg.get("reasoning_content")
|
|
||||||
):
|
|
||||||
bad_idx = idx
|
|
||||||
if bad_idx is None:
|
|
||||||
return messages
|
|
||||||
|
|
||||||
keep_from = None
|
|
||||||
for idx in range(bad_idx + 1, len(messages)):
|
|
||||||
if messages[idx].get("role") == "user":
|
|
||||||
keep_from = idx
|
|
||||||
break
|
|
||||||
|
|
||||||
if keep_from is None:
|
|
||||||
trimmed = messages[:bad_idx]
|
|
||||||
else:
|
|
||||||
prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"]
|
|
||||||
trimmed = prefix + messages[keep_from:]
|
|
||||||
logger.warning(
|
|
||||||
"Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content",
|
|
||||||
len(messages) - len(trimmed),
|
|
||||||
)
|
|
||||||
return trimmed
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Build kwargs
|
# Build kwargs
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -530,10 +489,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if spec and spec.strip_model_prefix:
|
if spec and spec.strip_model_prefix:
|
||||||
model_name = model_name.split("/")[-1]
|
model_name = model_name.split("/")[-1]
|
||||||
|
|
||||||
messages = self._drop_deepseek_incomplete_reasoning_history(
|
|
||||||
messages,
|
|
||||||
reasoning_effort,
|
|
||||||
)
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||||
@@ -598,22 +553,22 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
kwargs["tools"] = tools
|
kwargs["tools"] = tools
|
||||||
kwargs["tool_choice"] = tool_choice or "auto"
|
kwargs["tool_choice"] = tool_choice or "auto"
|
||||||
|
|
||||||
# Backfill reasoning_content on legacy assistant messages.
|
# Backfill reasoning_content="" on assistants missing it: DeepSeek
|
||||||
# DeepSeek V4 (and potentially others) rejects thinking-mode
|
# thinking mode rejects history otherwise (#3554, #3584); "" reads
|
||||||
# requests that contain assistant messages without reasoning_content
|
# as "no thinking that turn". DeepSeek-V4/reasoner reason natively,
|
||||||
# — even on turns that had no tool calls. This happens when a
|
# so backfill even without explicit reasoning_effort.
|
||||||
# session was started with a non-thinking model or without
|
explicit_thinking = (
|
||||||
# reasoning_effort, then the user switches thinking mode on
|
reasoning_effort is not None
|
||||||
# mid-session. Injecting an empty string satisfies the API
|
and semantic_effort not in ("none", "minimal")
|
||||||
# without altering semantics (the model treats it as "no
|
and ((spec and spec.thinking_style) or _is_kimi_thinking_model(model_name))
|
||||||
# thinking happened on that turn").
|
|
||||||
thinking_active = (
|
|
||||||
(spec and spec.thinking_style and reasoning_effort is not None
|
|
||||||
and semantic_effort not in ("none", "minimal"))
|
|
||||||
or (reasoning_effort is not None and _is_kimi_thinking_model(model_name)
|
|
||||||
and semantic_effort not in ("none", "minimal"))
|
|
||||||
)
|
)
|
||||||
if thinking_active:
|
implicit_deepseek_thinking = (
|
||||||
|
spec is not None
|
||||||
|
and spec.name == "deepseek"
|
||||||
|
and semantic_effort not in ("none", "minimal", "minimum")
|
||||||
|
and any(t in model_name.lower() for t in ("deepseek-v4", "deepseek-reasoner"))
|
||||||
|
)
|
||||||
|
if explicit_thinking or implicit_deepseek_thinking:
|
||||||
for msg in kwargs["messages"]:
|
for msg in kwargs["messages"]:
|
||||||
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
||||||
msg["reasoning_content"] = ""
|
msg["reasoning_content"] = ""
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class ProviderSpec:
|
|||||||
display_name: str = "" # shown in `nanobot status`
|
display_name: str = "" # shown in `nanobot status`
|
||||||
|
|
||||||
# which provider implementation to use
|
# which provider implementation to use
|
||||||
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot"
|
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
|
||||||
backend: str = "openai_compat"
|
backend: str = "openai_compat"
|
||||||
|
|
||||||
# extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),)
|
# extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),)
|
||||||
@@ -105,6 +105,29 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="azure_openai",
|
backend="azure_openai",
|
||||||
is_direct=True,
|
is_direct=True,
|
||||||
),
|
),
|
||||||
|
# === AWS Bedrock (native Converse API via bedrock-runtime) =============
|
||||||
|
ProviderSpec(
|
||||||
|
name="bedrock",
|
||||||
|
keywords=(
|
||||||
|
"bedrock",
|
||||||
|
"anthropic.claude",
|
||||||
|
"amazon.nova",
|
||||||
|
"meta.",
|
||||||
|
"mistral.",
|
||||||
|
"cohere.",
|
||||||
|
"qwen.",
|
||||||
|
"deepseek.",
|
||||||
|
"openai.gpt-oss",
|
||||||
|
"ai21.",
|
||||||
|
"moonshot.",
|
||||||
|
"writer.",
|
||||||
|
"zai.",
|
||||||
|
),
|
||||||
|
env_key="AWS_BEARER_TOKEN_BEDROCK",
|
||||||
|
display_name="AWS Bedrock",
|
||||||
|
backend="bedrock",
|
||||||
|
is_direct=True,
|
||||||
|
),
|
||||||
# === Gateways (detected by api_key / api_base, not model name) =========
|
# === Gateways (detected by api_key / api_base, not model name) =========
|
||||||
# Gateways can route any model, so they win in fallback.
|
# Gateways can route any model, so they win in fallback.
|
||||||
# OpenRouter: global gateway, keys start with "sk-or-"
|
# OpenRouter: global gateway, keys start with "sk-or-"
|
||||||
@@ -353,6 +376,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.xiaomimimo.com/v1",
|
default_api_base="https://api.xiaomimimo.com/v1",
|
||||||
),
|
),
|
||||||
|
# LongCat: OpenAI-compatible API
|
||||||
|
ProviderSpec(
|
||||||
|
name="longcat",
|
||||||
|
keywords=("longcat",),
|
||||||
|
env_key="LONGCAT_API_KEY",
|
||||||
|
display_name="LongCat",
|
||||||
|
backend="openai_compat",
|
||||||
|
default_api_base="https://api.longcat.chat/openai/v1",
|
||||||
|
),
|
||||||
# === Local deployment (matched by config key, NOT by api_base) =========
|
# === Local deployment (matched by config key, NOT by api_base) =========
|
||||||
# vLLM / any OpenAI-compatible local server
|
# vLLM / any OpenAI-compatible local server
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
|
|||||||
@@ -1,11 +1,121 @@
|
|||||||
"""Voice transcription providers (Groq and OpenAI Whisper)."""
|
"""Voice transcription providers (Groq and OpenAI Whisper)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# Without this, a voice message silently becomes the empty string.
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_BACKOFF_S = (1.0, 2.0, 4.0)
|
||||||
|
_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
|
||||||
|
_RETRYABLE_EXCEPTIONS = (
|
||||||
|
httpx.TimeoutException,
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.WriteError,
|
||||||
|
httpx.RemoteProtocolError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_transcription_with_retry(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
api_key: str | None,
|
||||||
|
path: Path,
|
||||||
|
model: str,
|
||||||
|
provider_label: str,
|
||||||
|
language: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""POST an audio file for transcription, retrying on transient errors.
|
||||||
|
|
||||||
|
Retries on connect/read/timeout failures and on 408/429/5xx responses.
|
||||||
|
Other errors (including 4xx such as 401/403) return "" immediately — the
|
||||||
|
caller's config is wrong and retrying only wastes quota.
|
||||||
|
|
||||||
|
When ``language`` is provided, it is forwarded as the ``language``
|
||||||
|
multipart field on every attempt (the dict is rebuilt per attempt so the
|
||||||
|
same field is present on retries).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("{} transcription error: cannot read audio file: {}", provider_label, e)
|
||||||
|
return ""
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for attempt in range(_MAX_RETRIES + 1):
|
||||||
|
files = {
|
||||||
|
"file": (path.name, data),
|
||||||
|
"model": (None, model),
|
||||||
|
}
|
||||||
|
if language:
|
||||||
|
files["language"] = (None, language)
|
||||||
|
try:
|
||||||
|
response = await client.post(url, headers=headers, files=files, timeout=60.0)
|
||||||
|
except _RETRYABLE_EXCEPTIONS as e:
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
logger.warning(
|
||||||
|
"{} transcription transient error (attempt {}/{}): {}",
|
||||||
|
provider_label,
|
||||||
|
attempt + 1,
|
||||||
|
_MAX_RETRIES + 1,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_BACKOFF_S[attempt])
|
||||||
|
continue
|
||||||
|
logger.error(
|
||||||
|
"{} transcription error after {} attempts: {}",
|
||||||
|
provider_label,
|
||||||
|
_MAX_RETRIES + 1,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("{} transcription error: {}", provider_label, e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if response.status_code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES:
|
||||||
|
logger.warning(
|
||||||
|
"{} transcription transient HTTP {} (attempt {}/{})",
|
||||||
|
provider_label,
|
||||||
|
response.status_code,
|
||||||
|
attempt + 1,
|
||||||
|
_MAX_RETRIES + 1,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_BACKOFF_S[attempt])
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("{} transcription error: {}", provider_label, e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"{} transcription error: malformed response body: {}",
|
||||||
|
provider_label,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
logger.error(
|
||||||
|
"{} transcription error: unexpected response shape: {!r}",
|
||||||
|
provider_label,
|
||||||
|
type(payload).__name__,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
return payload.get("text", "")
|
||||||
|
|
||||||
|
|
||||||
class OpenAITranscriptionProvider:
|
class OpenAITranscriptionProvider:
|
||||||
"""Voice transcription provider using OpenAI's Whisper API."""
|
"""Voice transcription provider using OpenAI's Whisper API."""
|
||||||
@@ -32,21 +142,14 @@ class OpenAITranscriptionProvider:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
logger.error("Audio file not found: {}", file_path)
|
logger.error("Audio file not found: {}", file_path)
|
||||||
return ""
|
return ""
|
||||||
try:
|
return await _post_transcription_with_retry(
|
||||||
async with httpx.AsyncClient() as client:
|
self.api_url,
|
||||||
with open(path, "rb") as f:
|
api_key=self.api_key,
|
||||||
files = {"file": (path.name, f), "model": (None, "whisper-1")}
|
path=path,
|
||||||
if self.language:
|
model="whisper-1",
|
||||||
files["language"] = (None, self.language)
|
provider_label="OpenAI",
|
||||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
language=self.language,
|
||||||
response = await client.post(
|
)
|
||||||
self.api_url, headers=headers, files=files, timeout=60.0,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json().get("text", "")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("OpenAI transcription error: {}", e)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
class GroqTranscriptionProvider:
|
class GroqTranscriptionProvider:
|
||||||
@@ -63,7 +166,11 @@ class GroqTranscriptionProvider:
|
|||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
):
|
):
|
||||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||||
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
|
self.api_url = (
|
||||||
|
api_base
|
||||||
|
or os.environ.get("GROQ_BASE_URL")
|
||||||
|
or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||||
|
)
|
||||||
self.language = language or None
|
self.language = language or None
|
||||||
|
|
||||||
async def transcribe(self, file_path: str | Path) -> str:
|
async def transcribe(self, file_path: str | Path) -> str:
|
||||||
@@ -85,30 +192,11 @@ class GroqTranscriptionProvider:
|
|||||||
logger.error("Audio file not found: {}", file_path)
|
logger.error("Audio file not found: {}", file_path)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
try:
|
return await _post_transcription_with_retry(
|
||||||
async with httpx.AsyncClient() as client:
|
self.api_url,
|
||||||
with open(path, "rb") as f:
|
api_key=self.api_key,
|
||||||
files = {
|
path=path,
|
||||||
"file": (path.name, f),
|
model="whisper-large-v3",
|
||||||
"model": (None, "whisper-large-v3"),
|
provider_label="Groq",
|
||||||
}
|
language=self.language,
|
||||||
if self.language:
|
)
|
||||||
files["language"] = (None, self.language)
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
self.api_url,
|
|
||||||
headers=headers,
|
|
||||||
files=files,
|
|
||||||
timeout=60.0
|
|
||||||
)
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
return data.get("text", "")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Groq transcription error: {}", e)
|
|
||||||
return ""
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
|
from contextlib import suppress
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
_BLOCKED_NETWORKS = [
|
_BLOCKED_NETWORKS = [
|
||||||
@@ -30,10 +31,8 @@ def configure_ssrf_whitelist(cidrs: list[str]) -> None:
|
|||||||
global _allowed_networks
|
global _allowed_networks
|
||||||
nets = []
|
nets = []
|
||||||
for cidr in cidrs:
|
for cidr in cidrs:
|
||||||
try:
|
with suppress(ValueError):
|
||||||
nets.append(ipaddress.ip_network(cidr, strict=False))
|
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
_allowed_networks = nets
|
_allowed_networks = nets
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -118,7 +119,7 @@ class Session:
|
|||||||
if include_timestamps:
|
if include_timestamps:
|
||||||
content = self._annotate_message_time(message, content)
|
content = self._annotate_message_time(message, content)
|
||||||
entry: dict[str, Any] = {"role": message["role"], "content": content}
|
entry: dict[str, Any] = {"role": message["role"], "content": content}
|
||||||
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"):
|
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"):
|
||||||
if key in message:
|
if key in message:
|
||||||
entry[key] = message[key]
|
entry[key] = message[key]
|
||||||
out.append(entry)
|
out.append(entry)
|
||||||
@@ -362,15 +363,11 @@ class SessionManager:
|
|||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
if data.get("created_at"):
|
if data.get("created_at"):
|
||||||
try:
|
with suppress(ValueError, TypeError):
|
||||||
created_at = datetime.fromisoformat(data["created_at"])
|
created_at = datetime.fromisoformat(data["created_at"])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if data.get("updated_at"):
|
if data.get("updated_at"):
|
||||||
try:
|
with suppress(ValueError, TypeError):
|
||||||
updated_at = datetime.fromisoformat(data["updated_at"])
|
updated_at = datetime.fromisoformat(data["updated_at"])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
last_consolidated = data.get("last_consolidated", 0)
|
last_consolidated = data.get("last_consolidated", 0)
|
||||||
else:
|
else:
|
||||||
messages.append(data)
|
messages.append(data)
|
||||||
@@ -440,14 +437,12 @@ class SessionManager:
|
|||||||
# On Windows, opening a directory with O_RDONLY raises
|
# On Windows, opening a directory with O_RDONLY raises
|
||||||
# PermissionError — skip the dir sync there (NTFS
|
# PermissionError — skip the dir sync there (NTFS
|
||||||
# journals metadata synchronously).
|
# journals metadata synchronously).
|
||||||
try:
|
with suppress(PermissionError):
|
||||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||||
try:
|
try:
|
||||||
os.fsync(fd)
|
os.fsync(fd)
|
||||||
finally:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
except PermissionError:
|
|
||||||
pass # Windows — directory fsync not supported
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
@@ -552,10 +547,13 @@ class SessionManager:
|
|||||||
data = json.loads(first_line)
|
data = json.loads(first_line)
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||||
|
metadata = data.get("metadata", {})
|
||||||
|
title = metadata.get("title") if isinstance(metadata, dict) else None
|
||||||
sessions.append({
|
sessions.append({
|
||||||
"key": key,
|
"key": key,
|
||||||
"created_at": data.get("created_at"),
|
"created_at": data.get("created_at"),
|
||||||
"updated_at": data.get("updated_at"),
|
"updated_at": data.get("updated_at"),
|
||||||
|
"title": title if isinstance(title, str) else "",
|
||||||
"path": str(path)
|
"path": str(path)
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -565,6 +563,11 @@ class SessionManager:
|
|||||||
"key": repaired.key,
|
"key": repaired.key,
|
||||||
"created_at": repaired.created_at.isoformat(),
|
"created_at": repaired.created_at.isoformat(),
|
||||||
"updated_at": repaired.updated_at.isoformat(),
|
"updated_at": repaired.updated_at.isoformat(),
|
||||||
|
"title": (
|
||||||
|
repaired.metadata.get("title")
|
||||||
|
if isinstance(repaired.metadata.get("title"), str)
|
||||||
|
else ""
|
||||||
|
),
|
||||||
"path": str(path)
|
"path": str(path)
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: create-instance
|
||||||
|
description: "Create a new nanobot instance with separate config and workspace. Use when the user wants to set up a new bot, create a new instance for a different channel, persona, or purpose. Triggers on: create instance, new bot, set up bot, add bot, create telegram/discord/feishu/slack/wechat/wecom/dingtalk/qq/email/matrix/msteams/whatsapp bot, multi-instance setup."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Instance
|
||||||
|
|
||||||
|
Set up a new nanobot instance with its own config and workspace.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Collect information** (ask one at a time if not already provided):
|
||||||
|
- **Instance name** (required): short identifier, e.g. `telegram-bot`, `work-slack`
|
||||||
|
- **Channel type** (required): see table below
|
||||||
|
- **Model** (optional): LLM model, defaults to current instance
|
||||||
|
|
||||||
|
2. **Do NOT collect secrets** in the chat (API keys, bot tokens). API keys are automatically inherited from the current instance via `--inherit-config`. Channel-specific tokens must be filled in manually after creation.
|
||||||
|
|
||||||
|
3. **Run the creation script**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python <skill-dir>/scripts/create_instance.py --name <name> --channel <channel> --inherit-config <current-config>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<skill-dir>` — the directory containing this SKILL.md
|
||||||
|
- `<current-config>` — current instance's config path, typically `~/.nanobot/config.json`
|
||||||
|
- Optional: `--model <model>`, `--config-dir <path>`
|
||||||
|
|
||||||
|
**Exec tool constraints:**
|
||||||
|
- Use forward-slash paths (works on all platforms)
|
||||||
|
- Do not wrap paths in quotes
|
||||||
|
- Do not use `cd`; pass the full script path directly
|
||||||
|
|
||||||
|
4. **Report results** to the user:
|
||||||
|
- Config and workspace paths (script outputs them)
|
||||||
|
- Required fields to fill in (script lists them)
|
||||||
|
- Start command: `nanobot gateway --config <config-path>`
|
||||||
|
|
||||||
|
## Available Channels
|
||||||
|
|
||||||
|
| Channel | Key | Required Fields |
|
||||||
|
|---------|-----|-----------------|
|
||||||
|
| Telegram | `telegram` | token |
|
||||||
|
| Discord | `discord` | token |
|
||||||
|
| Feishu / Lark | `feishu` | app_id, app_secret |
|
||||||
|
| DingTalk | `dingtalk` | client_id, client_secret |
|
||||||
|
| Slack | `slack` | bot_token, app_token |
|
||||||
|
| WeCom | `wecom` | bot_id, secret |
|
||||||
|
| WeChat OA | `weixin` | token |
|
||||||
|
| WhatsApp | `whatsapp` | bridge_token |
|
||||||
|
| QQ | `qq` | app_id, secret |
|
||||||
|
| Email | `email` | imap_host, imap_username, imap_password, smtp_host, smtp_username, smtp_password, from_address |
|
||||||
|
| Matrix | `matrix` | user_id, password or access_token |
|
||||||
|
| MS Teams | `msteams` | app_id, app_password, tenant_id |
|
||||||
|
| MoChat | `mochat` | claw_token |
|
||||||
|
| WebSocket | `websocket` | token |
|
||||||
|
|
||||||
|
For detailed channel configuration including optional fields, see `references/channels.md`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"Unknown channel"**: Channel name must match the Key column exactly. Run the script without arguments to see usage.
|
||||||
|
- **"Config already exists"**: Use a different `--name` or `--config-dir` to create in a new location.
|
||||||
|
- **Port conflicts**: The script auto-assigns free ports for gateway and API if defaults are in use.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Channel Configuration Reference
|
||||||
|
|
||||||
|
Detailed configuration for each supported channel.
|
||||||
|
|
||||||
|
## Field Types
|
||||||
|
|
||||||
|
- **Required**: defaults to empty string `""`, must be filled in before the instance can start
|
||||||
|
- **Optional**: has a sensible default, can be customized
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## telegram
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — Bot token from @BotFather
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `proxy` — HTTP proxy URL
|
||||||
|
- `group_policy` — `"open"` (all messages) or `"mention"` (default, only when @mentioned)
|
||||||
|
- `streaming` — Enable streaming responses (default: true)
|
||||||
|
- `reply_to_message` — Reply to the triggering message (default: false)
|
||||||
|
- `react_emoji` — Emoji for "thinking" reaction (default: `"eyes"`)
|
||||||
|
- `inline_keyboards` — Enable inline keyboard buttons (default: false)
|
||||||
|
|
||||||
|
## discord
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — Bot token from Discord Developer Portal
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `allow_channels` — Restrict to specific channel IDs
|
||||||
|
- `group_policy` — `"mention"` (default) or `"open"`
|
||||||
|
- `streaming` — Enable streaming (default: true)
|
||||||
|
- `proxy` — HTTP proxy URL
|
||||||
|
- `intents` — Discord gateway intents (default: 37377)
|
||||||
|
- `read_receipt_emoji` — Emoji for read receipt
|
||||||
|
- `working_emoji` — Emoji for "working" indicator
|
||||||
|
|
||||||
|
## feishu
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `app_id` — Feishu app ID
|
||||||
|
- `app_secret` — Feishu app secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `encrypt_key` — Event encryption key
|
||||||
|
- `verification_token` — Event verification token
|
||||||
|
- `domain` — `"feishu"` (default) or `"lark"`
|
||||||
|
- `group_policy` — `"mention"` (default) or `"open"`
|
||||||
|
- `streaming` — Enable streaming (default: true)
|
||||||
|
|
||||||
|
## dingtalk
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `client_id` — DingTalk app client ID
|
||||||
|
- `client_secret` — DingTalk app client secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `allow_from` — Allowed user IDs
|
||||||
|
|
||||||
|
## slack
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `bot_token` — Bot OAuth token (`xoxb-...`)
|
||||||
|
- `app_token` — App-level token (`xapp-...`)
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `mode` — `"socket"` (default, Socket Mode) or `"webhook"`
|
||||||
|
- `reply_in_thread` — Reply in thread (default: true)
|
||||||
|
- `react_emoji` — "thinking" emoji (default: `"eyes"`)
|
||||||
|
- `done_emoji` — "done" emoji (default: `"white_check_mark"`)
|
||||||
|
- `group_policy` — `"mention"` (default) or `"open"`
|
||||||
|
- `dm.enabled` — Enable DM support
|
||||||
|
- `dm.policy` — DM policy
|
||||||
|
- `dm.allow_from` — Allowed DM users
|
||||||
|
|
||||||
|
## wecom
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `bot_id` — WeCom bot ID
|
||||||
|
- `secret` — WeCom bot secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `allow_from` — Allowed users
|
||||||
|
- `welcome_message` — Welcome message for new chats
|
||||||
|
|
||||||
|
## weixin
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — WeChat Official Account token
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `base_url` — API base URL
|
||||||
|
- `cdn_base_url` — CDN base URL
|
||||||
|
- `state_dir` — State persistence directory
|
||||||
|
- `poll_timeout` — Long polling timeout
|
||||||
|
|
||||||
|
## whatsapp
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `bridge_token` — WhatsApp bridge token (auto-generated if absent)
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `bridge_url` — Bridge WebSocket URL (default: `"ws://localhost:3001"`)
|
||||||
|
- `group_policy` — `"open"` (default) or `"mention"`
|
||||||
|
|
||||||
|
## qq
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `app_id` — QQ bot app ID
|
||||||
|
- `secret` — QQ bot secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `msg_format` — `"plain"` or `"markdown"`
|
||||||
|
- `ack_message` — Acknowledgment message text
|
||||||
|
- `media_dir` — Media file directory
|
||||||
|
|
||||||
|
## email
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `imap_host` — IMAP server hostname
|
||||||
|
- `imap_username` — IMAP login username
|
||||||
|
- `imap_password` — IMAP login password
|
||||||
|
- `smtp_host` — SMTP server hostname
|
||||||
|
- `smtp_username` — SMTP login username
|
||||||
|
- `smtp_password` — SMTP login password
|
||||||
|
- `from_address` — Sender email address
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `imap_port` — IMAP port (default: 993)
|
||||||
|
- `smtp_port` — SMTP port (default: 587)
|
||||||
|
- `imap_use_ssl` — Use SSL for IMAP (default: true)
|
||||||
|
- `smtp_use_tls` — Use TLS for SMTP (default: true)
|
||||||
|
- `poll_interval_seconds` — Polling interval (default: 30)
|
||||||
|
- `mark_seen` — Mark emails as read (default: true)
|
||||||
|
- `max_body_chars` — Max email body length (default: 12000)
|
||||||
|
- `subject_prefix` — Reply subject prefix (default: `"Re: "`)
|
||||||
|
- `verify_dkim` — Verify DKIM signatures (default: true)
|
||||||
|
- `verify_spf` — Verify SPF records (default: true)
|
||||||
|
- `allowed_attachment_types` — Allowed file extensions
|
||||||
|
- `max_attachment_size` — Max attachment size in bytes
|
||||||
|
- `consent_granted` — Must be set to `true` for the channel to start (default: false)
|
||||||
|
- `auto_reply_enabled` — Enable auto-reply (default: true)
|
||||||
|
|
||||||
|
## matrix
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `user_id` — Matrix user ID (e.g. `@bot:matrix.org`)
|
||||||
|
- `password` or `access_token` — Login password OR access token
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `homeserver` — Homeserver URL (default: `"https://matrix.org"`)
|
||||||
|
- `device_id` — Device ID
|
||||||
|
- `e2eeEnabled` — Enable end-to-end encryption (default: true)
|
||||||
|
- `group_policy` — `"open"`, `"mention"`, or `"allowlist"`
|
||||||
|
- `streaming` — Enable streaming (default: false)
|
||||||
|
- `max_media_bytes` — Max media file size (default: 20MB)
|
||||||
|
|
||||||
|
## msteams
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `app_id` — Azure AD app ID
|
||||||
|
- `app_password` — Azure AD app password/secret
|
||||||
|
- `tenant_id` — Azure AD tenant ID
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `host` — Listen host (default: `"0.0.0.0"`)
|
||||||
|
- `port` — Listen port (default: 3978)
|
||||||
|
- `reply_in_thread` — Reply in thread (default: true)
|
||||||
|
- `validate_inbound_auth` — Validate incoming auth (default: true)
|
||||||
|
|
||||||
|
## mochat
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `claw_token` — MoChat Claw token
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `base_url` — API base URL
|
||||||
|
- `socket_url` — WebSocket URL
|
||||||
|
- `refresh_interval_ms` — Refresh interval in ms
|
||||||
|
- `watch_timeout_ms` — Watch timeout in ms
|
||||||
|
|
||||||
|
## websocket
|
||||||
|
|
||||||
|
Built-in WebSocket channel for programmatic access.
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — Authentication token (enabled by default; set `websocket_requires_token: false` to disable)
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `host` — Listen host (default: `"127.0.0.1"`)
|
||||||
|
- `port` — Listen port (default: 8765)
|
||||||
|
- `allow_from` — Allowed origins (default: `["*"]`)
|
||||||
|
- `streaming` — Enable streaming (default: true)
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a new nanobot instance with a dedicated config and workspace.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
create_instance.py --name <name> --channel <channel> [--model <model>] [--config-dir <dir>]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
create_instance.py --name telegram-bot --channel telegram
|
||||||
|
create_instance.py --name discord-bot --channel discord --model deepseek/deepseek-chat
|
||||||
|
create_instance.py --name my-bot --channel telegram --config-dir ~/.nanobot-custom
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name: str) -> str:
|
||||||
|
"""Normalize and validate instance name."""
|
||||||
|
name = name.strip().lower()
|
||||||
|
name = re.sub(r"[^a-z0-9-]", "-", name)
|
||||||
|
name = re.sub(r"-{2,}", "-", name)
|
||||||
|
name = name.strip("-")
|
||||||
|
if not name:
|
||||||
|
print("[ERROR] Instance name must contain at least one letter or digit.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if len(name) > 64:
|
||||||
|
print(f"[ERROR] Instance name too long ({len(name)} chars, max 64).", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _get_available_channels() -> list[str]:
|
||||||
|
"""Get list of available channel names without importing channel classes."""
|
||||||
|
from nanobot.channels.registry import discover_channel_names
|
||||||
|
|
||||||
|
return discover_channel_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_onboard(config_path: Path, workspace: Path) -> None:
|
||||||
|
"""Create skeleton config + workspace using nanobot's programmatic API."""
|
||||||
|
from nanobot.cli.commands import _onboard_plugins
|
||||||
|
from nanobot.config.loader import save_config, set_config_path
|
||||||
|
from nanobot.config.paths import get_workspace_path
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(workspace)
|
||||||
|
set_config_path(config_path)
|
||||||
|
save_config(config, config_path)
|
||||||
|
_onboard_plugins(config_path)
|
||||||
|
|
||||||
|
workspace_path = get_workspace_path(config.workspace_path)
|
||||||
|
if not workspace_path.exists():
|
||||||
|
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
sync_workspace_templates(workspace_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_config(
|
||||||
|
config_path: Path,
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
workspace: Path,
|
||||||
|
model: str | None,
|
||||||
|
inherit_config_path: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Patch the generated config: enable channel, set workspace, optionally set model."""
|
||||||
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Inherit providers and model from current instance
|
||||||
|
if inherit_config_path and inherit_config_path.exists():
|
||||||
|
try:
|
||||||
|
src = json.loads(inherit_config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Inherit providers (API keys, api_base, etc.)
|
||||||
|
src_providers = src.get("providers", {})
|
||||||
|
if src_providers:
|
||||||
|
data.setdefault("providers", {})
|
||||||
|
for key, val in src_providers.items():
|
||||||
|
if isinstance(val, dict) and val.get("apiKey"):
|
||||||
|
data["providers"][key] = val
|
||||||
|
|
||||||
|
# Inherit model if not explicitly overridden
|
||||||
|
if not model:
|
||||||
|
parent_model = src.get("agents", {}).get("defaults", {}).get("model")
|
||||||
|
if parent_model:
|
||||||
|
model = parent_model
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not inherit from {inherit_config_path}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Set workspace and model
|
||||||
|
data.setdefault("agents", {}).setdefault("defaults", {})
|
||||||
|
data["agents"]["defaults"]["workspace"] = str(workspace)
|
||||||
|
if model:
|
||||||
|
data["agents"]["defaults"]["model"] = model
|
||||||
|
|
||||||
|
# Enable the target channel
|
||||||
|
channels = data.setdefault("channels", {})
|
||||||
|
if channel in channels and isinstance(channels[channel], dict):
|
||||||
|
channels[channel]["enabled"] = True
|
||||||
|
else:
|
||||||
|
channels[channel] = {"enabled": True}
|
||||||
|
|
||||||
|
# Auto-assign ports if defaults are already in use
|
||||||
|
_assign_free_ports(data)
|
||||||
|
|
||||||
|
# Validate with Pydantic, then save
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
Config.model_validate(data)
|
||||||
|
config_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
||||||
|
"""Check if a port is already in use."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind((host, port))
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _find_free_port(start: int, host: str = "127.0.0.1", max_tries: int = 100) -> int:
|
||||||
|
"""Find the first free port starting from `start`."""
|
||||||
|
for port in range(start, start + max_tries):
|
||||||
|
if not _is_port_in_use(port, host):
|
||||||
|
return port
|
||||||
|
# OS-level fallback: ask the kernel for an ephemeral port
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind((host, 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_free_ports(data: dict) -> None:
|
||||||
|
"""If default gateway or API ports are in use, assign free ones."""
|
||||||
|
from nanobot.config.schema import ApiConfig, GatewayConfig
|
||||||
|
|
||||||
|
defaults = [
|
||||||
|
("gateway", GatewayConfig()),
|
||||||
|
("api", ApiConfig()),
|
||||||
|
]
|
||||||
|
for key, default_cfg in defaults:
|
||||||
|
section = data.setdefault(key, {})
|
||||||
|
port = section.get("port", default_cfg.port)
|
||||||
|
host = section.get("host", default_cfg.host)
|
||||||
|
if _is_port_in_use(port, host):
|
||||||
|
section["port"] = _find_free_port(port + 1, host)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_channel_required_fields(channel: str) -> list[str]:
|
||||||
|
"""Inspect a channel's default config and list fields that are empty strings."""
|
||||||
|
try:
|
||||||
|
from nanobot.channels.registry import load_channel_class
|
||||||
|
|
||||||
|
cls = load_channel_class(channel)
|
||||||
|
default = cls.default_config()
|
||||||
|
return sorted(k for k, v in default.items() if isinstance(v, str) and v == "" and k != "enabled")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not inspect channel '{channel}' defaults: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Create a new nanobot instance.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--name", required=True, help="Instance name (e.g. telegram-bot)")
|
||||||
|
parser.add_argument("--channel", required=True, help="Channel type (e.g. telegram, discord)")
|
||||||
|
parser.add_argument("--model", default=None, help="LLM model (default: same as current instance)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-dir",
|
||||||
|
default=None,
|
||||||
|
help="Config directory (default: ~/.nanobot-{name})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--inherit-config",
|
||||||
|
default=None,
|
||||||
|
help="Path to current instance's config.json to copy API keys from",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validate name
|
||||||
|
name = _validate_name(args.name)
|
||||||
|
|
||||||
|
# Validate channel
|
||||||
|
available = _get_available_channels()
|
||||||
|
if args.channel not in available:
|
||||||
|
print(f"[ERROR] Unknown channel: {args.channel}", file=sys.stderr)
|
||||||
|
print(f"Available channels: {', '.join(sorted(available))}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve paths
|
||||||
|
home = Path.home()
|
||||||
|
config_dir = Path(args.config_dir).expanduser().resolve() if args.config_dir else home / f".nanobot-{name}"
|
||||||
|
config_path = config_dir / "config.json"
|
||||||
|
workspace = config_dir / "workspace"
|
||||||
|
|
||||||
|
# Check for duplicate
|
||||||
|
if config_path.exists():
|
||||||
|
print(f"[ERROR] Config already exists at {config_path}", file=sys.stderr)
|
||||||
|
print("Delete it first or use a different --config-dir.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Creating instance '{name}'...")
|
||||||
|
print(f" Config dir: {config_dir}")
|
||||||
|
print(f" Workspace: {workspace}")
|
||||||
|
print(f" Channel: {args.channel}")
|
||||||
|
if args.model:
|
||||||
|
print(f" Model: {args.model}")
|
||||||
|
|
||||||
|
# Run onboard
|
||||||
|
_run_onboard(config_path, workspace)
|
||||||
|
|
||||||
|
# Patch config
|
||||||
|
inherit_path = Path(args.inherit_config).expanduser().resolve() if args.inherit_config else None
|
||||||
|
_patch_config(
|
||||||
|
config_path,
|
||||||
|
channel=args.channel,
|
||||||
|
workspace=workspace,
|
||||||
|
model=args.model,
|
||||||
|
inherit_config_path=inherit_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Report
|
||||||
|
print(f"\n[OK] Instance '{name}' created successfully.")
|
||||||
|
print(f" Config: {config_path}")
|
||||||
|
print(f" Workspace: {workspace}")
|
||||||
|
|
||||||
|
# List fields the user needs to fill in
|
||||||
|
required_fields = _get_channel_required_fields(args.channel)
|
||||||
|
if required_fields:
|
||||||
|
print(f"\n[IMPORTANT] Edit {config_path} and fill in these fields:")
|
||||||
|
for field in required_fields:
|
||||||
|
print(f" - channels.{args.channel}.{field}")
|
||||||
|
|
||||||
|
print(f"\nTo start the instance:")
|
||||||
|
print(f" nanobot gateway --config {config_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -12,25 +12,23 @@ Example:
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quick_validate import validate_skill
|
from quick_validate import validate_skill
|
||||||
|
|
||||||
|
|
||||||
def _is_within(path: Path, root: Path) -> bool:
|
def _is_within(path: Path, root: Path) -> bool:
|
||||||
try:
|
with suppress(ValueError):
|
||||||
path.relative_to(root)
|
path.relative_to(root)
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
return False
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_partial_archive(skill_filename: Path) -> None:
|
def _cleanup_partial_archive(skill_filename: Path) -> None:
|
||||||
try:
|
if skill_filename.exists():
|
||||||
if skill_filename.exists():
|
with suppress(OSError):
|
||||||
skill_filename.unlink()
|
skill_filename.unlink()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def package_skill(skill_path, output_dir=None):
|
def package_skill(skill_path, output_dir=None):
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
name: update-setup
|
||||||
|
description: One-time setup wizard for the nanobot upgrade skill. Triggers: setup update, configure update, 切设置更新, 初始化更新.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update Setup
|
||||||
|
|
||||||
|
Generate a personalized upgrade skill for this workspace.
|
||||||
|
|
||||||
|
## Step 1: Check Existing
|
||||||
|
|
||||||
|
Use `read_file` to check if `skills/update/SKILL.md` already exists in the workspace.
|
||||||
|
|
||||||
|
If it exists, use `ask_user` to ask: "An upgrade skill already exists. Reconfigure?" with options ["yes", "no"]. If no, stop here.
|
||||||
|
|
||||||
|
## Step 2: Current Version and Install Clues
|
||||||
|
|
||||||
|
Use `exec` to run `nanobot --version`. Tell the user the current version.
|
||||||
|
|
||||||
|
Then collect install clues with `exec`. These commands are best-effort; if one fails,
|
||||||
|
keep going and show the useful output:
|
||||||
|
|
||||||
|
```
|
||||||
|
command -v nanobot || true
|
||||||
|
python -m pip show nanobot-ai || true
|
||||||
|
pipx list | sed -n '/nanobot-ai/,+3p' || true
|
||||||
|
uv tool list | sed -n '/nanobot-ai/,+3p' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Summarize what you found in one short paragraph. Use the clues only to suggest a
|
||||||
|
likely install method. Do not treat them as confirmation.
|
||||||
|
|
||||||
|
## Step 3: Confirm Required Inputs
|
||||||
|
|
||||||
|
CRITICAL: Do not write `skills/update/SKILL.md` until the install method is
|
||||||
|
explicitly confirmed by the user. The install method must come from a user
|
||||||
|
answer or confirmation, not from inference alone. If you cannot get a clear
|
||||||
|
answer, stop and ask the user to rerun this setup when they know how nanobot was
|
||||||
|
installed.
|
||||||
|
|
||||||
|
Use `ask_user` for the questions below, one question per call. If `ask_user` is
|
||||||
|
not available or cannot collect the answer, ask in normal chat and stop without
|
||||||
|
writing the skill.
|
||||||
|
|
||||||
|
**Question 1 — Install method:**
|
||||||
|
|
||||||
|
```
|
||||||
|
question: "I found these install clues: <SUMMARY>. Which update method should this workspace use?"
|
||||||
|
options: ["uv", "pipx", "pip", "source (git clone)", "not sure"]
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user selected `not sure`, explain the difference between the options and
|
||||||
|
stop. Do not generate the upgrade skill.
|
||||||
|
|
||||||
|
If the user selected `source (git clone)`, ask for the local checkout path:
|
||||||
|
`question: "Where is your nanobot source checkout? Enter an absolute path or a path relative to this workspace:"`.
|
||||||
|
|
||||||
|
**Question 2 — Optional dependencies:**
|
||||||
|
|
||||||
|
```
|
||||||
|
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, wecom, weixin, msteams, matrix, discord, langsmith, pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names.
|
||||||
|
|
||||||
|
**Question 3 — Proxy:**
|
||||||
|
|
||||||
|
```
|
||||||
|
question: "Do you need an HTTP proxy to reach PyPI or GitHub?"
|
||||||
|
options: ["no", "yes"]
|
||||||
|
```
|
||||||
|
|
||||||
|
If yes, ask one more time for the proxy URL: `question: "Enter proxy URL (e.g. http://127.0.0.1:7890):"`.
|
||||||
|
|
||||||
|
## Step 4: Generate Skill
|
||||||
|
|
||||||
|
Build the extras string. If the user selected dependencies, format as `[dep1,dep2,...]`. Otherwise omit the brackets entirely.
|
||||||
|
|
||||||
|
Determine the upgrade command from the install method:
|
||||||
|
|
||||||
|
| Method | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| uv | `uv tool install "nanobot-ai[EXTRAS]" --force` |
|
||||||
|
| pipx | `pipx install --force "nanobot-ai[EXTRAS]"` |
|
||||||
|
| pip | `python -m pip install --upgrade "nanobot-ai[EXTRAS]"` |
|
||||||
|
| source | `cd <SOURCE_CHECKOUT> && git pull && python -m pip install -e ".[EXTRAS]"` |
|
||||||
|
|
||||||
|
For source installs, include extras in the editable install command when selected. Quote the source checkout path if it contains spaces.
|
||||||
|
|
||||||
|
Determine the preflight check from the install method:
|
||||||
|
|
||||||
|
| Method | Preflight check |
|
||||||
|
|--------|-----------------|
|
||||||
|
| uv | `command -v uv` |
|
||||||
|
| pipx | `command -v pipx` |
|
||||||
|
| pip | `python -m pip --version` |
|
||||||
|
| source | `test -d <SOURCE_CHECKOUT> && test -d <SOURCE_CHECKOUT>/.git && test -f <SOURCE_CHECKOUT>/pyproject.toml` |
|
||||||
|
|
||||||
|
For source installs, quote the source checkout path in the preflight check if it
|
||||||
|
contains spaces.
|
||||||
|
|
||||||
|
Build the skill content. If proxy is configured, add `export http_proxy=URL` and `export https_proxy=URL` lines before the upgrade command.
|
||||||
|
|
||||||
|
Use `write_file` to write `skills/update/SKILL.md` with this content:
|
||||||
|
|
||||||
|
```
|
||||||
|
---
|
||||||
|
name: update
|
||||||
|
description: "Upgrade nanobot to the latest version. Triggers: upgrade nanobot, update nanobot, 升级nanobot, 更新nanobot."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update Nanobot
|
||||||
|
|
||||||
|
1. (If proxy configured) Set proxy: `export http_proxy=URL && export https_proxy=URL`
|
||||||
|
2. Use `exec` to run the preflight check: <PREFLIGHT_CHECK>. If it fails, stop and tell the user to rerun `update-setup` because the saved install method no longer matches this environment.
|
||||||
|
3. Use `exec` to run the upgrade command: <UPGRADE_COMMAND>
|
||||||
|
4. Use `exec` to verify: `nanobot --version`
|
||||||
|
5. Tell the user the new version. Say: "Run `/restart` to restart nanobot and apply the update. If `/restart` is unavailable in this channel, restart the nanobot process manually."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Confirm
|
||||||
|
|
||||||
|
Tell the user: "Upgrade skill created. Say 'upgrade nanobot' when you want to update."
|
||||||
@@ -93,7 +93,7 @@ def _extract_pdf(path: Path) -> str:
|
|||||||
pages.append(f"--- Page {i} ---\n{text}")
|
pages.append(f"--- Page {i} ---\n{text}")
|
||||||
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
|
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to extract PDF {}: {}", path, e)
|
logger.exception("Failed to extract PDF {}", path)
|
||||||
return f"[error: failed to extract PDF: {e!s}]"
|
return f"[error: failed to extract PDF: {e!s}]"
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ def _extract_docx(path: Path) -> str:
|
|||||||
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
|
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
|
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to extract DOCX {}: {}", path, e)
|
logger.exception("Failed to extract DOCX {}", path)
|
||||||
return f"[error: failed to extract DOCX: {e!s}]"
|
return f"[error: failed to extract DOCX: {e!s}]"
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ def _extract_xlsx(path: Path) -> str:
|
|||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to extract XLSX {}: {}", path, e)
|
logger.exception("Failed to extract XLSX {}", path)
|
||||||
return f"[error: failed to extract XLSX: {e!s}]"
|
return f"[error: failed to extract XLSX: {e!s}]"
|
||||||
|
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ def _extract_pptx(path: Path) -> str:
|
|||||||
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
||||||
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
|
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to extract PPTX {}: {}", path, e)
|
logger.exception("Failed to extract PPTX {}", path)
|
||||||
return f"[error: failed to extract PPTX: {e!s}]"
|
return f"[error: failed to extract PPTX: {e!s}]"
|
||||||
|
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ def _extract_text_file(path: Path) -> str:
|
|||||||
content = path.read_text(encoding="latin-1")
|
content = path.read_text(encoding="latin-1")
|
||||||
return _truncate(content, _MAX_TEXT_LENGTH)
|
return _truncate(content, _MAX_TEXT_LENGTH)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to read text file {}: {}", path, e)
|
logger.exception("Failed to read text file {}", path)
|
||||||
return f"[error: failed to read file: {e!s}]"
|
return f"[error: failed to read file: {e!s}]"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class GitStore:
|
|||||||
logger.info("Git store initialized at {}", self._workspace)
|
logger.info("Git store initialized at {}", self._workspace)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Git store init failed for {}", self._workspace)
|
logger.exception("Git store init failed for {}", self._workspace)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# -- daily operations ------------------------------------------------------
|
# -- daily operations ------------------------------------------------------
|
||||||
@@ -149,7 +149,7 @@ class GitStore:
|
|||||||
logger.debug("Git auto-commit: {} ({})", sha, message)
|
logger.debug("Git auto-commit: {} ({})", sha, message)
|
||||||
return sha
|
return sha
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Git auto-commit failed: {}", message)
|
logger.exception("Git auto-commit failed: {}", message)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# -- internal helpers ------------------------------------------------------
|
# -- internal helpers ------------------------------------------------------
|
||||||
@@ -243,7 +243,7 @@ class GitStore:
|
|||||||
|
|
||||||
return entries
|
return entries
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Git log failed")
|
logger.exception("Git log failed")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def line_ages(self, file_path: str) -> list[LineAge]:
|
def line_ages(self, file_path: str) -> list[LineAge]:
|
||||||
@@ -266,7 +266,7 @@ class GitStore:
|
|||||||
|
|
||||||
annotated = porcelain.annotate(str(self._workspace), file_path)
|
annotated = porcelain.annotate(str(self._workspace), file_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Git line_ages annotate failed for {}", file_path)
|
logger.exception("Git line_ages annotate failed for {}", file_path)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not annotated:
|
if not annotated:
|
||||||
@@ -296,7 +296,7 @@ class GitStore:
|
|||||||
)
|
)
|
||||||
return out.getvalue().decode("utf-8", errors="replace")
|
return out.getvalue().decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Git diff_commits failed")
|
logger.exception("Git diff_commits failed")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
|
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
|
||||||
@@ -367,7 +367,7 @@ class GitStore:
|
|||||||
msg = f"revert: undo {commit}"
|
msg = f"revert: undo {commit}"
|
||||||
return self.auto_commit(msg)
|
return self.auto_commit(msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Git revert failed for {}", commit)
|
logger.exception("Git revert failed for {}", commit)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -31,6 +32,8 @@ def strip_think(text: str) -> str:
|
|||||||
explanatory prose that mentions these tokens.
|
explanatory prose that mentions these tokens.
|
||||||
5. Orphan closing tags `</think>` / `</thought>` **at the very start
|
5. Orphan closing tags `</think>` / `</thought>` **at the very start
|
||||||
or end of the text** only, for the same reason.
|
or end of the text** only, for the same reason.
|
||||||
|
6. Trailing partial control tags split across stream chunks, such as
|
||||||
|
`<thi`, `<thin`, or `<tho`.
|
||||||
|
|
||||||
Since this is also applied before persisting to history (memory.py),
|
Since this is also applied before persisting to history (memory.py),
|
||||||
the edge-only stripping of (4) and (5) is deliberate: stripping those
|
the edge-only stripping of (4) and (5) is deliberate: stripping those
|
||||||
@@ -57,6 +60,14 @@ def strip_think(text: str) -> str:
|
|||||||
text = re.sub(r"\s*</thought>\s*$", "", text)
|
text = re.sub(r"\s*</thought>\s*$", "", text)
|
||||||
# Edge-only channel markers (harmony / Gemma 4 variant leaks).
|
# Edge-only channel markers (harmony / Gemma 4 variant leaks).
|
||||||
text = re.sub(r"^\s*<\|?channel\|?>\s*", "", text)
|
text = re.sub(r"^\s*<\|?channel\|?>\s*", "", text)
|
||||||
|
# Stream chunks may end in the middle of a control tag. Strip only known
|
||||||
|
# control-token prefixes at the very end.
|
||||||
|
partial_control_tag = (
|
||||||
|
r"</?(?:t|th|thi|thin|think|tho|thou|thoug|though|thought)>?"
|
||||||
|
r"|<\|?(?:c|ch|cha|chan|chann|channe|channel)(?:\|?>?)?"
|
||||||
|
)
|
||||||
|
text = re.sub(rf"(?:{partial_control_tag})$", "", text)
|
||||||
|
text = re.sub(r"^\s*<\|?$", "", text)
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -257,8 +268,8 @@ def maybe_persist_tool_result(
|
|||||||
bucket = ensure_dir(root / safe_filename(session_key or "default"))
|
bucket = ensure_dir(root / safe_filename(session_key or "default"))
|
||||||
try:
|
try:
|
||||||
_cleanup_tool_result_buckets(root, bucket)
|
_cleanup_tool_result_buckets(root, bucket)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.warning("Failed to clean stale tool result buckets in {}: {}", root, exc)
|
logger.exception("Failed to clean stale tool result buckets in {}", root)
|
||||||
path = bucket / f"{safe_filename(tool_call_id)}.{suffix}"
|
path = bucket / f"{safe_filename(tool_call_id)}.{suffix}"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
if suffix == "json" and isinstance(content, list):
|
if suffix == "json" and isinstance(content, list):
|
||||||
@@ -416,13 +427,10 @@ def estimate_prompt_tokens_chain(
|
|||||||
"""Estimate prompt tokens via provider counter first, then tiktoken fallback."""
|
"""Estimate prompt tokens via provider counter first, then tiktoken fallback."""
|
||||||
provider_counter = getattr(provider, "estimate_prompt_tokens", None)
|
provider_counter = getattr(provider, "estimate_prompt_tokens", None)
|
||||||
if callable(provider_counter):
|
if callable(provider_counter):
|
||||||
try:
|
with suppress(Exception):
|
||||||
tokens, source = provider_counter(messages, tools, model)
|
tokens, source = provider_counter(messages, tools, model)
|
||||||
if isinstance(tokens, (int, float)) and tokens > 0:
|
if isinstance(tokens, (int, float)) and tokens > 0:
|
||||||
return int(tokens), str(source or "provider_counter")
|
return int(tokens), str(source or "provider_counter")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
estimated = estimate_prompt_tokens(messages, tools)
|
estimated = estimate_prompt_tokens(messages, tools)
|
||||||
if estimated > 0:
|
if estimated > 0:
|
||||||
return int(estimated), "tiktoken"
|
return int(estimated), "tiktoken"
|
||||||
@@ -532,6 +540,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
|
|||||||
)
|
)
|
||||||
gs.init()
|
gs.init()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to initialize git store for {}", workspace)
|
logger.exception("Failed to initialize git store for {}", workspace)
|
||||||
|
|
||||||
return added
|
return added
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Utilities for redirecting stdlib logging to loguru."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class _LoguruBridge(logging.Handler):
|
||||||
|
"""Route stdlib log records into loguru with consistent formatting."""
|
||||||
|
|
||||||
|
_LEVEL_MAP: dict[int, str] = {
|
||||||
|
logging.DEBUG: "DEBUG",
|
||||||
|
logging.INFO: "INFO",
|
||||||
|
logging.WARNING: "WARNING",
|
||||||
|
logging.ERROR: "ERROR",
|
||||||
|
logging.CRITICAL: "CRITICAL",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, lib_name: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.lib_name = lib_name
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
level = self._LEVEL_MAP.get(record.levelno, "INFO")
|
||||||
|
frame, depth = logging.currentframe(), 2
|
||||||
|
while frame and frame.f_code.co_filename == logging.__file__:
|
||||||
|
frame, depth = frame.f_back, depth + 1
|
||||||
|
logger.opt(depth=depth, exception=record.exc_info).log(
|
||||||
|
level, "[{lib}] {message}", lib=self.lib_name, message=record.getMessage()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_lib_logging(name: str, level: str | None = None) -> None:
|
||||||
|
"""Redirect stdlib logging from *name* into loguru.
|
||||||
|
|
||||||
|
Adds a bridge handler if one is not already present and disables
|
||||||
|
propagation so messages are not duplicated. When *level* is None the
|
||||||
|
handler does not filter — loguru's own level controls visibility.
|
||||||
|
"""
|
||||||
|
lib_logger = logging.getLogger(name)
|
||||||
|
if not any(isinstance(h, _LoguruBridge) for h in lib_logger.handlers):
|
||||||
|
handler = _LoguruBridge(name)
|
||||||
|
if level is not None:
|
||||||
|
handler.setLevel(getattr(logging, level.upper(), logging.WARNING))
|
||||||
|
lib_logger.handlers = [handler]
|
||||||
|
lib_logger.propagate = False
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -26,11 +27,9 @@ def format_restart_completed_message(started_at_raw: str) -> str:
|
|||||||
"""Build restart completion text and include elapsed time when available."""
|
"""Build restart completion text and include elapsed time when available."""
|
||||||
elapsed_suffix = ""
|
elapsed_suffix = ""
|
||||||
if started_at_raw:
|
if started_at_raw:
|
||||||
try:
|
with suppress(ValueError):
|
||||||
elapsed_s = max(0.0, time.time() - float(started_at_raw))
|
elapsed_s = max(0.0, time.time() - float(started_at_raw))
|
||||||
elapsed_suffix = f" in {elapsed_s:.1f}s"
|
elapsed_suffix = f" in {elapsed_s:.1f}s"
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return f"Restart completed{elapsed_suffix}."
|
return f"Restart completed{elapsed_suffix}."
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -10,6 +12,9 @@ from nanobot.utils.helpers import stringify_text_blocks
|
|||||||
|
|
||||||
_MAX_REPEAT_EXTERNAL_LOOKUPS = 2
|
_MAX_REPEAT_EXTERNAL_LOOKUPS = 2
|
||||||
|
|
||||||
|
# Third same-target workspace violation in a turn escalates to "stop retrying".
|
||||||
|
_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2
|
||||||
|
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE = (
|
EMPTY_FINAL_RESPONSE_MESSAGE = (
|
||||||
"I completed the tool steps but couldn't produce a final answer. "
|
"I completed the tool steps but couldn't produce a final answer. "
|
||||||
"Please try again or narrow the task."
|
"Please try again or narrow the task."
|
||||||
@@ -95,3 +100,71 @@ def repeated_external_lookup_error(
|
|||||||
"Error: repeated external lookup blocked. "
|
"Error: repeated external lookup blocked. "
|
||||||
"Use the results you already have to answer, or try a meaningfully different source."
|
"Use the results you already have to answer, or try a meaningfully different source."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Workspace-boundary violations are soft errors, with per-target throttling.
|
||||||
|
|
||||||
|
_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))")
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_violation_signature(
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
) -> str | None:
|
||||||
|
"""Return a stable cross-tool signature for the outside-workspace target."""
|
||||||
|
for key in ("path", "file_path", "target", "source", "destination"):
|
||||||
|
val = arguments.get(key)
|
||||||
|
if isinstance(val, str) and val.strip():
|
||||||
|
return _normalize_violation_target(val.strip())
|
||||||
|
|
||||||
|
if tool_name in {"exec", "shell"}:
|
||||||
|
cmd = str(arguments.get("command") or "").strip()
|
||||||
|
if cmd:
|
||||||
|
match = _OUTSIDE_PATH_PATTERN.search(cmd)
|
||||||
|
if match:
|
||||||
|
return _normalize_violation_target(match.group(1))
|
||||||
|
cwd = str(arguments.get("working_dir") or "").strip()
|
||||||
|
if cwd:
|
||||||
|
return _normalize_violation_target(cwd)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_violation_target(raw: str) -> str:
|
||||||
|
"""Normalize *raw* path so that equivalent spellings collide on the same key."""
|
||||||
|
try:
|
||||||
|
normalized = Path(raw).expanduser().resolve().as_posix()
|
||||||
|
except Exception:
|
||||||
|
normalized = raw.replace("\\", "/")
|
||||||
|
return f"violation:{normalized}".lower()
|
||||||
|
|
||||||
|
|
||||||
|
def repeated_workspace_violation_error(
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
seen_counts: dict[str, int],
|
||||||
|
) -> str | None:
|
||||||
|
"""Return an escalated error after repeated bypass attempts."""
|
||||||
|
signature = workspace_violation_signature(tool_name, arguments)
|
||||||
|
if signature is None:
|
||||||
|
return None
|
||||||
|
count = seen_counts.get(signature, 0) + 1
|
||||||
|
seen_counts[signature] = count
|
||||||
|
if count <= _MAX_REPEAT_WORKSPACE_VIOLATIONS:
|
||||||
|
return None
|
||||||
|
logger.warning(
|
||||||
|
"Escalating repeated workspace bypass attempt {} (attempt {})",
|
||||||
|
signature[:160],
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
target = signature.split("violation:", 1)[1] if "violation:" in signature else signature
|
||||||
|
return (
|
||||||
|
"Error: refusing repeated workspace-bypass attempts.\n"
|
||||||
|
f"You have tried to access '{target}' (or an equivalent path) "
|
||||||
|
f"{count} times in this turn. This is a hard policy boundary -- "
|
||||||
|
"switching tools, shell tricks, working_dir overrides, symlinks, "
|
||||||
|
"or base64 piping will NOT change the answer. Stop retrying. "
|
||||||
|
"If the user genuinely needs this resource, tell them you cannot "
|
||||||
|
"access it and ask how they want to proceed (e.g. copy the file "
|
||||||
|
"into the workspace, or disable restrict_to_workspace for this run)."
|
||||||
|
)
|
||||||
|
|||||||
+16
-14
@@ -27,7 +27,7 @@ _PATH_IN_CMD_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_tool_hints(tool_calls: list) -> str:
|
def format_tool_hints(tool_calls: list, max_length: int = 40) -> str:
|
||||||
"""Format tool calls as concise hints with smart abbreviation."""
|
"""Format tool calls as concise hints with smart abbreviation."""
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
return ""
|
return ""
|
||||||
@@ -36,11 +36,11 @@ def format_tool_hints(tool_calls: list) -> str:
|
|||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
fmt = _TOOL_FORMATS.get(tc.name)
|
fmt = _TOOL_FORMATS.get(tc.name)
|
||||||
if fmt:
|
if fmt:
|
||||||
formatted.append(_fmt_known(tc, fmt))
|
formatted.append(_fmt_known(tc, fmt, max_length))
|
||||||
elif tc.name.startswith("mcp_"):
|
elif tc.name.startswith("mcp_"):
|
||||||
formatted.append(_fmt_mcp(tc))
|
formatted.append(_fmt_mcp(tc, max_length))
|
||||||
else:
|
else:
|
||||||
formatted.append(_fmt_fallback(tc))
|
formatted.append(_fmt_fallback(tc, max_length))
|
||||||
|
|
||||||
hints = []
|
hints = []
|
||||||
for hint in formatted:
|
for hint in formatted:
|
||||||
@@ -80,26 +80,28 @@ def _extract_arg(tc, key_args: list[str]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _fmt_known(tc, fmt: tuple) -> str:
|
def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str:
|
||||||
"""Format a registered tool using its template."""
|
"""Format a registered tool using its template."""
|
||||||
val = _extract_arg(tc, fmt[0])
|
val = _extract_arg(tc, fmt[0])
|
||||||
if val is None:
|
if val is None:
|
||||||
return tc.name
|
return tc.name
|
||||||
if fmt[2]: # is_path
|
if fmt[2]: # is_path
|
||||||
val = abbreviate_path(val)
|
val = abbreviate_path(val, max_len=max_length)
|
||||||
elif fmt[3]: # is_command
|
elif fmt[3]: # is_command
|
||||||
val = _abbreviate_command(val)
|
val = _abbreviate_command(val, max_len=max_length)
|
||||||
return fmt[1].format(val)
|
return fmt[1].format(val)
|
||||||
|
|
||||||
|
|
||||||
def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
|
def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
|
||||||
"""Abbreviate paths in a command string, then truncate."""
|
"""Abbreviate paths in a command string, then truncate."""
|
||||||
|
path_max = max(max_len // 2, 25)
|
||||||
|
|
||||||
def _replace_path(match: re.Match[str]) -> str:
|
def _replace_path(match: re.Match[str]) -> str:
|
||||||
if match.group("double") is not None:
|
if match.group("double") is not None:
|
||||||
return f'"{abbreviate_path(match.group("double"), max_len=25)}"'
|
return f'"{abbreviate_path(match.group("double"), max_len=path_max)}"'
|
||||||
if match.group("single") is not None:
|
if match.group("single") is not None:
|
||||||
return f"'{abbreviate_path(match.group('single'), max_len=25)}'"
|
return f"'{abbreviate_path(match.group('single'), max_len=path_max)}'"
|
||||||
return abbreviate_path(match.group("bare"), max_len=25)
|
return abbreviate_path(match.group("bare"), max_len=path_max)
|
||||||
|
|
||||||
abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd)
|
abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd)
|
||||||
if len(abbreviated) <= max_len:
|
if len(abbreviated) <= max_len:
|
||||||
@@ -107,7 +109,7 @@ def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
|
|||||||
return abbreviated[:max_len - 1] + "\u2026"
|
return abbreviated[:max_len - 1] + "\u2026"
|
||||||
|
|
||||||
|
|
||||||
def _fmt_mcp(tc) -> str:
|
def _fmt_mcp(tc, max_length: int = 40) -> str:
|
||||||
"""Format MCP tool as server::tool."""
|
"""Format MCP tool as server::tool."""
|
||||||
name = tc.name
|
name = tc.name
|
||||||
if "__" in name:
|
if "__" in name:
|
||||||
@@ -125,13 +127,13 @@ def _fmt_mcp(tc) -> str:
|
|||||||
val = next((v for v in args.values() if isinstance(v, str) and v), None)
|
val = next((v for v in args.values() if isinstance(v, str) and v), None)
|
||||||
if val is None:
|
if val is None:
|
||||||
return f"{server}::{tool}"
|
return f"{server}::{tool}"
|
||||||
return f'{server}::{tool}("{abbreviate_path(val, 40)}")'
|
return f'{server}::{tool}("{abbreviate_path(val, max_length)}")'
|
||||||
|
|
||||||
|
|
||||||
def _fmt_fallback(tc) -> str:
|
def _fmt_fallback(tc, max_length: int = 40) -> str:
|
||||||
"""Original formatting logic for unregistered tools."""
|
"""Original formatting logic for unregistered tools."""
|
||||||
args = _get_args(tc)
|
args = _get_args(tc)
|
||||||
val = next(iter(args.values()), None) if isinstance(args, dict) else None
|
val = next(iter(args.values()), None) if isinstance(args, dict) else None
|
||||||
if not isinstance(val, str):
|
if not isinstance(val, str):
|
||||||
return tc.name
|
return tc.name
|
||||||
return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")'
|
return f'{tc.name}("{abbreviate_path(val, max_length)}")' if len(val) > max_length else f'{tc.name}("{val}")'
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Helpers for WebUI chat title generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.providers.base import LLMProvider
|
||||||
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
from nanobot.utils.helpers import truncate_text
|
||||||
|
|
||||||
|
WEBUI_SESSION_METADATA_KEY = "webui"
|
||||||
|
WEBUI_TITLE_METADATA_KEY = "title"
|
||||||
|
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
|
||||||
|
TITLE_MAX_CHARS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
|
||||||
|
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
|
||||||
|
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||||
|
return False
|
||||||
|
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def clean_generated_title(raw: str | None) -> str:
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
|
||||||
|
text = text.strip().strip("\"'`“”‘’")
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
text = text.rstrip("。.!!??,,;;:")
|
||||||
|
if len(text) > TITLE_MAX_CHARS:
|
||||||
|
text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _title_inputs(session: Session) -> tuple[str, str]:
|
||||||
|
user_text = ""
|
||||||
|
assistant_text = ""
|
||||||
|
for message in session.messages:
|
||||||
|
role = message.get("role")
|
||||||
|
content = message.get("content")
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
continue
|
||||||
|
if role == "user" and not user_text:
|
||||||
|
user_text = content.strip()
|
||||||
|
elif role == "assistant" and not assistant_text:
|
||||||
|
assistant_text = content.strip()
|
||||||
|
if user_text and assistant_text:
|
||||||
|
break
|
||||||
|
return user_text, assistant_text
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_generate_webui_title(
|
||||||
|
*,
|
||||||
|
sessions: SessionManager,
|
||||||
|
session_key: str,
|
||||||
|
provider: LLMProvider,
|
||||||
|
model: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Generate and persist a short title for WebUI-owned sessions only."""
|
||||||
|
session = sessions.get_or_create(session_key)
|
||||||
|
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||||
|
return False
|
||||||
|
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
|
||||||
|
return False
|
||||||
|
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
|
||||||
|
if isinstance(current_title, str) and current_title.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_text, assistant_text = _title_inputs(session)
|
||||||
|
if not user_text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"Generate a concise title for this chat.\n"
|
||||||
|
"Rules:\n"
|
||||||
|
"- Use the same language as the user when practical.\n"
|
||||||
|
"- 3 to 8 words.\n"
|
||||||
|
"- No quotes.\n"
|
||||||
|
"- No punctuation at the end.\n"
|
||||||
|
"- Return only the title.\n\n"
|
||||||
|
f"User: {truncate_text(user_text, 1_000)}"
|
||||||
|
)
|
||||||
|
if assistant_text:
|
||||||
|
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await provider.chat_with_retry(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You write short, neutral chat titles. "
|
||||||
|
"Return only the title text."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
tools=None,
|
||||||
|
model=model,
|
||||||
|
max_tokens=32,
|
||||||
|
temperature=0.2,
|
||||||
|
retry_mode="standard",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
title = clean_generated_title(response.content)
|
||||||
|
if not title or title.lower().startswith("error"):
|
||||||
|
return False
|
||||||
|
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
|
||||||
|
sessions.save(session)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_generate_webui_title_after_turn(
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
sessions: SessionManager,
|
||||||
|
session_key: str,
|
||||||
|
provider: LLMProvider,
|
||||||
|
model: str,
|
||||||
|
) -> bool:
|
||||||
|
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||||
|
return False
|
||||||
|
return await maybe_generate_webui_title(
|
||||||
|
sessions=sessions,
|
||||||
|
session_key=session_key,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
@@ -61,6 +61,7 @@ dependencies = [
|
|||||||
"openpyxl>=3.1.0,<4.0.0",
|
"openpyxl>=3.1.0,<4.0.0",
|
||||||
"python-pptx>=1.0.0,<2.0.0",
|
"python-pptx>=1.0.0,<2.0.0",
|
||||||
"filelock>=3.25.2",
|
"filelock>=3.25.2",
|
||||||
|
"boto3>=1.43.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -87,6 +87,42 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
|||||||
assert "Return exactly: OK" in user_content
|
assert "Return exactly: OK" in user_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None:
|
||||||
|
"""Sender ID should be included in runtime context when provided."""
|
||||||
|
workspace = _make_workspace(tmp_path)
|
||||||
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
|
messages = builder.build_messages(
|
||||||
|
history=[],
|
||||||
|
current_message="Return exactly: OK",
|
||||||
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
|
sender_id="user-12345",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_content = messages[-1]["content"]
|
||||||
|
assert isinstance(user_content, str)
|
||||||
|
assert "Sender ID: user-12345" in user_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_context_excludes_sender_id_when_not_provided(tmp_path) -> None:
|
||||||
|
"""Sender ID should not be present in runtime context when not provided."""
|
||||||
|
workspace = _make_workspace(tmp_path)
|
||||||
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
|
messages = builder.build_messages(
|
||||||
|
history=[],
|
||||||
|
current_message="Return exactly: OK",
|
||||||
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
|
sender_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_content = messages[-1]["content"]
|
||||||
|
assert isinstance(user_content, str)
|
||||||
|
assert "Sender ID:" not in user_content
|
||||||
|
|
||||||
|
|
||||||
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
|
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
|
||||||
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
|
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for structured tool-event progress metadata emitted by AgentLoop."""
|
"""Tests for structured tool-event progress metadata emitted by AgentLoop."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
@@ -130,11 +131,44 @@ class TestToolEventProgress:
|
|||||||
assert finish["result"] == "file.txt"
|
assert finish["result"] == "file.txt"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bus_progress_streams_provider_deltas_for_codex_style_provider(
|
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
|
||||||
self,
|
self,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Providers that opt in can stream content deltas through _progress messages."""
|
"""Non-streaming channels should get one final reply, not token progress spam."""
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.supports_progress_deltas = True
|
||||||
|
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[]))
|
||||||
|
provider.chat_stream_with_retry = AsyncMock()
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
|
||||||
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
await loop._dispatch(InboundMessage(
|
||||||
|
channel="whatsapp",
|
||||||
|
sender_id="u1",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="say hello",
|
||||||
|
))
|
||||||
|
|
||||||
|
outbound = []
|
||||||
|
while bus.outbound_size > 0:
|
||||||
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
|
assert [m.content for m in outbound] == ["Hello"]
|
||||||
|
assert not any(m.metadata.get("_progress") for m in outbound)
|
||||||
|
assert not any(m.metadata.get("_streamed") for m in outbound)
|
||||||
|
provider.chat_stream_with_retry.assert_not_awaited()
|
||||||
|
provider.chat_with_retry.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_channel_streams_provider_deltas_for_codex_style_provider(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Streaming channels still receive provider deltas through _stream_delta messages."""
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.supports_progress_deltas = True
|
provider.supports_progress_deltas = True
|
||||||
@@ -149,23 +183,34 @@ class TestToolEventProgress:
|
|||||||
provider.chat_with_retry = AsyncMock()
|
provider.chat_with_retry = AsyncMock()
|
||||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
|
||||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
|
|
||||||
await loop._dispatch(InboundMessage(
|
await loop._dispatch(InboundMessage(
|
||||||
channel="websocket",
|
channel="websocket",
|
||||||
sender_id="u1",
|
sender_id="u1",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
content="say hello",
|
content="say hello",
|
||||||
|
metadata={"_wants_stream": True},
|
||||||
))
|
))
|
||||||
|
|
||||||
outbound = []
|
outbound = []
|
||||||
while bus.outbound_size > 0:
|
while bus.outbound_size > 0:
|
||||||
outbound.append(await bus.consume_outbound())
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
progress = [m for m in outbound if m.metadata.get("_progress")]
|
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
|
||||||
final = [m for m in outbound if not m.metadata.get("_progress")]
|
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
|
||||||
|
final = [
|
||||||
|
m for m in outbound
|
||||||
|
if not m.metadata.get("_stream_delta")
|
||||||
|
and not m.metadata.get("_stream_end")
|
||||||
|
and not m.metadata.get("_turn_end")
|
||||||
|
]
|
||||||
|
|
||||||
assert [m.content for m in progress] == ["Hel", "lo"]
|
assert [m.content for m in deltas] == ["Hel", "lo"]
|
||||||
|
assert len(stream_end) == 1
|
||||||
assert final[-1].content == "Hello"
|
assert final[-1].content == "Hello"
|
||||||
|
assert final[-1].metadata.get("_streamed") is True
|
||||||
|
assert outbound[-1].metadata.get("_turn_end") is True
|
||||||
provider.chat_with_retry.assert_not_awaited()
|
provider.chat_with_retry.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -195,8 +240,12 @@ class TestToolEventProgress:
|
|||||||
loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
|
loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
|
||||||
loop.tools.execute = AsyncMock(return_value="ok")
|
loop.tools.execute = AsyncMock(return_value="ok")
|
||||||
|
|
||||||
|
streamed: list[str] = []
|
||||||
progress: list[tuple[str, bool, list[dict] | None]] = []
|
progress: list[tuple[str, bool, list[dict] | None]] = []
|
||||||
|
|
||||||
|
async def on_stream(delta: str) -> None:
|
||||||
|
streamed.append(delta)
|
||||||
|
|
||||||
async def on_progress(
|
async def on_progress(
|
||||||
content: str,
|
content: str,
|
||||||
*,
|
*,
|
||||||
@@ -205,12 +254,107 @@ class TestToolEventProgress:
|
|||||||
) -> None:
|
) -> None:
|
||||||
progress.append((content, tool_hint, tool_events))
|
progress.append((content, tool_hint, tool_events))
|
||||||
|
|
||||||
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
|
final_content, _, _, _, _ = await loop._run_agent_loop(
|
||||||
|
[],
|
||||||
|
on_progress=on_progress,
|
||||||
|
on_stream=on_stream,
|
||||||
|
)
|
||||||
|
|
||||||
assert final_content == "Done"
|
assert final_content == "Done"
|
||||||
assert [item[0] for item in progress[:3]] == [
|
assert streamed == ["I will", " inspect it."]
|
||||||
"I will",
|
assert progress[0][0] == 'custom_tool("foo.txt")'
|
||||||
" inspect it.",
|
|
||||||
'custom_tool("foo.txt")',
|
|
||||||
]
|
|
||||||
assert all(item[0] != "I will inspect it." for item in progress)
|
assert all(item[0] != "I will inspect it." for item in progress)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_websocket_dispatch_publishes_final_turn_end_marker(self, tmp_path: Path) -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
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="say hello",
|
||||||
|
))
|
||||||
|
|
||||||
|
outbound = []
|
||||||
|
while bus.outbound_size > 0:
|
||||||
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
|
assert outbound[-2].content == "Done"
|
||||||
|
assert (outbound[-2].metadata or {}).get("_turn_end") is not True
|
||||||
|
assert outbound[-1].content == ""
|
||||||
|
assert (outbound[-1].metadata or {}).get("_turn_end") is True
|
||||||
|
assert outbound[-1].chat_id == "chat1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
title_started = asyncio.Event()
|
||||||
|
release_title = asyncio.Event()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return LLMResponse(content="Done", tool_calls=[])
|
||||||
|
title_started.set()
|
||||||
|
await release_title.wait()
|
||||||
|
return LLMResponse(content="Generated title", tool_calls=[])
|
||||||
|
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
await asyncio.wait_for(loop._dispatch(InboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
sender_id="u1",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="say hello",
|
||||||
|
metadata={"webui": True},
|
||||||
|
)), timeout=0.5)
|
||||||
|
|
||||||
|
outbound = [await bus.consume_outbound(), await bus.consume_outbound()]
|
||||||
|
assert outbound[0].content == "Done"
|
||||||
|
assert (outbound[1].metadata or {}).get("_turn_end") is True
|
||||||
|
|
||||||
|
await asyncio.wait_for(title_started.wait(), timeout=0.5)
|
||||||
|
release_title.set()
|
||||||
|
session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
|
||||||
|
|
||||||
|
assert (session_updated.metadata or {}).get("_session_updated") is True
|
||||||
|
assert provider.chat_with_retry.await_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
await loop._dispatch(InboundMessage(
|
||||||
|
channel="slack",
|
||||||
|
sender_id="u1",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="say hello",
|
||||||
|
))
|
||||||
|
|
||||||
|
outbound = []
|
||||||
|
while bus.outbound_size > 0:
|
||||||
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
|
assert len(outbound) == 1
|
||||||
|
assert outbound[0].content == "Done"
|
||||||
|
assert (outbound[0].metadata or {}).get("_turn_end") is not True
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder
|
|||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
|
from nanobot.utils.webui_titles import (
|
||||||
|
WEBUI_SESSION_METADATA_KEY,
|
||||||
|
WEBUI_TITLE_METADATA_KEY,
|
||||||
|
maybe_generate_webui_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mk_loop() -> AgentLoop:
|
def _mk_loop() -> AgentLoop:
|
||||||
@@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop:
|
|||||||
def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.get_default_model.return_value = "test-model"
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title"))
|
||||||
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
|
||||||
|
loop = _make_full_loop(tmp_path)
|
||||||
|
loop.provider.chat_with_retry = AsyncMock(
|
||||||
|
return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop")
|
||||||
|
)
|
||||||
|
session = loop.sessions.get_or_create("websocket:chat-title")
|
||||||
|
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
|
||||||
|
session.add_message("user", "帮我优化一下 webui 的 sidebar")
|
||||||
|
session.add_message("assistant", "可以,我会先调整布局和视觉层级。")
|
||||||
|
loop.sessions.save(session)
|
||||||
|
|
||||||
|
generated = await maybe_generate_webui_title(
|
||||||
|
sessions=loop.sessions,
|
||||||
|
session_key="websocket:chat-title",
|
||||||
|
provider=loop.provider,
|
||||||
|
model=loop.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert generated is True
|
||||||
|
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
|
||||||
|
loop.provider.chat_with_retry.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None:
|
||||||
|
loop = _make_full_loop(tmp_path)
|
||||||
|
loop.provider.chat_with_retry = AsyncMock(
|
||||||
|
return_value=LLMResponse(content="Plain websocket title", finish_reason="stop")
|
||||||
|
)
|
||||||
|
session = loop.sessions.get_or_create("websocket:custom-client")
|
||||||
|
session.add_message("user", "hello from a custom websocket client")
|
||||||
|
loop.sessions.save(session)
|
||||||
|
|
||||||
|
generated = await maybe_generate_webui_title(
|
||||||
|
sessions=loop.sessions,
|
||||||
|
session_key="websocket:custom-client",
|
||||||
|
provider=loop.provider,
|
||||||
|
model=loop.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert generated is False
|
||||||
|
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
|
||||||
|
loop.provider.chat_with_retry.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:runtime-only")
|
session = Session(key="test:runtime-only")
|
||||||
@@ -727,6 +780,7 @@ def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) ->
|
|||||||
loop._set_tool_context(
|
loop._set_tool_context(
|
||||||
"slack",
|
"slack",
|
||||||
"C123",
|
"C123",
|
||||||
|
message_id="msg-123",
|
||||||
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
|
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
|
||||||
session_key="slack:C123:1700.42",
|
session_key="slack:C123:1700.42",
|
||||||
)
|
)
|
||||||
@@ -734,6 +788,7 @@ def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) ->
|
|||||||
spawn_tool = loop.tools.get("spawn")
|
spawn_tool = loop.tools.get("spawn")
|
||||||
assert spawn_tool is not None
|
assert spawn_tool is not None
|
||||||
assert spawn_tool._session_key.get() == "slack:C123:1700.42"
|
assert spawn_tool._session_key.get() == "slack:C123:1700.42"
|
||||||
|
assert spawn_tool._origin_message_id.get() == "msg-123"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -766,14 +821,17 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
|
|||||||
chat_id="slack:C123",
|
chat_id="slack:C123",
|
||||||
content="subagent result",
|
content="subagent result",
|
||||||
session_key_override="slack:C123:1700.42",
|
session_key_override="slack:C123:1700.42",
|
||||||
metadata={"subagent_task_id": "sub-1"},
|
metadata={"subagent_task_id": "sub-1", "origin_message_id": "msg-123"},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert outbound is not None
|
assert outbound is not None
|
||||||
assert outbound.channel == "slack"
|
assert outbound.channel == "slack"
|
||||||
assert outbound.chat_id == "C123"
|
assert outbound.chat_id == "C123"
|
||||||
assert outbound.metadata == {"slack": {"thread_ts": "1700.42"}}
|
assert outbound.metadata == {
|
||||||
|
"slack": {"thread_ts": "1700.42"},
|
||||||
|
"origin_message_id": "msg-123",
|
||||||
|
}
|
||||||
assert "thread question" in seen["initial_messages"][1]["content"]
|
assert "thread question" in seen["initial_messages"][1]["content"]
|
||||||
|
|
||||||
loop.sessions.invalidate("slack:C123:1700.42")
|
loop.sessions.invalidate("slack:C123:1700.42")
|
||||||
|
|||||||
@@ -4,27 +4,22 @@ These tests focus on the business logic behind the onboard wizard,
|
|||||||
without testing the interactive UI components.
|
without testing the interactive UI components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from nanobot.cli import onboard as onboard_wizard
|
from nanobot.cli import onboard as onboard_wizard
|
||||||
|
|
||||||
# Import functions to test
|
|
||||||
from nanobot.cli.commands import _merge_missing_defaults
|
from nanobot.cli.commands import _merge_missing_defaults
|
||||||
from nanobot.cli.onboard import (
|
from nanobot.cli.onboard import (
|
||||||
_BACK_PRESSED,
|
_BACK_PRESSED,
|
||||||
_configure_pydantic_model,
|
_configure_pydantic_model,
|
||||||
_format_value,
|
_format_value,
|
||||||
|
_get_constraint_hint,
|
||||||
_get_field_display_name,
|
_get_field_display_name,
|
||||||
_get_field_type_info,
|
_get_field_type_info,
|
||||||
_get_constraint_hint,
|
|
||||||
_input_text,
|
_input_text,
|
||||||
_validate_field_constraint,
|
|
||||||
run_onboard,
|
run_onboard,
|
||||||
)
|
)
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
@@ -640,8 +635,8 @@ class TestValidateFieldConstraint:
|
|||||||
|
|
||||||
def test_real_send_max_retries_field(self):
|
def test_real_send_max_retries_field(self):
|
||||||
"""Validate against the actual ChannelsConfig.send_max_retries field."""
|
"""Validate against the actual ChannelsConfig.send_max_retries field."""
|
||||||
from nanobot.config.schema import ChannelsConfig
|
|
||||||
from nanobot.cli.onboard import _validate_field_constraint
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
from nanobot.config.schema import ChannelsConfig
|
||||||
|
|
||||||
field_info = ChannelsConfig.model_fields["send_max_retries"]
|
field_info = ChannelsConfig.model_fields["send_max_retries"]
|
||||||
assert _validate_field_constraint(3, field_info) is None
|
assert _validate_field_constraint(3, field_info) is None
|
||||||
@@ -833,12 +828,11 @@ class TestMainMenuUpdate:
|
|||||||
|
|
||||||
def test_main_menu_dispatch_includes_channel_common(self):
|
def test_main_menu_dispatch_includes_channel_common(self):
|
||||||
"""Main menu dispatch should route [H] to Channel Common."""
|
"""Main menu dispatch should route [H] to Channel Common."""
|
||||||
from nanobot.cli.onboard import run_onboard
|
|
||||||
|
|
||||||
# We verify by checking the dispatch table is set up correctly
|
# We verify by checking the dispatch table is set up correctly
|
||||||
# The menu items are defined inline in run_onboard, so we test
|
# The menu items are defined inline in run_onboard, so we test
|
||||||
# that _configure_general_settings handles the new sections.
|
# that _configure_general_settings handles the new sections.
|
||||||
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
|
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
|
||||||
|
|
||||||
assert "Channel Common" in _SETTINGS_SECTIONS
|
assert "Channel Common" in _SETTINGS_SECTIONS
|
||||||
assert "Channel Common" in _SETTINGS_GETTER
|
assert "Channel Common" in _SETTINGS_GETTER
|
||||||
@@ -846,7 +840,7 @@ class TestMainMenuUpdate:
|
|||||||
|
|
||||||
def test_main_menu_dispatch_includes_api_server(self):
|
def test_main_menu_dispatch_includes_api_server(self):
|
||||||
"""Main menu dispatch should route [I] to API Server."""
|
"""Main menu dispatch should route [I] to API Server."""
|
||||||
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
|
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
|
||||||
|
|
||||||
assert "API Server" in _SETTINGS_SECTIONS
|
assert "API Server" in _SETTINGS_SECTIONS
|
||||||
assert "API Server" in _SETTINGS_GETTER
|
assert "API Server" in _SETTINGS_GETTER
|
||||||
@@ -960,3 +954,464 @@ class TestMainMenuUpdate:
|
|||||||
|
|
||||||
assert result.should_save is True
|
assert result.should_save is True
|
||||||
assert pause_called["n"] == 1
|
assert pause_called["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputTextEmptyString:
|
||||||
|
"""Tests for _input_text empty-string handling bug fix."""
|
||||||
|
|
||||||
|
def test_empty_string_returned_not_none(self, monkeypatch):
|
||||||
|
"""_input_text should return empty string, not None, when user enters ''."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_get_questionary",
|
||||||
|
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _input_text("Name", "old", "str")
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_none_still_returns_none(self, monkeypatch):
|
||||||
|
"""_input_text should return None when questionary returns None."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_get_questionary",
|
||||||
|
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: None)),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _input_text("Name", "old", "str")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsStrOrNone:
|
||||||
|
"""Tests for _is_str_or_none helper."""
|
||||||
|
|
||||||
|
def test_str_or_none_true(self):
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(str | None) is True
|
||||||
|
|
||||||
|
def test_optional_str_true(self):
|
||||||
|
from typing import Optional
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(Optional[str]) is True
|
||||||
|
|
||||||
|
def test_str_only_false(self):
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(str) is False
|
||||||
|
|
||||||
|
def test_int_or_none_false(self):
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(int | None) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigurePydanticModelEmptyString:
|
||||||
|
"""Tests that optional string fields are cleared when empty string is entered."""
|
||||||
|
|
||||||
|
def test_optional_str_empty_string_becomes_none(self, monkeypatch):
|
||||||
|
"""Entering '' for an optional str field should set it to None."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
model = M(api_key="secret")
|
||||||
|
|
||||||
|
call_count = {"select": 0}
|
||||||
|
|
||||||
|
def fake_select(_prompt, choices, default=None):
|
||||||
|
call_count["select"] += 1
|
||||||
|
# First call: select the api_key field, then Done
|
||||||
|
if call_count["select"] == 1:
|
||||||
|
for c in choices:
|
||||||
|
if "Api Key" in c:
|
||||||
|
return c
|
||||||
|
return choices[0]
|
||||||
|
return "[Done]"
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_config_panel", lambda *a, **kw: None)
|
||||||
|
# Simulate user entering empty string
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_input_with_existing", lambda *a, **kw: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _configure_pydantic_model(model, "Test")
|
||||||
|
assert result is not None
|
||||||
|
assert result.api_key is None
|
||||||
|
|
||||||
|
def test_required_str_empty_string_kept(self, monkeypatch):
|
||||||
|
"""Entering '' for a required str field should keep the empty string."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
api_key: str = ""
|
||||||
|
|
||||||
|
model = M(api_key="secret")
|
||||||
|
|
||||||
|
call_count = {"select": 0}
|
||||||
|
|
||||||
|
def fake_select(_prompt, choices, default=None):
|
||||||
|
call_count["select"] += 1
|
||||||
|
if call_count["select"] == 1:
|
||||||
|
for c in choices:
|
||||||
|
if "Api Key" in c:
|
||||||
|
return c
|
||||||
|
return choices[0]
|
||||||
|
return "[Done]"
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_config_panel", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_input_with_existing", lambda *a, **kw: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _configure_pydantic_model(model, "Test")
|
||||||
|
assert result is not None
|
||||||
|
assert result.api_key == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelPresetWizard:
|
||||||
|
"""Tests for model preset CRUD in the onboard wizard."""
|
||||||
|
|
||||||
|
def test_sync_preset_cache(self):
|
||||||
|
"""_sync_preset_cache should populate the module-level cache."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _sync_preset_cache
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {
|
||||||
|
"fast": ModelPresetConfig(model="gpt-4.1-mini"),
|
||||||
|
"power": ModelPresetConfig(model="gpt-4.1"),
|
||||||
|
}
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
assert _MODEL_PRESET_CACHE == {"fast", "power"}
|
||||||
|
|
||||||
|
def test_model_preset_add(self, monkeypatch):
|
||||||
|
"""_configure_model_presets should add a new preset."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"[+] Add new preset",
|
||||||
|
"my-preset",
|
||||||
|
"<- Back",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_text(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_configure(*_model, **_kwargs):
|
||||||
|
return ModelPresetConfig(model="gpt-test", temperature=0.5)
|
||||||
|
|
||||||
|
# _select_with_back returns a string/sentinel directly (not a prompt object)
|
||||||
|
def fake_select_with_back(*_args, **_kwargs):
|
||||||
|
return next(responses)
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select, text=fake_text))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fake_configure)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
|
||||||
|
|
||||||
|
_configure_model_presets(config)
|
||||||
|
|
||||||
|
assert "my-preset" in config.model_presets
|
||||||
|
assert config.model_presets["my-preset"].model == "gpt-test"
|
||||||
|
assert config.model_presets["my-preset"].temperature == 0.5
|
||||||
|
|
||||||
|
def test_model_preset_delete(self, monkeypatch):
|
||||||
|
"""_configure_model_presets should delete an existing preset."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {"old": ModelPresetConfig(model="x")}
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.add("old")
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"old (x)",
|
||||||
|
"Delete",
|
||||||
|
True,
|
||||||
|
"<- Back",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_confirm(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_select_with_back(*_args, **_kwargs):
|
||||||
|
return next(responses)
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select, confirm=fake_confirm))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
|
||||||
|
|
||||||
|
_configure_model_presets(config)
|
||||||
|
|
||||||
|
assert "old" not in config.model_presets
|
||||||
|
assert "old" not in _MODEL_PRESET_CACHE
|
||||||
|
|
||||||
|
def test_model_preset_field_handler(self, monkeypatch):
|
||||||
|
"""_handle_model_preset_field should set a preset name from choices."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.update({"fast", "power"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "fast")
|
||||||
|
|
||||||
|
defaults = AgentDefaults()
|
||||||
|
_handle_model_preset_field(defaults, "model_preset", "Model Preset", None)
|
||||||
|
assert defaults.model_preset == "fast"
|
||||||
|
|
||||||
|
def test_model_preset_field_handler_clear(self, monkeypatch):
|
||||||
|
"""_handle_model_preset_field should clear preset when (clear/unset) chosen."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.add("fast")
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "(clear/unset)")
|
||||||
|
|
||||||
|
defaults = AgentDefaults(model_preset="fast")
|
||||||
|
_handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")
|
||||||
|
assert defaults.model_preset is None
|
||||||
|
|
||||||
|
def test_main_menu_dispatch_includes_model_presets(self):
|
||||||
|
"""run_onboard dispatch should route [M] to Model Presets."""
|
||||||
|
from nanobot.cli.onboard import _configure_model_presets
|
||||||
|
|
||||||
|
# The function should be importable and callable
|
||||||
|
assert callable(_configure_model_presets)
|
||||||
|
|
||||||
|
def test_run_onboard_model_presets_edit(self, monkeypatch):
|
||||||
|
"""run_onboard should handle [M] Model Presets correctly."""
|
||||||
|
initial_config = Config()
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"[M] Model Presets",
|
||||||
|
KeyboardInterrupt(),
|
||||||
|
"[S] Save and Exit",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
preset_mutated = {"n": 0}
|
||||||
|
|
||||||
|
def fake_configure_model_presets(config):
|
||||||
|
preset_mutated["n"] += 1
|
||||||
|
# Mutate config so unsaved changes are detected
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
config.model_presets["test"] = ModelPresetConfig(model="x")
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_configure_model_presets", fake_configure_model_presets)
|
||||||
|
|
||||||
|
result = run_onboard(initial_config=initial_config)
|
||||||
|
|
||||||
|
assert result.should_save is True
|
||||||
|
assert preset_mutated["n"] == 1
|
||||||
|
|
||||||
|
def test_summary_shows_model_presets(self, monkeypatch):
|
||||||
|
"""_show_summary should include model presets panel."""
|
||||||
|
from nanobot.cli.onboard import _show_summary
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {
|
||||||
|
"fast": ModelPresetConfig(model="gpt-4.1-mini"),
|
||||||
|
}
|
||||||
|
|
||||||
|
panels = []
|
||||||
|
|
||||||
|
def fake_print_summary(rows, title):
|
||||||
|
panels.append(title)
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", fake_print_summary)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_get_provider_names", lambda: {})
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {})
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_pause", lambda: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
_show_summary(config)
|
||||||
|
|
||||||
|
assert "Model Presets" in panels
|
||||||
|
|
||||||
|
def test_provider_field_handler(self, monkeypatch):
|
||||||
|
"""_handle_provider_field should set a provider from the registry list."""
|
||||||
|
from nanobot.cli.onboard import _handle_provider_field
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_get_provider_names", lambda: {"moonshot": "Moonshot", "openai": "OpenAI"}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "moonshot")
|
||||||
|
|
||||||
|
preset = ModelPresetConfig(model="x")
|
||||||
|
_handle_provider_field(preset, "provider", "Provider", "auto")
|
||||||
|
assert preset.provider == "moonshot"
|
||||||
|
|
||||||
|
def test_provider_field_handler_back_pressed(self, monkeypatch):
|
||||||
|
"""_handle_provider_field should not modify value when back is pressed."""
|
||||||
|
from nanobot.cli.onboard import _BACK_PRESSED, _handle_provider_field
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_get_provider_names", lambda: {"moonshot": "Moonshot"}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: _BACK_PRESSED)
|
||||||
|
|
||||||
|
preset = ModelPresetConfig(model="x", provider="auto")
|
||||||
|
_handle_provider_field(preset, "provider", "Provider", "auto")
|
||||||
|
assert preset.provider == "auto"
|
||||||
|
|
||||||
|
def test_fallback_presets_add_preset_and_done(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should add a preset and save on Done."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.update({"fast", "power"})
|
||||||
|
|
||||||
|
responses = iter(["[+] Add preset", "[Done]"])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "fast")
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults()
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", [])
|
||||||
|
assert defaults.fallback_presets == ["fast"]
|
||||||
|
|
||||||
|
def test_fallback_presets_back_preserves_existing(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should not modify value on Back."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.add("fast")
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt("<- Back")
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults(fallback_presets=["existing"])
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", ["existing"])
|
||||||
|
assert defaults.fallback_presets == ["existing"]
|
||||||
|
|
||||||
|
def test_fallback_presets_remove_last(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should remove last item."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
|
responses = iter(["[-] Remove last", "[Done]"])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults(fallback_presets=["a", "b"])
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", ["a", "b"])
|
||||||
|
assert defaults.fallback_presets == ["a"]
|
||||||
|
|
||||||
|
def test_fallback_presets_no_presets_shows_warning(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should warn when no presets exist."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
|
responses = iter(["[+] Add preset", "[Done]"])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select, press_any_key_to_continue=lambda: FakePrompt(None)))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults()
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", [])
|
||||||
|
assert defaults.fallback_presets == []
|
||||||
|
|||||||
+299
-16
@@ -313,21 +313,33 @@ async def test_runner_returns_structured_tool_error():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
|
async def test_runner_does_not_abort_on_workspace_violation_anymore():
|
||||||
|
"""v2 behavior: workspace-bound rejections are *soft* tool errors.
|
||||||
|
|
||||||
|
Previously (PR #3493) any workspace boundary error became a fatal
|
||||||
|
RuntimeError that aborted the turn. That silently killed legitimate
|
||||||
|
workspace commands once the heuristic guard misfired (#3599 #3605), so
|
||||||
|
we now hand the error back to the LLM as a recoverable tool result and
|
||||||
|
rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
|
||||||
|
"""
|
||||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||||
LLMResponse(
|
LLMResponse(
|
||||||
content="working",
|
content="trying outside",
|
||||||
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})],
|
tool_calls=[ToolCallRequest(
|
||||||
|
id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"},
|
||||||
|
)],
|
||||||
),
|
),
|
||||||
LLMResponse(content="should not continue", tool_calls=[]),
|
LLMResponse(content="ok, telling the user instead", tool_calls=[]),
|
||||||
])
|
])
|
||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
tools.execute = AsyncMock(
|
tools.execute = AsyncMock(
|
||||||
side_effect=PermissionError("Path /tmp/outside.md is outside allowed directory /workspace")
|
side_effect=PermissionError(
|
||||||
|
"Path /tmp/outside.md is outside allowed directory /workspace"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
runner = AgentRunner(provider)
|
||||||
@@ -336,20 +348,202 @@ async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
|
|||||||
initial_messages=[],
|
initial_messages=[],
|
||||||
tools=tools,
|
tools=tools,
|
||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=2,
|
max_iterations=3,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert provider.chat_with_retry.await_count == 1
|
assert provider.chat_with_retry.await_count == 2, (
|
||||||
assert result.stop_reason == "tool_error"
|
"workspace violation must NOT short-circuit the loop"
|
||||||
assert "outside allowed directory" in (result.error or "")
|
)
|
||||||
assert result.tool_events == [
|
assert result.stop_reason != "tool_error"
|
||||||
{
|
assert result.error is None
|
||||||
"name": "read_file",
|
assert result.final_content == "ok, telling the user instead"
|
||||||
"status": "error",
|
assert result.tool_events and result.tool_events[0]["status"] == "error"
|
||||||
"detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace",
|
# Detail still carries the workspace_violation breadcrumb for telemetry,
|
||||||
}
|
# but the runner did not raise.
|
||||||
|
assert "workspace_violation" in result.tool_events[0]["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_ssrf_violation_recognizes_private_url_blocks():
|
||||||
|
"""SSRF rejections are classified separately from workspace boundaries."""
|
||||||
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
|
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
|
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
|
||||||
|
assert AgentRunner._is_ssrf_violation(
|
||||||
|
"URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2"
|
||||||
|
) is True
|
||||||
|
|
||||||
|
# Workspace-bound markers are NOT classified as SSRF.
|
||||||
|
assert AgentRunner._is_ssrf_violation(
|
||||||
|
"Error: Command blocked by safety guard (path outside working dir)"
|
||||||
|
) is False
|
||||||
|
assert AgentRunner._is_ssrf_violation(
|
||||||
|
"Path /tmp/x is outside allowed directory /ws"
|
||||||
|
) is False
|
||||||
|
# Deny / allowlist filter messages stay non-fatal too.
|
||||||
|
assert AgentRunner._is_ssrf_violation(
|
||||||
|
"Error: Command blocked by deny pattern filter"
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
|
||||||
|
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
|
||||||
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||||
|
LLMResponse(
|
||||||
|
content="curl-ing metadata",
|
||||||
|
tool_calls=[ToolCallRequest(
|
||||||
|
id="call_ssrf",
|
||||||
|
name="exec",
|
||||||
|
arguments={"command": "curl http://169.254.169.254"},
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
LLMResponse(
|
||||||
|
content="I cannot access that private URL. Please share local files.",
|
||||||
|
tool_calls=[],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
tools.execute = AsyncMock(return_value=(
|
||||||
|
"Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
|
))
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=3,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert provider.chat_with_retry.await_count == 2
|
||||||
|
assert result.stop_reason == "completed"
|
||||||
|
assert result.error is None
|
||||||
|
assert result.final_content == "I cannot access that private URL. Please share local files."
|
||||||
|
assert result.tool_events and result.tool_events[0]["detail"].startswith("ssrf_violation:")
|
||||||
|
tool_messages = [m for m in result.messages if m.get("role") == "tool"]
|
||||||
|
assert tool_messages
|
||||||
|
assert "non-bypassable security boundary" in tool_messages[0]["content"]
|
||||||
|
assert "Do not retry" in tool_messages[0]["content"]
|
||||||
|
assert "tools.ssrfWhitelist" in tool_messages[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
|
||||||
|
"""Reporter scenario for #3599 / #3605 -- guard hit, agent recovers.
|
||||||
|
|
||||||
|
The shell `_guard_command` heuristic fires on `2>/dev/null`-style
|
||||||
|
redirects and other shell idioms. Before v2 that abort'd the whole
|
||||||
|
turn (silent hang on Telegram per #3605); now the LLM gets the soft
|
||||||
|
error back and can finalize on the next iteration.
|
||||||
|
"""
|
||||||
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
|
provider = MagicMock()
|
||||||
|
captured_second_call: list[dict] = []
|
||||||
|
|
||||||
|
async def chat_with_retry(*, messages, **kwargs):
|
||||||
|
if provider.chat_with_retry.await_count == 1:
|
||||||
|
return LLMResponse(
|
||||||
|
content="trying noisy cleanup",
|
||||||
|
tool_calls=[ToolCallRequest(
|
||||||
|
id="call_blocked",
|
||||||
|
name="exec",
|
||||||
|
arguments={"command": "rm scratch.txt 2>/dev/null"},
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
captured_second_call[:] = list(messages)
|
||||||
|
return LLMResponse(content="recovered final answer", tool_calls=[])
|
||||||
|
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
tools.execute = AsyncMock(
|
||||||
|
return_value="Error: Command blocked by safety guard (path outside working dir)"
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=3,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert provider.chat_with_retry.await_count == 2, (
|
||||||
|
"guard hit must NOT short-circuit the loop -- LLM should get a second turn"
|
||||||
|
)
|
||||||
|
assert result.stop_reason != "tool_error"
|
||||||
|
assert result.error is None
|
||||||
|
assert result.final_content == "recovered final answer"
|
||||||
|
assert result.tool_events and result.tool_events[0]["status"] == "error"
|
||||||
|
# v2: detail keeps the breadcrumb but the runner did not raise.
|
||||||
|
assert "workspace_violation" in result.tool_events[0]["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_throttles_repeated_workspace_bypass_attempts():
|
||||||
|
"""#3493 motivation: stop the LLM bypass loop without aborting the turn.
|
||||||
|
|
||||||
|
LLM keeps switching tools (read_file -> exec cat -> python -c open(...))
|
||||||
|
against the same outside path. After the soft retry budget is exhausted
|
||||||
|
the runner replaces the tool result with a hard "stop trying" message
|
||||||
|
so the model finally gives up and surfaces the boundary to the user.
|
||||||
|
"""
|
||||||
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
|
bypass_attempts = [
|
||||||
|
ToolCallRequest(
|
||||||
|
id=f"a{i}", name="exec",
|
||||||
|
arguments={"command": f"cat /Users/x/Downloads/01.md # try {i}"},
|
||||||
|
)
|
||||||
|
for i in range(4)
|
||||||
]
|
]
|
||||||
|
responses: list[LLMResponse] = [
|
||||||
|
LLMResponse(content=f"try {i}", tool_calls=[bypass_attempts[i]])
|
||||||
|
for i in range(4)
|
||||||
|
]
|
||||||
|
responses.append(LLMResponse(content="ok telling user", tool_calls=[]))
|
||||||
|
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=responses)
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
tools.execute = AsyncMock(
|
||||||
|
return_value="Error: Command blocked by safety guard (path outside working dir)"
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=10,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
# All 4 bypass attempts surface to the LLM (no fatal abort), and the
|
||||||
|
# runner finally completes once the LLM stops asking.
|
||||||
|
assert result.stop_reason != "tool_error"
|
||||||
|
assert result.error is None
|
||||||
|
assert result.final_content == "ok telling user"
|
||||||
|
# The third+ attempts must have been escalated -- look at the events.
|
||||||
|
escalated = [
|
||||||
|
ev for ev in result.tool_events
|
||||||
|
if ev["status"] == "error"
|
||||||
|
and ev["detail"].startswith("workspace_violation_escalated:")
|
||||||
|
]
|
||||||
|
assert escalated, (
|
||||||
|
"expected at least one escalated workspace_violation event, got: "
|
||||||
|
f"{result.tool_events}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -449,7 +643,7 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path):
|
|||||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.utils.helpers.logger.warning",
|
"nanobot.utils.helpers.logger.exception",
|
||||||
lambda message, *args: warnings.append(message.format(*args)),
|
lambda message, *args: warnings.append(message.format(*args)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -830,6 +1024,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
|
|||||||
ToolCallRequest(id="rw1", name="write_a", arguments={}),
|
ToolCallRequest(id="rw1", name="write_a", arguments={}),
|
||||||
],
|
],
|
||||||
{},
|
{},
|
||||||
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert shared_events[0:2] == ["start:read_a", "start:read_b"]
|
assert shared_events[0:2] == ["start:read_a", "start:read_b"]
|
||||||
@@ -874,6 +1069,7 @@ async def test_runner_does_not_batch_exclusive_read_only_tools():
|
|||||||
ToolCallRequest(id="ro2", name="read_b", arguments={}),
|
ToolCallRequest(id="ro2", name="read_b", arguments={}),
|
||||||
],
|
],
|
||||||
{},
|
{},
|
||||||
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert shared_events[0] == "start:read_a"
|
assert shared_events[0] == "start:read_a"
|
||||||
@@ -972,6 +1168,48 @@ async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp
|
|||||||
assert endings == [False]
|
assert endings == [False]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loop_stream_filter_hides_partial_trailing_think_prefix(tmp_path):
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
deltas: list[str] = []
|
||||||
|
|
||||||
|
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||||
|
await on_content_delta("Hello <thin")
|
||||||
|
await on_content_delta("k>hidden</think>World")
|
||||||
|
return LLMResponse(content="Hello <think>hidden</think>World", tool_calls=[], usage={})
|
||||||
|
|
||||||
|
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
||||||
|
|
||||||
|
async def on_stream(delta: str) -> None:
|
||||||
|
deltas.append(delta)
|
||||||
|
|
||||||
|
final_content, _, _, _, _ = await loop._run_agent_loop([], on_stream=on_stream)
|
||||||
|
|
||||||
|
assert final_content == "Hello World"
|
||||||
|
assert deltas == ["Hello", " World"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loop_stream_filter_hides_complete_trailing_think_tag(tmp_path):
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
deltas: list[str] = []
|
||||||
|
|
||||||
|
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||||
|
await on_content_delta("Hello <think>")
|
||||||
|
await on_content_delta("hidden</think>World")
|
||||||
|
return LLMResponse(content="Hello <think>hidden</think>World", tool_calls=[], usage={})
|
||||||
|
|
||||||
|
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
||||||
|
|
||||||
|
async def on_stream(delta: str) -> None:
|
||||||
|
deltas.append(delta)
|
||||||
|
|
||||||
|
final_content, _, _, _, _ = await loop._run_agent_loop([], on_stream=on_stream)
|
||||||
|
|
||||||
|
assert final_content == "Hello World"
|
||||||
|
assert deltas == ["Hello", " World"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_loop_retries_think_only_final_response(tmp_path):
|
async def test_loop_retries_think_only_final_response(tmp_path):
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
@@ -1059,6 +1297,51 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
|
|||||||
"_streamed must not be set when stop_reason is error"
|
"_streamed must not be set when stop_reason is error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
tool_call_resp = LLMResponse(
|
||||||
|
content="checking metadata",
|
||||||
|
tool_calls=[ToolCallRequest(
|
||||||
|
id="call_ssrf",
|
||||||
|
name="exec",
|
||||||
|
arguments={"command": "curl http://169.254.169.254/latest/meta-data/"},
|
||||||
|
)],
|
||||||
|
usage={},
|
||||||
|
)
|
||||||
|
provider.chat_stream_with_retry = AsyncMock(side_effect=[
|
||||||
|
tool_call_resp,
|
||||||
|
LLMResponse(
|
||||||
|
content="I cannot access private URLs. Please share the local file.",
|
||||||
|
tool_calls=[],
|
||||||
|
usage={},
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
loop.tools.prepare_call = MagicMock(return_value=(None, {}, None))
|
||||||
|
loop.tools.execute = AsyncMock(return_value=(
|
||||||
|
"Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
|
))
|
||||||
|
|
||||||
|
result = await loop._process_message(
|
||||||
|
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="hi"),
|
||||||
|
on_stream=AsyncMock(),
|
||||||
|
on_stream_end=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.content == "I cannot access private URLs. Please share the local file."
|
||||||
|
assert result.metadata.get("_streamed") is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
|
async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Tests for provider progress delta routing in the shared runner."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
|
|
||||||
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_can_disable_provider_progress_delta_streaming():
|
||||||
|
"""AgentLoop disables token progress streaming for non-streaming channels."""
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.supports_progress_deltas = True
|
||||||
|
provider.chat_with_retry = AsyncMock(
|
||||||
|
return_value=LLMResponse(content="done", tool_calls=[], usage={})
|
||||||
|
)
|
||||||
|
provider.chat_stream_with_retry = AsyncMock()
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
progress_cb = AsyncMock()
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[
|
||||||
|
{"role": "system", "content": "system"},
|
||||||
|
{"role": "user", "content": "hi"},
|
||||||
|
],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=1,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
progress_callback=progress_cb,
|
||||||
|
stream_progress_deltas=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.final_content == "done"
|
||||||
|
provider.chat_with_retry.assert_awaited_once()
|
||||||
|
provider.chat_stream_with_retry.assert_not_awaited()
|
||||||
|
progress_cb.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_streams_provider_progress_deltas_by_default():
|
||||||
|
"""Direct runner users keep the existing opt-in provider progress behavior."""
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.supports_progress_deltas = True
|
||||||
|
|
||||||
|
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||||
|
await on_content_delta("he")
|
||||||
|
await on_content_delta("llo")
|
||||||
|
return LLMResponse(content="hello", tool_calls=[], usage={})
|
||||||
|
|
||||||
|
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||||
|
provider.chat_with_retry = AsyncMock()
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
progress_cb = AsyncMock()
|
||||||
|
|
||||||
|
runner = AgentRunner(provider)
|
||||||
|
result = await runner.run(AgentRunSpec(
|
||||||
|
initial_messages=[
|
||||||
|
{"role": "system", "content": "system"},
|
||||||
|
{"role": "user", "content": "hi"},
|
||||||
|
],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=1,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
progress_callback=progress_cb,
|
||||||
|
))
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# tests/agent/test_self_model_preset.py
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.config.schema import ModelPresetConfig, MyToolConfig, ToolsConfig
|
||||||
|
from nanobot.providers.base import GenerationSettings
|
||||||
|
|
||||||
|
|
||||||
|
def _make_loop(presets: dict | None = None) -> tuple[AgentLoop, Any]:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation = GenerationSettings(temperature=0.1, max_tokens=8192)
|
||||||
|
|
||||||
|
def _factory(name: str):
|
||||||
|
preset = (presets or {}).get(name)
|
||||||
|
if preset:
|
||||||
|
new_provider = MagicMock()
|
||||||
|
new_provider.generation = GenerationSettings(
|
||||||
|
temperature=preset.temperature,
|
||||||
|
max_tokens=preset.max_tokens,
|
||||||
|
reasoning_effort=preset.reasoning_effort,
|
||||||
|
)
|
||||||
|
return new_provider
|
||||||
|
return provider
|
||||||
|
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=MagicMock(),
|
||||||
|
provider=provider,
|
||||||
|
workspace=Path("/tmp/test"),
|
||||||
|
model="test-model",
|
||||||
|
context_window_tokens=65536,
|
||||||
|
model_presets=presets or {},
|
||||||
|
provider_factory=_factory,
|
||||||
|
tools_config=ToolsConfig(my=MyToolConfig(allow_set=True)),
|
||||||
|
)
|
||||||
|
tool = loop.tools.get("my")
|
||||||
|
return loop, tool
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_model_preset_updates_all_fields() -> None:
|
||||||
|
presets = {
|
||||||
|
"gpt5": ModelPresetConfig(
|
||||||
|
model="gpt-5",
|
||||||
|
provider="openai",
|
||||||
|
max_tokens=16384,
|
||||||
|
context_window_tokens=128000,
|
||||||
|
temperature=0.2,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
loop, tool = _make_loop(presets)
|
||||||
|
await tool.execute(action="set", key="model_preset", value="gpt5")
|
||||||
|
|
||||||
|
assert loop.model == "gpt-5"
|
||||||
|
assert loop.context_window_tokens == 128000
|
||||||
|
assert loop.provider.generation.temperature == 0.2
|
||||||
|
assert loop.provider.generation.max_tokens == 16384
|
||||||
|
assert loop._active_preset == "gpt5"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_model_preset_unknown_returns_error() -> None:
|
||||||
|
loop, tool = _make_loop({})
|
||||||
|
result = await tool.execute(action="set", key="model_preset", value="nope")
|
||||||
|
|
||||||
|
assert "Error" in result or "not found" in result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_model_preset_shows_current() -> None:
|
||||||
|
presets = {"gpt5": ModelPresetConfig(model="gpt-5", provider="openai")}
|
||||||
|
loop, tool = _make_loop(presets)
|
||||||
|
await tool.execute(action="set", key="model_preset", value="gpt5")
|
||||||
|
result = await tool.execute(action="check", key="model_preset")
|
||||||
|
|
||||||
|
assert "gpt5" in result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_model_presets_shows_available() -> None:
|
||||||
|
presets = {
|
||||||
|
"gpt5": ModelPresetConfig(model="gpt-5", provider="openai"),
|
||||||
|
"ds": ModelPresetConfig(model="deepseek-chat", provider="deepseek"),
|
||||||
|
}
|
||||||
|
loop, tool = _make_loop(presets)
|
||||||
|
result = await tool.execute(action="check", key="model_presets")
|
||||||
|
|
||||||
|
assert "gpt5" in result
|
||||||
|
assert "ds" in result
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_orphans(history: list[dict]) -> None:
|
def _assert_no_orphans(history: list[dict]) -> None:
|
||||||
@@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_sessions_includes_metadata_title(tmp_path):
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
session = manager.get_or_create("websocket:chat-title")
|
||||||
|
session.metadata["title"] = "自动生成标题"
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
rows = manager.list_sessions()
|
||||||
|
|
||||||
|
assert rows[0]["key"] == "websocket:chat-title"
|
||||||
|
assert rows[0]["title"] == "自动生成标题"
|
||||||
|
|
||||||
|
|
||||||
# --- Original regression test (from PR 2075) ---
|
# --- Original regression test (from PR 2075) ---
|
||||||
|
|
||||||
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
||||||
@@ -180,6 +192,7 @@ def test_get_history_preserves_reasoning_content():
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "done",
|
"content": "done",
|
||||||
"reasoning_content": "hidden chain of thought",
|
"reasoning_content": "hidden chain of thought",
|
||||||
|
"thinking_blocks": [{"type": "thinking", "thinking": "hidden chain of thought", "signature": "sig"}],
|
||||||
})
|
})
|
||||||
|
|
||||||
history = session.get_history(max_messages=500)
|
history = session.get_history(max_messages=500)
|
||||||
@@ -190,6 +203,11 @@ def test_get_history_preserves_reasoning_content():
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "done",
|
"content": "done",
|
||||||
"reasoning_content": "hidden chain of thought",
|
"reasoning_content": "hidden chain of thought",
|
||||||
|
"thinking_blocks": [{
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": "hidden chain of thought",
|
||||||
|
"signature": "sig",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ def _tc(name: str, args) -> ToolCallRequest:
|
|||||||
return ToolCallRequest(id="c1", name=name, arguments=args)
|
return ToolCallRequest(id="c1", name=name, arguments=args)
|
||||||
|
|
||||||
|
|
||||||
def _hint(calls):
|
def _hint(calls, max_length=40):
|
||||||
"""Shortcut for format_tool_hints."""
|
"""Shortcut for format_tool_hints."""
|
||||||
return format_tool_hints(calls)
|
return format_tool_hints(calls, max_length=max_length)
|
||||||
|
|
||||||
|
|
||||||
class TestToolHintKnownTools:
|
class TestToolHintKnownTools:
|
||||||
@@ -254,3 +254,59 @@ class TestToolHintMixedFolding:
|
|||||||
assert "\u00d7" not in result
|
assert "\u00d7" not in result
|
||||||
parts = result.split(", ")
|
parts = result.split(", ")
|
||||||
assert len(parts) == 5
|
assert len(parts) == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolHintMaxLength:
|
||||||
|
"""Test max_length parameter controls truncation of tool hints."""
|
||||||
|
|
||||||
|
def test_exec_default_truncates_at_40(self):
|
||||||
|
cmd = "cd /very/long/path/to/some/project && npm run build && npm test"
|
||||||
|
result = _hint([_tc("exec", {"command": cmd})], max_length=40)
|
||||||
|
assert len(result) <= 50 # "$ " prefix + 40 + ellipsis
|
||||||
|
assert "\u2026" in result
|
||||||
|
|
||||||
|
def test_exec_larger_max_length_shows_more(self):
|
||||||
|
cmd = "cd /very/long/path/to/some/project && npm run build && npm test"
|
||||||
|
short = _hint([_tc("exec", {"command": cmd})], max_length=40)
|
||||||
|
long = _hint([_tc("exec", {"command": cmd})], max_length=120)
|
||||||
|
assert len(long) > len(short)
|
||||||
|
assert "npm test" in long
|
||||||
|
|
||||||
|
def test_exec_max_length_120_shows_full_command(self):
|
||||||
|
cmd = "cd /home/user/project && npm install && npm run build"
|
||||||
|
result = _hint([_tc("exec", {"command": cmd})], max_length=120)
|
||||||
|
assert "npm run build" in result
|
||||||
|
|
||||||
|
def test_fallback_respects_max_length(self):
|
||||||
|
long_val = "a" * 100
|
||||||
|
result = _hint([_tc("custom_tool", {"data": long_val})], max_length=60)
|
||||||
|
assert "\u2026" in result
|
||||||
|
result_40 = _hint([_tc("custom_tool", {"data": long_val})], max_length=40)
|
||||||
|
assert len(result) > len(result_40)
|
||||||
|
|
||||||
|
def test_mcp_respects_max_length(self):
|
||||||
|
long_url = "https://example.com/very/long/path/to/resource"
|
||||||
|
result = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=80)
|
||||||
|
result_40 = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=40)
|
||||||
|
assert len(result) >= len(result_40)
|
||||||
|
|
||||||
|
def test_path_type_respects_max_length(self):
|
||||||
|
"""Path-type tools (read_file, write_file, etc.) should honor max_length."""
|
||||||
|
long_path = "/home/user/.local/share/uv/tools/nanobot/agent/loop.py"
|
||||||
|
short = _hint([_tc("read_file", {"path": long_path})], max_length=40)
|
||||||
|
long = _hint([_tc("read_file", {"path": long_path})], max_length=120)
|
||||||
|
assert len(long) > len(short)
|
||||||
|
|
||||||
|
def test_edit_path_respects_max_length(self):
|
||||||
|
"""edit (is_path=True) should honor max_length, not stay hardcoded at 40."""
|
||||||
|
long_path = "/home/user/projects/nanobot/src/agent/loop.py"
|
||||||
|
short = _hint([_tc("edit", {"file_path": long_path})], max_length=40)
|
||||||
|
long = _hint([_tc("edit", {"file_path": long_path})], max_length=120)
|
||||||
|
assert len(long) > len(short)
|
||||||
|
|
||||||
|
def test_list_dir_path_respects_max_length(self):
|
||||||
|
"""list_dir (is_path=True) should honor max_length."""
|
||||||
|
long_path = "/home/user/.local/share/uv/tools/nanobot/"
|
||||||
|
short = _hint([_tc("list_dir", {"path": long_path})], max_length=40)
|
||||||
|
long = _hint([_tc("list_dir", {"path": long_path})], max_length=120)
|
||||||
|
assert len(long) > len(short)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -35,6 +35,7 @@ def _make_mock_loop(**overrides):
|
|||||||
loop._concurrency_gate = None
|
loop._concurrency_gate = None
|
||||||
loop._unified_session = False
|
loop._unified_session = False
|
||||||
loop._extra_hooks = []
|
loop._extra_hooks = []
|
||||||
|
loop.model_preset = None
|
||||||
|
|
||||||
# web_config mock — needed for check tests
|
# web_config mock — needed for check tests
|
||||||
loop.web_config = MagicMock()
|
loop.web_config = MagicMock()
|
||||||
@@ -76,7 +77,7 @@ class TestInspectSummary:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "max_iterations: 40" in result
|
assert "max_iterations: 40" in result
|
||||||
assert "context_window_tokens: 65536" in result
|
assert "model_preset" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_includes_runtime_vars(self):
|
async def test_inspect_includes_runtime_vars(self):
|
||||||
@@ -92,8 +93,7 @@ class TestInspectSummary:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "max_iterations" in result
|
assert "max_iterations" in result
|
||||||
assert "context_window_tokens" in result
|
assert "model_preset" in result
|
||||||
assert "model" in result
|
|
||||||
assert "workspace" in result
|
assert "workspace" in result
|
||||||
assert "provider_retry_mode" in result
|
assert "provider_retry_mode" in result
|
||||||
assert "max_tool_result_chars" in result
|
assert "max_tool_result_chars" in result
|
||||||
@@ -231,13 +231,13 @@ class TestModifyRestricted:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_string_int_coerced(self):
|
async def test_modify_string_int_coerced(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
await tool.execute(action="set", key="max_iterations", value="80")
|
||||||
assert tool._loop.max_iterations == 80
|
assert tool._loop.max_iterations == 80
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_context_window_valid(self):
|
async def test_modify_context_window_valid(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||||
assert tool._loop.context_window_tokens == 131072
|
assert tool._loop.context_window_tokens == 131072
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -337,13 +337,13 @@ class TestModifyFree:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_allows_list(self):
|
async def test_modify_allows_list(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||||
assert tool._loop._runtime_vars["items"] == [1, 2, 3]
|
assert tool._loop._runtime_vars["items"] == [1, 2, 3]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_allows_dict(self):
|
async def test_modify_allows_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
await tool.execute(action="set", key="data", value={"a": 1})
|
||||||
assert tool._loop._runtime_vars["data"] == {"a": 1}
|
assert tool._loop._runtime_vars["data"] == {"a": 1}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -392,6 +392,26 @@ class TestModifyFree:
|
|||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert tool._loop.max_tool_result_chars == 16000
|
assert tool._loop.max_tool_result_chars == 16000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_model_clears_active_preset(self):
|
||||||
|
"""Directly modifying model must clear _active_preset so state stays consistent."""
|
||||||
|
tool = _make_tool()
|
||||||
|
tool._loop._active_preset = "gpt5"
|
||||||
|
result = await tool.execute(action="set", key="model", value="other-model")
|
||||||
|
assert "Set model" in result
|
||||||
|
assert tool._loop.model == "other-model"
|
||||||
|
assert tool._loop._active_preset is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_context_window_tokens_clears_active_preset(self):
|
||||||
|
"""Directly modifying context_window_tokens must clear _active_preset."""
|
||||||
|
tool = _make_tool()
|
||||||
|
tool._loop._active_preset = "gpt5"
|
||||||
|
result = await tool.execute(action="set", key="context_window_tokens", value=32768)
|
||||||
|
assert "Set context_window_tokens" in result
|
||||||
|
assert tool._loop.context_window_tokens == 32768
|
||||||
|
assert tool._loop._active_preset is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# set — previously BLOCKED/READONLY now open
|
# set — previously BLOCKED/READONLY now open
|
||||||
@@ -689,8 +709,8 @@ class TestSubagentHookStatus:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_after_iteration_updates_status(self):
|
async def test_after_iteration_updates_status(self):
|
||||||
"""after_iteration should copy iteration, tool_events, usage to status."""
|
"""after_iteration should copy iteration, tool_events, usage to status."""
|
||||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
|
||||||
from nanobot.agent.hook import AgentHookContext
|
from nanobot.agent.hook import AgentHookContext
|
||||||
|
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||||
|
|
||||||
status = SubagentStatus(
|
status = SubagentStatus(
|
||||||
task_id="test",
|
task_id="test",
|
||||||
@@ -716,8 +736,8 @@ class TestSubagentHookStatus:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_after_iteration_with_error(self):
|
async def test_after_iteration_with_error(self):
|
||||||
"""after_iteration should set status.error when context has an error."""
|
"""after_iteration should set status.error when context has an error."""
|
||||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
|
||||||
from nanobot.agent.hook import AgentHookContext
|
from nanobot.agent.hook import AgentHookContext
|
||||||
|
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||||
|
|
||||||
status = SubagentStatus(
|
status = SubagentStatus(
|
||||||
task_id="test",
|
task_id="test",
|
||||||
@@ -739,8 +759,8 @@ class TestSubagentHookStatus:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_after_iteration_no_status_is_noop(self):
|
async def test_after_iteration_no_status_is_noop(self):
|
||||||
"""after_iteration with no status should be a no-op."""
|
"""after_iteration with no status should be a no-op."""
|
||||||
from nanobot.agent.subagent import _SubagentHook
|
|
||||||
from nanobot.agent.hook import AgentHookContext
|
from nanobot.agent.hook import AgentHookContext
|
||||||
|
from nanobot.agent.subagent import _SubagentHook
|
||||||
|
|
||||||
hook = _SubagentHook("test")
|
hook = _SubagentHook("test")
|
||||||
context = AgentHookContext(iteration=1, messages=[])
|
context = AgentHookContext(iteration=1, messages=[])
|
||||||
@@ -757,7 +777,6 @@ class TestCheckpointCallback:
|
|||||||
async def test_checkpoint_updates_phase_and_iteration(self):
|
async def test_checkpoint_updates_phase_and_iteration(self):
|
||||||
"""The _on_checkpoint callback should update status.phase and iteration."""
|
"""The _on_checkpoint callback should update status.phase and iteration."""
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
import asyncio
|
|
||||||
|
|
||||||
status = SubagentStatus(
|
status = SubagentStatus(
|
||||||
task_id="cp",
|
task_id="cp",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Focused tests for MyTool runtime sync side effects."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools.self import MyTool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
||||||
|
loop = MagicMock()
|
||||||
|
loop.max_iterations = 40
|
||||||
|
loop._runtime_vars = {}
|
||||||
|
loop.subagents = MagicMock()
|
||||||
|
loop.subagents.max_iterations = loop.max_iterations
|
||||||
|
|
||||||
|
def _sync_subagent_runtime_limits() -> None:
|
||||||
|
loop.subagents.max_iterations = loop.max_iterations
|
||||||
|
|
||||||
|
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
||||||
|
|
||||||
|
tool = MyTool(loop=loop)
|
||||||
|
|
||||||
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
|
|
||||||
|
assert "Set max_iterations = 80" in result
|
||||||
|
assert loop.max_iterations == 80
|
||||||
|
assert loop.subagents.max_iterations == 80
|
||||||
@@ -54,11 +54,198 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
|
|||||||
mgr.runner.run.assert_awaited_once()
|
mgr.runner.run.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subagent_uses_configured_max_iterations(tmp_path):
|
||||||
|
"""Subagents should honor the configured tool-iteration limit."""
|
||||||
|
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||||
|
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,
|
||||||
|
max_iterations=37,
|
||||||
|
)
|
||||||
|
mgr._announce_result = AsyncMock()
|
||||||
|
|
||||||
|
async def fake_run(spec):
|
||||||
|
assert spec.max_iterations == 37
|
||||||
|
return SimpleNamespace(
|
||||||
|
stop_reason="done",
|
||||||
|
final_content="done",
|
||||||
|
error=None,
|
||||||
|
tool_events=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr.runner.run = AsyncMock(side_effect=fake_run)
|
||||||
|
|
||||||
|
status = SubagentStatus(
|
||||||
|
task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()
|
||||||
|
)
|
||||||
|
await mgr._run_subagent(
|
||||||
|
"sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr.runner.run.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.tools.spawn import SpawnTool
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Block the first subagent so it stays "running"
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
async def fake_run(spec):
|
||||||
|
await release.wait()
|
||||||
|
return SimpleNamespace(
|
||||||
|
stop_reason="done",
|
||||||
|
final_content="done",
|
||||||
|
error=None,
|
||||||
|
tool_events=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr.runner.run = AsyncMock(side_effect=fake_run)
|
||||||
|
|
||||||
|
tool = SpawnTool(mgr)
|
||||||
|
tool.set_context("test", "c1", "test:c1")
|
||||||
|
|
||||||
|
# First spawn succeeds
|
||||||
|
result = await tool.execute(task="first task")
|
||||||
|
assert "started" in result
|
||||||
|
|
||||||
|
# Second spawn should be rejected (default limit is 1)
|
||||||
|
result = await tool.execute(task="second task")
|
||||||
|
assert "Cannot spawn subagent" in result
|
||||||
|
assert "concurrency limit reached" in result
|
||||||
|
|
||||||
|
# Release the first subagent
|
||||||
|
release.set()
|
||||||
|
# Allow cleanup
|
||||||
|
await asyncio.gather(*mgr._running_tasks.values(), return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_subagent_default_max_concurrent_matches_agent_defaults(tmp_path):
|
||||||
|
"""Direct SubagentManager construction should use the agent default concurrency limit."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mgr.max_concurrent_subagents == AgentDefaults().max_concurrent_subagents
|
||||||
|
|
||||||
|
|
||||||
|
def test_subagent_default_max_iterations_matches_agent_defaults(tmp_path):
|
||||||
|
"""Direct SubagentManager construction should use the agent default limit."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mgr.max_iterations == AgentDefaults().max_tool_iterations
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_loop_passes_max_iterations_to_subagents(tmp_path):
|
||||||
|
"""AgentLoop's configured limit should be shared with spawned subagents."""
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert loop.subagents.max_iterations == 42
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_loop_syncs_updated_max_iterations_before_run(tmp_path):
|
||||||
|
"""Runtime max_iterations changes should be reflected before tool execution."""
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=42,
|
||||||
|
)
|
||||||
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
async def fake_run(spec):
|
||||||
|
assert spec.max_iterations == 55
|
||||||
|
assert loop.subagents.max_iterations == 55
|
||||||
|
return SimpleNamespace(
|
||||||
|
stop_reason="done",
|
||||||
|
final_content="done",
|
||||||
|
error=None,
|
||||||
|
tool_events=[],
|
||||||
|
messages=[],
|
||||||
|
usage={},
|
||||||
|
had_injections=False,
|
||||||
|
tools_used=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
loop.runner.run = AsyncMock(side_effect=fake_run)
|
||||||
|
loop.max_iterations = 55
|
||||||
|
|
||||||
|
await loop._run_agent_loop([])
|
||||||
|
|
||||||
|
loop.runner.run.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||||
"""_drain_pending should block when no messages are available but sub-agents are still running."""
|
"""_drain_pending should block when no messages are available but sub-agents are still running."""
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.subagent import SubagentManager
|
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
@@ -74,8 +261,6 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
|||||||
injection_callback = None
|
injection_callback = None
|
||||||
|
|
||||||
# Capture the injection_callback that _run_agent_loop creates
|
# Capture the injection_callback that _run_agent_loop creates
|
||||||
original_run = loop.runner.run
|
|
||||||
|
|
||||||
async def fake_runner_run(spec):
|
async def fake_runner_run(spec):
|
||||||
nonlocal injection_callback
|
nonlocal injection_callback
|
||||||
injection_callback = spec.injection_callback
|
injection_callback = spec.injection_callback
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
from nanobot.config.schema import ChannelsConfig
|
from nanobot.config.schema import ChannelsConfig
|
||||||
|
from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider
|
||||||
|
from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider
|
||||||
from nanobot.utils.restart import RestartNotice
|
from nanobot.utils.restart import RestartNotice
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -338,11 +340,10 @@ async def test_base_channel_passes_language_to_groq_transcription_provider():
|
|||||||
# Transcription provider HTTP tests
|
# Transcription provider HTTP tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider
|
|
||||||
from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider
|
|
||||||
|
|
||||||
|
|
||||||
class _StubResponse:
|
class _StubResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
def raise_for_status(self):
|
def raise_for_status(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -791,6 +792,50 @@ async def test_send_with_retry_skips_send_when_streamed():
|
|||||||
assert send_delta_called is False
|
assert send_delta_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_outbound_duplicate_suppression_is_scoped_to_origin_message() -> None:
|
||||||
|
fake_config = SimpleNamespace(
|
||||||
|
channels=ChannelsConfig(send_max_retries=3),
|
||||||
|
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr = ChannelManager.__new__(ChannelManager)
|
||||||
|
mgr.config = fake_config
|
||||||
|
mgr.bus = MessageBus()
|
||||||
|
mgr.channels = {}
|
||||||
|
mgr._dispatch_task = None
|
||||||
|
mgr._origin_reply_fingerprints = {}
|
||||||
|
|
||||||
|
first = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="chat123",
|
||||||
|
content="Done",
|
||||||
|
metadata={"message_id": "msg-1"},
|
||||||
|
)
|
||||||
|
duplicate = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="chat123",
|
||||||
|
content=" Done ",
|
||||||
|
metadata={"origin_message_id": "msg-1"},
|
||||||
|
)
|
||||||
|
separate_turn = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="chat123",
|
||||||
|
content="Done",
|
||||||
|
metadata={"message_id": "msg-2"},
|
||||||
|
)
|
||||||
|
new_origin_content = OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="chat123",
|
||||||
|
content="Done with extra details",
|
||||||
|
metadata={"origin_message_id": "msg-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mgr._should_suppress_outbound(first) is False
|
||||||
|
assert mgr._should_suppress_outbound(duplicate) is True
|
||||||
|
assert mgr._should_suppress_outbound(separate_turn) is False
|
||||||
|
assert mgr._should_suppress_outbound(new_origin_content) is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_with_retry_propagates_cancelled_error():
|
async def test_send_with_retry_propagates_cancelled_error():
|
||||||
"""_send_with_retry should re-raise CancelledError for graceful shutdown."""
|
"""_send_with_retry should re-raise CancelledError for graceful shutdown."""
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
import zipfile
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -17,19 +16,27 @@ except ImportError:
|
|||||||
if not DINGTALK_AVAILABLE:
|
if not DINGTALK_AVAILABLE:
|
||||||
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
|
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
|
||||||
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
import nanobot.channels.dingtalk as dingtalk_module
|
import nanobot.channels.dingtalk as dingtalk_module
|
||||||
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.dingtalk import DingTalkConfig
|
from nanobot.channels.dingtalk import DingTalkChannel, DingTalkConfig, NanobotDingTalkHandler
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
class _FakeResponse:
|
||||||
def __init__(self, status_code: int = 200, json_body: dict | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
status_code: int = 200,
|
||||||
|
json_body: dict | None = None,
|
||||||
|
*,
|
||||||
|
content: bytes = b"",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
url: str = "https://example.com/file",
|
||||||
|
) -> None:
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self._json_body = json_body or {}
|
self._json_body = json_body or {}
|
||||||
self.text = "{}"
|
self.text = content.decode("utf-8", errors="replace") if content else "{}"
|
||||||
self.content = b""
|
self.content = content
|
||||||
self.headers = {"content-type": "application/json"}
|
self.headers = headers or {"content-type": "application/json"}
|
||||||
|
self.url = httpx.URL(url)
|
||||||
|
|
||||||
def json(self) -> dict:
|
def json(self) -> dict:
|
||||||
return self._json_body
|
return self._json_body
|
||||||
@@ -46,11 +53,13 @@ class _FakeHttp:
|
|||||||
return _FakeResponse()
|
return _FakeResponse()
|
||||||
|
|
||||||
async def post(self, url: str, json=None, headers=None, **kwargs):
|
async def post(self, url: str, json=None, headers=None, **kwargs):
|
||||||
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
|
self.calls.append(
|
||||||
|
{"method": "POST", "url": url, "json": json, "headers": headers, "kwargs": kwargs}
|
||||||
|
)
|
||||||
return self._next_response()
|
return self._next_response()
|
||||||
|
|
||||||
async def get(self, url: str, **kwargs):
|
async def get(self, url: str, **kwargs):
|
||||||
self.calls.append({"method": "GET", "url": url})
|
self.calls.append({"method": "GET", "url": url, "kwargs": kwargs})
|
||||||
return self._next_response()
|
return self._next_response()
|
||||||
|
|
||||||
|
|
||||||
@@ -242,6 +251,245 @@ async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
|||||||
assert channel._http.calls[1]["method"] == "GET"
|
assert channel._http.calls[1]["method"] == "GET"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_rejects_private_http_target_before_fetch() -> None:
|
||||||
|
"""Remote media fetches must not reach loopback/private addresses."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
200,
|
||||||
|
content=b"internal secret",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
url="http://127.0.0.1/admin.txt",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("http://127.0.0.1/admin.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (None, None, None)
|
||||||
|
assert channel._http.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_rejects_private_redirect_result() -> None:
|
||||||
|
"""A public-looking media URL must not be accepted after redirecting private."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
200,
|
||||||
|
content=b"metadata bytes",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
url="http://127.0.0.1/metadata",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("https://example.com/safe.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (None, None, None)
|
||||||
|
assert len(channel._http.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_rejects_oversized_remote_response(monkeypatch) -> None:
|
||||||
|
"""DingTalk media downloads should enforce a byte cap before upload."""
|
||||||
|
monkeypatch.setattr(dingtalk_module, "DINGTALK_MAX_REMOTE_MEDIA_BYTES", 8, raising=False)
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
200,
|
||||||
|
content=b"123456789",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
url="https://example.com/large.txt",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("https://example.com/large.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_does_not_follow_remote_redirects_by_default() -> None:
|
||||||
|
"""Redirects are refused by default instead of followed into internal networks."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
302,
|
||||||
|
headers={"location": "http://127.0.0.1/metadata"},
|
||||||
|
url="https://example.com/redirect.txt",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (None, None, None)
|
||||||
|
assert channel._http.calls[0]["kwargs"]["follow_redirects"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_follows_safe_redirect_when_explicitly_enabled() -> None:
|
||||||
|
"""Operators can opt in to public redirects without enabling private redirects."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(
|
||||||
|
client_id="app",
|
||||||
|
client_secret="secret",
|
||||||
|
allow_from=["*"],
|
||||||
|
allow_remote_media_redirects=True,
|
||||||
|
),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
302,
|
||||||
|
headers={"location": "https://example.com/final.txt"},
|
||||||
|
url="https://example.com/redirect.txt",
|
||||||
|
),
|
||||||
|
_FakeResponse(
|
||||||
|
200,
|
||||||
|
content=b"redirected media",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
url="https://example.com/final.txt",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (b"redirected media", "redirect.txt", "text/plain")
|
||||||
|
assert [call["url"] for call in channel._http.calls] == [
|
||||||
|
"https://example.com/redirect.txt",
|
||||||
|
"https://example.com/final.txt",
|
||||||
|
]
|
||||||
|
assert all(call["kwargs"]["follow_redirects"] is False for call in channel._http.calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_blocks_cross_host_redirect_without_allowlist() -> None:
|
||||||
|
"""Redirect opt-in should not allow arbitrary cross-host redirects by default."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(
|
||||||
|
client_id="app",
|
||||||
|
client_secret="secret",
|
||||||
|
allow_from=["*"],
|
||||||
|
allow_remote_media_redirects=True,
|
||||||
|
),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
302,
|
||||||
|
headers={"location": "https://example.org/final.txt"},
|
||||||
|
url="https://example.com/redirect.txt",
|
||||||
|
),
|
||||||
|
_FakeResponse(
|
||||||
|
200,
|
||||||
|
content=b"cross-host media",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
url="https://example.org/final.txt",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (None, None, None)
|
||||||
|
assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_allows_cross_host_redirect_when_allowlisted() -> None:
|
||||||
|
"""Operators can explicitly allow a known CDN/download host for redirects."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(
|
||||||
|
client_id="app",
|
||||||
|
client_secret="secret",
|
||||||
|
allow_from=["*"],
|
||||||
|
allow_remote_media_redirects=True,
|
||||||
|
remote_media_redirect_allowed_hosts=["example.org"],
|
||||||
|
),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
302,
|
||||||
|
headers={"location": "https://example.org/final.txt"},
|
||||||
|
url="https://example.com/redirect.txt",
|
||||||
|
),
|
||||||
|
_FakeResponse(
|
||||||
|
200,
|
||||||
|
content=b"cross-host media",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
url="https://example.org/final.txt",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (b"cross-host media", "redirect.txt", "text/plain")
|
||||||
|
assert [call["url"] for call in channel._http.calls] == [
|
||||||
|
"https://example.com/redirect.txt",
|
||||||
|
"https://example.org/final.txt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_media_bytes_blocks_private_redirect_even_when_redirects_enabled() -> None:
|
||||||
|
"""Redirect opt-in must still validate each hop before fetching it."""
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(
|
||||||
|
client_id="app",
|
||||||
|
client_secret="secret",
|
||||||
|
allow_from=["*"],
|
||||||
|
allow_remote_media_redirects=True,
|
||||||
|
),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._http = _FakeHttp(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
302,
|
||||||
|
headers={"location": "http://127.0.0.1/metadata"},
|
||||||
|
url="https://example.com/redirect.txt",
|
||||||
|
),
|
||||||
|
_FakeResponse(
|
||||||
|
200,
|
||||||
|
content=b"internal secret",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
url="http://127.0.0.1/metadata",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||||
|
|
||||||
|
assert (data, filename, content_type) == (None, None, None)
|
||||||
|
assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_upload_payload_zips_html_attachment() -> None:
|
def test_normalize_upload_payload_zips_html_attachment() -> None:
|
||||||
channel = DingTalkChannel(
|
channel = DingTalkChannel(
|
||||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
from email.message import EmailMessage
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
|
||||||
import imaplib
|
import imaplib
|
||||||
|
from datetime import date
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.email import EmailChannel
|
from nanobot.channels.email import EmailChannel, EmailConfig
|
||||||
from nanobot.channels.email import EmailConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _make_config(**overrides) -> EmailConfig:
|
def _make_config(**overrides) -> EmailConfig:
|
||||||
@@ -24,6 +23,7 @@ def _make_config(**overrides) -> EmailConfig:
|
|||||||
smtp_username="bot@example.com",
|
smtp_username="bot@example.com",
|
||||||
smtp_password="secret",
|
smtp_password="secret",
|
||||||
mark_seen=True,
|
mark_seen=True,
|
||||||
|
allow_from=["*"],
|
||||||
# Disable auth verification by default so existing tests are unaffected
|
# Disable auth verification by default so existing tests are unaffected
|
||||||
verify_dkim=False,
|
verify_dkim=False,
|
||||||
verify_spf=False,
|
verify_spf=False,
|
||||||
@@ -707,8 +707,8 @@ def test_email_content_tagged_with_email_context(monkeypatch) -> None:
|
|||||||
|
|
||||||
def test_check_authentication_results_method() -> None:
|
def test_check_authentication_results_method() -> None:
|
||||||
"""Unit test for the _check_authentication_results static method."""
|
"""Unit test for the _check_authentication_results static method."""
|
||||||
from email.parser import BytesParser
|
|
||||||
from email import policy
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
|
|
||||||
# No Authentication-Results header
|
# No Authentication-Results header
|
||||||
msg_no_auth = EmailMessage()
|
msg_no_auth = EmailMessage()
|
||||||
@@ -788,6 +788,32 @@ def _make_raw_email_with_attachment(
|
|||||||
return msg.as_bytes()
|
return msg.as_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None:
|
||||||
|
raw = _make_raw_email_with_attachment(from_addr="blocked@example.com")
|
||||||
|
fake = _make_fake_imap(raw)
|
||||||
|
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||||
|
|
||||||
|
called = {"attachments": False}
|
||||||
|
|
||||||
|
def _extract_attachments(*_args, **_kwargs):
|
||||||
|
called["attachments"] = True
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(EmailChannel, "_extract_attachments", _extract_attachments)
|
||||||
|
|
||||||
|
cfg = _make_config(
|
||||||
|
allow_from=["allowed@example.com"],
|
||||||
|
allowed_attachment_types=["application/pdf"],
|
||||||
|
verify_dkim=False,
|
||||||
|
verify_spf=False,
|
||||||
|
)
|
||||||
|
channel = EmailChannel(cfg, MessageBus())
|
||||||
|
|
||||||
|
assert channel._fetch_new_messages() == []
|
||||||
|
assert called["attachments"] is False
|
||||||
|
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||||
|
|
||||||
|
|
||||||
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
|
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
|
||||||
"""PDF attachment is saved to media dir and path returned in media list."""
|
"""PDF attachment is saved to media dir and path returned in media list."""
|
||||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||||
|
|||||||
@@ -445,6 +445,58 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
|
|||||||
assert len(captured) == 1
|
assert len(captured) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inbound media tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_audio_publishes_downloaded_path_and_transcription() -> None:
|
||||||
|
channel = _make_feishu_channel()
|
||||||
|
channel._processed_message_ids.clear()
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
async def capture(msg):
|
||||||
|
captured.append(msg)
|
||||||
|
|
||||||
|
channel.bus.publish_inbound = capture
|
||||||
|
channel._download_and_save_media = AsyncMock(
|
||||||
|
return_value=(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg", "[audio: voice.ogg]")
|
||||||
|
)
|
||||||
|
channel.transcribe_audio = AsyncMock(return_value="hello from voice")
|
||||||
|
channel._add_reaction = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
event = _make_feishu_event(
|
||||||
|
msg_type="audio",
|
||||||
|
content='{"file_key": "audio_key", "duration": 1000}',
|
||||||
|
message_id="om_audio",
|
||||||
|
)
|
||||||
|
await channel._on_message(event)
|
||||||
|
|
||||||
|
channel._download_and_save_media.assert_awaited_once_with(
|
||||||
|
"audio", {"file_key": "audio_key", "duration": 1000}, "om_audio"
|
||||||
|
)
|
||||||
|
channel.transcribe_audio.assert_awaited_once_with(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg")
|
||||||
|
assert len(captured) == 1
|
||||||
|
assert captured[0].media == [r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg"]
|
||||||
|
assert captured[0].content == "[transcription: hello from voice]"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_and_save_media_returns_absolute_path_in_content(monkeypatch, tmp_path) -> None:
|
||||||
|
channel = _make_feishu_channel()
|
||||||
|
monkeypatch.setattr(feishu, "get_media_dir", lambda _channel: tmp_path)
|
||||||
|
channel._download_file_sync = MagicMock(return_value=(b"voice-bytes", None))
|
||||||
|
|
||||||
|
file_path, content_text = await channel._download_and_save_media(
|
||||||
|
"audio", {"file_key": "voice_key"}, "om_audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert file_path == str(tmp_path / "voice_key.ogg")
|
||||||
|
assert (tmp_path / "voice_key.ogg").read_bytes() == b"voice-bytes"
|
||||||
|
assert content_text == f"[audio: {file_path}]"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session key derivation tests
|
# Session key derivation tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -580,6 +632,32 @@ async def test_reply_without_reply_in_thread_when_disabled() -> None:
|
|||||||
channel._client.im.v1.message.create.assert_called_once()
|
channel._client.im.v1.message.create.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topic_reply_does_not_force_reply_in_thread_when_disabled() -> None:
|
||||||
|
"""Topic replies must not create new Feishu topics when reply_to_message is False."""
|
||||||
|
channel = _make_feishu_channel(reply_to_message=False)
|
||||||
|
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_abc",
|
||||||
|
content="hello",
|
||||||
|
metadata={
|
||||||
|
"message_id": "om_child456",
|
||||||
|
"chat_type": "group",
|
||||||
|
"thread_id": "om_root123",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
channel._client.im.v1.message.reply.assert_called_once()
|
||||||
|
call_args = channel._client.im.v1.message.reply.call_args
|
||||||
|
request = call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is not True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
||||||
"""Even with reply_to_message=True, fallback to create on reply failure."""
|
"""Even with reply_to_message=True, fallback to create on reply failure."""
|
||||||
@@ -728,3 +806,26 @@ def test_on_background_task_done_removes_from_set() -> None:
|
|||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
assert task not in channel._background_tasks
|
assert task not in channel._background_tasks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_ignores_unauthorized_sender_before_side_effects() -> None:
|
||||||
|
channel = _make_feishu_channel(group_policy="open")
|
||||||
|
channel.config.allow_from = ["ou_allowed"]
|
||||||
|
channel._add_reaction = AsyncMock()
|
||||||
|
channel._download_and_save_media = AsyncMock(return_value=("/tmp/audio.ogg", "[audio]"))
|
||||||
|
channel.transcribe_audio = AsyncMock(return_value="transcript")
|
||||||
|
channel._handle_message = AsyncMock()
|
||||||
|
|
||||||
|
event = _make_feishu_event(
|
||||||
|
msg_type="audio",
|
||||||
|
content='{"file_key": "file_1"}',
|
||||||
|
sender_open_id="ou_blocked",
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._on_message(event)
|
||||||
|
|
||||||
|
channel._add_reaction.assert_not_awaited()
|
||||||
|
channel._download_and_save_media.assert_not_awaited()
|
||||||
|
channel.transcribe_audio.assert_not_awaited()
|
||||||
|
channel._handle_message.assert_not_awaited()
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||||
|
|
||||||
|
|
||||||
def _make_channel(streaming: bool = True) -> FeishuChannel:
|
def _make_channel(streaming: bool = True, reply_to_message: bool = False) -> FeishuChannel:
|
||||||
config = FeishuConfig(
|
config = FeishuConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
app_id="cli_test",
|
app_id="cli_test",
|
||||||
app_secret="secret",
|
app_secret="secret",
|
||||||
allow_from=["*"],
|
allow_from=["*"],
|
||||||
streaming=streaming,
|
streaming=streaming,
|
||||||
|
reply_to_message=reply_to_message,
|
||||||
)
|
)
|
||||||
ch = FeishuChannel(config, MessageBus())
|
ch = FeishuChannel(config, MessageBus())
|
||||||
ch._client = MagicMock()
|
ch._client = MagicMock()
|
||||||
@@ -148,6 +149,62 @@ class TestSendDelta:
|
|||||||
ch._client.im.v1.message.create.assert_called_once()
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_delta_uses_create_when_reply_disabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=False)
|
||||||
|
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||||
|
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
|
||||||
|
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||||
|
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1",
|
||||||
|
"Hello ",
|
||||||
|
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
|
ch._client.im.v1.message.reply.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_delta_keeps_existing_topic_when_reply_disabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=False)
|
||||||
|
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||||
|
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1",
|
||||||
|
"Hello ",
|
||||||
|
metadata={"message_id": "om_001", "chat_type": "group", "thread_id": "ot_001"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
|
ch._client.im.v1.message.create.assert_not_called()
|
||||||
|
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is not True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_delta_replies_in_thread_when_reply_enabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=True)
|
||||||
|
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||||
|
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1",
|
||||||
|
"Hello ",
|
||||||
|
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
|
ch._client.im.v1.message.create.assert_not_called()
|
||||||
|
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_second_delta_within_interval_skips_update(self):
|
async def test_second_delta_within_interval_skips_update(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
@@ -204,6 +261,70 @@ class TestSendDelta:
|
|||||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||||
ch._client.im.v1.message.create.assert_called_once()
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_end_fallback_group_uses_create_when_reply_disabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=False)
|
||||||
|
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
|
||||||
|
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
|
||||||
|
)
|
||||||
|
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
||||||
|
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1",
|
||||||
|
"",
|
||||||
|
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
|
ch._client.im.v1.message.reply.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_end_fallback_keeps_existing_topic_when_reply_disabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=False)
|
||||||
|
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
|
||||||
|
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
|
||||||
|
)
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1",
|
||||||
|
"",
|
||||||
|
metadata={
|
||||||
|
"_stream_end": True,
|
||||||
|
"message_id": "om_001",
|
||||||
|
"chat_type": "group",
|
||||||
|
"thread_id": "ot_001",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
|
ch._client.im.v1.message.create.assert_not_called()
|
||||||
|
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is not True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_end_fallback_group_replies_when_reply_enabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=True)
|
||||||
|
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
|
||||||
|
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
|
||||||
|
)
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
await ch.send_delta(
|
||||||
|
"oc_chat1",
|
||||||
|
"",
|
||||||
|
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
|
ch._client.im.v1.message.create.assert_not_called()
|
||||||
|
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stream_end_fallback_when_final_update_fails(self):
|
async def test_stream_end_fallback_when_final_update_fails(self):
|
||||||
"""If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card."""
|
"""If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card."""
|
||||||
@@ -316,6 +437,64 @@ class TestToolHintInlineStreaming:
|
|||||||
assert "oc_chat1" not in ch._stream_bufs
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
ch._client.im.v1.message.create.assert_called_once()
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_hint_group_uses_create_when_reply_disabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=False)
|
||||||
|
ch._client.im.v1.message.create.return_value = _mock_send_response("om_hint")
|
||||||
|
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu", chat_id="oc_chat1",
|
||||||
|
content='read_file("path")',
|
||||||
|
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
|
)
|
||||||
|
await ch.send(msg)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
|
ch._client.im.v1.message.reply.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_hint_keeps_existing_topic_when_reply_disabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=False)
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu", chat_id="oc_chat1",
|
||||||
|
content='read_file("path")',
|
||||||
|
metadata={
|
||||||
|
"_tool_hint": True,
|
||||||
|
"message_id": "om_001",
|
||||||
|
"chat_type": "group",
|
||||||
|
"thread_id": "ot_001",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ch.send(msg)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
|
ch._client.im.v1.message.create.assert_not_called()
|
||||||
|
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is not True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_hint_group_replies_when_reply_enabled(self):
|
||||||
|
ch = _make_channel(reply_to_message=True)
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
ch._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="feishu", chat_id="oc_chat1",
|
||||||
|
content='read_file("path")',
|
||||||
|
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
|
)
|
||||||
|
await ch.send(msg)
|
||||||
|
|
||||||
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
|
ch._client.im.v1.message.create.assert_not_called()
|
||||||
|
request = ch._client.im.v1.message.reply.call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_consecutive_tool_hints_append(self):
|
async def test_consecutive_tool_hints_append(self):
|
||||||
"""When multiple tool hints arrive consecutively, each appends to the card."""
|
"""When multiple tool hints arrive consecutively, each appends to the card."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
pytest.importorskip("nio")
|
pytest.importorskip("nio")
|
||||||
pytest.importorskip("nh3")
|
pytest.importorskip("nh3")
|
||||||
pytest.importorskip("mistune")
|
pytest.importorskip("mistune")
|
||||||
from nio import RoomSendResponse
|
from nio import RoomSendResponse, SyncError
|
||||||
|
|
||||||
from nanobot.channels.matrix import _build_matrix_text_content
|
from nanobot.channels.matrix import _build_matrix_text_content
|
||||||
|
|
||||||
@@ -266,6 +266,61 @@ async def test_start_disables_e2ee_when_configured(
|
|||||||
await channel.stop()
|
await channel.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_sync_error_stops_loop_on_unknown_token() -> None:
|
||||||
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
|
channel.client = client
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
await channel._on_sync_error(SyncError(message="bad", status_code="M_UNKNOWN_TOKEN"))
|
||||||
|
|
||||||
|
assert channel._running is False
|
||||||
|
assert client.stop_sync_forever_called is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_sync_error_keeps_running_on_transient_error() -> None:
|
||||||
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
|
channel.client = client
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
await channel._on_sync_error(SyncError(message="oops", status_code="M_LIMIT_EXCEEDED"))
|
||||||
|
|
||||||
|
assert channel._running is True
|
||||||
|
assert client.stop_sync_forever_called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_loop_backs_off_on_repeated_errors(monkeypatch) -> None:
|
||||||
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
async def _fake_sleep(delay: float) -> None:
|
||||||
|
sleeps.append(delay)
|
||||||
|
|
||||||
|
monkeypatch.setattr(matrix_module.asyncio, "sleep", _fake_sleep)
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
class _BoomClient:
|
||||||
|
async def sync_forever(self, **_kwargs) -> None:
|
||||||
|
call_count["n"] += 1
|
||||||
|
if call_count["n"] > 4:
|
||||||
|
channel._running = False
|
||||||
|
return
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
channel.client = _BoomClient()
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
await channel._sync_loop()
|
||||||
|
|
||||||
|
assert sleeps == [2.0, 4.0, 8.0, 16.0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None:
|
async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None:
|
||||||
channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus())
|
channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus())
|
||||||
@@ -380,6 +435,62 @@ async def test_on_message_skips_typing_for_self_message() -> None:
|
|||||||
assert client.typing_calls == []
|
assert client.typing_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_skips_pre_startup_event() -> None:
|
||||||
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
|
channel.client = client
|
||||||
|
channel._started_at_ms = 1_000_000
|
||||||
|
|
||||||
|
handled: list[str] = []
|
||||||
|
|
||||||
|
async def _fake_handle_message(**kwargs) -> None:
|
||||||
|
handled.append(kwargs["sender_id"])
|
||||||
|
|
||||||
|
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||||
|
|
||||||
|
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
|
||||||
|
old_event = SimpleNamespace(
|
||||||
|
sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999
|
||||||
|
)
|
||||||
|
fresh_event = SimpleNamespace(
|
||||||
|
sender="@alice:matrix.org", body="fresh", source={}, server_timestamp=1_000_001
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._on_message(room, old_event)
|
||||||
|
await channel._on_message(room, fresh_event)
|
||||||
|
|
||||||
|
assert handled == ["@alice:matrix.org"]
|
||||||
|
assert client.typing_calls == [
|
||||||
|
("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_media_message_skips_pre_startup_event() -> None:
|
||||||
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
|
channel.client = client
|
||||||
|
channel._started_at_ms = 1_000_000
|
||||||
|
|
||||||
|
handled: list[str] = []
|
||||||
|
|
||||||
|
async def _fake_handle_message(**kwargs) -> None:
|
||||||
|
handled.append(kwargs["sender_id"])
|
||||||
|
|
||||||
|
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||||
|
|
||||||
|
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
|
||||||
|
old_event = SimpleNamespace(
|
||||||
|
sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._on_media_message(room, old_event)
|
||||||
|
|
||||||
|
assert handled == []
|
||||||
|
assert client.typing_calls == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_message_skips_typing_for_denied_sender() -> None:
|
async def test_on_message_skips_typing_for_denied_sender() -> None:
|
||||||
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
||||||
@@ -1190,6 +1301,44 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None:
|
|||||||
await channel.stop()
|
await channel.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_empty_content_does_not_call_room_send() -> None:
|
||||||
|
"""Progress messages with empty content must not produce an empty body: '' event."""
|
||||||
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
|
channel.client = client
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="matrix",
|
||||||
|
chat_id="!room:matrix.org",
|
||||||
|
content="",
|
||||||
|
metadata={"_progress": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.room_send_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_whitespace_only_content_does_not_call_room_send() -> None:
|
||||||
|
"""Progress messages with whitespace-only content must not produce an empty message."""
|
||||||
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
|
channel.client = client
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="matrix",
|
||||||
|
chat_id="!room:matrix.org",
|
||||||
|
content=" \n\n ",
|
||||||
|
metadata={"_progress": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.room_send_calls == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_clears_typing_when_send_fails() -> None:
|
async def test_send_clears_typing_when_send_fails() -> None:
|
||||||
channel = MatrixChannel(_make_config(), MessageBus())
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for QQ channel media support: helpers, send, inbound, and upload."""
|
"""Tests for QQ channel media support: helpers, send, inbound, and upload."""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -182,6 +182,35 @@ async def test_send_media_failure_falls_back_to_text() -> None:
|
|||||||
assert "bad.png" in failure_calls[0]["content"]
|
assert "bad.png" in failure_calls[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_ignores_unauthorized_sender_before_attachments_and_ack() -> None:
|
||||||
|
channel = QQChannel(
|
||||||
|
QQConfig(
|
||||||
|
app_id="app",
|
||||||
|
secret="secret",
|
||||||
|
allow_from=["allowed-user"],
|
||||||
|
ack_message="Processing...",
|
||||||
|
),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._client = _FakeClient()
|
||||||
|
channel._handle_attachments = AsyncMock(return_value=(["/tmp/a.png"], ["file"], []))
|
||||||
|
channel._handle_message = AsyncMock()
|
||||||
|
|
||||||
|
data = SimpleNamespace(
|
||||||
|
id="msg-blocked",
|
||||||
|
content="hello",
|
||||||
|
author=SimpleNamespace(user_openid="blocked-user"),
|
||||||
|
attachments=[SimpleNamespace(filename="a.png")],
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._on_message(data, is_group=False)
|
||||||
|
|
||||||
|
channel._handle_attachments.assert_not_awaited()
|
||||||
|
channel._handle_message.assert_not_awaited()
|
||||||
|
assert channel._client.api.c2c_calls == []
|
||||||
|
|
||||||
|
|
||||||
# ── _on_message() exception handling ────────────────────────────────
|
# ── _on_message() exception handling ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -643,6 +643,14 @@ def test_slack_download_rejects_login_html() -> None:
|
|||||||
assert SlackChannel._looks_like_html_download(markdown_response) is False
|
assert SlackChannel._looks_like_html_download(markdown_response) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_slack_download_failure_marker_is_actionable() -> None:
|
||||||
|
marker = SlackChannel._download_failure_marker("image", "screenshot.png", "download failed")
|
||||||
|
|
||||||
|
assert "not available to nanobot" in marker
|
||||||
|
assert "files:read" in marker
|
||||||
|
assert "reinstall the Slack app" in marker
|
||||||
|
|
||||||
|
|
||||||
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
|
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
|
||||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||||
assert channel.is_allowed("U1") is True
|
assert channel.is_allowed("U1") is True
|
||||||
|
|||||||
@@ -306,17 +306,19 @@ async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None:
|
|||||||
recorded: list[tuple[str, str]] = []
|
recorded: list[tuple[str, str]] = []
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.telegram.logger.warning",
|
channel.logger,
|
||||||
|
"warning",
|
||||||
lambda message, error: recorded.append(("warning", message.format(error))),
|
lambda message, error: recorded.append(("warning", message.format(error))),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.telegram.logger.error",
|
channel.logger,
|
||||||
|
"error",
|
||||||
lambda message, error: recorded.append(("error", message.format(error))),
|
lambda message, error: recorded.append(("error", message.format(error))),
|
||||||
)
|
)
|
||||||
|
|
||||||
await channel._on_error(object(), SimpleNamespace(error=NetworkError("proxy disconnected")))
|
await channel._on_error(object(), SimpleNamespace(error=NetworkError("proxy disconnected")))
|
||||||
|
|
||||||
assert recorded == [("warning", "Telegram network issue: proxy disconnected")]
|
assert recorded == [("warning", "network issue: proxy disconnected")]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -330,13 +332,14 @@ async def test_on_error_summarizes_empty_network_error(monkeypatch) -> None:
|
|||||||
recorded: list[tuple[str, str]] = []
|
recorded: list[tuple[str, str]] = []
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.telegram.logger.warning",
|
channel.logger,
|
||||||
|
"warning",
|
||||||
lambda message, error: recorded.append(("warning", message.format(error))),
|
lambda message, error: recorded.append(("warning", message.format(error))),
|
||||||
)
|
)
|
||||||
|
|
||||||
await channel._on_error(object(), SimpleNamespace(error=NetworkError("")))
|
await channel._on_error(object(), SimpleNamespace(error=NetworkError("")))
|
||||||
|
|
||||||
assert recorded == [("warning", "Telegram network issue: NetworkError")]
|
assert recorded == [("warning", "network issue: NetworkError")]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -348,17 +351,19 @@ async def test_on_error_keeps_non_network_exceptions_as_error(monkeypatch) -> No
|
|||||||
recorded: list[tuple[str, str]] = []
|
recorded: list[tuple[str, str]] = []
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.telegram.logger.warning",
|
channel.logger,
|
||||||
|
"warning",
|
||||||
lambda message, error: recorded.append(("warning", message.format(error))),
|
lambda message, error: recorded.append(("warning", message.format(error))),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.telegram.logger.error",
|
channel.logger,
|
||||||
|
"error",
|
||||||
lambda message, error: recorded.append(("error", message.format(error))),
|
lambda message, error: recorded.append(("error", message.format(error))),
|
||||||
)
|
)
|
||||||
|
|
||||||
await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom")))
|
await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom")))
|
||||||
|
|
||||||
assert recorded == [("error", "Telegram error: boom")]
|
assert recorded == [("error", "error: boom")]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1309,6 +1314,58 @@ async def test_on_help_includes_restart_command() -> None:
|
|||||||
assert "/dream-restore" in help_text
|
assert "/dream-restore" in help_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_start_ignores_unauthorized_user_silently() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
update = _make_telegram_update(text="/start", chat_type="private")
|
||||||
|
update.message.reply_text = AsyncMock()
|
||||||
|
|
||||||
|
await channel._on_start(update, None)
|
||||||
|
|
||||||
|
update.message.reply_text.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_help_ignores_unauthorized_user_silently() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
update = _make_telegram_update(text="/help", chat_type="private")
|
||||||
|
update.message.reply_text = AsyncMock()
|
||||||
|
|
||||||
|
await channel._on_help(update, None)
|
||||||
|
|
||||||
|
update.message.reply_text.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_ignores_unauthorized_user_before_side_effects() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
started_typing: list[str] = []
|
||||||
|
handled: list[dict] = []
|
||||||
|
channel._start_typing = lambda chat_id: started_typing.append(chat_id)
|
||||||
|
channel._add_reaction = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
|
||||||
|
await channel._on_message(_make_telegram_update(text="hello", chat_type="private"), None)
|
||||||
|
|
||||||
|
assert started_typing == []
|
||||||
|
channel._add_reaction.assert_not_awaited()
|
||||||
|
assert handled == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_message_location_content() -> None:
|
async def test_on_message_location_content() -> None:
|
||||||
"""Location messages are forwarded as [location: lat, lon] content."""
|
"""Location messages are forwarded as [location: lat, lon] content."""
|
||||||
@@ -1750,3 +1807,32 @@ async def test_send_uses_native_keyboard_when_flag_on() -> None:
|
|||||||
sent = channel._app.bot.sent_messages[0]
|
sent = channel._app.bot.sent_messages[0]
|
||||||
assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup)
|
assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup)
|
||||||
assert "[Yes]" not in sent["text"] # native keyboard owns the rendering
|
assert "[Yes]" not in sent["text"] # native keyboard owns the rendering
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_query_ignores_unauthorized_user_before_side_effects() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], inline_keyboards=True),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._handle_message = AsyncMock()
|
||||||
|
|
||||||
|
query = SimpleNamespace(
|
||||||
|
id="cb_1",
|
||||||
|
data="Yes",
|
||||||
|
answer=AsyncMock(),
|
||||||
|
message=SimpleNamespace(
|
||||||
|
chat_id=123,
|
||||||
|
edit_reply_markup=AsyncMock(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
update = SimpleNamespace(
|
||||||
|
callback_query=query,
|
||||||
|
effective_user=SimpleNamespace(id=12345, username="alice", first_name="Alice"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._on_callback_query(update, None)
|
||||||
|
|
||||||
|
query.answer.assert_not_awaited()
|
||||||
|
query.message.edit_reply_markup.assert_not_awaited()
|
||||||
|
channel._handle_message.assert_not_awaited()
|
||||||
|
|||||||
@@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None:
|
|||||||
assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True
|
assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.remote_address = ("127.0.0.1", 50123)
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"webui-client",
|
||||||
|
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = bus.publish_inbound.await_args.args[0]
|
||||||
|
assert msg.channel == "websocket"
|
||||||
|
assert msg.chat_id == "chat-1"
|
||||||
|
assert msg.metadata["webui"] is True
|
||||||
|
assert msg.metadata["_wants_stream"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
conn = MagicMock()
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"custom-client",
|
||||||
|
{"type": "message", "chat_id": "chat-1", "content": "hello"},
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = bus.publish_inbound.await_args.args[0]
|
||||||
|
assert "webui" not in msg.metadata
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -287,6 +321,44 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
|||||||
assert second["stream_id"] == "sid"
|
assert second["stream_id"] == "sid"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="",
|
||||||
|
metadata={"_turn_end": True},
|
||||||
|
))
|
||||||
|
|
||||||
|
mock_ws.send.assert_awaited_once()
|
||||||
|
body = json.loads(mock_ws.send.await_args.args[0])
|
||||||
|
assert body == {"event": "turn_end", "chat_id": "chat-1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_session_updated_emits_session_updated_event() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="",
|
||||||
|
metadata={"_session_updated": True},
|
||||||
|
))
|
||||||
|
|
||||||
|
mock_ws.send.assert_awaited_once()
|
||||||
|
body = json.loads(mock_ws.send.await_args.args[0])
|
||||||
|
assert body == {"event": "session_updated", "chat_id": "chat-1"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -491,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
|
||||||
|
port = 29892
|
||||||
|
channel = _ch(bus, port=port)
|
||||||
|
channel._api_tokens["tok"] = time.monotonic() + 300
|
||||||
|
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
denied = await _http_get(f"http://127.0.0.1:{port}/api/commands")
|
||||||
|
assert denied.status_code == 401
|
||||||
|
|
||||||
|
response = await _http_get(
|
||||||
|
f"http://127.0.0.1:{port}/api/commands",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
commands = {row["command"]: row for row in body["commands"]}
|
||||||
|
assert commands["/stop"]["title"] == "Stop current task"
|
||||||
|
assert commands["/history"]["arg_hint"] == "[n]"
|
||||||
|
assert all("description" in row for row in body["commands"])
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_normalizes_camel_case_provider(
|
def test_settings_payload_normalizes_camel_case_provider(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -545,6 +645,16 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc
|
|||||||
end = json.loads(await client.recv())
|
end = json.loads(await client.recv())
|
||||||
assert end["event"] == "stream_end"
|
assert end["event"] == "stream_end"
|
||||||
assert end["stream_id"] == "s1"
|
assert end["stream_id"] == "s1"
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id=chat_id,
|
||||||
|
content="",
|
||||||
|
metadata={"_turn_end": True},
|
||||||
|
))
|
||||||
|
|
||||||
|
turn_end = json.loads(await client.recv())
|
||||||
|
assert turn_end == {"event": "turn_end", "chat_id": chat_id}
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|||||||
@@ -379,3 +379,111 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) ->
|
|||||||
headers = {"Authorization": "Bearer live"}
|
headers = {"Authorization": "Bearer live"}
|
||||||
|
|
||||||
assert channel._check_api_token(_LiveReq()) is True
|
assert channel._check_api_token(_LiveReq()) is True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
"""Minimal connection stub with a configurable remote_address."""
|
||||||
|
|
||||||
|
def __init__(self, remote_address: tuple[str, int]):
|
||||||
|
self.remote_address = remote_address
|
||||||
|
|
||||||
|
def respond(self, status: int, body: str) -> Any:
|
||||||
|
from websockets.http11 import Response
|
||||||
|
|
||||||
|
return Response(status=status, body=body.encode())
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeReq:
|
||||||
|
"""Minimal request stub with configurable headers."""
|
||||||
|
|
||||||
|
def __init__(self, headers: dict[str, str] | None = None):
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
|
||||||
|
_REMOTE = _FakeConn(("192.168.1.5", 12345))
|
||||||
|
_LOCAL = _FakeConn(("127.0.0.1", 12345))
|
||||||
|
_NO_HEADERS = _FakeReq()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None:
|
||||||
|
import pytest
|
||||||
|
from pydantic_core import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="token"):
|
||||||
|
_ch(bus, host="0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_host_with_token_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", token="my-token")
|
||||||
|
assert channel.config.host == "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
||||||
|
assert channel.config.host == "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
|
||||||
|
import pytest
|
||||||
|
from pydantic_core import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="token"):
|
||||||
|
_ch(bus, host="::")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
|
||||||
|
resp = channel._handle_webui_bootstrap(
|
||||||
|
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
|
||||||
|
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
|
||||||
|
channel = _ch(bus, host="0.0.0.0", token="static-tok")
|
||||||
|
resp = channel._handle_webui_bootstrap(
|
||||||
|
_REMOTE, _FakeReq({"Authorization": "Bearer static-tok"})
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = json.loads(resp.body)
|
||||||
|
assert body["token"].startswith("nbwt_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="127.0.0.1")
|
||||||
|
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct")
|
||||||
|
resp = channel._handle_webui_bootstrap(
|
||||||
|
_REMOTE, _FakeReq({"Authorization": "Bearer wrong"})
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
||||||
|
resp = channel._handle_webui_bootstrap(
|
||||||
|
_REMOTE, _FakeReq({"Authorization": "Bearer s3cret"})
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = json.loads(resp.body)
|
||||||
|
assert body["token"].startswith("nbwt_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
||||||
|
resp = channel._handle_webui_bootstrap(
|
||||||
|
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None:
|
||||||
|
"""When secret is set, even localhost must provide it (reverse-proxy safety)."""
|
||||||
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
||||||
|
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -451,6 +450,39 @@ async def test_process_text_message() -> None:
|
|||||||
assert msg.metadata["msg_type"] == "text"
|
assert msg.metadata["msg_type"] == "text"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enter_chat_ignores_unauthorized_user_before_welcome() -> None:
|
||||||
|
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
|
||||||
|
client = _FakeWeComClient()
|
||||||
|
channel._client = client
|
||||||
|
channel.config.welcome_message = "hello"
|
||||||
|
|
||||||
|
await channel._on_enter_chat(_FakeFrame(body={"chatid": "blocked"}))
|
||||||
|
|
||||||
|
client.reply_welcome.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_message_ignores_unauthorized_sender_before_download() -> None:
|
||||||
|
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
|
||||||
|
client = _FakeWeComClient()
|
||||||
|
channel._client = client
|
||||||
|
channel._handle_message = AsyncMock()
|
||||||
|
|
||||||
|
frame = _FakeFrame(body={
|
||||||
|
"msgid": "msg_blocked",
|
||||||
|
"chatid": "chat1",
|
||||||
|
"from": {"userid": "blocked"},
|
||||||
|
"image": {"url": "https://example.com/img.png", "aeskey": "key123"},
|
||||||
|
})
|
||||||
|
|
||||||
|
await channel._process_message(frame, "image")
|
||||||
|
|
||||||
|
client.download_file.assert_not_awaited()
|
||||||
|
channel._handle_message.assert_not_awaited()
|
||||||
|
assert channel.bus.inbound_size == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_image_message() -> None:
|
async def test_process_image_message() -> None:
|
||||||
"""Image message: download success → media_paths non-empty."""
|
"""Image message: download success → media_paths non-empty."""
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
import nanobot.channels.weixin as weixin_mod
|
import nanobot.channels.weixin as weixin_mod
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -15,10 +15,10 @@ from nanobot.channels.weixin import (
|
|||||||
ITEM_TEXT,
|
ITEM_TEXT,
|
||||||
MESSAGE_TYPE_BOT,
|
MESSAGE_TYPE_BOT,
|
||||||
WEIXIN_CHANNEL_VERSION,
|
WEIXIN_CHANNEL_VERSION,
|
||||||
_decrypt_aes_ecb,
|
|
||||||
_encrypt_aes_ecb,
|
|
||||||
WeixinChannel,
|
WeixinChannel,
|
||||||
WeixinConfig,
|
WeixinConfig,
|
||||||
|
_decrypt_aes_ecb,
|
||||||
|
_encrypt_aes_ecb,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,11 +48,11 @@ def test_make_headers_includes_route_tag_when_configured() -> None:
|
|||||||
assert headers["Authorization"] == "Bearer token"
|
assert headers["Authorization"] == "Bearer token"
|
||||||
assert headers["SKRouteTag"] == "123"
|
assert headers["SKRouteTag"] == "123"
|
||||||
assert headers["iLink-App-Id"] == "bot"
|
assert headers["iLink-App-Id"] == "bot"
|
||||||
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1)
|
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 7)
|
||||||
|
|
||||||
|
|
||||||
def test_channel_version_matches_reference_plugin_version() -> None:
|
def test_channel_version_matches_reference_plugin_version() -> None:
|
||||||
assert WEIXIN_CHANNEL_VERSION == "2.1.1"
|
assert WEIXIN_CHANNEL_VERSION == "2.1.7"
|
||||||
|
|
||||||
|
|
||||||
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
||||||
@@ -128,6 +128,34 @@ async def test_process_message_caches_context_token_and_send_uses_it() -> None:
|
|||||||
channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")
|
channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_message_ignores_unauthorized_sender_before_side_effects(tmp_path) -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = WeixinChannel(
|
||||||
|
WeixinConfig(enabled=True, allow_from=["allowed-user"], state_dir=str(tmp_path)),
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg")
|
||||||
|
channel._start_typing = AsyncMock()
|
||||||
|
|
||||||
|
await channel._process_message(
|
||||||
|
{
|
||||||
|
"message_type": 1,
|
||||||
|
"message_id": "m-unauthorized",
|
||||||
|
"from_user_id": "blocked-user",
|
||||||
|
"context_token": "ctx-blocked",
|
||||||
|
"item_list": [
|
||||||
|
{"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel._context_tokens == {}
|
||||||
|
channel._download_media_item.assert_not_awaited()
|
||||||
|
channel._start_typing.assert_not_awaited()
|
||||||
|
assert bus.inbound_size == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None:
|
async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
@@ -291,21 +319,22 @@ async def test_process_message_does_not_fallback_when_top_level_media_exists_but
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_without_context_token_does_not_send_text() -> None:
|
async def test_send_without_context_token_raises() -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._client = object()
|
channel._client = object()
|
||||||
channel._token = "token"
|
channel._token = "token"
|
||||||
channel._send_text = AsyncMock()
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
await channel.send(
|
with pytest.raises(RuntimeError, match="context_token missing"):
|
||||||
type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
|
await channel.send(
|
||||||
)
|
type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
|
||||||
|
)
|
||||||
|
|
||||||
channel._send_text.assert_not_awaited()
|
channel._send_text.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_does_not_send_when_session_is_paused() -> None:
|
async def test_send_raises_when_session_is_paused() -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._client = object()
|
channel._client = object()
|
||||||
channel._token = "token"
|
channel._token = "token"
|
||||||
@@ -313,9 +342,10 @@ async def test_send_does_not_send_when_session_is_paused() -> None:
|
|||||||
channel._pause_session(60)
|
channel._pause_session(60)
|
||||||
channel._send_text = AsyncMock()
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
await channel.send(
|
with pytest.raises(RuntimeError, match="session paused"):
|
||||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
await channel.send(
|
||||||
)
|
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||||
|
)
|
||||||
|
|
||||||
channel._send_text.assert_not_awaited()
|
channel._send_text.assert_not_awaited()
|
||||||
|
|
||||||
@@ -1185,3 +1215,257 @@ async def test_send_media_network_error_does_not_double_api_calls() -> None:
|
|||||||
# _send_media_file called once, _send_text never called
|
# _send_media_file called once, _send_text never called
|
||||||
channel._send_media_file.assert_awaited_once()
|
channel._send_media_file.assert_awaited_once()
|
||||||
channel._send_text.assert_not_awaited()
|
channel._send_text.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for _send_text raising on API errors (previously silently swallowed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_on_api_error() -> None:
|
||||||
|
"""_send_text must raise RuntimeError when the API returns a non-zero errcode,
|
||||||
|
matching _send_media_file behavior. This ensures ChannelManager can retry."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
return_value={"errcode": -14, "errmsg": "session expired"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error.*-14"):
|
||||||
|
await channel._send_text("wx-user", "hello", "ctx-expired")
|
||||||
|
|
||||||
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_succeeds_on_zero_errcode() -> None:
|
||||||
|
"""_send_text must NOT raise when errcode is 0."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._api_post = AsyncMock(return_value={"errcode": 0})
|
||||||
|
|
||||||
|
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||||
|
|
||||||
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
|
||||||
|
"""_send_text must raise when the API returns ret != 0, even if errcode is 0.
|
||||||
|
|
||||||
|
The iLink API signals failure through either field. Checking only errcode
|
||||||
|
caused silent message drops (responses generated but never delivered).
|
||||||
|
"""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
|
||||||
|
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||||
|
|
||||||
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for _poll_once not silently dropping messages on processing errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_once_logs_exception_on_process_message_failure(monkeypatch) -> None:
|
||||||
|
"""When _process_message raises, _poll_once must log the error and continue
|
||||||
|
processing remaining messages instead of silently swallowing the exception."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = SimpleNamespace(timeout=None)
|
||||||
|
channel._token = "token"
|
||||||
|
channel._get_updates_buf = "old-buf"
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
logged_messages: list[str] = []
|
||||||
|
|
||||||
|
async def _failing_process(msg: dict) -> None:
|
||||||
|
calls.append(msg.get("message_id"))
|
||||||
|
if msg.get("message_id") == "msg-1":
|
||||||
|
raise RuntimeError("processing failed")
|
||||||
|
|
||||||
|
channel._process_message = _failing_process # type: ignore[method-assign]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
channel.logger,
|
||||||
|
"exception",
|
||||||
|
lambda message, *args, **kwargs: logged_messages.append(str(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock( # type: ignore[method-assign]
|
||||||
|
return_value={
|
||||||
|
"ret": 0,
|
||||||
|
"errcode": 0,
|
||||||
|
"get_updates_buf": "new-buf",
|
||||||
|
"msgs": [
|
||||||
|
{"message_id": "msg-1", "message_type": 1},
|
||||||
|
{"message_id": "msg-2", "message_type": 1},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._poll_once()
|
||||||
|
|
||||||
|
# Both messages should have been attempted
|
||||||
|
assert calls == ["msg-1", "msg-2"]
|
||||||
|
# Buffer should still advance (already updated before processing)
|
||||||
|
assert channel._get_updates_buf == "new-buf"
|
||||||
|
# Error should be logged
|
||||||
|
assert any("Failed to process WeChat message" in m for m in logged_messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_loop_logs_exception_and_continues_on_poll_failure(monkeypatch) -> None:
|
||||||
|
"""When _poll_once raises a non-timeout exception, the start() loop must log
|
||||||
|
the error and continue polling instead of exiting silently."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.config.token = "token" # skip QR login in start()
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
logged_messages: list[str] = []
|
||||||
|
|
||||||
|
async def _failing_poll() -> None:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise RuntimeError("poll exploded")
|
||||||
|
channel._running = False # Stop after second call
|
||||||
|
|
||||||
|
channel._poll_once = _failing_poll # type: ignore[method-assign]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
channel.logger,
|
||||||
|
"exception",
|
||||||
|
lambda message, *args, **kwargs: logged_messages.append(str(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use a tiny retry delay so the test finishes quickly
|
||||||
|
original_retry = weixin_mod.RETRY_DELAY_S
|
||||||
|
weixin_mod.RETRY_DELAY_S = 0.01
|
||||||
|
try:
|
||||||
|
await channel.start()
|
||||||
|
finally:
|
||||||
|
weixin_mod.RETRY_DELAY_S = original_retry
|
||||||
|
|
||||||
|
assert call_count == 2
|
||||||
|
assert any("WeChat poll loop error" in m for m in logged_messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_retries_without_context_token_on_ret_minus_two() -> None:
|
||||||
|
"""If sendmessage returns ret=-2 with a context_token, retry without it."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._context_tokens["wx-user"] = "expired-token"
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"ret": -2}, # first attempt with token fails
|
||||||
|
{"ret": 0}, # retry without token succeeds
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._send_text("wx-user", "hello", "expired-token")
|
||||||
|
|
||||||
|
# Should have called API twice
|
||||||
|
assert channel._api_post.await_count == 2
|
||||||
|
# First call includes context_token
|
||||||
|
first_body = channel._api_post.await_args_list[0].args[1]
|
||||||
|
assert first_body["msg"]["context_token"] == "expired-token"
|
||||||
|
# Second call does NOT include context_token
|
||||||
|
second_body = channel._api_post.await_args_list[1].args[1]
|
||||||
|
assert "context_token" not in second_body["msg"]
|
||||||
|
# Expired token should be cleared from cache
|
||||||
|
assert "wx-user" not in channel._context_tokens
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_when_retry_also_fails_with_stale_session() -> None:
|
||||||
|
"""If both attempts return stale-session ret=-2, raise so ChannelManager retries."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._context_tokens["wx-user"] = "bad-token"
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"ret": -2}, # with token
|
||||||
|
{"ret": -2}, # without token
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error"):
|
||||||
|
await channel._send_text("wx-user", "hello", "bad-token")
|
||||||
|
|
||||||
|
assert channel._api_post.await_count == 2
|
||||||
|
# Token is NOT cleared because retry also failed
|
||||||
|
assert channel._context_tokens.get("wx-user") == "bad-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_on_ret_minus_two_when_no_context_token() -> None:
|
||||||
|
"""If no context_token was provided, ret=-2 stale session is raised."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock(return_value={"ret": -2})
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error"):
|
||||||
|
await channel._send_text("wx-user", "hello", "")
|
||||||
|
|
||||||
|
# Only one API call (no retry possible without token)
|
||||||
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for _is_stale_session_ret (hermes-agent#17228 / #18105)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsStaleSessionRet:
|
||||||
|
"""Verify stale-session detection for iLink ret=-2 / errcode=-2 responses."""
|
||||||
|
|
||||||
|
def test_ret_minus_2_with_empty_errmsg_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "") is True
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, None) is True
|
||||||
|
|
||||||
|
def test_errcode_minus_2_with_empty_errmsg_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, "") is True
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, None) is True
|
||||||
|
|
||||||
|
def test_ret_minus_2_with_unknown_error_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "unknown error") is True
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "UNKNOWN ERROR") is True
|
||||||
|
|
||||||
|
def test_errcode_minus_2_with_unknown_error_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, "unknown error") is True
|
||||||
|
|
||||||
|
def test_ret_minus_2_with_frequency_limit_is_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "frequency limit") is False
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "too frequently") is False
|
||||||
|
|
||||||
|
def test_errcode_minus_2_with_frequency_limit_is_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, "freq limit") is False
|
||||||
|
|
||||||
|
def test_success_codes_are_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, 0, "") is False
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, 0, None) is False
|
||||||
|
|
||||||
|
def test_other_errors_are_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-14, -14, "session timeout") is False
|
||||||
|
assert weixin_mod._is_stale_session_ret(-100, 0, "internal error") is False
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ async def test_send_when_disconnected_is_noop():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_group_policy_mention_skips_unmentioned_group_message():
|
async def test_group_policy_mention_skips_unmentioned_group_message():
|
||||||
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock())
|
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
|
||||||
ch._handle_message = AsyncMock()
|
ch._handle_message = AsyncMock()
|
||||||
|
|
||||||
await ch._handle_bridge_message(
|
await ch._handle_bridge_message(
|
||||||
@@ -139,7 +139,7 @@ async def test_group_policy_mention_skips_unmentioned_group_message():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_group_policy_mention_accepts_mentioned_group_message():
|
async def test_group_policy_mention_accepts_mentioned_group_message():
|
||||||
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock())
|
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
|
||||||
ch._handle_message = AsyncMock()
|
ch._handle_message = AsyncMock()
|
||||||
|
|
||||||
await ch._handle_bridge_message(
|
await ch._handle_bridge_message(
|
||||||
@@ -166,7 +166,7 @@ async def test_group_policy_mention_accepts_mentioned_group_message():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sender_id_prefers_phone_jid_over_lid():
|
async def test_sender_id_prefers_phone_jid_over_lid():
|
||||||
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
|
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
|
||||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||||
ch._handle_message = AsyncMock()
|
ch._handle_message = AsyncMock()
|
||||||
|
|
||||||
await ch._handle_bridge_message(
|
await ch._handle_bridge_message(
|
||||||
@@ -187,7 +187,7 @@ async def test_sender_id_prefers_phone_jid_over_lid():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_lid_to_phone_cache_resolves_lid_only_messages():
|
async def test_lid_to_phone_cache_resolves_lid_only_messages():
|
||||||
"""When only LID is present, a cached LID→phone mapping should be used."""
|
"""When only LID is present, a cached LID→phone mapping should be used."""
|
||||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||||
ch._handle_message = AsyncMock()
|
ch._handle_message = AsyncMock()
|
||||||
|
|
||||||
# First message: both phone and LID → builds cache
|
# First message: both phone and LID → builds cache
|
||||||
@@ -220,7 +220,7 @@ async def test_lid_to_phone_cache_resolves_lid_only_messages():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_voice_message_transcription_uses_media_path():
|
async def test_voice_message_transcription_uses_media_path():
|
||||||
"""Voice messages are transcribed when media path is available."""
|
"""Voice messages are transcribed when media path is available."""
|
||||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||||
ch.transcription_provider = "openai"
|
ch.transcription_provider = "openai"
|
||||||
ch.transcription_api_key = "sk-test"
|
ch.transcription_api_key = "sk-test"
|
||||||
ch._handle_message = AsyncMock()
|
ch._handle_message = AsyncMock()
|
||||||
@@ -243,10 +243,32 @@ async def test_voice_message_transcription_uses_media_path():
|
|||||||
assert kwargs["content"].startswith("Hello world")
|
assert kwargs["content"].startswith("Hello world")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthorized_voice_message_does_not_transcribe() -> None:
|
||||||
|
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
|
||||||
|
ch._handle_message = AsyncMock()
|
||||||
|
ch.transcribe_audio = AsyncMock(return_value="Hello world")
|
||||||
|
|
||||||
|
await ch._handle_bridge_message(
|
||||||
|
json.dumps({
|
||||||
|
"type": "message",
|
||||||
|
"id": "v-blocked",
|
||||||
|
"sender": "blocked@s.whatsapp.net",
|
||||||
|
"pn": "",
|
||||||
|
"content": "[Voice Message]",
|
||||||
|
"timestamp": 1,
|
||||||
|
"media": ["/tmp/voice.ogg"],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ch.transcribe_audio.assert_not_awaited()
|
||||||
|
ch._handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_voice_message_no_media_shows_not_available():
|
async def test_voice_message_no_media_shows_not_available():
|
||||||
"""Voice messages without media produce a fallback placeholder."""
|
"""Voice messages without media produce a fallback placeholder."""
|
||||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||||
ch._handle_message = AsyncMock()
|
ch._handle_message = AsyncMock()
|
||||||
|
|
||||||
await ch._handle_bridge_message(
|
await ch._handle_bridge_message(
|
||||||
|
|||||||
+181
-26
@@ -9,7 +9,7 @@ import pytest
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.cli.commands import _make_provider, app
|
from nanobot.cli.commands import app
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.cron.types import CronJob, CronPayload
|
from nanobot.cron.types import CronJob, CronPayload
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
@@ -220,6 +220,89 @@ def test_config_dump_excludes_oauth_provider_blocks():
|
|||||||
assert "githubCopilot" not in providers
|
assert "githubCopilot" not in providers
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch):
|
||||||
|
token_path = tmp_path / "auth" / "codex.json"
|
||||||
|
lock_path = token_path.with_suffix(".lock")
|
||||||
|
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
token_path.write_text("{}", encoding="utf-8")
|
||||||
|
lock_path.write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provider", "logout", "openai-codex"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert not token_path.exists()
|
||||||
|
assert not lock_path.exists()
|
||||||
|
assert "Logged out from OpenAI Codex" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path):
|
||||||
|
token_path = tmp_path / "auth" / "codex.json"
|
||||||
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provider", "logout", "openai-codex"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No local OAuth credentials found for OpenAI Codex" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_logout_github_copilot_removes_local_oauth_files(tmp_path, monkeypatch):
|
||||||
|
token_path = tmp_path / "auth" / "github-copilot.json"
|
||||||
|
lock_path = token_path.with_suffix(".lock")
|
||||||
|
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
token_path.write_text("{}", encoding="utf-8")
|
||||||
|
lock_path.write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provider", "logout", "github-copilot"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert not token_path.exists()
|
||||||
|
assert not lock_path.exists()
|
||||||
|
assert "Logged out from GitHub Copilot" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_logout_github_copilot_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path):
|
||||||
|
token_path = tmp_path / "auth" / "github-copilot.json"
|
||||||
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provider", "logout", "github-copilot"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No local OAuth credentials found for GitHub Copilot" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_logout_rejects_unknown_provider():
|
||||||
|
result = runner.invoke(app, ["provider", "logout", "not-a-real-provider"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Unknown OAuth provider" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_logout_paths_resolve_to_expected_files():
|
||||||
|
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
||||||
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
|
|
||||||
|
from nanobot.providers.github_copilot_provider import get_storage
|
||||||
|
|
||||||
|
codex_storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
|
||||||
|
codex_path = codex_storage.get_token_path()
|
||||||
|
assert codex_path.name == "codex.json"
|
||||||
|
assert codex_path.parent.name == "auth"
|
||||||
|
|
||||||
|
gh_storage = get_storage()
|
||||||
|
gh_path = gh_storage.get_token_path()
|
||||||
|
assert gh_path.name == "github-copilot.json"
|
||||||
|
assert gh_path.parent.name == "auth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_login_rejects_unknown_provider():
|
||||||
|
result = runner.invoke(app, ["provider", "login", "not-a-real-provider"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Unknown OAuth provider" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "ollama/llama3.2"
|
config.agents.defaults.model = "ollama/llama3.2"
|
||||||
@@ -285,6 +368,40 @@ def test_find_by_name_accepts_camel_case_and_hyphen_aliases():
|
|||||||
assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan"
|
assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan"
|
||||||
assert find_by_name("github-copilot") is not None
|
assert find_by_name("github-copilot") is not None
|
||||||
assert find_by_name("github-copilot").name == "github_copilot"
|
assert find_by_name("github-copilot").name == "github_copilot"
|
||||||
|
assert find_by_name("longcat") is not None
|
||||||
|
assert find_by_name("longcat").name == "longcat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_explicit_longcat_provider_resolves_provider_name():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "longcat",
|
||||||
|
"model": "LongCat-Flash-Chat",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"longcat": {
|
||||||
|
"apiKey": "test-key",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.get_provider_name() == "longcat"
|
||||||
|
assert config.get_api_base() == "https://api.longcat.chat/openai/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_auto_detects_longcat_from_model_keyword():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"agents": {"defaults": {"provider": "auto", "model": "longcat/LongCat-Flash-Chat"}},
|
||||||
|
"providers": {"longcat": {"apiKey": "test-key"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.get_provider_name() == "longcat"
|
||||||
|
|
||||||
|
|
||||||
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
|
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
|
||||||
@@ -371,8 +488,8 @@ def test_openai_compat_provider_passes_model_through():
|
|||||||
|
|
||||||
|
|
||||||
def test_make_provider_uses_github_copilot_backend():
|
def test_make_provider_uses_github_copilot_backend():
|
||||||
from nanobot.cli.commands import _make_provider
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.providers.factory import build_provider_for_preset
|
||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
@@ -386,7 +503,7 @@ def test_make_provider_uses_github_copilot_backend():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = _make_provider(config)
|
provider = build_provider_for_preset(config, config.resolve_preset())
|
||||||
|
|
||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||||
|
|
||||||
@@ -445,6 +562,8 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
|||||||
|
|
||||||
|
|
||||||
def test_make_provider_passes_extra_headers_to_custom_provider():
|
def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||||
|
from nanobot.providers.factory import build_provider_for_preset
|
||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
||||||
@@ -462,7 +581,7 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
||||||
_make_provider(config)
|
build_provider_for_preset(config, config.resolve_preset())
|
||||||
|
|
||||||
kwargs = mock_async_openai.call_args.kwargs
|
kwargs = mock_async_openai.call_args.kwargs
|
||||||
assert kwargs["api_key"] == "test-key"
|
assert kwargs["api_key"] == "test-key"
|
||||||
@@ -480,11 +599,11 @@ def mock_agent_runtime(tmp_path):
|
|||||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||||
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
||||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||||
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
patch("nanobot.providers.factory.build_provider_for_preset", return_value=MagicMock(generation=MagicMock(max_tokens=8192))), \
|
||||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||||
patch("nanobot.bus.queue.MessageBus"), \
|
patch("nanobot.bus.queue.MessageBus"), \
|
||||||
patch("nanobot.cron.service.CronService"), \
|
patch("nanobot.cron.service.CronService"), \
|
||||||
patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls:
|
patch("nanobot.cli.commands.AgentLoop") as mock_agent_loop_cls:
|
||||||
agent_loop = MagicMock()
|
agent_loop = MagicMock()
|
||||||
agent_loop.channels_config = None
|
agent_loop.channels_config = None
|
||||||
agent_loop.process_direct = AsyncMock(
|
agent_loop.process_direct = AsyncMock(
|
||||||
@@ -492,6 +611,7 @@ def mock_agent_runtime(tmp_path):
|
|||||||
)
|
)
|
||||||
agent_loop.close_mcp = AsyncMock(return_value=None)
|
agent_loop.close_mcp = AsyncMock(return_value=None)
|
||||||
mock_agent_loop_cls.return_value = agent_loop
|
mock_agent_loop_cls.return_value = agent_loop
|
||||||
|
mock_agent_loop_cls.from_config.return_value = agent_loop
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"config": config,
|
"config": config,
|
||||||
@@ -522,7 +642,7 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
|
|||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
||||||
mock_agent_runtime["config"].workspace_path,
|
mock_agent_runtime["config"].workspace_path,
|
||||||
)
|
)
|
||||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == (
|
assert mock_agent_runtime["agent_loop_cls"].from_config.call_args.args[0].workspace_path == (
|
||||||
mock_agent_runtime["config"].workspace_path
|
mock_agent_runtime["config"].workspace_path
|
||||||
)
|
)
|
||||||
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
||||||
@@ -555,7 +675,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
||||||
|
|
||||||
@@ -563,13 +683,17 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
@@ -590,7 +714,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
|
|
||||||
class _FakeCron:
|
class _FakeCron:
|
||||||
@@ -601,6 +725,10 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
@@ -608,7 +736,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
@@ -636,7 +764,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
|
|
||||||
@@ -648,6 +776,10 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
@@ -655,7 +787,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
@@ -689,7 +821,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
|
|
||||||
@@ -701,6 +833,10 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
@@ -708,7 +844,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
||||||
)
|
)
|
||||||
@@ -729,7 +865,7 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
||||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
|
assert mock_agent_runtime["agent_loop_cls"].from_config.call_args.args[0].workspace_path == workspace_path
|
||||||
|
|
||||||
|
|
||||||
def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
|
def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
|
||||||
@@ -746,7 +882,7 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
|
|||||||
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
||||||
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
||||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
|
assert mock_agent_runtime["agent_loop_cls"].from_config.call_args.args[0].workspace_path == workspace_path
|
||||||
|
|
||||||
|
|
||||||
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
|
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
|
||||||
@@ -811,8 +947,8 @@ def _patch_cli_command_runtime(
|
|||||||
sync_templates or (lambda _path: None),
|
sync_templates or (lambda _path: None),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._make_provider",
|
"nanobot.providers.factory.build_provider_for_preset",
|
||||||
provider_factory,
|
lambda *_a, **_k: provider_factory(Config()),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
@@ -845,6 +981,10 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
seen["workspace"] = kwargs["workspace"]
|
seen["workspace"] = kwargs["workspace"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config, bus=None, **kwargs):
|
||||||
|
return cls(workspace=config.workspace_path, **kwargs)
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -868,7 +1008,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||||
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
|
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
|
||||||
|
|
||||||
@@ -960,7 +1100,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider)
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *_a, **_k: provider)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
lambda _config: _test_provider_snapshot(provider, _config),
|
lambda _config: _test_provider_snapshot(provider, _config),
|
||||||
@@ -1000,8 +1140,13 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
class _FakeAgentLoop:
|
class _FakeAgentLoop:
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
self.model = "test-model"
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
self.tools = {}
|
self.tools = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel="telegram",
|
channel="telegram",
|
||||||
@@ -1035,7 +1180,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.utils.evaluator.evaluate_response",
|
"nanobot.utils.evaluator.evaluate_response",
|
||||||
@@ -1064,7 +1209,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
|
|
||||||
assert response == "Time to stretch."
|
assert response == "Time to stretch."
|
||||||
assert seen["response"] == "Time to stretch."
|
assert seen["response"] == "Time to stretch."
|
||||||
assert seen["provider"] is provider
|
assert seen["provider"] is not None # provider resolved inside AgentLoop
|
||||||
assert seen["model"] == "test-model"
|
assert seen["model"] == "test-model"
|
||||||
assert seen["task_context"] == (
|
assert seen["task_context"] == (
|
||||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||||
@@ -1111,7 +1256,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
lambda _config: _test_provider_snapshot(object(), _config),
|
lambda _config: _test_provider_snapshot(object(), _config),
|
||||||
@@ -1131,8 +1276,13 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
|||||||
class _FakeAgentLoop:
|
class _FakeAgentLoop:
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
self.model = "test-model"
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
self.tools = {}
|
self.tools = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, on_progress=None, **_kwargs):
|
async def process_direct(self, *_args, on_progress=None, **_kwargs):
|
||||||
seen["on_progress"] = on_progress
|
seen["on_progress"] = on_progress
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@@ -1158,7 +1308,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.utils.evaluator.evaluate_response",
|
"nanobot.utils.evaluator.evaluate_response",
|
||||||
@@ -1363,9 +1513,14 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
class _FakeAgentLoop:
|
class _FakeAgentLoop:
|
||||||
def __init__(self, **_kwargs) -> None:
|
def __init__(self, **_kwargs) -> None:
|
||||||
self.model = "test-model"
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
self.dream = _FakeDream()
|
self.dream = _FakeDream()
|
||||||
self.sessions = _FakeSessionManager()
|
self.sessions = _FakeSessionManager()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
@@ -1454,7 +1609,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||||
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
|
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.cli import commands
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress_disabled():
|
||||||
|
"""Provider retry waits should not fall through as assistant responses."""
|
||||||
|
calls: list[tuple[str, object | None]] = []
|
||||||
|
thinking = None
|
||||||
|
channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False)
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content="Model request failed, retry in 2s (attempt 1).",
|
||||||
|
metadata={"_retry_wait": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_print(text: str, active_thinking: object | None) -> None:
|
||||||
|
calls.append((text, active_thinking))
|
||||||
|
|
||||||
|
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
|
||||||
|
handled = await commands._maybe_print_interactive_progress(
|
||||||
|
msg,
|
||||||
|
thinking,
|
||||||
|
channels_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert handled is True
|
||||||
|
assert calls == [("Model request failed, retry in 2s (attempt 1).", thinking)]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user