mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +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`
|
||||
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?
|
||||
|
||||
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
|
||||
|
||||
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
|
||||
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
|
||||
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack / 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-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-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.
|
||||
@@ -122,6 +123,7 @@
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## 📦 Install
|
||||
|
||||
+11
-6
@@ -17,7 +17,7 @@ import { Boom } from '@hapi/boom';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import pino from 'pino';
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { join, basename } from 'path';
|
||||
import { join, basename, resolve, sep } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const VERSION = '0.1.0';
|
||||
@@ -165,6 +165,10 @@ export class WhatsAppClient {
|
||||
fallbackContent = '[Video]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
|
||||
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 : '') || '';
|
||||
@@ -196,17 +200,18 @@ export class WhatsAppClient {
|
||||
|
||||
let outFilename: string;
|
||||
if (fileName) {
|
||||
// Documents have a filename — use it with a unique prefix to avoid collisions
|
||||
const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
|
||||
outFilename = prefix + fileName;
|
||||
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
|
||||
} else {
|
||||
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');
|
||||
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);
|
||||
|
||||
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) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.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) |
|
||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.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) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
|
||||
| `ollama` | LLM (local, Ollama) | — |
|
||||
| `lm_studio` | LLM (local, LM Studio) | — |
|
||||
| `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` |
|
||||
| `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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
## 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
|
||||
|
||||
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).
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
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 |
|
||||
|--------|---------|-------------|
|
||||
| `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.
|
||||
- **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
|
||||
|
||||
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
|
||||
my(action="check")
|
||||
# → max_iterations: 40
|
||||
# context_window_tokens: 65536
|
||||
# model: 'anthropic/claude-sonnet-4-20250514'
|
||||
# model_preset: 'fast'
|
||||
# workspace: PosixPath('/tmp/workspace')
|
||||
# provider_retry_mode: 'standard'
|
||||
# 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")
|
||||
# → How many prompt tokens I've used so far
|
||||
|
||||
my(action="check", key="model")
|
||||
# → What model I'm currently running on
|
||||
my(action="check", key="model_preset")
|
||||
# → 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")
|
||||
# → Whether web search is enabled
|
||||
@@ -66,7 +75,7 @@ my(action="check", key="web_config.enable")
|
||||
|
||||
| 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 tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
|
||||
| "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)
|
||||
# → Bump iteration limit from 40 to 80
|
||||
|
||||
my(action="set", key="model", value="fast-model")
|
||||
# → Switch to a faster model
|
||||
my(action="set", key="model_preset", value="fast")
|
||||
# → 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)
|
||||
# → Expand context window for long documents
|
||||
@@ -101,15 +113,17 @@ my(action="set", key="task_complexity", value="high")
|
||||
|
||||
### 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 |
|
||||
| `context_window_tokens` | int | 4,096–1,000,000 | Context window size |
|
||||
| `model` | str | non-empty | LLM model to use |
|
||||
| `model_preset` | str | must exist in `model_presets` | Switch to a named preset bundle |
|
||||
|
||||
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"
|
||||
|
||||
```text
|
||||
Agent: This is a straightforward question, let me switch to a faster model.
|
||||
→ my(action="set", key="model", value="fast-model")
|
||||
Agent: This is a straightforward question, let me switch to the fast preset.
|
||||
→ my(action="set", key="model_preset", value="fast")
|
||||
```
|
||||
|
||||
### "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**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import base64
|
||||
import mimetypes
|
||||
import platform
|
||||
from contextlib import suppress
|
||||
from importlib.resources import files as pkg_files
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -82,12 +83,14 @@ class ContextBuilder:
|
||||
@staticmethod
|
||||
def _build_runtime_context(
|
||||
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:
|
||||
"""Build untrusted runtime metadata block for injection before the user message."""
|
||||
lines = [f"Current Time: {current_time_str(timezone)}"]
|
||||
if channel and chat_id:
|
||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
||||
if sender_id:
|
||||
lines += [f"Sender ID: {sender_id}"]
|
||||
if session_summary:
|
||||
lines += ["", "[Resumed Session]", session_summary]
|
||||
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
|
||||
@@ -121,12 +124,10 @@ class ContextBuilder:
|
||||
@staticmethod
|
||||
def _is_template_content(content: str, template_path: str) -> bool:
|
||||
"""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
|
||||
if tpl.is_file():
|
||||
return content.strip() == tpl.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def build_messages(
|
||||
@@ -139,9 +140,10 @@ class ContextBuilder:
|
||||
chat_id: str | None = None,
|
||||
current_role: str = "user",
|
||||
session_summary: str | None = None,
|
||||
sender_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""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)
|
||||
|
||||
# Merge runtime context and user content into a single user message
|
||||
|
||||
@@ -102,3 +102,22 @@ class CompositeHook(AgentHook):
|
||||
for h in self._hooks:
|
||||
content = h.finalize_content(context, 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 os
|
||||
import time
|
||||
from contextlib import AsyncExitStack, nullcontext
|
||||
from contextlib import AsyncExitStack, nullcontext, suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
@@ -28,6 +28,7 @@ from nanobot.agent.tools.ask import (
|
||||
pending_ask_user_id,
|
||||
)
|
||||
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.message import MessageTool
|
||||
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.queue import MessageBus
|
||||
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.factory import ProviderSnapshot
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@@ -54,6 +55,7 @@ from nanobot.utils.progress_events import (
|
||||
on_progress_accepts_tool_events,
|
||||
)
|
||||
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:
|
||||
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
|
||||
@@ -111,6 +113,11 @@ class _LoopHook(AgentHook):
|
||||
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
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:
|
||||
if self._on_progress:
|
||||
@@ -181,6 +188,50 @@ class AgentLoop:
|
||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||
_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__(
|
||||
self,
|
||||
bus: MessageBus,
|
||||
@@ -192,6 +243,9 @@ class AgentLoop:
|
||||
context_block_limit: int | None = None,
|
||||
max_tool_result_chars: int | None = None,
|
||||
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,
|
||||
exec_config: ExecToolConfig | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
@@ -209,6 +263,8 @@ class AgentLoop:
|
||||
tools_config: ToolsConfig | None = None,
|
||||
provider_snapshot_loader: Callable[[], ProviderSnapshot] | 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
|
||||
|
||||
@@ -216,7 +272,12 @@ class AgentLoop:
|
||||
defaults = AgentDefaults()
|
||||
self.bus = bus
|
||||
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_signature = provider_signature
|
||||
self.workspace = workspace
|
||||
@@ -236,6 +297,10 @@ class AgentLoop:
|
||||
else defaults.max_tool_result_chars
|
||||
)
|
||||
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.exec_config = exec_config or ExecToolConfig()
|
||||
self.cron_service = cron_service
|
||||
@@ -247,9 +312,12 @@ class AgentLoop:
|
||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||
self.sessions = session_manager or SessionManager(workspace)
|
||||
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(
|
||||
provider=provider,
|
||||
provider=wrapped_provider,
|
||||
workspace=workspace,
|
||||
bus=bus,
|
||||
model=self.model,
|
||||
@@ -258,6 +326,7 @@ class AgentLoop:
|
||||
exec_config=self.exec_config,
|
||||
restrict_to_workspace=restrict_to_workspace,
|
||||
disabled_skills=disabled_skills,
|
||||
max_iterations=self.max_iterations,
|
||||
)
|
||||
self._unified_session = unified_session
|
||||
self._max_messages = max_messages if max_messages > 0 else 120
|
||||
@@ -280,13 +349,13 @@ class AgentLoop:
|
||||
)
|
||||
self.consolidator = Consolidator(
|
||||
store=self.context.memory,
|
||||
provider=provider,
|
||||
provider=wrapped_provider,
|
||||
model=self.model,
|
||||
sessions=self.sessions,
|
||||
context_window_tokens=self.context_window_tokens,
|
||||
build_messages=self.context.build_messages,
|
||||
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,
|
||||
)
|
||||
self.auto_compact = AutoCompact(
|
||||
@@ -296,9 +365,13 @@ class AgentLoop:
|
||||
)
|
||||
self.dream = Dream(
|
||||
store=self.context.memory,
|
||||
provider=provider,
|
||||
provider=wrapped_provider,
|
||||
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()
|
||||
if _tc.my.enable:
|
||||
self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set))
|
||||
@@ -307,6 +380,42 @@ class AgentLoop:
|
||||
self.commands = CommandRouter()
|
||||
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:
|
||||
"""Swap model/provider for future turns without disturbing an active one."""
|
||||
provider = snapshot.provider
|
||||
@@ -315,14 +424,13 @@ class AgentLoop:
|
||||
if self.provider is provider and self.model == model:
|
||||
return
|
||||
old_model = self.model
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.context_window_tokens = context_window_tokens
|
||||
self.runner.provider = provider
|
||||
self.subagents.set_provider(provider, model)
|
||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||
self.dream.set_provider(provider, model)
|
||||
provider = self._wrap_with_failover(provider, model)
|
||||
self._apply_provider_state(provider, model, context_window_tokens)
|
||||
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)
|
||||
|
||||
def _refresh_provider_snapshot(self) -> None:
|
||||
@@ -337,6 +445,58 @@ class AgentLoop:
|
||||
return
|
||||
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:
|
||||
"""Register the default set of tools."""
|
||||
allowed_dir = (
|
||||
@@ -346,7 +506,9 @@ class AgentLoop:
|
||||
self.tools.register(AskUserTool())
|
||||
self.tools.register(
|
||||
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):
|
||||
@@ -363,6 +525,8 @@ class AgentLoop:
|
||||
sandbox=self.exec_config.sandbox,
|
||||
path_append=self.exec_config.path_append,
|
||||
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:
|
||||
@@ -404,7 +568,7 @@ class AgentLoop:
|
||||
logger.warning("MCP connection cancelled (will retry next message)")
|
||||
self._mcp_stacks.clear()
|
||||
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()
|
||||
finally:
|
||||
self._mcp_connecting = False
|
||||
@@ -430,6 +594,8 @@ class AgentLoop:
|
||||
if hasattr(tool, "set_context"):
|
||||
if name == "spawn":
|
||||
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":
|
||||
tool.set_context(channel, chat_id, metadata=metadata, session_key=session_key)
|
||||
elif name == "message":
|
||||
@@ -451,12 +617,11 @@ class AgentLoop:
|
||||
"""Return the chat id shown in runtime metadata for the model."""
|
||||
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
||||
|
||||
@staticmethod
|
||||
def _tool_hint(tool_calls: list) -> str:
|
||||
def _tool_hint(self, tool_calls: list) -> str:
|
||||
"""Format tool calls as concise hints with smart abbreviation."""
|
||||
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(
|
||||
self,
|
||||
@@ -481,10 +646,8 @@ class AgentLoop:
|
||||
tasks = self._active_tasks.pop(key, [])
|
||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||
for t in tasks:
|
||||
try:
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||
return cancelled + sub_cancelled
|
||||
|
||||
@@ -531,6 +694,8 @@ class AgentLoop:
|
||||
|
||||
Returns (final_content, tools_used, messages, stop_reason, had_injections).
|
||||
"""
|
||||
self._sync_subagent_runtime_limits()
|
||||
|
||||
loop_hook = _LoopHook(
|
||||
self,
|
||||
on_progress=on_progress,
|
||||
@@ -611,25 +776,31 @@ class AgentLoop:
|
||||
|
||||
return items
|
||||
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
initial_messages=initial_messages,
|
||||
tools=self.tools,
|
||||
model=self.model,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hook=hook,
|
||||
error_message="Sorry, I encountered an error calling the AI model.",
|
||||
concurrent_tools=True,
|
||||
workspace=self.workspace,
|
||||
session_key=session.key if session else None,
|
||||
context_window_tokens=self.context_window_tokens,
|
||||
context_block_limit=self.context_block_limit,
|
||||
provider_retry_mode=self.provider_retry_mode,
|
||||
progress_callback=on_progress,
|
||||
retry_wait_callback=on_retry_wait,
|
||||
checkpoint_callback=_checkpoint,
|
||||
injection_callback=_drain_pending,
|
||||
))
|
||||
active_session_key = session.key if session else session_key
|
||||
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||
try:
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
initial_messages=initial_messages,
|
||||
tools=self.tools,
|
||||
model=self.model,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hook=hook,
|
||||
error_message="Sorry, I encountered an error calling the AI model.",
|
||||
concurrent_tools=True,
|
||||
workspace=self.workspace,
|
||||
session_key=session.key if session else None,
|
||||
context_window_tokens=self.context_window_tokens,
|
||||
context_block_limit=self.context_block_limit,
|
||||
provider_retry_mode=self.provider_retry_mode,
|
||||
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
|
||||
if result.stop_reason == "max_iterations":
|
||||
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
||||
@@ -776,6 +947,33 @@ class AgentLoop:
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
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:
|
||||
logger.info("Task cancelled for session {}", session_key)
|
||||
# Preserve partial context from the interrupted turn so
|
||||
@@ -879,6 +1077,8 @@ class AgentLoop:
|
||||
self.sessions.save(session)
|
||||
|
||||
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(
|
||||
session,
|
||||
@@ -891,6 +1091,7 @@ class AgentLoop:
|
||||
# LLM via the merged prompt. See _persist_subagent_followup.
|
||||
is_subagent = msg.sender_id == "subagent"
|
||||
if is_subagent and self._persist_subagent_followup(session, msg):
|
||||
logger.debug("Subagent result persisted for session {}", key)
|
||||
self.sessions.save(session)
|
||||
self._set_tool_context(
|
||||
channel, chat_id, msg.metadata.get("message_id"),
|
||||
@@ -913,6 +1114,7 @@ class AgentLoop:
|
||||
chat_id=chat_id,
|
||||
session_summary=pending,
|
||||
current_role=current_role,
|
||||
sender_id=msg.sender_id,
|
||||
)
|
||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||
messages, session=session, channel=channel, chat_id=chat_id,
|
||||
@@ -940,6 +1142,8 @@ class AgentLoop:
|
||||
outbound_metadata: dict[str, Any] = {}
|
||||
if channel == "slack" and key.startswith("slack:") and key.count(":") >= 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(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
@@ -959,6 +1163,7 @@ class AgentLoop:
|
||||
|
||||
key = session_key or msg.session_key
|
||||
session = self.sessions.get_or_create(key)
|
||||
mark_webui_session(session, msg.metadata)
|
||||
if self._restore_runtime_checkpoint(session):
|
||||
self.sessions.save(session)
|
||||
if self._restore_pending_user_turn(session):
|
||||
@@ -1008,6 +1213,7 @@ class AgentLoop:
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel,
|
||||
chat_id=self._runtime_chat_id(msg),
|
||||
sender_id=msg.sender_id,
|
||||
)
|
||||
|
||||
async def _bus_progress(
|
||||
@@ -1103,7 +1309,7 @@ class AgentLoop:
|
||||
ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [],
|
||||
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
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
|
||||
+21
-21
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import weakref
|
||||
from contextlib import suppress
|
||||
import tiktoken
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -296,10 +297,8 @@ class MemoryStore:
|
||||
def _next_cursor(self) -> int:
|
||||
"""Read the current cursor counter and return the next value."""
|
||||
if self._cursor_file.exists():
|
||||
try:
|
||||
with suppress(ValueError, OSError):
|
||||
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
|
||||
# file and take ``max`` — that stays correct even if the monotonic
|
||||
# invariant was broken by external writes.
|
||||
@@ -328,7 +327,7 @@ class MemoryStore:
|
||||
def _read_entries(self) -> list[dict[str, Any]]:
|
||||
"""Read all entries from history.jsonl."""
|
||||
entries: list[dict[str, Any]] = []
|
||||
try:
|
||||
with suppress(FileNotFoundError):
|
||||
with open(self.history_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
@@ -337,8 +336,7 @@ class MemoryStore:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return entries
|
||||
|
||||
def _read_last_entry(self) -> dict[str, Any] | None:
|
||||
@@ -374,14 +372,12 @@ class MemoryStore:
|
||||
# On Windows, opening a directory with O_RDONLY raises
|
||||
# PermissionError — skip the dir sync there (NTFS
|
||||
# journals metadata synchronously).
|
||||
try:
|
||||
with suppress(PermissionError):
|
||||
fd = os.open(str(self.history_file.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except PermissionError:
|
||||
pass # Windows — directory fsync not supported
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -390,10 +386,8 @@ class MemoryStore:
|
||||
|
||||
def get_last_dream_cursor(self) -> int:
|
||||
if self._dream_cursor_file.exists():
|
||||
try:
|
||||
with suppress(ValueError, OSError):
|
||||
return int(self._dream_cursor_file.read_text(encoding="utf-8").strip())
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
def set_last_dream_cursor(self, cursor: int) -> None:
|
||||
@@ -524,6 +518,7 @@ class Consolidator:
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
session_summary=session_summary,
|
||||
sender_id=None,
|
||||
)
|
||||
return estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
@@ -753,23 +748,28 @@ class Dream:
|
||||
def _build_tools(self) -> ToolRegistry:
|
||||
"""Build a minimal tool registry for the Dream agent."""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
||||
|
||||
tools = ToolRegistry()
|
||||
workspace = self.store.workspace
|
||||
# Allow reading builtin skills for reference during skill creation
|
||||
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(
|
||||
workspace=workspace,
|
||||
allowed_dir=workspace,
|
||||
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 under skills/ so the prompt can safely use skills/<name>/SKILL.md.
|
||||
skills_dir = workspace / "skills"
|
||||
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
|
||||
|
||||
# -- skill listing --------------------------------------------------------
|
||||
@@ -974,12 +974,10 @@ class Dream:
|
||||
if event["status"] == "ok":
|
||||
changelog.append(f"{event['name']}: {event['detail']}")
|
||||
|
||||
# Advance cursor — always, to avoid re-processing Phase 1
|
||||
new_cursor = batch[-1]["cursor"]
|
||||
self.store.set_last_dream_cursor(new_cursor)
|
||||
self.store.compact_history()
|
||||
|
||||
# Only advance cursor on successful completion to prevent silent loss
|
||||
if result and result.stop_reason == "completed":
|
||||
new_cursor = batch[-1]["cursor"]
|
||||
self.store.set_last_dream_cursor(new_cursor)
|
||||
logger.info(
|
||||
"Dream done: {} change(s), cursor advanced to {}",
|
||||
len(changelog), new_cursor,
|
||||
@@ -987,10 +985,12 @@ class Dream:
|
||||
else:
|
||||
reason = result.stop_reason if result else "exception"
|
||||
logger.warning(
|
||||
"Dream incomplete ({}): cursor advanced to {}",
|
||||
reason, new_cursor,
|
||||
"Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle",
|
||||
reason,
|
||||
)
|
||||
|
||||
self.store.compact_history()
|
||||
|
||||
# Git auto-commit (only when there are actual changes)
|
||||
if changelog and self.store.git.is_initialized():
|
||||
ts = batch[-1]["timestamp"]
|
||||
|
||||
+136
-51
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -32,6 +33,7 @@ from nanobot.utils.runtime import (
|
||||
ensure_nonempty_tool_result,
|
||||
is_blank_text,
|
||||
repeated_external_lookup_error,
|
||||
repeated_workspace_violation_error,
|
||||
)
|
||||
|
||||
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
||||
@@ -74,6 +76,7 @@ class AgentRunSpec:
|
||||
context_block_limit: int | None = None
|
||||
provider_retry_mode: str = "standard"
|
||||
progress_callback: Any | None = None
|
||||
stream_progress_deltas: bool = True
|
||||
retry_wait_callback: Any | None = None
|
||||
checkpoint_callback: Any | None = None
|
||||
injection_callback: Any | None = None
|
||||
@@ -238,6 +241,8 @@ class AgentRunner:
|
||||
stop_reason = "completed"
|
||||
tool_events: list[dict[str, str]] = []
|
||||
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
|
||||
length_recovery_count = 0
|
||||
had_injections = False
|
||||
@@ -257,12 +262,11 @@ class AgentRunner:
|
||||
# Snipping may have created new orphans; clean them up.
|
||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Context governance failed on turn {} for {}: {}; applying minimal repair",
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Context governance failed on turn {} for {}; applying minimal repair",
|
||||
iteration,
|
||||
spec.session_key or "default",
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
@@ -313,6 +317,7 @@ class AgentRunner:
|
||||
spec,
|
||||
tool_calls,
|
||||
external_lookup_counts,
|
||||
workspace_violation_counts,
|
||||
)
|
||||
tool_events.extend(new_events)
|
||||
context.tool_results = list(results)
|
||||
@@ -611,6 +616,7 @@ class AgentRunner:
|
||||
wants_streaming = hook.wants_streaming()
|
||||
wants_progress_streaming = (
|
||||
not wants_streaming
|
||||
and spec.stream_progress_deltas
|
||||
and spec.progress_callback is not None
|
||||
and getattr(self.provider, "supports_progress_deltas", False) is True
|
||||
)
|
||||
@@ -697,20 +703,25 @@ class AgentRunner:
|
||||
spec: AgentRunSpec,
|
||||
tool_calls: list[ToolCallRequest],
|
||||
external_lookup_counts: dict[str, int],
|
||||
workspace_violation_counts: dict[str, int],
|
||||
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
|
||||
batches = self._partition_tool_batches(spec, tool_calls)
|
||||
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
||||
for batch in batches:
|
||||
if spec.concurrent_tools and len(batch) > 1:
|
||||
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
|
||||
))
|
||||
tool_results.extend(batch_results)
|
||||
else:
|
||||
batch_results = []
|
||||
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)
|
||||
batch_results.append(result)
|
||||
if isinstance(result[2], AskUserInterrupt):
|
||||
@@ -733,6 +744,7 @@ class AgentRunner:
|
||||
spec: AgentRunSpec,
|
||||
tool_call: ToolCallRequest,
|
||||
external_lookup_counts: dict[str, int],
|
||||
workspace_violation_counts: dict[str, int],
|
||||
) -> tuple[Any, dict[str, str], BaseException | None]:
|
||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||
lookup_error = repeated_external_lookup_error(
|
||||
@@ -752,28 +764,28 @@ class AgentRunner:
|
||||
prepare_call = getattr(spec.tools, "prepare_call", None)
|
||||
tool, params, prep_error = None, tool_call.arguments, None
|
||||
if callable(prepare_call):
|
||||
try:
|
||||
with suppress(Exception):
|
||||
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
||||
if isinstance(prepared, tuple) and len(prepared) == 3:
|
||||
tool, params, prep_error = prepared
|
||||
except Exception:
|
||||
pass
|
||||
if prep_error:
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
"detail": prep_error.split(": ", 1)[-1][:120],
|
||||
}
|
||||
if self._is_workspace_violation(prep_error):
|
||||
logger.warning(
|
||||
"Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}",
|
||||
tool_call.name,
|
||||
prep_error.replace("\n", " ").strip()[:200],
|
||||
)
|
||||
event["detail"] = ("workspace_violation: "
|
||||
+ prep_error.replace("\n", " ").strip())[:160]
|
||||
return prep_error, event, RuntimeError(prep_error)
|
||||
return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||
handled = self._classify_violation(
|
||||
raw_text=prep_error,
|
||||
soft_payload=prep_error + hint,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
workspace_violation_counts=workspace_violation_counts,
|
||||
)
|
||||
if handled is not None:
|
||||
return handled
|
||||
return prep_error + hint, event, (
|
||||
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||
)
|
||||
try:
|
||||
if tool is not None:
|
||||
result = await tool.execute(**params)
|
||||
@@ -790,18 +802,20 @@ class AgentRunner:
|
||||
if isinstance(exc, AskUserInterrupt):
|
||||
event["status"] = "waiting"
|
||||
return "", event, exc
|
||||
if self._is_workspace_violation(str(exc)):
|
||||
logger.warning(
|
||||
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
|
||||
tool_call.name,
|
||||
str(exc).replace("\n", " ").strip()[:200],
|
||||
)
|
||||
event["detail"] = ("workspace_violation: "
|
||||
+ str(exc).replace("\n", " ").strip())[:160]
|
||||
return f"Error: {type(exc).__name__}: {exc}", event, exc
|
||||
payload = f"Error: {type(exc).__name__}: {exc}"
|
||||
handled = self._classify_violation(
|
||||
raw_text=str(exc),
|
||||
# Preserve legacy exception payloads without the retry hint.
|
||||
soft_payload=payload,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
workspace_violation_counts=workspace_violation_counts,
|
||||
)
|
||||
if handled is not None:
|
||||
return handled
|
||||
if spec.fail_on_tool_error:
|
||||
return f"Error: {type(exc).__name__}: {exc}", event, exc
|
||||
return f"Error: {type(exc).__name__}: {exc}", event, None
|
||||
return payload, event, exc
|
||||
return payload, event, None
|
||||
|
||||
if isinstance(result, str) and result.startswith("Error"):
|
||||
event = {
|
||||
@@ -809,17 +823,15 @@ class AgentRunner:
|
||||
"status": "error",
|
||||
"detail": result.replace("\n", " ").strip()[:120],
|
||||
}
|
||||
|
||||
# check the outside workspace error and break loop
|
||||
if self._is_workspace_violation(result):
|
||||
logger.warning(
|
||||
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
|
||||
tool_call.name,
|
||||
result.replace("\n", " ").strip()[:200],
|
||||
)
|
||||
event["detail"] = ("workspace_violation: "
|
||||
+ result.replace("\n", " ").strip())[:160]
|
||||
return result, event, RuntimeError(result)
|
||||
handled = self._classify_violation(
|
||||
raw_text=result,
|
||||
soft_payload=result + hint,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
workspace_violation_counts=workspace_violation_counts,
|
||||
)
|
||||
if handled is not None:
|
||||
return handled
|
||||
if spec.fail_on_tool_error:
|
||||
return result + hint, event, RuntimeError(result)
|
||||
return result + hint, event, None
|
||||
@@ -832,23 +844,97 @@ class AgentRunner:
|
||||
detail = detail[:120] + "..."
|
||||
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
|
||||
|
||||
# Markers identifying tool results that represent a workspace / safety boundary rejection.
|
||||
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
|
||||
"blocked by safety guard",
|
||||
# SSRF is a hard security block at the tool boundary, but the agent turn
|
||||
# should recover conversationally instead of aborting the runtime.
|
||||
_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 allowed directory",
|
||||
"working_dir is outside",
|
||||
"working_dir could not be resolved",
|
||||
"path traversal detected",
|
||||
"path outside working dir",
|
||||
"path traversal detected",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_workspace_violation(cls, text: str) -> bool:
|
||||
def _is_ssrf_violation(cls, text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
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(
|
||||
self,
|
||||
@@ -896,12 +982,11 @@ class AgentRunner:
|
||||
result,
|
||||
max_chars=spec.max_tool_result_chars,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Tool result persist failed for {} in {}: {}; using raw result",
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Tool result persist failed for {} in {}; using raw result",
|
||||
tool_call_id,
|
||||
spec.session_key or "default",
|
||||
exc,
|
||||
)
|
||||
content = result
|
||||
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.bus.events import InboundMessage
|
||||
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.utils.prompt_templates import render_template
|
||||
|
||||
@@ -81,7 +81,9 @@ class SubagentManager:
|
||||
exec_config: "ExecToolConfig | None" = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
max_iterations: int | None = None,
|
||||
):
|
||||
defaults = AgentDefaults()
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
self.bus = bus
|
||||
@@ -91,6 +93,12 @@ class SubagentManager:
|
||||
self.exec_config = exec_config or ExecToolConfig()
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
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._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
@@ -108,6 +116,7 @@ class SubagentManager:
|
||||
origin_channel: str = "cli",
|
||||
origin_chat_id: str = "direct",
|
||||
session_key: str | None = None,
|
||||
origin_message_id: str | None = None,
|
||||
) -> str:
|
||||
"""Spawn a subagent to execute a task in the background."""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
@@ -123,7 +132,7 @@ class SubagentManager:
|
||||
self._task_statuses[task_id] = status
|
||||
|
||||
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
|
||||
if session_key:
|
||||
@@ -149,6 +158,7 @@ class SubagentManager:
|
||||
label: str,
|
||||
origin: dict[str, str],
|
||||
status: SubagentStatus,
|
||||
origin_message_id: str | None = None,
|
||||
) -> None:
|
||||
"""Execute the subagent task and announce the result."""
|
||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||
@@ -162,12 +172,16 @@ class SubagentManager:
|
||||
tools = ToolRegistry()
|
||||
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
|
||||
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
||||
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||
tools.register(GlobTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||
tools.register(GrepTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||
# Subagent gets its own FileStates so its read-dedup cache is
|
||||
# isolated from the parent loop's sessions (issue #3571).
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
file_states = FileStates()
|
||||
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read, file_states=file_states))
|
||||
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:
|
||||
tools.register(ExecTool(
|
||||
working_dir=str(self.workspace),
|
||||
@@ -176,6 +190,8 @@ class SubagentManager:
|
||||
sandbox=self.exec_config.sandbox,
|
||||
path_append=self.exec_config.path_append,
|
||||
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:
|
||||
tools.register(
|
||||
@@ -202,7 +218,7 @@ class SubagentManager:
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model=self.model,
|
||||
max_iterations=15,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hook=_SubagentHook(task_id, status),
|
||||
max_iterations_message="Task completed but no final response was generated.",
|
||||
@@ -218,24 +234,24 @@ class SubagentManager:
|
||||
await self._announce_result(
|
||||
task_id, label, task,
|
||||
self._format_partial_progress(result),
|
||||
origin, "error",
|
||||
origin, "error", origin_message_id,
|
||||
)
|
||||
elif result.stop_reason == "error":
|
||||
await self._announce_result(
|
||||
task_id, label, task,
|
||||
result.error or "Error: subagent execution failed.",
|
||||
origin, "error",
|
||||
origin, "error", origin_message_id,
|
||||
)
|
||||
else:
|
||||
final_result = result.final_content or "Task completed but no final response was generated."
|
||||
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:
|
||||
status.phase = "error"
|
||||
status.error = str(e)
|
||||
logger.error("Subagent [{}] failed: {}", task_id, e)
|
||||
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error")
|
||||
logger.exception("Subagent [{}] failed", task_id)
|
||||
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
|
||||
|
||||
async def _announce_result(
|
||||
self,
|
||||
@@ -245,6 +261,7 @@ class SubagentManager:
|
||||
result: str,
|
||||
origin: dict[str, str],
|
||||
status: str,
|
||||
origin_message_id: str | None = None,
|
||||
) -> None:
|
||||
"""Announce the subagent result to the main agent via the message bus."""
|
||||
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
|
||||
# being dispatched as a competing independent task.
|
||||
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(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||
content=announce_content,
|
||||
session_key_override=override,
|
||||
metadata={
|
||||
"injected_event": "subagent_result",
|
||||
"subagent_task_id": task_id,
|
||||
},
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,9 +18,6 @@ class ReadState:
|
||||
can_dedup: bool
|
||||
|
||||
|
||||
_state: dict[str, ReadState] = {}
|
||||
|
||||
|
||||
def _hash_file(p: str) -> str | None:
|
||||
try:
|
||||
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
|
||||
@@ -27,93 +25,181 @@ def _hash_file(p: str) -> str | 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:
|
||||
"""Record that a file was read (called after successful read)."""
|
||||
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,
|
||||
)
|
||||
_default.record_read(path, offset=offset, limit=limit)
|
||||
|
||||
|
||||
def record_write(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:
|
||||
_state.pop(p, None)
|
||||
return
|
||||
_state[p] = ReadState(
|
||||
mtime=mtime,
|
||||
offset=1,
|
||||
limit=None,
|
||||
content_hash=_hash_file(p),
|
||||
can_dedup=False,
|
||||
)
|
||||
_default.record_write(path)
|
||||
|
||||
|
||||
def check_read(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 = _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
|
||||
return _default.check_read(path)
|
||||
|
||||
|
||||
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."""
|
||||
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
|
||||
return _default.is_unchanged(path, offset=offset, limit=limit)
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""Clear all tracked state (useful for testing)."""
|
||||
_state.clear()
|
||||
_default.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.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.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(
|
||||
path: str,
|
||||
workspace: Path | None = None,
|
||||
@@ -29,7 +36,10 @@ def _resolve_path(
|
||||
media_path = get_media_dir().resolve()
|
||||
all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
|
||||
if not any(_is_under(resolved, d) for d in all_dirs):
|
||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
||||
raise PermissionError(
|
||||
f"Path {path} is outside allowed directory {allowed_dir}"
|
||||
+ _FS_WORKSPACE_BOUNDARY_NOTE
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
@@ -49,10 +59,22 @@ class _FsTool(Tool):
|
||||
workspace: Path | None = None,
|
||||
allowed_dir: Path | None = None,
|
||||
extra_allowed_dirs: list[Path] | None = None,
|
||||
file_states: FileStates | None = None,
|
||||
):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
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:
|
||||
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
|
||||
# Always check for external modifications before dedup
|
||||
entry = file_state._state.get(str(fp.resolve()))
|
||||
entry = self._file_states.get(fp)
|
||||
try:
|
||||
current_mtime = os.path.getmtime(fp)
|
||||
except OSError:
|
||||
@@ -193,21 +215,21 @@ class ReadFileTool(_FsTool):
|
||||
if current_mtime != entry.mtime:
|
||||
# File was modified externally - force full read and mark as not dedupable
|
||||
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)
|
||||
else:
|
||||
# File unchanged - return dedup message
|
||||
# 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:
|
||||
return f"[File unchanged since last read: {path}]"
|
||||
else:
|
||||
# Content changed despite same mtime - force full read
|
||||
entry.can_dedup = False
|
||||
file_state.record_read(fp, offset=offset, limit=limit)
|
||||
self._file_states.record_read(fp, offset=offset, limit=limit)
|
||||
else:
|
||||
# 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
|
||||
if entry:
|
||||
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.)"
|
||||
else:
|
||||
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
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
@@ -365,7 +387,7 @@ class WriteFileTool(_FsTool):
|
||||
fp = self._resolve(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
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}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
@@ -699,7 +721,7 @@ class EditFileTool(_FsTool):
|
||||
if old_text == "":
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
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 self._file_not_found_msg(path, fp)
|
||||
|
||||
@@ -718,11 +740,11 @@ class EditFileTool(_FsTool):
|
||||
if content.strip():
|
||||
return f"Error: Cannot create file — {path} already exists and is not empty."
|
||||
fp.write_text(new_text, encoding="utf-8")
|
||||
file_state.record_write(fp)
|
||||
self._file_states.record_write(fp)
|
||||
return f"Successfully edited {fp}"
|
||||
|
||||
# Read-before-edit check
|
||||
warning = file_state.check_read(fp)
|
||||
warning = self._file_states.check_read(fp)
|
||||
|
||||
raw = fp.read_bytes()
|
||||
uses_crlf = b"\r\n" in raw
|
||||
@@ -767,7 +789,7 @@ class EditFileTool(_FsTool):
|
||||
new_content = new_content.replace("\n", "\r\n")
|
||||
|
||||
fp.write_bytes(new_content.encode("utf-8"))
|
||||
file_state.record_write(fp)
|
||||
self._file_states.record_write(fp)
|
||||
msg = f"Successfully edited {fp}"
|
||||
if warning:
|
||||
msg = f"{warning}\n{msg}"
|
||||
|
||||
+18
-29
@@ -4,7 +4,7 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from contextlib import AsyncExitStack
|
||||
from contextlib import AsyncExitStack, suppress
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -198,11 +198,10 @@ class MCPToolWrapper(Tool):
|
||||
await asyncio.sleep(1) # Brief backoff before retry
|
||||
continue
|
||||
# Second transient failure — give up with retry-specific message
|
||||
logger.error(
|
||||
"MCP tool '{}' failed after retry: {}: {}",
|
||||
logger.exception(
|
||||
"MCP tool '{}' failed after retry: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP tool call failed after retry: {type(exc).__name__})"
|
||||
logger.exception(
|
||||
@@ -287,11 +286,10 @@ class MCPResourceWrapper(Tool):
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
logger.error(
|
||||
"MCP resource '{}' failed after retry: {}: {}",
|
||||
logger.exception(
|
||||
"MCP resource '{}' failed after retry: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP resource read failed after retry: {type(exc).__name__})"
|
||||
logger.exception(
|
||||
@@ -383,7 +381,7 @@ class MCPPromptWrapper(Tool):
|
||||
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
||||
return "(MCP prompt call was cancelled)"
|
||||
except McpError as exc:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
"MCP prompt '{}' failed: code={} message={}",
|
||||
self._name,
|
||||
exc.error.code,
|
||||
@@ -400,11 +398,10 @@ class MCPPromptWrapper(Tool):
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
logger.error(
|
||||
"MCP prompt '{}' failed after retry: {}: {}",
|
||||
logger.exception(
|
||||
"MCP prompt '{}' failed after retry: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP prompt call failed after retry: {type(exc).__name__})"
|
||||
logger.exception(
|
||||
@@ -439,8 +436,8 @@ async def connect_mcp_servers(
|
||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||
|
||||
Returns a dict mapping server name -> its dedicated AsyncExitStack.
|
||||
Each server gets its own stack and runs in its own task to prevent
|
||||
cancel scope conflicts when multiple MCP servers are configured.
|
||||
Each server gets its own stack to prevent cancel scope conflicts
|
||||
when multiple MCP servers are configured.
|
||||
"""
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
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 "
|
||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||
)
|
||||
logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint)
|
||||
try:
|
||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||
with suppress(Exception):
|
||||
await server_stack.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
return name, None
|
||||
|
||||
server_stacks: dict[str, AsyncExitStack] = {}
|
||||
|
||||
tasks: list[asyncio.Task] = []
|
||||
for name, cfg in mcp_servers.items():
|
||||
task = asyncio.create_task(connect_single_server(name, cfg))
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
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:
|
||||
try:
|
||||
result = await connect_single_server(name, cfg)
|
||||
except Exception as e:
|
||||
logger.error("MCP server '{}' connection failed: {}", name, e)
|
||||
continue
|
||||
if result is not None and result[1] is not None:
|
||||
server_stacks[result[0]] = result[1]
|
||||
|
||||
return server_stacks
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
from contextlib import suppress
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterable, TypeVar
|
||||
|
||||
@@ -92,10 +93,8 @@ class _SearchTool(_FsTool):
|
||||
|
||||
def _display_path(self, target: Path, root: Path) -> str:
|
||||
if self._workspace:
|
||||
try:
|
||||
with suppress(ValueError):
|
||||
return target.relative_to(self._workspace).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
return target.relative_to(root).as_posix()
|
||||
|
||||
def _iter_files(self, root: Path) -> Iterable[Path]:
|
||||
|
||||
@@ -76,8 +76,6 @@ class MyTool(Tool):
|
||||
|
||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||
"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
|
||||
@@ -118,13 +116,14 @@ class MyTool(Tool):
|
||||
"Scratchpad keys persist across turns but not restarts.\n"
|
||||
"Key values: _current_iteration (current progress), "
|
||||
"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"
|
||||
"\n"
|
||||
"When to use:\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"
|
||||
"- 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:
|
||||
base += "\nREAD-ONLY MODE: set is disabled."
|
||||
@@ -132,7 +131,7 @@ class MyTool(Tool):
|
||||
base += (
|
||||
"\nIMPORTANT: Before setting state, predict the potential impact. "
|
||||
"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
|
||||
|
||||
@@ -148,7 +147,7 @@ class MyTool(Tool):
|
||||
},
|
||||
"key": {
|
||||
"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.",
|
||||
},
|
||||
"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
|
||||
for k in self.RESTRICTED:
|
||||
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
|
||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
|
||||
if _has_real_attr(loop, k):
|
||||
@@ -386,6 +387,8 @@ class MyTool(Tool):
|
||||
value = expected(value)
|
||||
except (ValueError, TypeError):
|
||||
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
||||
|
||||
# --- existing restricted key logic ---
|
||||
old = getattr(self._loop, key)
|
||||
if "min" in spec and value < 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"]:
|
||||
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
||||
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}")
|
||||
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__}",
|
||||
)
|
||||
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}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
if callable(value):
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -18,6 +19,16 @@ from nanobot.config.paths import get_media_dir
|
||||
_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_schema(
|
||||
command=StringSchema("The shell command to execute"),
|
||||
@@ -51,7 +62,7 @@ class ExecTool(Tool):
|
||||
self.timeout = timeout
|
||||
self.working_dir = working_dir
|
||||
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"\bdel\s+/[fq]\b", # del /f, del /q
|
||||
r"\brmdir\s+/s\b", # rmdir /s
|
||||
@@ -82,6 +93,19 @@ class ExecTool(Tool):
|
||||
_MAX_TIMEOUT = 600
|
||||
_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
|
||||
def description(self) -> str:
|
||||
return (
|
||||
@@ -112,9 +136,15 @@ class ExecTool(Tool):
|
||||
requested = Path(cwd).expanduser().resolve()
|
||||
workspace_root = Path(self.working_dir).expanduser().resolve()
|
||||
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:
|
||||
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)
|
||||
if guard_error:
|
||||
@@ -190,9 +220,12 @@ class ExecTool(Tool):
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Launch *command* in a platform-appropriate shell."""
|
||||
if _IS_WINDOWS:
|
||||
comspec = env.get("COMSPEC", os.environ.get("COMSPEC", "cmd.exe"))
|
||||
return await asyncio.create_subprocess_exec(
|
||||
comspec, "/c", command,
|
||||
# create_subprocess_exec re-quotes args via list2cmdline, which
|
||||
# breaks commands containing paths with spaces (e.g. "D:\Program
|
||||
# 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,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
@@ -212,9 +245,8 @@ class ExecTool(Tool):
|
||||
"""Kill a subprocess and reap it to prevent zombies."""
|
||||
process.kill()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
finally:
|
||||
if not _IS_WINDOWS:
|
||||
try:
|
||||
@@ -273,31 +305,49 @@ class ExecTool(Tool):
|
||||
cmd = command.strip()
|
||||
lower = cmd.lower()
|
||||
|
||||
for pattern in self.deny_patterns:
|
||||
if re.search(pattern, lower):
|
||||
return "Error: Command blocked by safety guard (dangerous pattern detected)"
|
||||
# allow_patterns take priority over deny_patterns so that users can
|
||||
# exempt specific commands (e.g. "rm -rf" inside a build directory)
|
||||
# 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 not any(re.search(p, lower) for p in self.allow_patterns):
|
||||
return "Error: Command blocked by safety guard (not in allowlist)"
|
||||
if self.allow_patterns:
|
||||
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
||||
|
||||
from nanobot.security.network import contains_internal_url
|
||||
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)"
|
||||
|
||||
if self.restrict_to_workspace:
|
||||
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()
|
||||
|
||||
for raw in self._extract_absolute_paths(cmd):
|
||||
try:
|
||||
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()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if self._is_benign_device_path(str(p)):
|
||||
continue
|
||||
|
||||
media_path = get_media_dir().resolve()
|
||||
if (p.is_absolute()
|
||||
and cwd_path not in p.parents
|
||||
@@ -305,15 +355,25 @@ class ExecTool(Tool):
|
||||
and media_path not in p.parents
|
||||
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
|
||||
|
||||
@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
|
||||
def _extract_absolute_paths(command: str) -> list[str]:
|
||||
# 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.
|
||||
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
|
||||
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
|
||||
|
||||
@@ -25,6 +25,10 @@ class SpawnTool(Tool):
|
||||
self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli")
|
||||
self._origin_chat_id: ContextVar[str] = ContextVar("spawn_origin_chat_id", default="direct")
|
||||
self._session_key: ContextVar[str] = ContextVar("spawn_session_key", default="cli:direct")
|
||||
self._origin_message_id: ContextVar[str | None] = ContextVar(
|
||||
"spawn_origin_message_id",
|
||||
default=None,
|
||||
)
|
||||
|
||||
def set_context(self, channel: str, chat_id: str, effective_key: str | None = None) -> None:
|
||||
"""Set the origin context for subagent announcements."""
|
||||
@@ -32,6 +36,10 @@ class SpawnTool(Tool):
|
||||
self._origin_chat_id.set(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
|
||||
def name(self) -> str:
|
||||
return "spawn"
|
||||
@@ -48,10 +56,19 @@ class SpawnTool(Tool):
|
||||
|
||||
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
||||
"""Spawn a subagent to execute the given task."""
|
||||
running = self._manager.get_running_count()
|
||||
limit = self._manager.max_concurrent_subagents
|
||||
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(
|
||||
task=task,
|
||||
label=label,
|
||||
origin_channel=self._origin_channel.get(),
|
||||
origin_chat_id=self._origin_chat_id.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,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
url = url.strip(" \t\r\n`\"'")
|
||||
extract_mode = kwargs.pop("extractMode", extract_mode)
|
||||
max_chars = kwargs.pop("maxChars", max_chars) or self.max_chars
|
||||
is_valid, error_msg = _validate_url_safe(url)
|
||||
@@ -499,10 +500,10 @@ class WebFetchTool(Tool):
|
||||
"untrusted": True, "text": text,
|
||||
}, ensure_ascii=False)
|
||||
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)
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json as _json
|
||||
import time
|
||||
import uuid
|
||||
@@ -18,8 +19,12 @@ from loguru import logger
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.media_decode import (
|
||||
FileSizeExceeded as _FileSizeExceeded,
|
||||
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,
|
||||
)
|
||||
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]}"
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
stream_failed = False
|
||||
emitted_content = False
|
||||
|
||||
async def _on_stream(token: str) -> None:
|
||||
nonlocal emitted_content
|
||||
if token:
|
||||
emitted_content = True
|
||||
await queue.put(token)
|
||||
|
||||
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:
|
||||
nonlocal stream_failed
|
||||
try:
|
||||
async with session_lock:
|
||||
await asyncio.wait_for(
|
||||
response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=text,
|
||||
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,
|
||||
)
|
||||
if not emitted_content:
|
||||
response_text = _response_text(response)
|
||||
if response_text.strip():
|
||||
await queue.put(response_text)
|
||||
except Exception:
|
||||
stream_failed = True
|
||||
logger.exception("Streaming error for session {}", session_key)
|
||||
finally:
|
||||
await queue.put(None)
|
||||
|
||||
task = asyncio.create_task(_run())
|
||||
@@ -276,7 +293,10 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
break
|
||||
await resp.write(_sse_chunk(token, model_name, chunk_id))
|
||||
finally:
|
||||
task.cancel()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
if not stream_failed:
|
||||
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
|
||||
|
||||
# -- non-streaming path (original logic) --
|
||||
_FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
try:
|
||||
async with session_lock:
|
||||
@@ -316,7 +336,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
response_text = _response_text(retry_response)
|
||||
if not response_text or not response_text.strip():
|
||||
logger.warning("Empty response after retry, using fallback")
|
||||
response_text = _FALLBACK
|
||||
response_text = fallback
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
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.
|
||||
"""
|
||||
self.config = config
|
||||
self.logger = logger.bind(channel=self.name)
|
||||
self.bus = bus
|
||||
self._running = False
|
||||
|
||||
@@ -61,8 +62,8 @@ class BaseChannel(ABC):
|
||||
language=self.transcription_language or None,
|
||||
)
|
||||
return await provider.transcribe(file_path)
|
||||
except Exception as e:
|
||||
logger.warning("{}: audio transcription failed: {}", self.name, e)
|
||||
except Exception:
|
||||
self.logger.exception("Audio transcription failed")
|
||||
return ""
|
||||
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
@@ -136,7 +137,7 @@ class BaseChannel(ABC):
|
||||
else:
|
||||
allow_list = getattr(self.config, "allow_from", [])
|
||||
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
|
||||
if "*" in allow_list:
|
||||
return True
|
||||
@@ -165,10 +166,10 @@ class BaseChannel(ABC):
|
||||
session_key: Optional session key override (e.g. thread-scoped sessions).
|
||||
"""
|
||||
if not self.is_allowed(sender_id):
|
||||
logger.warning(
|
||||
"Access denied for sender {} on channel {}. "
|
||||
self.logger.warning(
|
||||
"Access denied for sender {}. "
|
||||
"Add them to allowFrom list in config to grant access.",
|
||||
sender_id, self.name,
|
||||
sender_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
+213
-77
@@ -9,16 +9,19 @@ import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlparse
|
||||
from urllib.parse import unquote, urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
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:
|
||||
from dingtalk_stream import (
|
||||
@@ -109,7 +112,7 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
content = content + "\n\nReceived files:\n" + file_list
|
||||
|
||||
if not content:
|
||||
logger.warning(
|
||||
self.channel.logger.warning(
|
||||
"Received empty or unsupported message type: {}",
|
||||
chatbot_msg.message_type,
|
||||
)
|
||||
@@ -124,7 +127,7 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
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).
|
||||
# Store reference to prevent GC before task completes.
|
||||
@@ -142,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing DingTalk message: {}", e)
|
||||
except Exception:
|
||||
self.channel.logger.exception("Error processing message")
|
||||
# Return OK to avoid retry loop from DingTalk server
|
||||
return AckMessage.STATUS_OK, "Error"
|
||||
|
||||
@@ -155,6 +158,8 @@ class DingTalkConfig(Base):
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
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):
|
||||
@@ -198,20 +203,20 @@ class DingTalkChannel(BaseChannel):
|
||||
"""Start the DingTalk bot with Stream Mode."""
|
||||
try:
|
||||
if not DINGTALK_AVAILABLE:
|
||||
logger.error(
|
||||
"DingTalk Stream SDK not installed. Run: pip install dingtalk-stream"
|
||||
self.logger.error(
|
||||
"Stream SDK not installed. Run: pip install dingtalk-stream"
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
self._running = True
|
||||
self._http = httpx.AsyncClient()
|
||||
|
||||
logger.info(
|
||||
"Initializing DingTalk Stream Client with Client ID: {}...",
|
||||
self.logger.info(
|
||||
"Initializing Stream Client with Client ID: {}...",
|
||||
self.config.client_id,
|
||||
)
|
||||
credential = Credential(self.config.client_id, self.config.client_secret)
|
||||
@@ -221,20 +226,20 @@ class DingTalkChannel(BaseChannel):
|
||||
handler = NanobotDingTalkHandler(self)
|
||||
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
|
||||
while self._running:
|
||||
try:
|
||||
await self._client.start()
|
||||
except Exception as e:
|
||||
logger.warning("DingTalk stream error: {}", e)
|
||||
self.logger.warning("stream error: {}", e)
|
||||
if self._running:
|
||||
logger.info("Reconnecting DingTalk stream in 5 seconds...")
|
||||
self.logger.info("Reconnecting stream in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start DingTalk channel: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to start channel")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the DingTalk bot."""
|
||||
@@ -260,7 +265,7 @@ class DingTalkChannel(BaseChannel):
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -271,8 +276,8 @@ class DingTalkChannel(BaseChannel):
|
||||
# Expire 60s early to be safe
|
||||
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
|
||||
return self._access_token
|
||||
except Exception as e:
|
||||
logger.error("Failed to get DingTalk access token: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to get access token")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -281,9 +286,12 @@ class DingTalkChannel(BaseChannel):
|
||||
|
||||
def _guess_upload_type(self, media_ref: str) -> str:
|
||||
ext = Path(urlparse(media_ref).path).suffix.lower()
|
||||
if ext in self._IMAGE_EXTS: return "image"
|
||||
if ext in self._AUDIO_EXTS: return "voice"
|
||||
if ext in self._VIDEO_EXTS: return "video"
|
||||
if ext in self._IMAGE_EXTS:
|
||||
return "image"
|
||||
if ext in self._AUDIO_EXTS:
|
||||
return "voice"
|
||||
if ext in self._VIDEO_EXTS:
|
||||
return "video"
|
||||
return "file"
|
||||
|
||||
def _guess_filename(self, media_ref: str, upload_type: str) -> str:
|
||||
@@ -308,13 +316,153 @@ class DingTalkChannel(BaseChannel):
|
||||
) -> tuple[bytes, str, str | None]:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html":
|
||||
logger.info(
|
||||
"DingTalk does not accept raw HTML attachments, zipping {} before upload",
|
||||
self.logger.info(
|
||||
"does not accept raw HTML attachments, zipping {} before upload",
|
||||
filename,
|
||||
)
|
||||
return self._zip_bytes(filename, data)
|
||||
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(
|
||||
self,
|
||||
media_ref: str,
|
||||
@@ -323,26 +471,12 @@ class DingTalkChannel(BaseChannel):
|
||||
return None, None, None
|
||||
|
||||
if self._is_http_url(media_ref):
|
||||
if not self._http:
|
||||
return None, None, 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)
|
||||
data, raw_content_type = await self._fetch_remote_media_bytes(media_ref)
|
||||
if data is 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:
|
||||
if media_ref.startswith("file://"):
|
||||
@@ -351,13 +485,13 @@ class DingTalkChannel(BaseChannel):
|
||||
else:
|
||||
local_path = Path(os.path.expanduser(media_ref))
|
||||
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
|
||||
data = await asyncio.to_thread(local_path.read_bytes)
|
||||
content_type = mimetypes.guess_type(local_path.name)[0]
|
||||
return data, local_path.name, content_type
|
||||
except Exception as e:
|
||||
logger.error("DingTalk media read error ref={} err={}", media_ref, e)
|
||||
except Exception:
|
||||
self.logger.exception("media read error ref={}", media_ref)
|
||||
return None, None, None
|
||||
|
||||
async def _upload_media(
|
||||
@@ -379,23 +513,23 @@ class DingTalkChannel(BaseChannel):
|
||||
text = resp.text
|
||||
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
||||
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
|
||||
errcode = result.get("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
|
||||
sub = result.get("result") or {}
|
||||
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
|
||||
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 str(media_id)
|
||||
except httpx.TransportError as e:
|
||||
logger.error("DingTalk media upload network error type={} err={}", media_type, e)
|
||||
except httpx.TransportError:
|
||||
self.logger.exception("media upload network error type={}", media_type)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("DingTalk media upload error type={} err={}", media_type, e)
|
||||
except Exception:
|
||||
self.logger.exception("media upload error type={}", media_type)
|
||||
return None
|
||||
|
||||
async def _send_batch_message(
|
||||
@@ -406,7 +540,7 @@ class DingTalkChannel(BaseChannel):
|
||||
msg_param: dict[str, Any],
|
||||
) -> bool:
|
||||
if not self._http:
|
||||
logger.warning("DingTalk HTTP client not initialized, cannot send")
|
||||
self.logger.warning("HTTP client not initialized, cannot send")
|
||||
return False
|
||||
|
||||
headers = {"x-acs-dingtalk-access-token": token}
|
||||
@@ -433,21 +567,23 @@ class DingTalkChannel(BaseChannel):
|
||||
resp = await self._http.post(url, json=payload, headers=headers)
|
||||
body = resp.text
|
||||
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
|
||||
try: result = resp.json()
|
||||
except Exception: result = {}
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception:
|
||||
result = {}
|
||||
errcode = result.get("errcode")
|
||||
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
|
||||
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
|
||||
except httpx.TransportError as e:
|
||||
logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e)
|
||||
except httpx.TransportError:
|
||||
self.logger.exception("network error sending message msgKey={}", msg_key)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message msgKey={}", msg_key)
|
||||
return False
|
||||
|
||||
async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool:
|
||||
@@ -473,11 +609,11 @@ class DingTalkChannel(BaseChannel):
|
||||
)
|
||||
if ok:
|
||||
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)
|
||||
if not data:
|
||||
logger.error("DingTalk media read failed: {}", media_ref)
|
||||
self.logger.error("media read failed: {}", media_ref)
|
||||
return False
|
||||
|
||||
filename = filename or self._guess_filename(media_ref, upload_type)
|
||||
@@ -509,7 +645,7 @@ class DingTalkChannel(BaseChannel):
|
||||
)
|
||||
if ok:
|
||||
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(
|
||||
token,
|
||||
@@ -531,7 +667,7 @@ class DingTalkChannel(BaseChannel):
|
||||
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
|
||||
if ok:
|
||||
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.
|
||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||
await self._send_markdown_text(
|
||||
@@ -554,7 +690,7 @@ class DingTalkChannel(BaseChannel):
|
||||
permission checks before publishing to the bus.
|
||||
"""
|
||||
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
|
||||
chat_id = f"group:{conversation_id}" if is_group else sender_id
|
||||
await self._handle_message(
|
||||
@@ -567,8 +703,8 @@ class DingTalkChannel(BaseChannel):
|
||||
"conversation_type": conversation_type,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error publishing DingTalk message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error publishing message")
|
||||
|
||||
async def _download_dingtalk_file(
|
||||
self,
|
||||
@@ -582,7 +718,7 @@ class DingTalkChannel(BaseChannel):
|
||||
try:
|
||||
token = await self._get_access_token()
|
||||
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
|
||||
|
||||
# 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}
|
||||
resp = await self._http.post(api_url, json=payload, headers=headers)
|
||||
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
|
||||
|
||||
result = resp.json()
|
||||
download_url = result.get("downloadUrl")
|
||||
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
|
||||
|
||||
# Step 2: Download the file content
|
||||
file_resp = await self._http.get(download_url, follow_redirects=True)
|
||||
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
|
||||
|
||||
# Save to media directory (accessible under workspace)
|
||||
@@ -611,8 +747,8 @@ class DingTalkChannel(BaseChannel):
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = download_dir / filename
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error("DingTalk file download error: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("file download error")
|
||||
return None
|
||||
|
||||
+45
-51
@@ -5,11 +5,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -85,12 +85,12 @@ if DISCORD_AVAILABLE:
|
||||
|
||||
async def on_ready(self) -> 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:
|
||||
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:
|
||||
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:
|
||||
await self._channel._handle_discord_message(message)
|
||||
@@ -110,7 +110,7 @@ if DISCORD_AVAILABLE:
|
||||
await interaction.response.send_message(text, ephemeral=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Discord interaction response failed: {}", e)
|
||||
self._channel.logger.warning("interaction response failed: {}", e)
|
||||
return False
|
||||
|
||||
async def _resolve_interaction_channel(
|
||||
@@ -125,7 +125,7 @@ if DISCORD_AVAILABLE:
|
||||
try:
|
||||
channel = await self.fetch_channel(channel_id)
|
||||
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
|
||||
self._channel._remember_channel(channel)
|
||||
return channel
|
||||
@@ -153,7 +153,7 @@ if DISCORD_AVAILABLE:
|
||||
channel_id = interaction.channel_id
|
||||
|
||||
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
|
||||
|
||||
if not self._channel.is_allowed(sender_id):
|
||||
@@ -225,8 +225,8 @@ if DISCORD_AVAILABLE:
|
||||
error: app_commands.AppCommandError,
|
||||
) -> None:
|
||||
command_name = interaction.command.qualified_name if interaction.command else "?"
|
||||
logger.warning(
|
||||
"Discord app command failed user={} channel={} cmd={} error={}",
|
||||
self._channel.logger.warning(
|
||||
"app command failed user={} channel={} cmd={} error={}",
|
||||
interaction.user.id,
|
||||
interaction.channel_id,
|
||||
command_name,
|
||||
@@ -242,7 +242,7 @@ if DISCORD_AVAILABLE:
|
||||
try:
|
||||
channel = await self.fetch_channel(channel_id)
|
||||
except Exception as e:
|
||||
logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e)
|
||||
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
|
||||
return
|
||||
|
||||
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."""
|
||||
path = Path(file_path)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -293,10 +293,10 @@ if DISCORD_AVAILABLE:
|
||||
kwargs["reference"] = reference
|
||||
kwargs["allowed_mentions"] = mention_settings
|
||||
await channel.send(**kwargs)
|
||||
logger.info("Discord file sent: {}", path.name)
|
||||
self._channel.logger.info("file sent: {}", path.name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Error sending Discord file {}: {}", path.name, e)
|
||||
except Exception:
|
||||
self._channel.logger.exception("Error sending file {}", path.name)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@@ -320,7 +320,7 @@ if DISCORD_AVAILABLE:
|
||||
try:
|
||||
message_id = int(reply_to)
|
||||
except ValueError:
|
||||
logger.warning("Invalid Discord reply target: {}", reply_to)
|
||||
self._channel.logger.warning("Invalid reply target: {}", reply_to)
|
||||
return None, mention_settings
|
||||
|
||||
return channel.get_partial_message(message_id), mention_settings
|
||||
@@ -384,11 +384,11 @@ class DiscordChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Discord client."""
|
||||
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
|
||||
|
||||
if not self.config.token:
|
||||
logger.error("Discord bot token not configured")
|
||||
self.logger.error("bot token not configured")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -406,8 +406,8 @@ class DiscordChannel(BaseChannel):
|
||||
password=self.config.proxy_password,
|
||||
)
|
||||
elif has_user != has_pass:
|
||||
logger.warning(
|
||||
"Discord proxy auth incomplete: both proxy_username and "
|
||||
self.logger.warning(
|
||||
"proxy auth incomplete: both proxy_username and "
|
||||
"proxy_password must be set; ignoring partial credentials",
|
||||
)
|
||||
|
||||
@@ -417,21 +417,21 @@ class DiscordChannel(BaseChannel):
|
||||
proxy=self.config.proxy,
|
||||
proxy_auth=proxy_auth,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Discord client: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to initialize client")
|
||||
self._client = None
|
||||
self._running = False
|
||||
return
|
||||
|
||||
self._running = True
|
||||
logger.info("Starting Discord client via discord.py...")
|
||||
self.logger.info("Starting client via discord.py...")
|
||||
|
||||
try:
|
||||
await self._client.start(self.config.token)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Discord client startup failed: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("client startup failed")
|
||||
finally:
|
||||
self._running = False
|
||||
await self._reset_runtime_state(close_client=True)
|
||||
@@ -445,15 +445,15 @@ class DiscordChannel(BaseChannel):
|
||||
"""Send a message through Discord using discord.py."""
|
||||
client = self._client
|
||||
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
|
||||
|
||||
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||
|
||||
try:
|
||||
await client.send_outbound(msg)
|
||||
except Exception as e:
|
||||
logger.error("Error sending Discord message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message")
|
||||
raise
|
||||
finally:
|
||||
if not is_progress:
|
||||
@@ -466,7 +466,7 @@ class DiscordChannel(BaseChannel):
|
||||
"""Progressive Discord delivery: send once, then edit until the stream ends."""
|
||||
client = self._client
|
||||
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
|
||||
|
||||
meta = metadata or {}
|
||||
@@ -496,7 +496,7 @@ class DiscordChannel(BaseChannel):
|
||||
|
||||
target = await self._resolve_channel(chat_id)
|
||||
if target is None:
|
||||
logger.warning("Discord stream target {} unavailable", chat_id)
|
||||
self.logger.warning("stream target {} unavailable", chat_id)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
@@ -505,7 +505,7 @@ class DiscordChannel(BaseChannel):
|
||||
buf.message = await target.send(content=buf.text)
|
||||
buf.last_edit = now
|
||||
except Exception as e:
|
||||
logger.warning("Discord stream initial send failed: {}", e)
|
||||
self.logger.warning("stream initial send failed: {}", e)
|
||||
raise
|
||||
return
|
||||
|
||||
@@ -516,7 +516,7 @@ class DiscordChannel(BaseChannel):
|
||||
await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
|
||||
buf.last_edit = now
|
||||
except Exception as e:
|
||||
logger.warning("Discord stream edit failed: {}", e)
|
||||
self.logger.warning("stream edit failed: {}", e)
|
||||
raise
|
||||
|
||||
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)
|
||||
self._pending_reactions[channel_id] = message
|
||||
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)
|
||||
async def _delayed_working_emoji() -> None:
|
||||
await asyncio.sleep(self.config.working_emoji_delay)
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await message.add_reaction(self.config.working_emoji)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji())
|
||||
|
||||
@@ -604,7 +602,7 @@ class DiscordChannel(BaseChannel):
|
||||
try:
|
||||
return await client.fetch_channel(channel_id)
|
||||
except Exception as e:
|
||||
logger.warning("Discord channel {} unavailable: {}", chat_id, e)
|
||||
self.logger.warning("channel {} unavailable: {}", chat_id, e)
|
||||
return None
|
||||
|
||||
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
|
||||
@@ -617,12 +615,12 @@ class DiscordChannel(BaseChannel):
|
||||
try:
|
||||
await buf.message.edit(content=chunks[0])
|
||||
except Exception as e:
|
||||
logger.warning("Discord final stream edit failed: {}", e)
|
||||
self.logger.warning("final stream edit failed: {}", e)
|
||||
raise
|
||||
|
||||
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
|
||||
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)
|
||||
return
|
||||
|
||||
@@ -674,7 +672,7 @@ class DiscordChannel(BaseChannel):
|
||||
media_paths.append(str(file_path))
|
||||
markers.append(f"[attachment: {file_path.name}]")
|
||||
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]")
|
||||
|
||||
return media_paths, markers
|
||||
@@ -716,8 +714,8 @@ class DiscordChannel(BaseChannel):
|
||||
if bot_user_id is None and self._client and self._client.user:
|
||||
bot_user_id = str(self._client.user.id)
|
||||
if bot_user_id is None:
|
||||
logger.debug(
|
||||
"Discord message in {} ignored (bot identity unavailable)", message.channel.id
|
||||
self.logger.debug(
|
||||
"message in {} ignored (bot identity unavailable)", message.channel.id
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -730,7 +728,7 @@ class DiscordChannel(BaseChannel):
|
||||
if self._references_bot_message(message, bot_user_id):
|
||||
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 True
|
||||
@@ -760,7 +758,7 @@ class DiscordChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
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
|
||||
|
||||
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
|
||||
@@ -771,10 +769,8 @@ class DiscordChannel(BaseChannel):
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _clear_reactions(self, chat_id: str) -> None:
|
||||
"""Remove all pending reactions after bot replies."""
|
||||
@@ -788,10 +784,8 @@ class DiscordChannel(BaseChannel):
|
||||
return
|
||||
bot_user = self._client.user if self._client else None
|
||||
for emoji in (self.config.read_receipt_emoji, self.config.working_emoji):
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await msg_obj.remove_reaction(emoji, bot_user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _cancel_all_typing(self) -> None:
|
||||
"""Stop all typing tasks."""
|
||||
@@ -808,6 +802,6 @@ class DiscordChannel(BaseChannel):
|
||||
try:
|
||||
await self._client.close()
|
||||
except Exception as e:
|
||||
logger.warning("Discord client close failed: {}", e)
|
||||
self.logger.warning("client close failed: {}", e)
|
||||
self._client = None
|
||||
self._bot_user_id = None
|
||||
|
||||
+33
-28
@@ -6,6 +6,7 @@ import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from contextlib import suppress
|
||||
from datetime import date
|
||||
from email import policy
|
||||
from email.header import decode_header, make_header
|
||||
@@ -127,7 +128,7 @@ class EmailChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start polling IMAP for inbound emails."""
|
||||
if not self.config.consent_granted:
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Email channel disabled: consent_granted is false. "
|
||||
"Set channels.email.consentGranted=true after explicit user permission."
|
||||
)
|
||||
@@ -138,12 +139,12 @@ class EmailChannel(BaseChannel):
|
||||
|
||||
self._running = True
|
||||
if not self.config.verify_dkim and not self.config.verify_spf:
|
||||
logger.warning(
|
||||
"Email channel: DKIM and SPF verification are both DISABLED. "
|
||||
self.logger.warning(
|
||||
"DKIM and SPF verification are both DISABLED. "
|
||||
"Emails with spoofed From headers will be accepted. "
|
||||
"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))
|
||||
while self._running:
|
||||
@@ -166,8 +167,8 @@ class EmailChannel(BaseChannel):
|
||||
media=item.get("media") or None,
|
||||
metadata=item.get("metadata", {}),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Email polling error: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Polling error")
|
||||
|
||||
await asyncio.sleep(poll_seconds)
|
||||
|
||||
@@ -178,16 +179,16 @@ class EmailChannel(BaseChannel):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send email via SMTP."""
|
||||
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
|
||||
|
||||
if not self.config.smtp_host:
|
||||
logger.warning("Email channel SMTP host not configured")
|
||||
self.logger.warning("SMTP host not configured")
|
||||
return
|
||||
|
||||
to_addr = msg.chat_id.strip()
|
||||
if not to_addr:
|
||||
logger.warning("Email channel missing recipient address")
|
||||
self.logger.warning("Missing recipient address")
|
||||
return
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
|
||||
@@ -219,8 +220,8 @@ class EmailChannel(BaseChannel):
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(self._smtp_send, email_msg)
|
||||
except Exception as e:
|
||||
logger.error("Error sending email to {}: {}", to_addr, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending to {}", to_addr)
|
||||
raise
|
||||
|
||||
def _validate_config(self) -> bool:
|
||||
@@ -239,7 +240,7 @@ class EmailChannel(BaseChannel):
|
||||
missing.append("smtp_password")
|
||||
|
||||
if missing:
|
||||
logger.error("Email channel not configured, missing: {}", ', '.join(missing))
|
||||
self.logger.error("Channel not configured, missing: {}", ', '.join(missing))
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -320,7 +321,7 @@ class EmailChannel(BaseChannel):
|
||||
except Exception as exc:
|
||||
if attempt == 1 or not self._is_stale_imap_error(exc):
|
||||
raise
|
||||
logger.warning("Email IMAP connection went stale, retrying once: {}", exc)
|
||||
self.logger.warning("IMAP connection went stale, retrying once: {}", exc)
|
||||
|
||||
return messages
|
||||
|
||||
@@ -347,11 +348,11 @@ class EmailChannel(BaseChannel):
|
||||
status, _ = client.select(mailbox)
|
||||
except Exception as 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
|
||||
raise
|
||||
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
|
||||
|
||||
status, data = client.search(None, *search_criteria)
|
||||
@@ -381,7 +382,7 @@ class EmailChannel(BaseChannel):
|
||||
if not sender:
|
||||
continue
|
||||
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)
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
@@ -390,22 +391,28 @@ class EmailChannel(BaseChannel):
|
||||
# --- Anti-spoofing: verify Authentication-Results ---
|
||||
spf_pass, dkim_pass = self._check_authentication_results(parsed)
|
||||
if self.config.verify_spf and not spf_pass:
|
||||
logger.warning(
|
||||
"Email from {} rejected: SPF verification failed "
|
||||
self.logger.warning(
|
||||
"From {} rejected: SPF verification failed "
|
||||
"(no 'spf=pass' in Authentication-Results header)",
|
||||
sender,
|
||||
)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
continue
|
||||
if self.config.verify_dkim and not dkim_pass:
|
||||
logger.warning(
|
||||
"Email from {} rejected: DKIM verification failed "
|
||||
self.logger.warning(
|
||||
"From {} rejected: DKIM verification failed "
|
||||
"(no 'dkim=pass' in Authentication-Results header)",
|
||||
sender,
|
||||
)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
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", ""))
|
||||
date_value = parsed.get("Date", "")
|
||||
message_id = parsed.get("Message-ID", "").strip()
|
||||
@@ -460,10 +467,8 @@ class EmailChannel(BaseChannel):
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _collect_self_addresses(self) -> set[str]:
|
||||
"""Return normalized email addresses owned by this channel instance."""
|
||||
@@ -636,7 +641,7 @@ class EmailChannel(BaseChannel):
|
||||
|
||||
content_type = part.get_content_type()
|
||||
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
|
||||
|
||||
payload = part.get_payload(decode=True)
|
||||
@@ -644,7 +649,7 @@ class EmailChannel(BaseChannel):
|
||||
continue
|
||||
if len(payload) > max_size:
|
||||
logger.warning(
|
||||
"Email attachment skipped: size {} exceeds limit {}",
|
||||
"Attachment skipped: size {} exceeds limit {}",
|
||||
len(payload),
|
||||
max_size,
|
||||
)
|
||||
@@ -657,9 +662,9 @@ class EmailChannel(BaseChannel):
|
||||
try:
|
||||
dest.write_bytes(payload)
|
||||
saved.append(dest)
|
||||
logger.info("Email attachment saved: {}", dest)
|
||||
logger.info("Attachment saved: {}", dest)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save email attachment {}: {}", dest, exc)
|
||||
logger.warning("Failed to save attachment {}: {}", dest, exc)
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
+131
-110
@@ -9,12 +9,12 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -22,6 +22,7 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
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
|
||||
|
||||
@@ -319,15 +320,17 @@ class FeishuChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Feishu bot with WebSocket long connection."""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
import lark_oapi as lark
|
||||
|
||||
redirect_lib_logging("Lark")
|
||||
|
||||
self._running = True
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -389,7 +392,7 @@ class FeishuChannel(BaseChannel):
|
||||
try:
|
||||
self._ws_client.start()
|
||||
except Exception as e:
|
||||
logger.warning("Feishu WebSocket error: {}", e)
|
||||
self.logger.warning("WebSocket error: {}", e)
|
||||
if self._running:
|
||||
time.sleep(5)
|
||||
finally:
|
||||
@@ -403,12 +406,12 @@ class FeishuChannel(BaseChannel):
|
||||
None, self._fetch_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:
|
||||
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")
|
||||
logger.info("No public IP required - using WebSocket to receive events")
|
||||
self.logger.info("bot started with WebSocket long connection")
|
||||
self.logger.info("No public IP required - using WebSocket to receive events")
|
||||
|
||||
# Keep running until stopped
|
||||
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
|
||||
"""
|
||||
self._running = False
|
||||
logger.info("Feishu bot stopped")
|
||||
self.logger.info("bot stopped")
|
||||
|
||||
def _fetch_bot_open_id(self) -> str | None:
|
||||
"""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)
|
||||
bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
|
||||
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
|
||||
except Exception as e:
|
||||
logger.warning("Error fetching bot info: {}", e)
|
||||
self.logger.warning("Error fetching bot info: {}", e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -538,15 +541,15 @@ class FeishuChannel(BaseChannel):
|
||||
response = self._client.im.v1.message_reaction.create(request)
|
||||
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Failed to add reaction: code={}, msg={}", response.code, response.msg
|
||||
)
|
||||
return None
|
||||
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
|
||||
except Exception as e:
|
||||
logger.warning("Error adding reaction: {}", e)
|
||||
self.logger.warning("Error adding reaction: {}", e)
|
||||
return 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)
|
||||
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:
|
||||
logger.debug(
|
||||
self.logger.debug(
|
||||
"Failed to remove reaction: code={}, msg={}", response.code, response.msg
|
||||
)
|
||||
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:
|
||||
"""
|
||||
@@ -606,18 +609,17 @@ class FeishuChannel(BaseChannel):
|
||||
try:
|
||||
task.result()
|
||||
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:
|
||||
"""Callback: store reaction_id after background add-reaction completes."""
|
||||
if task.cancelled():
|
||||
return
|
||||
try:
|
||||
# Failures already logged by _on_background_task_done.
|
||||
with suppress(Exception):
|
||||
reaction_id = task.result()
|
||||
if 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
|
||||
if len(self._reaction_ids) > 500:
|
||||
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
||||
@@ -917,15 +919,15 @@ class FeishuChannel(BaseChannel):
|
||||
response = self._client.im.v1.image.create(request)
|
||||
if response.success():
|
||||
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
|
||||
else:
|
||||
logger.error(
|
||||
self.logger.error(
|
||||
"Failed to upload image: code={}, msg={}", response.code, response.msg
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error uploading image {}: {}", file_path, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error uploading image {}", file_path)
|
||||
return 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)
|
||||
if response.success():
|
||||
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
|
||||
else:
|
||||
logger.error(
|
||||
self.logger.error(
|
||||
"Failed to upload file: code={}, msg={}", response.code, response.msg
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error uploading file {}: {}", file_path, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error uploading file {}", file_path)
|
||||
return None
|
||||
|
||||
def _download_image_sync(
|
||||
@@ -984,12 +986,12 @@ class FeishuChannel(BaseChannel):
|
||||
file_data = file_data.read()
|
||||
return file_data, response.file_name
|
||||
else:
|
||||
logger.error(
|
||||
self.logger.error(
|
||||
"Failed to download image: code={}, msg={}", response.code, response.msg
|
||||
)
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error("Error downloading image {}: {}", image_key, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error downloading image {}", image_key)
|
||||
return None, None
|
||||
|
||||
def _download_file_sync(
|
||||
@@ -1018,7 +1020,7 @@ class FeishuChannel(BaseChannel):
|
||||
file_data = file_data.read()
|
||||
return file_data, response.file_name
|
||||
else:
|
||||
logger.error(
|
||||
self.logger.error(
|
||||
"Failed to download {}: code={}, msg={}",
|
||||
resource_type,
|
||||
response.code,
|
||||
@@ -1026,7 +1028,7 @@ class FeishuChannel(BaseChannel):
|
||||
)
|
||||
return None, None
|
||||
except Exception:
|
||||
logger.exception("Error downloading {} {}", resource_type, file_key)
|
||||
self.logger.exception("Error downloading {} {}", resource_type, file_key)
|
||||
return None, None
|
||||
|
||||
async def _download_and_save_media(
|
||||
@@ -1055,10 +1057,10 @@ class FeishuChannel(BaseChannel):
|
||||
elif msg_type in ("audio", "file", "media"):
|
||||
file_key = content_json.get("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]"
|
||||
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]"
|
||||
|
||||
data, filename = await loop.run_in_executor(
|
||||
@@ -1066,7 +1068,7 @@ class FeishuChannel(BaseChannel):
|
||||
)
|
||||
|
||||
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]"
|
||||
|
||||
if not filename:
|
||||
@@ -1081,8 +1083,9 @@ class FeishuChannel(BaseChannel):
|
||||
if data and filename:
|
||||
file_path = media_dir / filename
|
||||
file_path.write_bytes(data)
|
||||
logger.debug("Downloaded {} to {}", msg_type, file_path)
|
||||
return str(file_path), f"[{msg_type}: {filename}]"
|
||||
path_str = str(file_path)
|
||||
self.logger.debug("Downloaded {} to {}", msg_type, path_str)
|
||||
return path_str, f"[{msg_type}: {path_str}]"
|
||||
|
||||
return None, f"[{msg_type}: download failed]"
|
||||
|
||||
@@ -1099,8 +1102,8 @@ class FeishuChannel(BaseChannel):
|
||||
request = GetMessageRequest.builder().message_id(message_id).build()
|
||||
response = self._client.im.v1.message.get(request)
|
||||
if not response.success():
|
||||
logger.debug(
|
||||
"Feishu: could not fetch parent message {}: code={}, msg={}",
|
||||
self.logger.debug(
|
||||
"could not fetch parent message {}: code={}, msg={}",
|
||||
message_id,
|
||||
response.code,
|
||||
response.msg,
|
||||
@@ -1132,7 +1135,7 @@ class FeishuChannel(BaseChannel):
|
||||
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
|
||||
return f"[Reply to: {text}]"
|
||||
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
|
||||
|
||||
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)
|
||||
if not response.success():
|
||||
logger.error(
|
||||
"Failed to reply to Feishu message {}: code={}, msg={}, log_id={}",
|
||||
self.logger.error(
|
||||
"Failed to reply to message {}: code={}, msg={}, log_id={}",
|
||||
parent_message_id,
|
||||
response.code,
|
||||
response.msg,
|
||||
response.get_log_id(),
|
||||
)
|
||||
return False
|
||||
logger.debug("Feishu reply sent to message {}", parent_message_id)
|
||||
self.logger.debug("reply sent to message {}", parent_message_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Error replying to Feishu message {}: {}", parent_message_id, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error replying to message {}", parent_message_id)
|
||||
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(
|
||||
self, receive_id_type: str, receive_id: str, msg_type: str, content: str
|
||||
) -> str | None:
|
||||
@@ -1191,8 +1209,8 @@ class FeishuChannel(BaseChannel):
|
||||
)
|
||||
response = self._client.im.v1.message.create(request)
|
||||
if not response.success():
|
||||
logger.error(
|
||||
"Failed to send Feishu {} message: code={}, msg={}, log_id={}",
|
||||
self.logger.error(
|
||||
"Failed to send {} message: code={}, msg={}, log_id={}",
|
||||
msg_type,
|
||||
response.code,
|
||||
response.msg,
|
||||
@@ -1200,10 +1218,10 @@ class FeishuChannel(BaseChannel):
|
||||
)
|
||||
return 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
|
||||
except Exception as e:
|
||||
logger.error("Error sending Feishu {} message: {}", msg_type, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending {} message", msg_type)
|
||||
return None
|
||||
|
||||
def _create_streaming_card_sync(
|
||||
@@ -1211,13 +1229,15 @@ class FeishuChannel(BaseChannel):
|
||||
receive_id_type: str,
|
||||
chat_id: str,
|
||||
reply_message_id: str | None = None,
|
||||
*,
|
||||
reply_in_thread: bool = False,
|
||||
) -> str | None:
|
||||
"""Create a CardKit streaming card, send it to chat, return card_id.
|
||||
|
||||
When *reply_message_id* is provided the card is delivered via the
|
||||
reply API (with reply_in_thread=True) so it lands inside the
|
||||
originating thread / topic. Otherwise the plain create-message
|
||||
API is used.
|
||||
reply API. *reply_in_thread* controls whether Feishu creates a
|
||||
thread/topic for that reply. Otherwise the plain create-message API is
|
||||
used.
|
||||
"""
|
||||
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
||||
|
||||
@@ -1241,7 +1261,7 @@ class FeishuChannel(BaseChannel):
|
||||
)
|
||||
response = self._client.cardkit.v1.card.create(request)
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Failed to create streaming card: code={}, msg={}", response.code, response.msg
|
||||
)
|
||||
return None
|
||||
@@ -1253,7 +1273,7 @@ class FeishuChannel(BaseChannel):
|
||||
if reply_message_id:
|
||||
sent = self._reply_message_sync(
|
||||
reply_message_id, "interactive", card_content,
|
||||
reply_in_thread=True,
|
||||
reply_in_thread=reply_in_thread,
|
||||
)
|
||||
else:
|
||||
sent = self._send_message_sync(
|
||||
@@ -1261,12 +1281,12 @@ class FeishuChannel(BaseChannel):
|
||||
) is not None
|
||||
if sent:
|
||||
return card_id
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Created streaming card {} but failed to send it to {}", card_id, chat_id
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Error creating streaming card: {}", e)
|
||||
self.logger.warning("Error creating streaming card: {}", e)
|
||||
return None
|
||||
|
||||
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)
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Failed to stream-update card {}: code={}, msg={}",
|
||||
card_id,
|
||||
response.code,
|
||||
@@ -1300,7 +1320,7 @@ class FeishuChannel(BaseChannel):
|
||||
return False
|
||||
return True
|
||||
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
|
||||
|
||||
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)
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Failed to close streaming on card {}: code={}, msg={}",
|
||||
card_id,
|
||||
response.code,
|
||||
@@ -1337,7 +1357,7 @@ class FeishuChannel(BaseChannel):
|
||||
return False
|
||||
return True
|
||||
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
|
||||
|
||||
async def send_delta(
|
||||
@@ -1398,7 +1418,7 @@ class FeishuChannel(BaseChannel):
|
||||
buf.sequence,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Streaming card {} final update failed, falling back to regular card",
|
||||
buf.card_id,
|
||||
)
|
||||
@@ -1409,16 +1429,14 @@ class FeishuChannel(BaseChannel):
|
||||
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
# Fallback: reply via the Reply API for group chats.
|
||||
# Target message_id — the Feishu API keeps the reply in
|
||||
# the same topic automatically.
|
||||
_f_msg = meta.get("message_id")
|
||||
fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None
|
||||
# Fallback replies stay in existing topics, but only create a
|
||||
# new topic when reply-to-message is enabled.
|
||||
fallback_msg_id = self._thread_reply_target(meta)
|
||||
if fallback_msg_id:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: self._reply_message_sync(
|
||||
fallback_msg_id, "interactive", card,
|
||||
reply_in_thread=True,
|
||||
reply_in_thread=self._should_use_reply_in_thread(meta),
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -1438,16 +1456,18 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
now = time.monotonic()
|
||||
if buf.card_id is None:
|
||||
# Send the streaming card as a reply for group chats so it
|
||||
# lands inside the originating topic/thread. Always target
|
||||
# message_id (the actual inbound message) — the Feishu Reply
|
||||
# API keeps the response in the same topic automatically.
|
||||
is_group = meta.get("chat_type", "group") == "group"
|
||||
reply_msg_id = meta.get("message_id") if is_group else None
|
||||
# Use the Reply API for existing topics, and only create new topics
|
||||
# when reply-to-message is enabled.
|
||||
use_reply_in_thread = self._should_use_reply_in_thread(meta)
|
||||
reply_msg_id = self._thread_reply_target(meta)
|
||||
card_id = await loop.run_in_executor(
|
||||
None,
|
||||
self._create_streaming_card_sync,
|
||||
rid_type, chat_id, reply_msg_id,
|
||||
lambda: self._create_streaming_card_sync(
|
||||
rid_type,
|
||||
chat_id,
|
||||
reply_msg_id,
|
||||
reply_in_thread=use_reply_in_thread,
|
||||
),
|
||||
)
|
||||
if card_id:
|
||||
buf.card_id = card_id
|
||||
@@ -1466,7 +1486,7 @@ class FeishuChannel(BaseChannel):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Feishu, including media (images/files) if present."""
|
||||
if not self._client:
|
||||
logger.warning("Feishu client not initialized")
|
||||
self.logger.warning("client not initialized")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -1489,22 +1509,21 @@ class FeishuChannel(BaseChannel):
|
||||
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
||||
)
|
||||
return
|
||||
# No active streaming card — send as a regular
|
||||
# interactive card with the same 🔧 prefix style.
|
||||
# Use reply API for group chats so the hint stays in topic.
|
||||
# No active streaming card — send as a regular interactive card
|
||||
# with the same 🔧 prefix style. Existing topics stay threaded;
|
||||
# new topics are created only when reply-to-message is enabled.
|
||||
card = json.dumps(
|
||||
{"config": {"wide_screen_mode": True}, "elements": [
|
||||
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
||||
]},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
_th_msg_id = msg.metadata.get("message_id")
|
||||
_th_chat_type = msg.metadata.get("chat_type", "group")
|
||||
if _th_msg_id and _th_chat_type == "group":
|
||||
_th_msg_id = self._thread_reply_target(msg.metadata)
|
||||
if _th_msg_id:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: self._reply_message_sync(
|
||||
_th_msg_id, "interactive", card,
|
||||
reply_in_thread=True,
|
||||
reply_in_thread=self._should_use_reply_in_thread(msg.metadata),
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -1531,18 +1550,16 @@ class FeishuChannel(BaseChannel):
|
||||
def _do_send(m_type: str, content: str) -> None:
|
||||
"""Send via reply (first message) or create (subsequent).
|
||||
|
||||
For group chats the reply API always uses reply_in_thread=True.
|
||||
The Feishu API automatically keeps replies inside existing
|
||||
topics — reply_in_thread only creates a *new* topic when the
|
||||
target message is a plain (non-topic) message.
|
||||
Group chats only set reply_in_thread=True when
|
||||
reply_to_message is enabled; otherwise a Reply API call for an
|
||||
existing topic must not create a new topic.
|
||||
"""
|
||||
nonlocal first_send
|
||||
if reply_message_id and first_send:
|
||||
first_send = False
|
||||
chat_type = msg.metadata.get("chat_type", "group")
|
||||
ok = self._reply_message_sync(
|
||||
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:
|
||||
return
|
||||
@@ -1551,7 +1568,7 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
for file_path in msg.media:
|
||||
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
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in self._IMAGE_EXTS:
|
||||
@@ -1607,8 +1624,8 @@ class FeishuChannel(BaseChannel):
|
||||
json.dumps(card, ensure_ascii=False),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending Feishu message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message")
|
||||
raise
|
||||
|
||||
def _on_message_sync(self, data: Any) -> None:
|
||||
@@ -1626,18 +1643,10 @@ class FeishuChannel(BaseChannel):
|
||||
message = event.message
|
||||
sender = event.sender
|
||||
|
||||
logger.debug("Feishu raw message: {}", message.content)
|
||||
logger.debug("Feishu mentions: {}", getattr(message, "mentions", None))
|
||||
self.logger.debug("raw message: {}", message.content)
|
||||
self.logger.debug("mentions: {}", getattr(message, "mentions", None))
|
||||
|
||||
# Deduplication check
|
||||
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
|
||||
if sender.sender_type == "bot":
|
||||
@@ -1648,10 +1657,22 @@ class FeishuChannel(BaseChannel):
|
||||
chat_type = message.chat_type
|
||||
msg_type = message.message_type
|
||||
|
||||
if chat_type == "group" and not self._is_group_message_for_bot(message):
|
||||
logger.debug("Feishu: skipping group message (not mentioned)")
|
||||
if not self.is_allowed(sender_id):
|
||||
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)
|
||||
task = asyncio.create_task(
|
||||
self._add_reaction(message_id, self.config.react_emoji)
|
||||
@@ -1765,8 +1786,8 @@ class FeishuChannel(BaseChannel):
|
||||
session_key=session_key,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing Feishu message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error processing message")
|
||||
|
||||
def _on_reaction_created(self, data: Any) -> None:
|
||||
"""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:
|
||||
"""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
|
||||
|
||||
@staticmethod
|
||||
|
||||
+48
-11
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -36,7 +38,6 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = {
|
||||
"send_tool_hints": "sendToolHints",
|
||||
}
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
"""
|
||||
Manages chat channels and coordinates message routing.
|
||||
@@ -59,6 +60,7 @@ class ChannelManager:
|
||||
self._session_manager = session_manager
|
||||
self.channels: dict[str, BaseChannel] = {}
|
||||
self._dispatch_task: asyncio.Task | None = None
|
||||
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
||||
|
||||
self._init_channels()
|
||||
|
||||
@@ -172,8 +174,8 @@ class ChannelManager:
|
||||
"""Start a channel and log any exceptions."""
|
||||
try:
|
||||
await channel.start()
|
||||
except Exception as e:
|
||||
logger.error("Failed to start channel {}: {}", name, e)
|
||||
except Exception:
|
||||
logger.exception("Failed to start channel {}", name)
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""Start all channels and the outbound dispatcher."""
|
||||
@@ -220,18 +222,43 @@ class ChannelManager:
|
||||
# Stop dispatcher
|
||||
if self._dispatch_task:
|
||||
self._dispatch_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._dispatch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop all channels
|
||||
for name, channel in self.channels.items():
|
||||
try:
|
||||
await channel.stop()
|
||||
logger.info("Stopped {} channel", name)
|
||||
except Exception as e:
|
||||
logger.error("Error stopping {}: {}", name, e)
|
||||
except Exception:
|
||||
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:
|
||||
"""Dispatch outbound messages to the appropriate channel."""
|
||||
@@ -273,6 +300,16 @@ class ChannelManager:
|
||||
|
||||
channel = self.channels.get(msg.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)
|
||||
else:
|
||||
logger.warning("Unknown channel: {}", msg.channel)
|
||||
@@ -355,9 +392,9 @@ class ChannelManager:
|
||||
raise # Propagate cancellation for graceful shutdown
|
||||
except Exception as e:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.error(
|
||||
"Failed to send to {} after {} attempts: {} - {}",
|
||||
msg.channel, max_attempts, type(e).__name__, e
|
||||
logger.exception(
|
||||
"Failed to send to {} after {} attempts",
|
||||
msg.channel, max_attempts
|
||||
)
|
||||
return
|
||||
delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)]
|
||||
|
||||
+69
-63
@@ -2,14 +2,13 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypeAlias
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
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.schema import Base
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||
|
||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||
# 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
|
||||
|
||||
|
||||
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):
|
||||
"""Matrix (Element) channel configuration."""
|
||||
|
||||
@@ -214,7 +192,7 @@ class MatrixConfig(Base):
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
allow_room_mentions: bool = False,
|
||||
allow_room_mentions: bool = False
|
||||
streaming: bool = False
|
||||
|
||||
|
||||
@@ -251,12 +229,14 @@ class MatrixChannel(BaseChannel):
|
||||
self._server_upload_limit_bytes: int | None = None
|
||||
self._server_upload_limit_checked = False
|
||||
self._stream_bufs: dict[str, _StreamBuf] = {}
|
||||
self._started_at_ms: int = 0
|
||||
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start Matrix client and begin sync loop."""
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
@@ -280,15 +260,15 @@ class MatrixChannel(BaseChannel):
|
||||
self._register_response_callbacks()
|
||||
|
||||
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.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
|
||||
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:
|
||||
with open(self.session_path, "r", encoding="utf-8") as f:
|
||||
session = json.load(f)
|
||||
@@ -296,20 +276,20 @@ class MatrixChannel(BaseChannel):
|
||||
self.client.access_token = session["access_token"]
|
||||
self.client.device_id = session["device_id"]
|
||||
self.client.load_store()
|
||||
logger.info("Successfully loaded from existing session")
|
||||
self.logger.info("Successfully loaded from existing session")
|
||||
create_new_session = False
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load from existing session: {}", e)
|
||||
logger.info("Falling back to password login...")
|
||||
self.logger.warning("Failed to load from existing session: {}", e)
|
||||
self.logger.info("Falling back to password login...")
|
||||
|
||||
if create_new_session:
|
||||
logger.info("Using password login...")
|
||||
self.logger.info("Using password login...")
|
||||
resp = await self.client.login(self.config.password)
|
||||
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)
|
||||
else:
|
||||
logger.error("Failed to log in: {}", resp)
|
||||
self.logger.error("Failed to log in: {}", resp)
|
||||
return
|
||||
|
||||
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.device_id = self.config.device_id
|
||||
self.client.load_store()
|
||||
logger.info("Successfully loaded from existing session")
|
||||
self.logger.info("Successfully loaded from existing session")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load from existing session: {}", e)
|
||||
self.logger.warning("Failed to load from existing session: {}", e)
|
||||
|
||||
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
|
||||
|
||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||
@@ -341,10 +321,8 @@ class MatrixChannel(BaseChannel):
|
||||
timeout=self.config.sync_stop_grace_seconds)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
self._sync_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
|
||||
@@ -357,9 +335,9 @@ class MatrixChannel(BaseChannel):
|
||||
try:
|
||||
with open(self.session_path, "w", encoding="utf-8") as f:
|
||||
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:
|
||||
logger.warning("Failed to save session: {}", e)
|
||||
self.logger.warning("Failed to save session: {}", e)
|
||||
|
||||
def _is_workspace_path_allowed(self, path: Path) -> bool:
|
||||
"""Check path is inside workspace (when restriction enabled)."""
|
||||
@@ -523,7 +501,7 @@ class MatrixChannel(BaseChannel):
|
||||
failures.append(fail)
|
||||
if 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)
|
||||
if 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_send_error, RoomSendError)
|
||||
|
||||
def _log_response_error(self, label: str, response: Any) -> None:
|
||||
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
|
||||
def _is_fatal_auth_response(self, response: Any) -> bool:
|
||||
code = getattr(response, "status_code", None)
|
||||
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
|
||||
is_fatal = is_auth or getattr(response, "soft_logout", False)
|
||||
(logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response)
|
||||
return is_auth or bool(getattr(response, "soft_logout", False))
|
||||
|
||||
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:
|
||||
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:
|
||||
self._log_response_error("join", response)
|
||||
@@ -609,13 +598,11 @@ class MatrixChannel(BaseChannel):
|
||||
"""Best-effort typing indicator update."""
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
with suppress(Exception):
|
||||
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
|
||||
timeout=TYPING_NOTICE_TIMEOUT_MS)
|
||||
if isinstance(response, RoomTypingError):
|
||||
logger.debug("Matrix typing failed for {}: {}", room_id, response)
|
||||
except Exception:
|
||||
pass
|
||||
self.logger.debug("typing failed for {}: {}", room_id, response)
|
||||
|
||||
async def _start_typing_keepalive(self, room_id: str) -> None:
|
||||
"""Start periodic typing refresh (spec-recommended keepalive)."""
|
||||
@@ -625,33 +612,34 @@ class MatrixChannel(BaseChannel):
|
||||
return
|
||||
|
||||
async def loop() -> None:
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
while self._running:
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000)
|
||||
await self._set_typing(room_id, True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._typing_tasks[room_id] = asyncio.create_task(loop())
|
||||
|
||||
async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None:
|
||||
if task := self._typing_tasks.pop(room_id, None):
|
||||
task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if clear_typing:
|
||||
await self._set_typing(room_id, False)
|
||||
|
||||
async def _sync_loop(self) -> None:
|
||||
backoff = 2.0
|
||||
while self._running:
|
||||
try:
|
||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
||||
backoff = 2.0
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
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:
|
||||
if self.is_allowed(event.sender):
|
||||
@@ -674,6 +662,16 @@ class MatrixChannel(BaseChannel):
|
||||
return 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:
|
||||
"""Apply sender and room policy checks."""
|
||||
if not self.is_allowed(event.sender):
|
||||
@@ -775,7 +773,7 @@ class MatrixChannel(BaseChannel):
|
||||
return None
|
||||
response = await self.client.download(mxc=mxc_url)
|
||||
if isinstance(response, DownloadError):
|
||||
logger.warning("Matrix download failed for {}: {}", mxc_url, response)
|
||||
self.logger.warning("download failed for {}: {}", mxc_url, response)
|
||||
return None
|
||||
body = getattr(response, "body", None)
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
@@ -800,7 +798,7 @@ class MatrixChannel(BaseChannel):
|
||||
try:
|
||||
return decrypt_attachment(ciphertext, key, sha256, iv)
|
||||
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
|
||||
|
||||
async def _fetch_media_attachment(
|
||||
@@ -858,7 +856,11 @@ class MatrixChannel(BaseChannel):
|
||||
return meta
|
||||
|
||||
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
|
||||
await self._start_typing_keepalive(room.room_id)
|
||||
try:
|
||||
@@ -871,7 +873,11 @@ class MatrixChannel(BaseChannel):
|
||||
raise
|
||||
|
||||
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
|
||||
attachment, marker = await self._fetch_media_attachment(room, event)
|
||||
parts: list[str] = []
|
||||
|
||||
+24
-28
@@ -5,12 +5,12 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -302,7 +302,7 @@ class MochatChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start Mochat channel workers and websocket connection."""
|
||||
if not self.config.claw_token:
|
||||
logger.error("Mochat claw_token not configured")
|
||||
self.logger.error("claw_token not configured")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
@@ -330,10 +330,8 @@ class MochatChannel(BaseChannel):
|
||||
await self._cancel_delay_timers()
|
||||
|
||||
if self._socket:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._socket.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
|
||||
if self._cursor_save_task:
|
||||
@@ -349,7 +347,7 @@ class MochatChannel(BaseChannel):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send outbound message to session or panel."""
|
||||
if not self.config.claw_token:
|
||||
logger.warning("Mochat claw_token missing, skip send")
|
||||
self.logger.warning("claw_token missing, skip send")
|
||||
return
|
||||
|
||||
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)
|
||||
if not target.id:
|
||||
logger.warning("Mochat outbound target is empty")
|
||||
self.logger.warning("outbound target is empty")
|
||||
return
|
||||
|
||||
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:
|
||||
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
|
||||
content, msg.reply_to)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send Mochat message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to send message")
|
||||
raise
|
||||
|
||||
# ---- config / init helpers ---------------------------------------------
|
||||
@@ -396,7 +394,7 @@ class MochatChannel(BaseChannel):
|
||||
|
||||
async def _start_socket_client(self) -> bool:
|
||||
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
|
||||
|
||||
serializer = "default"
|
||||
@@ -404,7 +402,7 @@ class MochatChannel(BaseChannel):
|
||||
if MSGPACK_AVAILABLE:
|
||||
serializer = "msgpack"
|
||||
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(
|
||||
reconnection=True,
|
||||
@@ -417,7 +415,7 @@ class MochatChannel(BaseChannel):
|
||||
@client.event
|
||||
async def connect() -> None:
|
||||
self._ws_connected, self._ws_ready = True, False
|
||||
logger.info("Mochat websocket connected")
|
||||
self.logger.info("websocket connected")
|
||||
subscribed = await self._subscribe_all()
|
||||
self._ws_ready = subscribed
|
||||
await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
|
||||
@@ -427,12 +425,12 @@ class MochatChannel(BaseChannel):
|
||||
if not self._running:
|
||||
return
|
||||
self._ws_connected = self._ws_ready = False
|
||||
logger.warning("Mochat websocket disconnected")
|
||||
self.logger.warning("websocket disconnected")
|
||||
await self._ensure_fallback_workers()
|
||||
|
||||
@client.event
|
||||
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")
|
||||
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),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect Mochat websocket: {}", e)
|
||||
try:
|
||||
except Exception:
|
||||
self.logger.exception("Failed to connect websocket")
|
||||
with suppress(Exception):
|
||||
await client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
@@ -496,7 +492,7 @@ class MochatChannel(BaseChannel):
|
||||
"limit": self.config.watch_limit,
|
||||
})
|
||||
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
|
||||
|
||||
data = ack.get("data")
|
||||
@@ -518,7 +514,7 @@ class MochatChannel(BaseChannel):
|
||||
return True
|
||||
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
|
||||
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 True
|
||||
|
||||
@@ -540,7 +536,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
await self._refresh_targets(subscribe_new=self._ws_ready)
|
||||
except Exception as e:
|
||||
logger.warning("Mochat refresh failed: {}", e)
|
||||
self.logger.warning("refresh failed: {}", e)
|
||||
if self._fallback_mode:
|
||||
await self._ensure_fallback_workers()
|
||||
|
||||
@@ -554,7 +550,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
response = await self._post_json("/api/claw/sessions/list", {})
|
||||
except Exception as e:
|
||||
logger.warning("Mochat listSessions failed: {}", e)
|
||||
self.logger.warning("listSessions failed: {}", e)
|
||||
return
|
||||
|
||||
sessions = response.get("sessions")
|
||||
@@ -588,7 +584,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
response = await self._post_json("/api/claw/groups/get", {})
|
||||
except Exception as e:
|
||||
logger.warning("Mochat getWorkspaceGroup failed: {}", e)
|
||||
self.logger.warning("getWorkspaceGroup failed: {}", e)
|
||||
return
|
||||
|
||||
raw_panels = response.get("panels")
|
||||
@@ -650,7 +646,7 @@ class MochatChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
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))
|
||||
|
||||
async def _panel_poll_worker(self, panel_id: str) -> None:
|
||||
@@ -677,7 +673,7 @@ class MochatChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
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)
|
||||
|
||||
# ---- inbound event processing ------------------------------------------
|
||||
@@ -888,7 +884,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
data = json.loads(self._cursor_path.read_text("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read Mochat cursor file: {}", e)
|
||||
self.logger.warning("Failed to read cursor file: {}", e)
|
||||
return
|
||||
cursors = data.get("cursors") if isinstance(data, dict) else None
|
||||
if isinstance(cursors, dict):
|
||||
@@ -904,7 +900,7 @@ class MochatChannel(BaseChannel):
|
||||
"cursors": self._session_cursor,
|
||||
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save Mochat cursor file: {}", e)
|
||||
self.logger.warning("Failed to save cursor file: {}", e)
|
||||
|
||||
# ---- HTTP helpers ------------------------------------------------------
|
||||
|
||||
|
||||
+22
-25
@@ -20,7 +20,7 @@ import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -32,7 +32,6 @@ except ImportError: # pragma: no cover
|
||||
fcntl = None
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -134,16 +133,16 @@ class MSTeamsChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Teams webhook listener."""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
if not self.config.validate_inbound_auth:
|
||||
logger.warning(
|
||||
"MSTeams inbound auth validation was explicitly DISABLED in config. "
|
||||
self.logger.warning(
|
||||
"Inbound auth validation was explicitly DISABLED in config. "
|
||||
"Anyone who knows the webhook URL can send messages as any user. "
|
||||
"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"{}"
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("MSTeams invalid request body: {}", e)
|
||||
channel.logger.warning("Invalid request body: {}", e)
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
@@ -180,7 +179,7 @@ class MSTeamsChannel(BaseChannel):
|
||||
)
|
||||
fut.result(timeout=15)
|
||||
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_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
@@ -193,7 +192,7 @@ class MSTeamsChannel(BaseChannel):
|
||||
)
|
||||
fut.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.warning("MSTeams activity handling failed: {}", e)
|
||||
channel.logger.warning("Activity handling failed: {}", e)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
@@ -211,8 +210,8 @@ class MSTeamsChannel(BaseChannel):
|
||||
)
|
||||
self._server_thread.start()
|
||||
|
||||
logger.info(
|
||||
"MSTeams webhook listening on http://{}:{}{}",
|
||||
self.logger.info(
|
||||
"Webhook listening on http://{}:{}{}",
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
self.config.path,
|
||||
@@ -261,10 +260,10 @@ class MSTeamsChannel(BaseChannel):
|
||||
try:
|
||||
resp = await self._http.post(base_url, headers=headers, json=payload)
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error("MSTeams send failed: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Send failed")
|
||||
raise
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
text = self._sanitize_inbound_text(activity)
|
||||
if not text:
|
||||
text = self.config.mention_only_response.strip()
|
||||
if not text:
|
||||
logger.debug("MSTeams ignoring empty message after Teams text sanitization")
|
||||
self.logger.debug("Ignoring empty message after Teams text sanitization")
|
||||
return
|
||||
|
||||
if not self.is_allowed(sender_id):
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Access denied for sender {} on channel {}. "
|
||||
"Add them to allowFrom list in config to grant access.",
|
||||
sender_id, self.name,
|
||||
@@ -554,7 +553,7 @@ class MSTeamsChannel(BaseChannel):
|
||||
if isinstance(loaded, dict):
|
||||
main_data = loaded
|
||||
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:
|
||||
try:
|
||||
@@ -562,7 +561,7 @@ class MSTeamsChannel(BaseChannel):
|
||||
if isinstance(loaded_meta, dict):
|
||||
meta_data = loaded_meta
|
||||
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
|
||||
|
||||
@@ -660,8 +659,8 @@ class MSTeamsChannel(BaseChannel):
|
||||
|
||||
for key in keys_to_drop:
|
||||
self._conversation_refs.pop(key, None)
|
||||
logger.info(
|
||||
"MSTeams pruned {} stale/unsupported conversation refs (ttl={} days)",
|
||||
self.logger.info(
|
||||
"Pruned {} stale/unsupported conversation refs (ttl={} days)",
|
||||
len(keys_to_drop),
|
||||
ttl_days,
|
||||
)
|
||||
@@ -712,10 +711,8 @@ class MSTeamsChannel(BaseChannel):
|
||||
os.replace(tmp_path, path)
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
with suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _save_refs_locked(self, *, prune: bool = True) -> None:
|
||||
"""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_meta_path, refs_meta)
|
||||
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:
|
||||
"""Persist conversation references."""
|
||||
|
||||
+45
-45
@@ -25,6 +25,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import unquote, urlparse
|
||||
@@ -37,7 +38,7 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.security.network import validate_url_target
|
||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||
|
||||
try:
|
||||
from nanobot.config.paths import get_media_dir
|
||||
@@ -186,24 +187,25 @@ class QQChannel(BaseChannel):
|
||||
root = Path.home() / ".nanobot" / "media" / "qq"
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("QQ media directory: {}", str(root))
|
||||
self.logger.info("media directory: {}", str(root))
|
||||
return root
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the QQ bot with auto-reconnect loop."""
|
||||
redirect_lib_logging("botpy", level="WARNING")
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
self._running = True
|
||||
self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
|
||||
|
||||
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()
|
||||
|
||||
async def _run_bot(self) -> None:
|
||||
@@ -212,29 +214,25 @@ class QQChannel(BaseChannel):
|
||||
try:
|
||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||
except Exception as e:
|
||||
logger.warning("QQ bot error: {}", e)
|
||||
self.logger.warning("bot error: {}", e)
|
||||
if self._running:
|
||||
logger.info("Reconnecting QQ bot in 5 seconds...")
|
||||
self.logger.info("Reconnecting bot in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop bot and cleanup resources."""
|
||||
self._running = False
|
||||
if self._client:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
if self._http:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._http.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._http = None
|
||||
|
||||
logger.info("QQ bot stopped")
|
||||
self.logger.info("bot stopped")
|
||||
|
||||
# ---------------------------
|
||||
# Outbound (send)
|
||||
@@ -244,7 +242,7 @@ class QQChannel(BaseChannel):
|
||||
"""Send attachments first, then text."""
|
||||
try:
|
||||
if not self._client:
|
||||
logger.warning("QQ client not initialized")
|
||||
self.logger.warning("client not initialized")
|
||||
return
|
||||
|
||||
msg_id = msg.metadata.get("message_id")
|
||||
@@ -284,7 +282,7 @@ class QQChannel(BaseChannel):
|
||||
# Network / transport errors — propagate so ChannelManager can retry
|
||||
raise
|
||||
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(
|
||||
self,
|
||||
@@ -342,7 +340,7 @@ class QQChannel(BaseChannel):
|
||||
srv_send_msg=False,
|
||||
)
|
||||
if not media_obj:
|
||||
logger.error("QQ media upload failed: empty response")
|
||||
self.logger.error("media upload failed: empty response")
|
||||
return False
|
||||
|
||||
self._msg_seq += 1
|
||||
@@ -363,15 +361,15 @@ class QQChannel(BaseChannel):
|
||||
media=media_obj,
|
||||
)
|
||||
|
||||
logger.info("QQ media sent: {}", filename)
|
||||
self.logger.info("media sent: {}", filename)
|
||||
return True
|
||||
except (aiohttp.ClientError, OSError) as e:
|
||||
# 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
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# 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
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
data = await asyncio.to_thread(local_path.read_bytes)
|
||||
return data, local_path.name
|
||||
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
|
||||
|
||||
# Remote URL
|
||||
ok, err = validate_url_target(media_ref)
|
||||
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
|
||||
|
||||
if not self._http:
|
||||
@@ -412,8 +410,8 @@ class QQChannel(BaseChannel):
|
||||
try:
|
||||
async with self._http.get(media_ref, allow_redirects=True) as resp:
|
||||
if resp.status >= 400:
|
||||
logger.warning(
|
||||
"QQ outbound media download failed status={} url={}",
|
||||
self.logger.warning(
|
||||
"outbound media download failed status={} url={}",
|
||||
resp.status,
|
||||
media_ref,
|
||||
)
|
||||
@@ -424,7 +422,7 @@ class QQChannel(BaseChannel):
|
||||
filename = os.path.basename(urlparse(media_ref).path) or "file.bin"
|
||||
return data, filename
|
||||
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
|
||||
|
||||
# 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:
|
||||
"""Parse inbound message, download attachments, and publish to the bus."""
|
||||
try:
|
||||
if data.id in self._processed_ids:
|
||||
return
|
||||
self._processed_ids.append(data.id)
|
||||
|
||||
if is_group:
|
||||
chat_id = data.group_openid
|
||||
user_id = data.author.member_openid
|
||||
self._chat_type_cache[chat_id] = "group"
|
||||
chat_type = "group"
|
||||
else:
|
||||
chat_id = str(
|
||||
getattr(data.author, "id", None)
|
||||
or getattr(data.author, "user_openid", "unknown")
|
||||
)
|
||||
user_id = chat_id
|
||||
self._chat_type_cache[chat_id] = "c2c"
|
||||
chat_type = "c2c"
|
||||
|
||||
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
|
||||
# so we use getattr with a default of [] to avoid AttributeError in tests
|
||||
attachments = getattr(data, "attachments", None) or []
|
||||
@@ -524,7 +526,7 @@ class QQChannel(BaseChannel):
|
||||
content=self.config.ack_message,
|
||||
)
|
||||
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(
|
||||
sender_id=user_id,
|
||||
@@ -537,7 +539,7 @@ class QQChannel(BaseChannel):
|
||||
},
|
||||
)
|
||||
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(
|
||||
self,
|
||||
@@ -556,7 +558,7 @@ class QQChannel(BaseChannel):
|
||||
filename = getattr(att, "filename", 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)
|
||||
|
||||
att_meta.append(
|
||||
@@ -607,7 +609,7 @@ class QQChannel(BaseChannel):
|
||||
allow_redirects=True,
|
||||
) as resp:
|
||||
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
|
||||
|
||||
ctype = (resp.headers.get("Content-Type") or "").lower()
|
||||
@@ -661,8 +663,8 @@ class QQChannel(BaseChannel):
|
||||
continue
|
||||
downloaded += len(chunk)
|
||||
if downloaded > max_bytes:
|
||||
logger.warning(
|
||||
"QQ download exceeded max_bytes={} url={} -> abort",
|
||||
self.logger.warning(
|
||||
"download exceeded max_bytes={} url={} -> abort",
|
||||
max_bytes,
|
||||
url,
|
||||
)
|
||||
@@ -674,16 +676,14 @@ class QQChannel(BaseChannel):
|
||||
# Atomic rename
|
||||
await asyncio.to_thread(os.replace, tmp_path, target)
|
||||
tmp_path = None # mark as moved
|
||||
logger.info("QQ file saved: {}", str(target))
|
||||
self.logger.info("file saved: {}", str(target))
|
||||
return str(target)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("QQ download error: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("download error")
|
||||
return None
|
||||
finally:
|
||||
# Cleanup partial file
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+30
-24
@@ -6,7 +6,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
@@ -84,10 +83,10 @@ class SlackChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Slack Socket Mode client."""
|
||||
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
|
||||
if self.config.mode != "socket":
|
||||
logger.error("Unsupported Slack mode: {}", self.config.mode)
|
||||
self.logger.error("Unsupported mode: {}", self.config.mode)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
@@ -104,11 +103,11 @@ class SlackChannel(BaseChannel):
|
||||
try:
|
||||
auth = await self._web_client.auth_test()
|
||||
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:
|
||||
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()
|
||||
|
||||
while self._running:
|
||||
@@ -121,13 +120,13 @@ class SlackChannel(BaseChannel):
|
||||
try:
|
||||
await self._socket_client.close()
|
||||
except Exception as e:
|
||||
logger.warning("Slack socket close failed: {}", e)
|
||||
self.logger.warning("socket close failed: {}", e)
|
||||
self._socket_client = None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Slack."""
|
||||
if not self._web_client:
|
||||
logger.warning("Slack client not running")
|
||||
self.logger.warning("client not running")
|
||||
return
|
||||
try:
|
||||
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
|
||||
@@ -162,16 +161,16 @@ class SlackChannel(BaseChannel):
|
||||
file=media_path,
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to upload file {}: {}", media_path, e)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to upload file {}", media_path)
|
||||
|
||||
# Update reaction emoji when the final (non-progress) response is sent
|
||||
if not (msg.metadata or {}).get("_progress"):
|
||||
event = slack_meta.get("event", {})
|
||||
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending Slack message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message")
|
||||
raise
|
||||
|
||||
async def _resolve_target_chat_id(self, target: str) -> str:
|
||||
@@ -328,8 +327,8 @@ class SlackChannel(BaseChannel):
|
||||
return
|
||||
|
||||
# Debug: log basic event shape
|
||||
logger.debug(
|
||||
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
|
||||
self.logger.debug(
|
||||
"event: type={} subtype={} user={} channel={} channel_type={} text={}",
|
||||
event_type,
|
||||
subtype,
|
||||
sender_id,
|
||||
@@ -371,7 +370,7 @@ class SlackChannel(BaseChannel):
|
||||
timestamp=event.get("ts"),
|
||||
)
|
||||
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
|
||||
# (raw_thread_ts is set). DM threads get their own session, separate
|
||||
@@ -420,7 +419,7 @@ class SlackChannel(BaseChannel):
|
||||
session_key=session_key,
|
||||
)
|
||||
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]:
|
||||
"""Download a Slack private file to the local media directory."""
|
||||
@@ -435,9 +434,9 @@ class SlackChannel(BaseChannel):
|
||||
marker = f"[{marker_type}: {name}]"
|
||||
url = str(file_info.get("url_private_download") or file_info.get("url_private") or "")
|
||||
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:
|
||||
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}")
|
||||
path = Path(get_media_dir("slack")) / filename
|
||||
@@ -453,8 +452,15 @@ class SlackChannel(BaseChannel):
|
||||
path.write_bytes(response.content)
|
||||
return str(path), marker
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download Slack file {}: {}", file_id, e)
|
||||
return None, f"[{marker_type}: {name}: download failed]"
|
||||
self.logger.warning("Failed to download file {}: {}", file_id, e)
|
||||
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
|
||||
def _looks_like_html_download(response: httpx.Response) -> bool:
|
||||
@@ -493,7 +499,7 @@ class SlackChannel(BaseChannel):
|
||||
session_key=session_key,
|
||||
)
|
||||
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(
|
||||
self,
|
||||
@@ -530,7 +536,7 @@ class SlackChannel(BaseChannel):
|
||||
limit=max(1, self.config.thread_context_limit),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Slack thread context unavailable for {}: {}", key, e)
|
||||
self.logger.warning("thread context unavailable for {}: {}", key, e)
|
||||
return text
|
||||
|
||||
lines = self._format_thread_context(
|
||||
@@ -590,7 +596,7 @@ class SlackChannel(BaseChannel):
|
||||
timestamp=ts,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Slack reactions_remove failed: {}", e)
|
||||
self.logger.debug("reactions_remove failed: {}", e)
|
||||
if self.config.done_emoji:
|
||||
try:
|
||||
await self._web_client.reactions_add(
|
||||
@@ -599,7 +605,7 @@ class SlackChannel(BaseChannel):
|
||||
timestamp=ts,
|
||||
)
|
||||
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:
|
||||
if channel_type == "im":
|
||||
|
||||
@@ -6,11 +6,11 @@ import asyncio
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from telegram import (
|
||||
BotCommand,
|
||||
@@ -319,7 +319,7 @@ class TelegramChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Telegram bot with long polling."""
|
||||
if not self.config.token:
|
||||
logger.error("Telegram bot token not configured")
|
||||
self.logger.error("bot token not configured")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
@@ -381,11 +381,11 @@ class TelegramChannel(BaseChannel):
|
||||
if self.config.inline_keyboards:
|
||||
self._app.add_handler(CallbackQueryHandler(self._on_callback_query))
|
||||
allowed_updates = ["message", "callback_query"]
|
||||
logger.debug("Telegram inline keyboards enabled")
|
||||
self.logger.debug("inline keyboards enabled")
|
||||
else:
|
||||
allowed_updates = ["message"]
|
||||
|
||||
logger.info("Starting Telegram bot (polling mode)...")
|
||||
self.logger.info("Starting bot (polling mode)...")
|
||||
|
||||
# Initialize and start polling
|
||||
await self._app.initialize()
|
||||
@@ -395,13 +395,13 @@ class TelegramChannel(BaseChannel):
|
||||
bot_info = await self._app.bot.get_me()
|
||||
self._bot_user_id = getattr(bot_info, "id", 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:
|
||||
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:
|
||||
logger.warning("Failed to register bot commands: {}", e)
|
||||
self.logger.warning("Failed to register bot commands: {}", e)
|
||||
|
||||
# Start polling (this runs until stopped)
|
||||
await self._app.updater.start_polling(
|
||||
@@ -428,7 +428,7 @@ class TelegramChannel(BaseChannel):
|
||||
self._media_group_buffers.clear()
|
||||
|
||||
if self._app:
|
||||
logger.info("Stopping Telegram bot...")
|
||||
self.logger.info("Stopping bot...")
|
||||
await self._app.updater.stop()
|
||||
await self._app.stop()
|
||||
await self._app.shutdown()
|
||||
@@ -455,22 +455,20 @@ class TelegramChannel(BaseChannel):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Telegram."""
|
||||
if not self._app:
|
||||
logger.warning("Telegram bot not running")
|
||||
self.logger.warning("bot not running")
|
||||
return
|
||||
|
||||
# Only stop typing indicator and remove reaction for final responses
|
||||
if not msg.metadata.get("_progress", False):
|
||||
self._stop_typing(msg.chat_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))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
chat_id = int(msg.chat_id)
|
||||
except ValueError:
|
||||
logger.error("Invalid chat_id: {}", msg.chat_id)
|
||||
self.logger.exception("Invalid chat_id: {}", msg.chat_id)
|
||||
return
|
||||
reply_to_message_id = msg.metadata.get("message_id")
|
||||
message_thread_id = msg.metadata.get("message_thread_id")
|
||||
@@ -534,9 +532,9 @@ class TelegramChannel(BaseChannel):
|
||||
**extra,
|
||||
**send_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
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(
|
||||
chat_id=chat_id,
|
||||
text=f"[Failed to send: {filename}]",
|
||||
@@ -573,8 +571,8 @@ class TelegramChannel(BaseChannel):
|
||||
if attempt == _SEND_MAX_RETRIES:
|
||||
raise
|
||||
delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
"Telegram timeout (attempt {}/{}), retrying in {:.1f}s",
|
||||
self.logger.warning(
|
||||
"timeout (attempt {}/{}), retrying in {:.1f}s",
|
||||
attempt, _SEND_MAX_RETRIES, delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
@@ -582,8 +580,8 @@ class TelegramChannel(BaseChannel):
|
||||
if attempt == _SEND_MAX_RETRIES:
|
||||
raise
|
||||
delay = float(e.retry_after)
|
||||
logger.warning(
|
||||
"Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s",
|
||||
self.logger.warning(
|
||||
"Flood Control (attempt {}/{}), retrying in {:.1f}s",
|
||||
attempt, _SEND_MAX_RETRIES, delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
@@ -608,7 +606,7 @@ class TelegramChannel(BaseChannel):
|
||||
**(thread_kwargs or {}),
|
||||
)
|
||||
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:
|
||||
await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
@@ -618,8 +616,8 @@ class TelegramChannel(BaseChannel):
|
||||
reply_markup=reply_markup,
|
||||
**(thread_kwargs or {}),
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.error("Error sending Telegram message: {}", e2)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
@@ -642,10 +640,8 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
self._stop_typing(chat_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))
|
||||
except ValueError:
|
||||
pass
|
||||
thread_kwargs = {}
|
||||
if message_thread_id := meta.get("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
|
||||
# to avoid doubling connection demand during pool exhaustion.
|
||||
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)
|
||||
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.
|
||||
primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text
|
||||
try:
|
||||
@@ -683,9 +679,9 @@ class TelegramChannel(BaseChannel):
|
||||
)
|
||||
except Exception as 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:
|
||||
logger.warning("Final stream edit failed: {}", e2)
|
||||
self.logger.warning("Final stream edit failed: {}", e2)
|
||||
raise # Let ChannelManager handle retry
|
||||
for extra_html_chunk in extra_html_chunks:
|
||||
try:
|
||||
@@ -727,7 +723,7 @@ class TelegramChannel(BaseChannel):
|
||||
buf.message_id = sent.message_id
|
||||
buf.last_edit = now
|
||||
except Exception as e:
|
||||
logger.warning("Stream initial send failed: {}", e)
|
||||
self.logger.warning("Stream initial send failed: {}", e)
|
||||
raise # Let ChannelManager handle retry
|
||||
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
|
||||
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
|
||||
@@ -746,7 +742,7 @@ class TelegramChannel(BaseChannel):
|
||||
if self._is_not_modified_error(e):
|
||||
buf.last_edit = now
|
||||
return
|
||||
logger.warning("Stream edit failed: {}", e)
|
||||
self.logger.warning("Stream edit failed: {}", e)
|
||||
raise # Let ChannelManager handle retry
|
||||
|
||||
async def _flush_stream_overflow(
|
||||
@@ -772,7 +768,7 @@ class TelegramChannel(BaseChannel):
|
||||
)
|
||||
except Exception as 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
|
||||
for chunk in chunks[1:-1]:
|
||||
await self._call_with_retry(
|
||||
@@ -793,6 +789,8 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
|
||||
user = update.effective_user
|
||||
if not self.is_allowed(self._sender_id(user)):
|
||||
return
|
||||
await update.message.reply_text(
|
||||
f"👋 Hi {user.first_name}! I'm nanobot.\n\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:
|
||||
"""Handle /help command, bypassing ACL so all users can access it."""
|
||||
if not update.message:
|
||||
"""Handle /help command for allowed users only."""
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
if not self.is_allowed(self._sender_id(update.effective_user)):
|
||||
return
|
||||
await update.message.reply_text(build_help_text())
|
||||
|
||||
@@ -902,12 +902,12 @@ class TelegramChannel(BaseChannel):
|
||||
if media_type in ("voice", "audio"):
|
||||
transcription = await self.transcribe_audio(file_path)
|
||||
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"[{media_type}: {path_str}]"]
|
||||
return [path_str], [f"[{media_type}: {path_str}]"]
|
||||
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:
|
||||
return [], [f"[{media_type}: download failed]"]
|
||||
return [], []
|
||||
@@ -992,6 +992,9 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
message = update.message
|
||||
user = update.effective_user
|
||||
sender_id = self._sender_id(user)
|
||||
if not self.is_allowed(sender_id):
|
||||
return
|
||||
self._remember_thread_context(message)
|
||||
|
||||
# Strip @bot_username suffix if present
|
||||
@@ -1003,7 +1006,7 @@ class TelegramChannel(BaseChannel):
|
||||
content = self._normalize_telegram_command(content)
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=self._sender_id(user),
|
||||
sender_id=sender_id,
|
||||
chat_id=str(message.chat_id),
|
||||
content=content,
|
||||
metadata=self._build_message_metadata(message, user),
|
||||
@@ -1019,6 +1022,8 @@ class TelegramChannel(BaseChannel):
|
||||
user = update.effective_user
|
||||
chat_id = message.chat_id
|
||||
sender_id = self._sender_id(user)
|
||||
if not self.is_allowed(sender_id):
|
||||
return
|
||||
self._remember_thread_context(message)
|
||||
|
||||
# Store chat_id for replies
|
||||
@@ -1050,7 +1055,7 @@ class TelegramChannel(BaseChannel):
|
||||
media_paths.extend(current_media_paths)
|
||||
content_parts.extend(current_media_parts)
|
||||
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 = getattr(message, "reply_to_message", None)
|
||||
@@ -1059,13 +1064,13 @@ class TelegramChannel(BaseChannel):
|
||||
reply_media, reply_media_parts = await self._download_message_media(reply)
|
||||
if reply_media:
|
||||
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)
|
||||
if tag:
|
||||
content_parts.insert(0, tag)
|
||||
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)
|
||||
metadata = self._build_message_metadata(message, user)
|
||||
@@ -1144,7 +1149,7 @@ class TelegramChannel(BaseChannel):
|
||||
reaction=[ReactionTypeEmoji(emoji=emoji)],
|
||||
)
|
||||
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:
|
||||
"""Remove emoji reaction from a message (best-effort, non-blocking)."""
|
||||
@@ -1157,18 +1162,17 @@ class TelegramChannel(BaseChannel):
|
||||
reaction=[],
|
||||
)
|
||||
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:
|
||||
"""Repeatedly send 'typing' action until cancelled."""
|
||||
try:
|
||||
while self._app:
|
||||
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
|
||||
await asyncio.sleep(4)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
with suppress(asyncio.CancelledError):
|
||||
while self._app:
|
||||
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
|
||||
await asyncio.sleep(4)
|
||||
except Exception as e:
|
||||
logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
||||
self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
||||
|
||||
@staticmethod
|
||||
def _format_telegram_error(exc: Exception) -> str:
|
||||
@@ -1188,18 +1192,18 @@ class TelegramChannel(BaseChannel):
|
||||
"""Keep long-polling network failures to a single readable line."""
|
||||
summary = self._format_telegram_error(exc)
|
||||
if isinstance(exc, (NetworkError, TimedOut)):
|
||||
logger.warning("Telegram polling network issue: {}", summary)
|
||||
self.logger.warning("polling network issue: {}", summary)
|
||||
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:
|
||||
"""Log polling / handler errors instead of silently swallowing them."""
|
||||
summary = self._format_telegram_error(context.error)
|
||||
|
||||
if isinstance(context.error, (NetworkError, TimedOut)):
|
||||
logger.warning("Telegram network issue: {}", summary)
|
||||
self.logger.warning("network issue: {}", summary)
|
||||
else:
|
||||
logger.error("Telegram error: {}", summary)
|
||||
self.logger.error("error: {}", summary)
|
||||
|
||||
def _get_extension(
|
||||
self,
|
||||
@@ -1260,16 +1264,16 @@ class TelegramChannel(BaseChannel):
|
||||
chat_id = query.message.chat_id if query.message else None
|
||||
sender_id = self._sender_id(user)
|
||||
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
|
||||
button_label = query.data or ""
|
||||
await query.answer()
|
||||
if query.message:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await query.message.edit_reply_markup(reply_markup=None)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
||||
self.logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
||||
self._start_typing(str(chat_id))
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
|
||||
@@ -32,6 +32,7 @@ from websockets.http11 import Response
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.command.builtin import builtin_command_palette
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
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)")
|
||||
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:
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
@@ -148,7 +160,7 @@ def _read_webui_model_name() -> str | None:
|
||||
try:
|
||||
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
|
||||
except Exception as e:
|
||||
logger.debug("webui bootstrap could not load model name: {}", e)
|
||||
@@ -448,7 +460,7 @@ class WebSocketChannel(BaseChannel):
|
||||
except ConnectionClosed:
|
||||
self._cleanup_connection(connection)
|
||||
except Exception as e:
|
||||
logger.warning("websocket: failed to send {} event: {}", event, e)
|
||||
self.logger.warning("failed to send {} event: {}", event, e)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
@@ -464,7 +476,7 @@ class WebSocketChannel(BaseChannel):
|
||||
return None
|
||||
if not cert or not key:
|
||||
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.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
@@ -501,14 +513,14 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _issue_route_secret_matches(request.headers, secret):
|
||||
return connection.respond(401, "Unauthorized")
|
||||
else:
|
||||
logger.warning(
|
||||
"websocket: token_issue_path is set but token_issue_secret is empty; "
|
||||
self.logger.warning(
|
||||
"token_issue_path is set but token_issue_secret is empty; "
|
||||
"any client can obtain connection tokens — set token_issue_secret for production."
|
||||
)
|
||||
self._purge_expired_issued_tokens()
|
||||
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
|
||||
logger.error(
|
||||
"websocket: too many outstanding issued tokens ({}), rejecting issuance",
|
||||
self.logger.error(
|
||||
"too many outstanding issued tokens ({}), rejecting issuance",
|
||||
len(self._issued_tokens),
|
||||
)
|
||||
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
|
||||
@@ -531,9 +543,9 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == issue_expected:
|
||||
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":
|
||||
return self._handle_webui_bootstrap(connection)
|
||||
return self._handle_webui_bootstrap(connection, request)
|
||||
|
||||
# 3. REST surface for the embedded UI.
|
||||
if got == "/api/sessions":
|
||||
@@ -542,6 +554,9 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
|
||||
if got == "/api/commands":
|
||||
return self._handle_commands(request)
|
||||
|
||||
if got == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
|
||||
@@ -606,8 +621,16 @@ class WebSocketChannel(BaseChannel):
|
||||
if now > expiry:
|
||||
self._api_tokens.pop(token_key, None)
|
||||
|
||||
def _handle_webui_bootstrap(self, connection: Any) -> Response:
|
||||
if not _is_localhost(connection):
|
||||
def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||
# 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")
|
||||
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
|
||||
self._purge_expired_issued_tokens()
|
||||
@@ -689,6 +712,11 @@ class WebSocketChannel(BaseChannel):
|
||||
return _http_error(401, "Unauthorized")
|
||||
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:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
@@ -821,7 +849,7 @@ class WebSocketChannel(BaseChannel):
|
||||
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
|
||||
shutil.copyfile(path, staged)
|
||||
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
|
||||
signed = self._sign_media_path(staged)
|
||||
if signed is None:
|
||||
@@ -917,7 +945,7 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
body = candidate.read_bytes()
|
||||
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")
|
||||
ctype, _ = mimetypes.guess_type(candidate.name)
|
||||
if ctype is None:
|
||||
@@ -972,7 +1000,7 @@ class WebSocketChannel(BaseChannel):
|
||||
async def handler(connection: ServerConnection) -> None:
|
||||
await self._connection_loop(connection)
|
||||
|
||||
logger.info(
|
||||
self.logger.info(
|
||||
"WebSocket server listening on {}://{}:{}{}",
|
||||
scheme,
|
||||
self.config.host,
|
||||
@@ -980,7 +1008,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self.config.path,
|
||||
)
|
||||
if self.config.token_issue_path:
|
||||
logger.info(
|
||||
self.logger.info(
|
||||
"WebSocket token issue route: {}://{}:{}{}",
|
||||
scheme,
|
||||
self.config.host,
|
||||
@@ -1014,7 +1042,7 @@ class WebSocketChannel(BaseChannel):
|
||||
if not client_id:
|
||||
client_id = f"anon-{uuid.uuid4().hex[:12]}"
|
||||
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]
|
||||
|
||||
default_chat_id = str(uuid.uuid4())
|
||||
@@ -1039,7 +1067,7 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
logger.warning("websocket: ignoring non-utf8 binary frame")
|
||||
self.logger.warning("ignoring non-utf8 binary frame")
|
||||
continue
|
||||
|
||||
envelope = _parse_envelope(raw)
|
||||
@@ -1057,7 +1085,7 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("websocket connection ended: {}", e)
|
||||
self.logger.debug("connection ended: {}", e)
|
||||
finally:
|
||||
self._cleanup_connection(connection)
|
||||
|
||||
@@ -1097,8 +1125,8 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"websocket: failed to unlink partial media {}: {}", p, exc
|
||||
self.logger.warning(
|
||||
"failed to unlink partial media {}: {}", p, exc
|
||||
)
|
||||
return [], reason
|
||||
|
||||
@@ -1122,7 +1150,7 @@ class WebSocketChannel(BaseChannel):
|
||||
except FileSizeExceeded:
|
||||
return _abort("size")
|
||||
except Exception as exc:
|
||||
logger.warning("websocket: media decode failed: {}", exc)
|
||||
self.logger.warning("media decode failed: {}", exc)
|
||||
return _abort("decode")
|
||||
if saved is None:
|
||||
return _abort("decode")
|
||||
@@ -1184,12 +1212,15 @@ class WebSocketChannel(BaseChannel):
|
||||
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
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(
|
||||
sender_id=client_id,
|
||||
chat_id=cid,
|
||||
content=content,
|
||||
media=media_paths or None,
|
||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||
metadata=metadata,
|
||||
)
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
@@ -1204,7 +1235,7 @@ class WebSocketChannel(BaseChannel):
|
||||
try:
|
||||
await self._server_task
|
||||
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._subs.clear()
|
||||
self._conn_chats.clear()
|
||||
@@ -1218,16 +1249,23 @@ class WebSocketChannel(BaseChannel):
|
||||
await connection.send(raw)
|
||||
except ConnectionClosed:
|
||||
self._cleanup_connection(connection)
|
||||
logger.warning("websocket{}connection gone", label)
|
||||
except Exception as e:
|
||||
logger.error("websocket{}send failed: {}", label, e)
|
||||
self.logger.warning("connection gone{}", label)
|
||||
except Exception:
|
||||
self.logger.exception("send failed{}", label)
|
||||
raise
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
|
||||
conns = list(self._subs.get(msg.chat_id, ()))
|
||||
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
|
||||
text = msg.content
|
||||
if msg.buttons:
|
||||
@@ -1285,3 +1323,23 @@ class WebSocketChannel(BaseChannel):
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
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 typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
from pydantic import Field
|
||||
|
||||
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
|
||||
|
||||
@@ -103,11 +102,11 @@ class WecomChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the WeCom bot with WebSocket long connection."""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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("event.enter_chat", self._on_enter_chat)
|
||||
|
||||
logger.info("WeCom bot starting with WebSocket long connection")
|
||||
logger.info("No public IP required - using WebSocket to receive events")
|
||||
self.logger.info("bot starting with WebSocket long connection")
|
||||
self.logger.info("No public IP required - using WebSocket to receive events")
|
||||
|
||||
# Connect
|
||||
await self._client.connect_async()
|
||||
@@ -152,24 +151,24 @@ class WecomChannel(BaseChannel):
|
||||
self._running = False
|
||||
if self._client:
|
||||
await self._client.disconnect()
|
||||
logger.info("WeCom bot stopped")
|
||||
self.logger.info("bot stopped")
|
||||
|
||||
async def _on_connected(self, frame: Any) -> None:
|
||||
"""Handle WebSocket connected event."""
|
||||
logger.info("WeCom WebSocket connected")
|
||||
self.logger.info("WebSocket connected")
|
||||
|
||||
async def _on_authenticated(self, frame: Any) -> None:
|
||||
"""Handle authentication success event."""
|
||||
logger.info("WeCom authenticated successfully")
|
||||
self.logger.info("authenticated successfully")
|
||||
|
||||
async def _on_disconnected(self, frame: Any) -> None:
|
||||
"""Handle WebSocket disconnected event."""
|
||||
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:
|
||||
"""Handle error event."""
|
||||
logger.error("WeCom error: {}", frame)
|
||||
self.logger.error("error: {}", frame)
|
||||
|
||||
async def _on_text_message(self, frame: Any) -> None:
|
||||
"""Handle text message."""
|
||||
@@ -204,13 +203,16 @@ class WecomChannel(BaseChannel):
|
||||
|
||||
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:
|
||||
await self._client.reply_welcome(frame, {
|
||||
"msgtype": "text",
|
||||
"text": {"content": self.config.welcome_message},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error handling enter_chat: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error handling enter_chat")
|
||||
|
||||
async def _process_message(self, frame: Any, msg_type: str) -> None:
|
||||
"""Process incoming message and forward to bus."""
|
||||
@@ -225,7 +227,7 @@ class WecomChannel(BaseChannel):
|
||||
|
||||
# Ensure body is a dict
|
||||
if not isinstance(body, dict):
|
||||
logger.warning("Invalid body type: {}", type(body))
|
||||
self.logger.warning("Invalid body type: {}", type(body))
|
||||
return
|
||||
|
||||
# Extract message info
|
||||
@@ -233,6 +235,12 @@ class WecomChannel(BaseChannel):
|
||||
if not msg_id:
|
||||
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
|
||||
if msg_id in self._processed_message_ids:
|
||||
return
|
||||
@@ -242,10 +250,6 @@ class WecomChannel(BaseChannel):
|
||||
while len(self._processed_message_ids) > 1000:
|
||||
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 group chat, chatid is provided in body
|
||||
chat_type = body.get("chattype", "single")
|
||||
@@ -345,8 +349,8 @@ class WecomChannel(BaseChannel):
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing WeCom message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error processing message")
|
||||
|
||||
async def _download_and_save_media(
|
||||
self,
|
||||
@@ -365,12 +369,12 @@ class WecomChannel(BaseChannel):
|
||||
data, fname = await self._client.download_file(file_url, aes_key)
|
||||
|
||||
if not data:
|
||||
logger.warning("Failed to download media from WeCom")
|
||||
self.logger.warning("Failed to download media")
|
||||
return None
|
||||
|
||||
if len(data) > WECOM_UPLOAD_MAX_BYTES:
|
||||
logger.warning(
|
||||
"WeCom inbound media too large: {} bytes (max {})",
|
||||
self.logger.warning(
|
||||
"inbound media too large: {} bytes (max {})",
|
||||
len(data),
|
||||
WECOM_UPLOAD_MAX_BYTES,
|
||||
)
|
||||
@@ -383,11 +387,11 @@ class WecomChannel(BaseChannel):
|
||||
|
||||
file_path = media_dir / filename
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error downloading media: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error downloading media")
|
||||
return None
|
||||
|
||||
async def _upload_media_ws(
|
||||
@@ -424,9 +428,9 @@ class WecomChannel(BaseChannel):
|
||||
# MD5 is used for file integrity only, not cryptographic security
|
||||
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)
|
||||
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)
|
||||
del mv, data
|
||||
|
||||
@@ -440,11 +444,11 @@ class WecomChannel(BaseChannel):
|
||||
"md5": md5_hash,
|
||||
}, "aibot_upload_media_init")
|
||||
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
|
||||
upload_id = resp.body.get("upload_id") if resp.body else None
|
||||
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
|
||||
|
||||
# Step 2: send chunks
|
||||
@@ -456,7 +460,7 @@ class WecomChannel(BaseChannel):
|
||||
"base64_data": base64.b64encode(chunk).decode(),
|
||||
}, "aibot_upload_media_chunk")
|
||||
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
|
||||
|
||||
# Step 3: finish
|
||||
@@ -465,29 +469,29 @@ class WecomChannel(BaseChannel):
|
||||
"upload_id": upload_id,
|
||||
}, "aibot_upload_media_finish")
|
||||
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
|
||||
|
||||
media_id = resp.body.get("media_id") if resp.body else None
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e)
|
||||
except Exception:
|
||||
self.logger.exception("_upload_media_ws error for {}", file_path)
|
||||
return None, None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through WeCom."""
|
||||
if not self._client:
|
||||
logger.warning("WeCom client not initialized")
|
||||
self.logger.warning("client not initialized")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -500,7 +504,7 @@ class WecomChannel(BaseChannel):
|
||||
# Send media files via WebSocket upload
|
||||
for file_path in msg.media or []:
|
||||
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
|
||||
media_id, media_type = await self._upload_media_ws(self._client, file_path)
|
||||
if media_id:
|
||||
@@ -514,7 +518,7 @@ class WecomChannel(BaseChannel):
|
||||
"msgtype": media_type,
|
||||
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:
|
||||
content += f"\n[file upload failed: {os.path.basename(file_path)}]"
|
||||
|
||||
@@ -532,8 +536,8 @@ class WecomChannel(BaseChannel):
|
||||
content,
|
||||
finish=not is_progress,
|
||||
)
|
||||
logger.debug(
|
||||
"WeCom {} sent to {}",
|
||||
self.logger.debug(
|
||||
"{} sent to {}",
|
||||
"progress" if is_progress else "message",
|
||||
msg.chat_id,
|
||||
)
|
||||
@@ -543,7 +547,7 @@ class WecomChannel(BaseChannel):
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": content},
|
||||
})
|
||||
logger.info("WeCom proactive send to {}", msg.chat_id)
|
||||
self.logger.info("proactive send to {}", msg.chat_id)
|
||||
|
||||
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 base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
@@ -53,7 +54,7 @@ MESSAGE_TYPE_BOT = 2
|
||||
MESSAGE_STATE_FINISH = 2
|
||||
|
||||
WEIXIN_MAX_MESSAGE_LEN = 4000
|
||||
WEIXIN_CHANNEL_VERSION = "2.1.1"
|
||||
WEIXIN_CHANNEL_VERSION = "2.1.7"
|
||||
ILINK_APP_ID = "bot"
|
||||
|
||||
|
||||
@@ -79,6 +80,36 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
|
||||
ERRCODE_SESSION_EXPIRED = -14
|
||||
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)
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
BACKOFF_DELAY_S = 30
|
||||
@@ -211,7 +242,7 @@ class WeixinChannel(BaseChannel):
|
||||
|
||||
def _save_state(self) -> None:
|
||||
state_file = self._get_state_dir() / "account.json"
|
||||
try:
|
||||
with suppress(Exception):
|
||||
data = {
|
||||
"token": self._token,
|
||||
"get_updates_buf": self._get_updates_buf,
|
||||
@@ -220,8 +251,6 @@ class WeixinChannel(BaseChannel):
|
||||
"base_url": self.config.base_url,
|
||||
}
|
||||
state_file.write_text(json.dumps(data, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers (matches api.ts buildHeaders / apiFetch)
|
||||
@@ -367,14 +396,14 @@ class WeixinChannel(BaseChannel):
|
||||
if base_url:
|
||||
self.config.base_url = base_url
|
||||
self._save_state()
|
||||
logger.info(
|
||||
"WeChat login successful! bot_id={} user_id={}",
|
||||
self.logger.info(
|
||||
"login successful! bot_id={} user_id={}",
|
||||
bot_id,
|
||||
user_id,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error("Login confirmed but no bot_token in response")
|
||||
self.logger.error("Login confirmed but no bot_token in response")
|
||||
return False
|
||||
elif status == "scaned_but_redirect":
|
||||
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
|
||||
@@ -388,7 +417,7 @@ class WeixinChannel(BaseChannel):
|
||||
elif status == "expired":
|
||||
refresh_count += 1
|
||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"QR code expired too many times ({}/{}), giving up.",
|
||||
refresh_count - 1,
|
||||
MAX_QR_REFRESH_COUNT,
|
||||
@@ -402,8 +431,8 @@ class WeixinChannel(BaseChannel):
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("WeChat QR login failed: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("QR login failed")
|
||||
|
||||
return False
|
||||
|
||||
@@ -470,11 +499,11 @@ class WeixinChannel(BaseChannel):
|
||||
self._token = self.config.token
|
||||
elif not self._load_state():
|
||||
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
|
||||
return
|
||||
|
||||
logger.info("WeChat channel starting with long-poll...")
|
||||
self.logger.info("channel starting with long-poll...")
|
||||
|
||||
consecutive_failures = 0
|
||||
while self._running:
|
||||
@@ -487,6 +516,7 @@ class WeixinChannel(BaseChannel):
|
||||
except Exception:
|
||||
if not self._running:
|
||||
break
|
||||
self.logger.exception("WeChat poll loop error")
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
consecutive_failures = 0
|
||||
@@ -526,6 +556,22 @@ class WeixinChannel(BaseChannel):
|
||||
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:
|
||||
remaining = self._session_pause_remaining_s()
|
||||
if remaining > 0:
|
||||
@@ -552,8 +598,8 @@ class WeixinChannel(BaseChannel):
|
||||
if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED:
|
||||
self._pause_session()
|
||||
remaining = self._session_pause_remaining_s()
|
||||
logger.warning(
|
||||
"WeChat session expired (errcode {}). Pausing {} min.",
|
||||
self.logger.warning(
|
||||
"session expired (errcode {}). Pausing {} min.",
|
||||
errcode,
|
||||
max((remaining + 59) // 60, 1),
|
||||
)
|
||||
@@ -579,7 +625,7 @@ class WeixinChannel(BaseChannel):
|
||||
try:
|
||||
await self._process_message(msg)
|
||||
except Exception:
|
||||
pass
|
||||
self.logger.exception("Failed to process WeChat message")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||
@@ -591,20 +637,24 @@ class WeixinChannel(BaseChannel):
|
||||
if msg.get("message_type") == MESSAGE_TYPE_BOT:
|
||||
return
|
||||
|
||||
# Deduplication by message_id
|
||||
msg_id = str(msg.get("message_id", "") or msg.get("seq", ""))
|
||||
if not msg_id:
|
||||
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:
|
||||
return
|
||||
self._processed_ids[msg_id] = None
|
||||
while len(self._processed_ids) > 1000:
|
||||
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)
|
||||
ctx_token = msg.get("context_token", "")
|
||||
if ctx_token:
|
||||
@@ -758,8 +808,8 @@ class WeixinChannel(BaseChannel):
|
||||
if not content:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"WeChat inbound: from={} items={} bodyLen={}",
|
||||
self.logger.info(
|
||||
"inbound: from={} items={} bodyLen={}",
|
||||
from_user_id,
|
||||
",".join(str(i.get("type", 0)) for i in item_list),
|
||||
len(content),
|
||||
@@ -842,8 +892,8 @@ class WeixinChannel(BaseChannel):
|
||||
and self._is_retryable_media_download_error(e)
|
||||
)
|
||||
if should_fallback:
|
||||
logger.warning(
|
||||
"WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
|
||||
self.logger.warning(
|
||||
"media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
|
||||
media_type,
|
||||
e,
|
||||
)
|
||||
@@ -868,8 +918,8 @@ class WeixinChannel(BaseChannel):
|
||||
file_path.write_bytes(data)
|
||||
return str(file_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error downloading WeChat media: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error downloading media")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -932,21 +982,15 @@ class WeixinChannel(BaseChannel):
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
||||
if stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._send_typing(user_id, typing_ticket, TYPING_STATUS_TYPING)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
pass
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
if not self._client or not self._token:
|
||||
logger.warning("WeChat client not initialized or not authenticated")
|
||||
return
|
||||
try:
|
||||
self._assert_session_active()
|
||||
except RuntimeError:
|
||||
return
|
||||
raise RuntimeError("WeChat client not initialized or not authenticated")
|
||||
self._assert_session_active()
|
||||
|
||||
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
||||
if not is_progress:
|
||||
@@ -955,23 +999,17 @@ class WeixinChannel(BaseChannel):
|
||||
content = msg.content.strip()
|
||||
ctx_token = self._context_tokens.get(msg.chat_id, "")
|
||||
if not ctx_token:
|
||||
logger.warning(
|
||||
"WeChat: no context_token for chat_id={}, cannot send",
|
||||
msg.chat_id,
|
||||
raise RuntimeError(
|
||||
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
|
||||
)
|
||||
return
|
||||
|
||||
typing_ticket = ""
|
||||
try:
|
||||
with suppress(Exception):
|
||||
typing_ticket = await self._get_typing_ticket(msg.chat_id, ctx_token)
|
||||
except Exception:
|
||||
typing_ticket = ""
|
||||
|
||||
if typing_ticket:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
typing_keepalive_stop = asyncio.Event()
|
||||
typing_keepalive_task: asyncio.Task | None = None
|
||||
@@ -985,14 +1023,13 @@ class WeixinChannel(BaseChannel):
|
||||
for media_path in (msg.media or []):
|
||||
try:
|
||||
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 —
|
||||
# the text send would also likely fail, and the outer
|
||||
# except will re-raise so ChannelManager retries properly.
|
||||
logger.error(
|
||||
"Network error sending WeChat media {}: {}",
|
||||
self.logger.opt(exception=True).warning(
|
||||
"Network error sending media {}",
|
||||
media_path,
|
||||
net_err,
|
||||
)
|
||||
raise
|
||||
except httpx.HTTPStatusError as http_err:
|
||||
@@ -1003,27 +1040,26 @@ class WeixinChannel(BaseChannel):
|
||||
)
|
||||
if status_code >= 500:
|
||||
# Server-side / retryable HTTP error — same as network.
|
||||
logger.error(
|
||||
"Server error ({} {}) sending WeChat media {}: {}",
|
||||
self.logger.exception(
|
||||
"Server error ({} {}) sending media {}",
|
||||
status_code,
|
||||
http_err.response.reason_phrase
|
||||
if http_err.response is not None
|
||||
else "",
|
||||
media_path,
|
||||
http_err,
|
||||
)
|
||||
raise
|
||||
# 4xx client errors are NOT retryable — fall back to text.
|
||||
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(
|
||||
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Non-network errors (format, file-not-found, etc.):
|
||||
# notify the user via text fallback.
|
||||
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
|
||||
await self._send_text(
|
||||
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)
|
||||
for chunk in chunks:
|
||||
await self._send_text(msg.chat_id, chunk, ctx_token)
|
||||
except Exception as e:
|
||||
logger.error("Error sending WeChat message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message")
|
||||
raise
|
||||
finally:
|
||||
if typing_keepalive_task:
|
||||
typing_keepalive_stop.set()
|
||||
typing_keepalive_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await typing_keepalive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if typing_ticket and not is_progress:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
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:
|
||||
"""Start typing indicator immediately when a message is received."""
|
||||
@@ -1065,7 +1097,7 @@ class WeixinChannel(BaseChannel):
|
||||
return
|
||||
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
|
||||
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
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
@@ -1076,10 +1108,8 @@ class WeixinChannel(BaseChannel):
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_S)
|
||||
if stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
pass
|
||||
|
||||
@@ -1095,10 +1125,8 @@ class WeixinChannel(BaseChannel):
|
||||
if stop_event:
|
||||
stop_event.set()
|
||||
task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if not clear_remote:
|
||||
return
|
||||
entry = self._typing_tickets.get(chat_id)
|
||||
@@ -1108,7 +1136,15 @@ class WeixinChannel(BaseChannel):
|
||||
try:
|
||||
await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL)
|
||||
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(
|
||||
self,
|
||||
@@ -1117,7 +1153,7 @@ class WeixinChannel(BaseChannel):
|
||||
context_token: str,
|
||||
) -> None:
|
||||
"""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] = []
|
||||
if text:
|
||||
@@ -1141,13 +1177,47 @@ class WeixinChannel(BaseChannel):
|
||||
}
|
||||
|
||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||
ret = data.get("ret", 0)
|
||||
errcode = data.get("errcode", 0)
|
||||
if errcode and errcode != 0:
|
||||
logger.warning(
|
||||
"WeChat send error (code {}): {}",
|
||||
errcode,
|
||||
data.get("errmsg", ""),
|
||||
errmsg = data.get("errmsg", "")
|
||||
|
||||
# The iLink sendmessage API may return ret=-2 / errcode=-2 for two
|
||||
# different reasons:
|
||||
# - 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(
|
||||
self,
|
||||
@@ -1273,7 +1343,7 @@ class WeixinChannel(BaseChannel):
|
||||
media_item["len"] = str(raw_size)
|
||||
|
||||
# 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}]
|
||||
|
||||
weixin_msg: dict[str, Any] = {
|
||||
@@ -1293,11 +1363,35 @@ class WeixinChannel(BaseChannel):
|
||||
}
|
||||
|
||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||
ret = data.get("ret", 0)
|
||||
errcode = data.get("errcode", 0)
|
||||
if errcode and errcode != 0:
|
||||
raise RuntimeError(
|
||||
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
|
||||
errmsg = 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
|
||||
padded = data + bytes([pad_len] * pad_len)
|
||||
|
||||
try:
|
||||
with suppress(ImportError):
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
return cipher.encrypt(padded)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
try:
|
||||
with suppress(ImportError):
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(data)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if decrypted is None:
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""WhatsApp channel implementation using Node.js bridge."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -8,6 +9,7 @@ import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
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)
|
||||
token = secrets.token_urlsafe(32)
|
||||
path.write_text(token, encoding="utf-8")
|
||||
try:
|
||||
with suppress(OSError):
|
||||
path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return token
|
||||
|
||||
|
||||
@@ -99,15 +99,15 @@ class WhatsAppChannel(BaseChannel):
|
||||
"""
|
||||
try:
|
||||
bridge_dir = _ensure_bridge_setup()
|
||||
except RuntimeError as e:
|
||||
logger.error("{}", e)
|
||||
except RuntimeError:
|
||||
self.logger.exception("bridge setup failed")
|
||||
return False
|
||||
|
||||
env = {**os.environ}
|
||||
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
|
||||
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:
|
||||
subprocess.run(
|
||||
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
|
||||
@@ -123,7 +123,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
|
||||
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
|
||||
|
||||
@@ -135,24 +135,24 @@ class WhatsAppChannel(BaseChannel):
|
||||
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
|
||||
)
|
||||
self._connected = True
|
||||
logger.info("Connected to WhatsApp bridge")
|
||||
self.logger.info("Connected to WhatsApp bridge")
|
||||
|
||||
# Listen for messages
|
||||
async for message in ws:
|
||||
try:
|
||||
await self._handle_bridge_message(message)
|
||||
except Exception as e:
|
||||
logger.error("Error handling bridge message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error handling bridge message")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
self._ws = None
|
||||
logger.warning("WhatsApp bridge connection error: {}", e)
|
||||
self.logger.warning("WhatsApp bridge connection error: {}", e)
|
||||
|
||||
if self._running:
|
||||
logger.info("Reconnecting in 5 seconds...")
|
||||
self.logger.info("Reconnecting in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def stop(self) -> None:
|
||||
@@ -167,7 +167,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through WhatsApp."""
|
||||
if not self._ws or not self._connected:
|
||||
logger.warning("WhatsApp bridge not connected")
|
||||
self.logger.warning("WhatsApp bridge not connected")
|
||||
return
|
||||
|
||||
chat_id = msg.chat_id
|
||||
@@ -176,8 +176,8 @@ class WhatsAppChannel(BaseChannel):
|
||||
try:
|
||||
payload = {"type": "send", "to": chat_id, "text": msg.content}
|
||||
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.error("Error sending WhatsApp message: {}", e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message")
|
||||
raise
|
||||
|
||||
for media_path in msg.media or []:
|
||||
@@ -191,8 +191,8 @@ class WhatsAppChannel(BaseChannel):
|
||||
"fileName": media_path.rsplit("/", 1)[-1],
|
||||
}
|
||||
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.error("Error sending WhatsApp media {}: {}", media_path, e)
|
||||
except Exception:
|
||||
self.logger.exception("Error sending media {}", media_path)
|
||||
raise
|
||||
|
||||
async def _handle_bridge_message(self, raw: str) -> None:
|
||||
@@ -200,7 +200,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid JSON from bridge: {}", raw[:100])
|
||||
self.logger.warning("Invalid JSON from bridge: {}", raw[:100])
|
||||
return
|
||||
|
||||
msg_type = data.get("type")
|
||||
@@ -214,13 +214,6 @@ class WhatsAppChannel(BaseChannel):
|
||||
content = data.get("content", "")
|
||||
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
|
||||
is_group = data.get("isGroup", False)
|
||||
was_mentioned = data.get("wasMentioned", False)
|
||||
@@ -246,11 +239,21 @@ class WhatsAppChannel(BaseChannel):
|
||||
elif extracted and not phone_id:
|
||||
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:
|
||||
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)
|
||||
media_paths = data.get("media") or []
|
||||
@@ -258,11 +261,11 @@ class WhatsAppChannel(BaseChannel):
|
||||
# Handle voice transcription if it's a voice message
|
||||
if content == "[Voice Message]":
|
||||
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])
|
||||
if transcription:
|
||||
content = transcription
|
||||
logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
|
||||
self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
|
||||
else:
|
||||
content = "[Voice Message: Transcription failed]"
|
||||
else:
|
||||
@@ -291,7 +294,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
elif msg_type == "status":
|
||||
# Connection status update
|
||||
status = data.get("status")
|
||||
logger.info("WhatsApp status: {}", status)
|
||||
self.logger.info("Status: {}", status)
|
||||
|
||||
if status == "connected":
|
||||
self._connected = True
|
||||
@@ -300,10 +303,10 @@ class WhatsAppChannel(BaseChannel):
|
||||
|
||||
elif msg_type == "qr":
|
||||
# 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":
|
||||
logger.error("WhatsApp bridge error: {}", data.get("error"))
|
||||
self.logger.error("Bridge error: {}", data.get("error"))
|
||||
|
||||
|
||||
def _ensure_bridge_setup() -> Path:
|
||||
@@ -316,13 +319,7 @@ def _ensure_bridge_setup() -> Path:
|
||||
from nanobot.config.paths import get_bridge_install_dir
|
||||
|
||||
user_bridge = get_bridge_install_dir()
|
||||
|
||||
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.")
|
||||
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
|
||||
|
||||
# Find source bridge
|
||||
current_file = Path(__file__)
|
||||
@@ -341,6 +338,33 @@ def _ensure_bridge_setup() -> Path:
|
||||
"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...")
|
||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||
if user_bridge.exists():
|
||||
@@ -352,6 +376,7 @@ def _ensure_bridge_setup() -> Path:
|
||||
|
||||
logger.info(" Building...")
|
||||
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")
|
||||
return user_bridge
|
||||
|
||||
+216
-143
@@ -5,7 +5,8 @@ import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from collections.abc import Callable
|
||||
from contextlib import nullcontext, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -14,14 +15,28 @@ if sys.platform == "win32":
|
||||
if sys.stdout.encoding != "utf-8":
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
# Re-open stdout/stderr with UTF-8 encoding
|
||||
try:
|
||||
with suppress(Exception):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import typer
|
||||
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.application import run_in_terminal
|
||||
from prompt_toolkit.formatted_text import ANSI, HTML
|
||||
@@ -33,6 +48,7 @@ from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from nanobot import __logo__, __version__
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
|
||||
|
||||
class SafeFileHistory(FileHistory):
|
||||
@@ -83,35 +99,29 @@ def _flush_pending_tty_input() -> None:
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
with suppress(Exception):
|
||||
import termios
|
||||
|
||||
termios.tcflush(fd, termios.TCIFLUSH)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with suppress(Exception):
|
||||
while True:
|
||||
ready, _, _ = select.select([fd], [], [], 0)
|
||||
if not ready:
|
||||
break
|
||||
if not os.read(fd, 4096):
|
||||
break
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _restore_terminal() -> None:
|
||||
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
||||
if _SAVED_TERM_ATTRS is None:
|
||||
return
|
||||
try:
|
||||
with suppress(Exception):
|
||||
import termios
|
||||
|
||||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _init_prompt_session() -> None:
|
||||
@@ -119,12 +129,10 @@ def _init_prompt_session() -> None:
|
||||
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
|
||||
|
||||
# Save terminal state so we can restore it on exit
|
||||
try:
|
||||
with suppress(Exception):
|
||||
import termios
|
||||
|
||||
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""Return True when input should end interactive chat."""
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""Load config and optionally override the active workspace."""
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
||||
@@ -498,7 +515,6 @@ def serve(
|
||||
raise typer.Exit(1)
|
||||
|
||||
from loguru import logger
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.api.server import create_app
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import SessionManager
|
||||
@@ -515,37 +531,20 @@ def serve(
|
||||
timeout = timeout if timeout is not None else api_cfg.timeout
|
||||
sync_workspace_templates(runtime_config.workspace_path)
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(runtime_config)
|
||||
defaults = runtime_config.agents.defaults
|
||||
session_manager = SessionManager(runtime_config.workspace_path)
|
||||
agent_loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
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,
|
||||
resolved_preset = runtime_config.resolve_preset()
|
||||
agent_loop = AgentLoop.from_config(
|
||||
runtime_config, bus,
|
||||
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" [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(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
@@ -583,9 +582,19 @@ def gateway(
|
||||
):
|
||||
"""Start the nanobot gateway."""
|
||||
if verbose:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger.remove(_log_handler_id)
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
format=(
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||
"<level>{level: <5}</level> | "
|
||||
"<cyan>{extra[channel]}</cyan> | "
|
||||
"<level>{message}</level>"
|
||||
),
|
||||
level="DEBUG",
|
||||
colorize=None,
|
||||
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
|
||||
)
|
||||
cfg = _load_runtime_config(config, workspace)
|
||||
_run_gateway(cfg, port=port)
|
||||
|
||||
@@ -597,7 +606,6 @@ def _run_gateway(
|
||||
open_browser_url: str | None = None,
|
||||
) -> None:
|
||||
"""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.message import MessageTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -618,7 +626,6 @@ def _run_gateway(
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
provider = provider_snapshot.provider
|
||||
session_manager = SessionManager(config.workspace_path)
|
||||
|
||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||
@@ -630,30 +637,10 @@ def _run_gateway(
|
||||
cron = CronService(cron_store_path)
|
||||
|
||||
# Create agent with cron service
|
||||
agent = AgentLoop(
|
||||
bus=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,
|
||||
agent = AgentLoop.from_config(
|
||||
config, bus,
|
||||
cron_service=cron,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
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_signature=provider_snapshot.signature,
|
||||
)
|
||||
@@ -756,7 +743,7 @@ def _run_gateway(
|
||||
|
||||
if job.payload.deliver and job.payload.to and response:
|
||||
should_notify = await evaluate_response(
|
||||
response, reminder_note, provider, agent.model,
|
||||
response, reminder_note, agent.provider, agent.model,
|
||||
)
|
||||
if should_notify:
|
||||
await _deliver_to_channel(
|
||||
@@ -846,7 +833,7 @@ def _run_gateway(
|
||||
hb_cfg = config.gateway.heartbeat
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=config.workspace_path,
|
||||
provider=provider,
|
||||
provider=agent.provider,
|
||||
model=agent.model,
|
||||
on_execute=on_heartbeat_execute,
|
||||
on_notify=on_heartbeat_notify,
|
||||
@@ -936,10 +923,8 @@ def _run_gateway(
|
||||
config.gateway.host or "127.0.0.1", port
|
||||
)
|
||||
writer.close()
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
except OSError:
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -1001,7 +986,6 @@ def agent(
|
||||
"""Interact with the agent directly."""
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
@@ -1009,8 +993,6 @@ def agent(
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
|
||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||
if is_default_workspace(config.workspace_path):
|
||||
_migrate_cron_store(config)
|
||||
@@ -1024,29 +1006,10 @@ def agent(
|
||||
else:
|
||||
logger.disable("nanobot")
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
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,
|
||||
resolved_preset = config.resolve_preset()
|
||||
agent_loop = AgentLoop.from_config(
|
||||
config, bus,
|
||||
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()
|
||||
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
|
||||
from nanobot.bus.events import InboundMessage
|
||||
_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:
|
||||
cli_channel, cli_chat_id = session_id.split(":", 1)
|
||||
@@ -1139,15 +1102,11 @@ def agent(
|
||||
turn_done.set()
|
||||
continue
|
||||
|
||||
if msg.metadata.get("_progress"):
|
||||
is_tool_hint = msg.metadata.get("_tool_hint", False)
|
||||
ch = agent_loop.channels_config
|
||||
if ch and is_tool_hint and not ch.send_tool_hints:
|
||||
pass
|
||||
elif ch and not is_tool_hint and not ch.send_progress:
|
||||
pass
|
||||
else:
|
||||
await _print_interactive_progress_line(msg.content, _thinking)
|
||||
if await _maybe_print_interactive_progress(
|
||||
msg,
|
||||
_thinking,
|
||||
agent_loop.channels_config,
|
||||
):
|
||||
continue
|
||||
|
||||
if not turn_done.is_set():
|
||||
@@ -1271,6 +1230,7 @@ def channels_status(
|
||||
|
||||
def _get_bridge_dir() -> Path:
|
||||
"""Get the bridge directory, setting it up if needed."""
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
@@ -1278,16 +1238,7 @@ def _get_bridge_dir() -> Path:
|
||||
from nanobot.config.paths import get_bridge_install_dir
|
||||
|
||||
user_bridge = get_bridge_install_dir()
|
||||
|
||||
# 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)
|
||||
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
|
||||
|
||||
# Find source bridge: first check package data, then source dir
|
||||
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")
|
||||
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...")
|
||||
|
||||
# Copy to user directory
|
||||
@@ -1319,6 +1300,7 @@ def _get_bridge_dir() -> Path:
|
||||
|
||||
console.print(" Building...")
|
||||
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")
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -1429,7 +1411,10 @@ def status():
|
||||
if config_path.exists():
|
||||
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
|
||||
for spec in PROVIDERS:
|
||||
@@ -1457,10 +1442,17 @@ provider_app = typer.Typer(help="Manage providers")
|
||||
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):
|
||||
"""Register an OAuth login handler."""
|
||||
def decorator(fn):
|
||||
_LOGIN_HANDLERS[name] = fn
|
||||
return fn
|
||||
@@ -1468,11 +1460,16 @@ def _register_login(name: str):
|
||||
return decorator
|
||||
|
||||
|
||||
@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."""
|
||||
def _register_logout(name: str):
|
||||
"""Register an OAuth logout handler."""
|
||||
def decorator(fn):
|
||||
_LOGOUT_HANDLERS[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
def _resolve_oauth_provider(provider: str):
|
||||
"""Resolve and validate an OAuth provider configuration."""
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
key = provider.replace("-", "_")
|
||||
@@ -1481,6 +1478,15 @@ def provider_login(
|
||||
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
|
||||
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
|
||||
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)
|
||||
if not handler:
|
||||
@@ -1491,16 +1497,30 @@ def provider_login(
|
||||
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")
|
||||
def _login_openai_codex() -> None:
|
||||
try:
|
||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||
|
||||
token = None
|
||||
try:
|
||||
with suppress(Exception):
|
||||
token = get_token()
|
||||
except Exception:
|
||||
pass
|
||||
if not (token and token.access):
|
||||
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||
token = login_oauth_interactive(
|
||||
@@ -1516,6 +1536,59 @@ def _login_openai_codex() -> None:
|
||||
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")
|
||||
def _login_github_copilot() -> None:
|
||||
try:
|
||||
|
||||
+271
-11
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
|
||||
get_model_suggestions,
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -49,6 +49,16 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
|
||||
|
||||
_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():
|
||||
"""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)
|
||||
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"):
|
||||
return FieldTypeInfo("list", args[0] if args else str)
|
||||
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
|
||||
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:
|
||||
return FieldTypeInfo(name, None)
|
||||
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()
|
||||
|
||||
if value is None or value == "":
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if field_type == "int":
|
||||
@@ -507,7 +517,7 @@ def _input_model_with_autocomplete(
|
||||
qmark=">",
|
||||
).ask()
|
||||
|
||||
return value if value else None
|
||||
return value if value is not None else None
|
||||
|
||||
|
||||
def _input_context_window_with_recommendation(
|
||||
@@ -588,12 +598,112 @@ def _handle_context_window_field(
|
||||
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] = {
|
||||
"model": _handle_model_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(
|
||||
model: BaseModel,
|
||||
display_name: str,
|
||||
@@ -626,11 +736,20 @@ def _configure_pydantic_model(
|
||||
items.append(f"{display}: {formatted}")
|
||||
return items + ["[Done]"]
|
||||
|
||||
last_field_name: str | None = None
|
||||
while True:
|
||||
console.clear()
|
||||
_show_config_panel(display_name, working_model, fields)
|
||||
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:
|
||||
return None
|
||||
@@ -641,6 +760,8 @@ def _configure_pydantic_model(
|
||||
if field_idx < 0 or field_idx >= len(fields):
|
||||
return None
|
||||
|
||||
last_field_name = fields[field_idx][0]
|
||||
|
||||
field_name, field_info = fields[field_idx]
|
||||
current_value = getattr(working_model, field_name, None)
|
||||
ftype = _get_field_type_info(field_info)
|
||||
@@ -697,6 +818,10 @@ def _configure_pydantic_model(
|
||||
else:
|
||||
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
|
||||
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)
|
||||
|
||||
|
||||
@@ -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]")
|
||||
|
||||
|
||||
# --- 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 ---
|
||||
|
||||
|
||||
@@ -795,12 +1027,23 @@ def _configure_providers(config: Config) -> None:
|
||||
choices.append(display)
|
||||
return choices + ["<- Back"]
|
||||
|
||||
last_provider_key: str | None = None
|
||||
while True:
|
||||
try:
|
||||
console.clear()
|
||||
_show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
|
||||
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":
|
||||
break
|
||||
@@ -812,6 +1055,7 @@ def _configure_providers(config: Config) -> None:
|
||||
# Find the actual provider key from display names
|
||||
for name, display in _get_provider_names().items():
|
||||
if display == provider_name:
|
||||
last_provider_key = name
|
||||
_configure_provider(config, name)
|
||||
break
|
||||
|
||||
@@ -840,7 +1084,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:
|
||||
display_name = getattr(channel_cls, "display_name", name.capitalize())
|
||||
result[name] = (display_name, config_cls)
|
||||
except Exception:
|
||||
logger.warning(f"Failed to load channel module: {name}")
|
||||
logger.warning("Failed to load channel module: {}", name)
|
||||
return result
|
||||
|
||||
|
||||
@@ -885,17 +1129,21 @@ def _configure_channels(config: Config) -> None:
|
||||
channel_names = list(_get_channel_names().keys())
|
||||
choices = channel_names + ["<- Back"]
|
||||
|
||||
last_choice: str | None = None
|
||||
while True:
|
||||
try:
|
||||
console.clear()
|
||||
_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":
|
||||
break
|
||||
|
||||
# Type guard: answer is now guaranteed to be a string
|
||||
assert isinstance(answer, str)
|
||||
last_choice = answer
|
||||
_configure_channel(config, answer)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Returning to main menu...[/dim]")
|
||||
@@ -1003,6 +1251,12 @@ def _show_summary(config: Config) -> None:
|
||||
channel_rows.append((display, status))
|
||||
_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
|
||||
for title, model in [
|
||||
("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)
|
||||
config = base_config.model_copy(deep=True)
|
||||
_sync_preset_cache(config)
|
||||
|
||||
last_main_choice: str | None = None
|
||||
while True:
|
||||
console.clear()
|
||||
_show_main_menu_header()
|
||||
@@ -1082,6 +1338,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
||||
"What would you like to configure?",
|
||||
choices=[
|
||||
"[P] LLM Provider",
|
||||
"[M] Model Presets",
|
||||
"[C] Chat Channel",
|
||||
"[H] Channel Common",
|
||||
"[A] Agent Settings",
|
||||
@@ -1092,6 +1349,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
||||
"[S] Save and Exit",
|
||||
"[X] Exit Without Saving",
|
||||
],
|
||||
default=last_main_choice,
|
||||
qmark=">",
|
||||
).ask()
|
||||
except KeyboardInterrupt:
|
||||
@@ -1105,8 +1363,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
||||
return OnboardResult(config=original_config, should_save=False)
|
||||
continue
|
||||
|
||||
_MENU_DISPATCH = {
|
||||
_menu_dispatch = {
|
||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||
"[M] Model Presets": lambda: _configure_model_presets(config),
|
||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
||||
"[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":
|
||||
return OnboardResult(config=original_config, should_save=False)
|
||||
|
||||
action_fn = _MENU_DISPATCH.get(answer)
|
||||
action_fn = _menu_dispatch.get(answer)
|
||||
if action_fn:
|
||||
last_main_choice = answer
|
||||
action_fn()
|
||||
|
||||
+94
-21
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nanobot import __version__
|
||||
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
|
||||
|
||||
|
||||
@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:
|
||||
"""Cancel all active tasks and subagents for the session."""
|
||||
loop = ctx.loop
|
||||
@@ -50,16 +134,15 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
||||
loop = ctx.loop
|
||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||
ctx_est = 0
|
||||
try:
|
||||
with suppress(Exception):
|
||||
ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(session)
|
||||
except Exception:
|
||||
pass
|
||||
if ctx_est <= 0:
|
||||
ctx_est = loop._last_usage.get("prompt_tokens", 0)
|
||||
|
||||
# Fetch web search provider usage (best-effort, never blocks the response)
|
||||
search_usage_text: str | None = None
|
||||
try:
|
||||
# Never let usage fetch break /status
|
||||
with suppress(Exception):
|
||||
from nanobot.utils.searchusage import fetch_search_usage
|
||||
web_cfg = getattr(loop, "web_config", 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
|
||||
usage = await fetch_search_usage(provider=provider, api_key=api_key)
|
||||
search_usage_text = usage.format()
|
||||
except Exception:
|
||||
pass # Never let usage fetch break /status
|
||||
active_tasks = loop._active_tasks.get(ctx.key, [])
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
return OutboundMessage(
|
||||
channel=ctx.msg.channel,
|
||||
chat_id=ctx.msg.chat_id,
|
||||
@@ -382,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
def build_help_text() -> str:
|
||||
"""Build canonical help text shared across channels."""
|
||||
lines = [
|
||||
"🐈 nanobot commands:",
|
||||
"/new — Stop current task and start a new conversation",
|
||||
"/stop — Stop the current task",
|
||||
"/restart — Restart the bot",
|
||||
"/status — Show bot status",
|
||||
"/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",
|
||||
]
|
||||
lines = ["🐈 nanobot commands:"]
|
||||
for spec in BUILTIN_COMMAND_SPECS:
|
||||
command = spec.command
|
||||
if spec.arg_hint:
|
||||
command = f"{command} {spec.arg_hint}"
|
||||
lines.append(f"{command} — {spec.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
data = _migrate_config(data)
|
||||
config = Config.model_validate(data)
|
||||
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.")
|
||||
|
||||
_apply_ssrf_whitelist(config)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from pathlib import Path
|
||||
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_settings import BaseSettings
|
||||
|
||||
@@ -65,22 +65,48 @@ class DreamConfig(Base):
|
||||
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):
|
||||
"""Default agent configuration."""
|
||||
|
||||
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"
|
||||
provider: str = (
|
||||
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
||||
)
|
||||
max_tokens: int = 8192
|
||||
context_window_tokens: int = 65_536
|
||||
context_block_limit: int | None = None
|
||||
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_concurrent_subagents: int = Field(default=1, ge=1)
|
||||
max_tool_result_chars: int = 16_000
|
||||
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"
|
||||
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"])
|
||||
@@ -119,11 +145,19 @@ class ProviderConfig(Base):
|
||||
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):
|
||||
"""Configuration for LLM providers."""
|
||||
|
||||
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
|
||||
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)
|
||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
@@ -143,6 +177,7 @@ class ProvidersConfig(Base):
|
||||
mistral: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
||||
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
|
||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
|
||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
|
||||
@@ -214,6 +249,8 @@ class ExecToolConfig(Base):
|
||||
path_append: str = ""
|
||||
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"])
|
||||
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):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
@@ -254,6 +291,54 @@ class Config(BaseSettings):
|
||||
api: ApiConfig = Field(default_factory=ApiConfig)
|
||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||
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
|
||||
def workspace_path(self) -> Path:
|
||||
@@ -266,15 +351,16 @@ class Config(BaseSettings):
|
||||
"""Match provider config and its registry name. Returns (config, spec_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":
|
||||
spec = find_by_name(forced)
|
||||
if spec:
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
return (p, spec.name) if p else (None, None)
|
||||
provider_cfg = getattr(self.providers, spec.name, None)
|
||||
return (provider_cfg, spec.name) if provider_cfg else (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_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
@@ -287,14 +373,14 @@ class Config(BaseSettings):
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
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
|
||||
|
||||
# Match by keyword (order follows PROVIDERS registry)
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
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
|
||||
|
||||
# Fallback: configured local providers can route models without
|
||||
|
||||
+107
-12
@@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -12,7 +14,14 @@ from typing import Any, Callable, Coroutine, Literal
|
||||
from filelock import FileLock
|
||||
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:
|
||||
@@ -83,8 +92,20 @@ class CronService:
|
||||
self._timer_active = False
|
||||
self.max_sleep_ms = max_sleep_ms
|
||||
|
||||
def _load_jobs(self) -> tuple[list[CronJob], int]:
|
||||
jobs = []
|
||||
def _load_jobs(self) -> tuple[list[CronJob], int] | None:
|
||||
"""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
|
||||
if self.store_path.exists():
|
||||
try:
|
||||
@@ -135,8 +156,22 @@ class CronService:
|
||||
updated_at_ms=j.get("updatedAtMs", 0),
|
||||
delete_after_run=j.get("deleteAfterRun", False),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load cron store: {}", e)
|
||||
except Exception:
|
||||
# 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
|
||||
|
||||
def _merge_action(self):
|
||||
@@ -166,8 +201,8 @@ class CronService:
|
||||
else:
|
||||
_update(action.get("params", {}))
|
||||
changed = True
|
||||
except Exception as exp:
|
||||
logger.debug(f"load action line error: {exp}")
|
||||
except Exception:
|
||||
logger.exception("load action line error")
|
||||
continue
|
||||
self._store.jobs = list(jobs_map.values())
|
||||
if self._running and changed:
|
||||
@@ -175,15 +210,28 @@ class CronService:
|
||||
self._save_store()
|
||||
return
|
||||
|
||||
def _load_store(self) -> CronStore:
|
||||
def _load_store(self) -> CronStore | None:
|
||||
"""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.
|
||||
- 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.
|
||||
- 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:
|
||||
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._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:
|
||||
"""Start the cron service."""
|
||||
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._save_store()
|
||||
self._arm_timer()
|
||||
@@ -302,6 +394,9 @@ class CronService:
|
||||
async def _on_timer(self) -> None:
|
||||
"""Handle timer tick - run due jobs."""
|
||||
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:
|
||||
self._arm_timer()
|
||||
return
|
||||
@@ -338,7 +433,7 @@ class CronService:
|
||||
except Exception as e:
|
||||
job.state.last_status = "error"
|
||||
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()
|
||||
job.state.last_run_at_ms = start_ms
|
||||
|
||||
@@ -144,8 +144,8 @@ class HeartbeatService:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Heartbeat error: {}", e)
|
||||
except Exception:
|
||||
logger.exception("Heartbeat error")
|
||||
|
||||
@staticmethod
|
||||
def _is_deliverable(response: str) -> bool:
|
||||
|
||||
+10
-35
@@ -6,9 +6,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
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.bus.queue import MessageBus
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -62,31 +61,7 @@ class Nanobot:
|
||||
Path(workspace).expanduser().resolve()
|
||||
)
|
||||
|
||||
provider = _make_provider(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,
|
||||
)
|
||||
loop = AgentLoop.from_config(config)
|
||||
return cls(loop)
|
||||
|
||||
async def run(
|
||||
@@ -104,9 +79,10 @@ class Nanobot:
|
||||
Different keys get independent history.
|
||||
hooks: Optional lifecycle hooks for this run.
|
||||
"""
|
||||
capture = SDKCaptureHook()
|
||||
prev = self._loop._extra_hooks
|
||||
if hooks is not None:
|
||||
self._loop._extra_hooks = list(hooks)
|
||||
base_hooks = list(hooks) if hooks is not None else list(prev or [])
|
||||
self._loop._extra_hooks = [capture, *base_hooks]
|
||||
try:
|
||||
response = await self._loop.process_direct(
|
||||
message, session_key=session_key,
|
||||
@@ -115,11 +91,10 @@ class Nanobot:
|
||||
self._loop._extra_hooks = prev
|
||||
|
||||
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",
|
||||
"GitHubCopilotProvider",
|
||||
"AzureOpenAIProvider",
|
||||
"BedrockProvider",
|
||||
]
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
@@ -23,11 +24,13 @@ _LAZY_IMPORTS = {
|
||||
"OpenAICodexProvider": ".openai_codex_provider",
|
||||
"GitHubCopilotProvider": ".github_copilot_provider",
|
||||
"AzureOpenAIProvider": ".azure_openai_provider",
|
||||
"BedrockProvider": ".bedrock_provider",
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
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.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
@@ -537,6 +537,13 @@ class AnthropicProvider(LLMProvider):
|
||||
# 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(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -555,6 +562,21 @@ class AnthropicProvider(LLMProvider):
|
||||
response = await self._client.messages.create(**kwargs)
|
||||
return self._parse_response(response)
|
||||
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)
|
||||
|
||||
async def chat_stream(
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import suppress
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -136,7 +137,9 @@ class LLMProvider(ABC):
|
||||
"insufficient_quota",
|
||||
"insufficient quota",
|
||||
"quota exceeded",
|
||||
"quota_exceeded",
|
||||
"quota exhausted",
|
||||
"quota_exhausted",
|
||||
"billing hard limit",
|
||||
"billing_hard_limit_reached",
|
||||
"billing not active",
|
||||
@@ -643,14 +646,12 @@ class LLMProvider(ABC):
|
||||
return value
|
||||
return None
|
||||
|
||||
try:
|
||||
with suppress(TypeError, ValueError):
|
||||
retry_ms = _header_value("retry-after-ms")
|
||||
if retry_ms is not None:
|
||||
value = float(retry_ms) / 1000.0
|
||||
if value > 0:
|
||||
return value
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
retry_after = _header_value("retry-after")
|
||||
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 pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||
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)
|
||||
class ProviderSnapshot:
|
||||
@@ -18,22 +23,62 @@ class ProviderSnapshot:
|
||||
signature: tuple[object, ...]
|
||||
|
||||
|
||||
def make_provider(config: Config) -> LLMProvider:
|
||||
"""Create the LLM provider implied by config."""
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
p = config.get_provider(model)
|
||||
spec = find_by_name(provider_name) if provider_name else None
|
||||
@dataclass(frozen=True)
|
||||
class _ProviderInfo:
|
||||
"""Resolved metadata needed to build and validate an LLM provider."""
|
||||
|
||||
name: str | 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"
|
||||
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 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.")
|
||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||
needs_key = not (p and p.api_key)
|
||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||
needs_key = not (cfg and cfg.api_key)
|
||||
exempt = info.spec and (info.spec.is_oauth or info.spec.is_local or info.spec.is_direct)
|
||||
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":
|
||||
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
|
||||
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key=p.api_key,
|
||||
api_base=p.api_base,
|
||||
api_key=cfg.api_key if cfg else None,
|
||||
api_base=info.api_base,
|
||||
default_model=model,
|
||||
)
|
||||
elif backend == "github_copilot":
|
||||
@@ -55,54 +100,103 @@ def make_provider(config: Config) -> LLMProvider:
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
api_key=cfg.api_key if cfg else None,
|
||||
api_base=info.api_base,
|
||||
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:
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
api_key=cfg.api_key if cfg else None,
|
||||
api_base=info.api_base,
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
spec=spec,
|
||||
extra_body=p.extra_body if p else None,
|
||||
extra_headers=cfg.extra_headers if cfg else None,
|
||||
spec=info.spec,
|
||||
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
|
||||
|
||||
|
||||
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, ...]:
|
||||
"""Return the config fields that affect the primary LLM provider."""
|
||||
model = config.agents.defaults.model
|
||||
resolved = config.resolve_preset()
|
||||
defaults = config.agents.defaults
|
||||
return (
|
||||
model,
|
||||
defaults.provider,
|
||||
config.get_provider_name(model),
|
||||
config.get_api_key(model),
|
||||
config.get_api_base(model),
|
||||
defaults.max_tokens,
|
||||
defaults.temperature,
|
||||
defaults.reasoning_effort,
|
||||
defaults.context_window_tokens,
|
||||
resolved.model,
|
||||
resolved.provider,
|
||||
config.get_provider_name(resolved.model),
|
||||
config.get_api_key(resolved.model),
|
||||
config.get_api_base(resolved.model),
|
||||
resolved.max_tokens,
|
||||
resolved.temperature,
|
||||
resolved.reasoning_effort,
|
||||
resolved.context_window_tokens,
|
||||
tuple(defaults.fallback_presets),
|
||||
)
|
||||
|
||||
|
||||
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
|
||||
resolved = config.resolve_preset()
|
||||
return ProviderSnapshot(
|
||||
provider=make_provider(config),
|
||||
model=config.agents.defaults.model,
|
||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
||||
model=resolved.model,
|
||||
context_window_tokens=resolved.context_window_tokens,
|
||||
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 webbrowser
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
|
||||
import httpx
|
||||
from oauth_cli_kit.models import OAuthToken
|
||||
@@ -28,7 +29,7 @@ _EXPIRY_SKEW_SECONDS = 60
|
||||
_LONG_LIVED_TOKEN_SECONDS = 315360000
|
||||
|
||||
|
||||
def _storage() -> FileTokenStorage:
|
||||
def get_storage() -> FileTokenStorage:
|
||||
return FileTokenStorage(
|
||||
token_filename=TOKEN_FILENAME,
|
||||
app_name=TOKEN_APP_NAME,
|
||||
@@ -47,7 +48,7 @@ def _copilot_headers(token: str) -> dict[str, str]:
|
||||
|
||||
|
||||
def _load_github_token() -> OAuthToken | None:
|
||||
token = _storage().load()
|
||||
token = get_storage().load()
|
||||
if not token or not token.access:
|
||||
return None
|
||||
return token
|
||||
@@ -86,10 +87,8 @@ def login_github_copilot(
|
||||
printer(f"Open: {verify_url}")
|
||||
printer(f"Code: {user_code}")
|
||||
if verify_complete:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
webbrowser.open(verify_complete)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
deadline = time.time() + expires_in
|
||||
current_interval = interval
|
||||
@@ -151,7 +150,7 @@ def login_github_copilot(
|
||||
expires=expires_ms,
|
||||
account_id=str(account_id) if account_id else None,
|
||||
)
|
||||
_storage().save(token)
|
||||
get_storage().save(token)
|
||||
return token
|
||||
|
||||
|
||||
|
||||
@@ -449,47 +449,6 @@ class OpenAICompatProvider(LLMProvider):
|
||||
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -530,10 +489,6 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if spec and spec.strip_model_prefix:
|
||||
model_name = model_name.split("/")[-1]
|
||||
|
||||
messages = self._drop_deepseek_incomplete_reasoning_history(
|
||||
messages,
|
||||
reasoning_effort,
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||
@@ -598,22 +553,22 @@ class OpenAICompatProvider(LLMProvider):
|
||||
kwargs["tools"] = tools
|
||||
kwargs["tool_choice"] = tool_choice or "auto"
|
||||
|
||||
# Backfill reasoning_content on legacy assistant messages.
|
||||
# DeepSeek V4 (and potentially others) rejects thinking-mode
|
||||
# requests that contain assistant messages without reasoning_content
|
||||
# — even on turns that had no tool calls. This happens when a
|
||||
# session was started with a non-thinking model or without
|
||||
# reasoning_effort, then the user switches thinking mode on
|
||||
# mid-session. Injecting an empty string satisfies the API
|
||||
# without altering semantics (the model treats it as "no
|
||||
# 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"))
|
||||
# Backfill reasoning_content="" on assistants missing it: DeepSeek
|
||||
# thinking mode rejects history otherwise (#3554, #3584); "" reads
|
||||
# as "no thinking that turn". DeepSeek-V4/reasoner reason natively,
|
||||
# so backfill even without explicit reasoning_effort.
|
||||
explicit_thinking = (
|
||||
reasoning_effort is not None
|
||||
and semantic_effort not in ("none", "minimal")
|
||||
and ((spec and spec.thinking_style) or _is_kimi_thinking_model(model_name))
|
||||
)
|
||||
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"]:
|
||||
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
||||
msg["reasoning_content"] = ""
|
||||
|
||||
@@ -34,7 +34,7 @@ class ProviderSpec:
|
||||
display_name: str = "" # shown in `nanobot status`
|
||||
|
||||
# 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"
|
||||
|
||||
# extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),)
|
||||
@@ -105,6 +105,29 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
backend="azure_openai",
|
||||
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 can route any model, so they win in fallback.
|
||||
# OpenRouter: global gateway, keys start with "sk-or-"
|
||||
@@ -353,6 +376,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
backend="openai_compat",
|
||||
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) =========
|
||||
# vLLM / any OpenAI-compatible local server
|
||||
ProviderSpec(
|
||||
|
||||
@@ -1,11 +1,121 @@
|
||||
"""Voice transcription providers (Groq and OpenAI Whisper)."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
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:
|
||||
"""Voice transcription provider using OpenAI's Whisper API."""
|
||||
@@ -32,21 +142,14 @@ class OpenAITranscriptionProvider:
|
||||
if not path.exists():
|
||||
logger.error("Audio file not found: {}", file_path)
|
||||
return ""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with open(path, "rb") as f:
|
||||
files = {"file": (path.name, f), "model": (None, "whisper-1")}
|
||||
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()
|
||||
return response.json().get("text", "")
|
||||
except Exception as e:
|
||||
logger.error("OpenAI transcription error: {}", e)
|
||||
return ""
|
||||
return await _post_transcription_with_retry(
|
||||
self.api_url,
|
||||
api_key=self.api_key,
|
||||
path=path,
|
||||
model="whisper-1",
|
||||
provider_label="OpenAI",
|
||||
language=self.language,
|
||||
)
|
||||
|
||||
|
||||
class GroqTranscriptionProvider:
|
||||
@@ -63,7 +166,11 @@ class GroqTranscriptionProvider:
|
||||
language: str | None = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
self.api_url = (
|
||||
api_base
|
||||
or os.environ.get("GROQ_BASE_URL")
|
||||
or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
)
|
||||
self.language = language or None
|
||||
|
||||
async def transcribe(self, file_path: str | Path) -> str:
|
||||
@@ -85,30 +192,11 @@ class GroqTranscriptionProvider:
|
||||
logger.error("Audio file not found: {}", file_path)
|
||||
return ""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with open(path, "rb") as f:
|
||||
files = {
|
||||
"file": (path.name, f),
|
||||
"model": (None, "whisper-large-v3"),
|
||||
}
|
||||
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 ""
|
||||
return await _post_transcription_with_retry(
|
||||
self.api_url,
|
||||
api_key=self.api_key,
|
||||
path=path,
|
||||
model="whisper-large-v3",
|
||||
provider_label="Groq",
|
||||
language=self.language,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
from contextlib import suppress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_BLOCKED_NETWORKS = [
|
||||
@@ -30,10 +31,8 @@ def configure_ssrf_whitelist(cidrs: list[str]) -> None:
|
||||
global _allowed_networks
|
||||
nets = []
|
||||
for cidr in cidrs:
|
||||
try:
|
||||
with suppress(ValueError):
|
||||
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
_allowed_networks = nets
|
||||
|
||||
|
||||
|
||||
+13
-10
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -118,7 +119,7 @@ class Session:
|
||||
if include_timestamps:
|
||||
content = self._annotate_message_time(message, 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:
|
||||
entry[key] = message[key]
|
||||
out.append(entry)
|
||||
@@ -362,15 +363,11 @@ class SessionManager:
|
||||
if data.get("_type") == "metadata":
|
||||
metadata = data.get("metadata", {})
|
||||
if data.get("created_at"):
|
||||
try:
|
||||
with suppress(ValueError, TypeError):
|
||||
created_at = datetime.fromisoformat(data["created_at"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if data.get("updated_at"):
|
||||
try:
|
||||
with suppress(ValueError, TypeError):
|
||||
updated_at = datetime.fromisoformat(data["updated_at"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
last_consolidated = data.get("last_consolidated", 0)
|
||||
else:
|
||||
messages.append(data)
|
||||
@@ -440,14 +437,12 @@ class SessionManager:
|
||||
# On Windows, opening a directory with O_RDONLY raises
|
||||
# PermissionError — skip the dir sync there (NTFS
|
||||
# journals metadata synchronously).
|
||||
try:
|
||||
with suppress(PermissionError):
|
||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except PermissionError:
|
||||
pass # Windows — directory fsync not supported
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -552,10 +547,13 @@ class SessionManager:
|
||||
data = json.loads(first_line)
|
||||
if data.get("_type") == "metadata":
|
||||
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({
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"title": title if isinstance(title, str) else "",
|
||||
"path": str(path)
|
||||
})
|
||||
except Exception:
|
||||
@@ -565,6 +563,11 @@ class SessionManager:
|
||||
"key": repaired.key,
|
||||
"created_at": repaired.created_at.isoformat(),
|
||||
"updated_at": repaired.updated_at.isoformat(),
|
||||
"title": (
|
||||
repaired.metadata.get("title")
|
||||
if isinstance(repaired.metadata.get("title"), str)
|
||||
else ""
|
||||
),
|
||||
"path": str(path)
|
||||
})
|
||||
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 zipfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from quick_validate import validate_skill
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
with suppress(ValueError):
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _cleanup_partial_archive(skill_filename: Path) -> None:
|
||||
try:
|
||||
if skill_filename.exists():
|
||||
if skill_filename.exists():
|
||||
with suppress(OSError):
|
||||
skill_filename.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
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}")
|
||||
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
|
||||
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}]"
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ def _extract_docx(path: Path) -> str:
|
||||
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
|
||||
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}]"
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ def _extract_xlsx(path: Path) -> str:
|
||||
finally:
|
||||
wb.close()
|
||||
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}]"
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ def _extract_pptx(path: Path) -> str:
|
||||
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
||||
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
|
||||
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}]"
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ def _extract_text_file(path: Path) -> str:
|
||||
content = path.read_text(encoding="latin-1")
|
||||
return _truncate(content, _MAX_TEXT_LENGTH)
|
||||
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}]"
|
||||
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ class GitStore:
|
||||
logger.info("Git store initialized at {}", self._workspace)
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("Git store init failed for {}", self._workspace)
|
||||
logger.exception("Git store init failed for {}", self._workspace)
|
||||
return False
|
||||
|
||||
# -- daily operations ------------------------------------------------------
|
||||
@@ -149,7 +149,7 @@ class GitStore:
|
||||
logger.debug("Git auto-commit: {} ({})", sha, message)
|
||||
return sha
|
||||
except Exception:
|
||||
logger.warning("Git auto-commit failed: {}", message)
|
||||
logger.exception("Git auto-commit failed: {}", message)
|
||||
return None
|
||||
|
||||
# -- internal helpers ------------------------------------------------------
|
||||
@@ -243,7 +243,7 @@ class GitStore:
|
||||
|
||||
return entries
|
||||
except Exception:
|
||||
logger.warning("Git log failed")
|
||||
logger.exception("Git log failed")
|
||||
return []
|
||||
|
||||
def line_ages(self, file_path: str) -> list[LineAge]:
|
||||
@@ -266,7 +266,7 @@ class GitStore:
|
||||
|
||||
annotated = porcelain.annotate(str(self._workspace), file_path)
|
||||
except Exception:
|
||||
logger.warning("Git line_ages annotate failed for {}", file_path)
|
||||
logger.exception("Git line_ages annotate failed for {}", file_path)
|
||||
return []
|
||||
|
||||
if not annotated:
|
||||
@@ -296,7 +296,7 @@ class GitStore:
|
||||
)
|
||||
return out.getvalue().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
logger.warning("Git diff_commits failed")
|
||||
logger.exception("Git diff_commits failed")
|
||||
return ""
|
||||
|
||||
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
|
||||
@@ -367,7 +367,7 @@ class GitStore:
|
||||
msg = f"revert: undo {commit}"
|
||||
return self.auto_commit(msg)
|
||||
except Exception:
|
||||
logger.warning("Git revert failed for {}", commit)
|
||||
logger.exception("Git revert failed for {}", commit)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -31,6 +32,8 @@ def strip_think(text: str) -> str:
|
||||
explanatory prose that mentions these tokens.
|
||||
5. Orphan closing tags `</think>` / `</thought>` **at the very start
|
||||
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),
|
||||
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)
|
||||
# Edge-only channel markers (harmony / Gemma 4 variant leaks).
|
||||
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()
|
||||
|
||||
|
||||
@@ -257,8 +268,8 @@ def maybe_persist_tool_result(
|
||||
bucket = ensure_dir(root / safe_filename(session_key or "default"))
|
||||
try:
|
||||
_cleanup_tool_result_buckets(root, bucket)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to clean stale tool result buckets in {}: {}", root, exc)
|
||||
except Exception:
|
||||
logger.exception("Failed to clean stale tool result buckets in {}", root)
|
||||
path = bucket / f"{safe_filename(tool_call_id)}.{suffix}"
|
||||
if not path.exists():
|
||||
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."""
|
||||
provider_counter = getattr(provider, "estimate_prompt_tokens", None)
|
||||
if callable(provider_counter):
|
||||
try:
|
||||
with suppress(Exception):
|
||||
tokens, source = provider_counter(messages, tools, model)
|
||||
if isinstance(tokens, (int, float)) and tokens > 0:
|
||||
return int(tokens), str(source or "provider_counter")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
estimated = estimate_prompt_tokens(messages, tools)
|
||||
if estimated > 0:
|
||||
return int(estimated), "tiktoken"
|
||||
@@ -532,6 +540,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
|
||||
)
|
||||
gs.init()
|
||||
except Exception:
|
||||
logger.warning("Failed to initialize git store for {}", workspace)
|
||||
logger.exception("Failed to initialize git store for {}", workspace)
|
||||
|
||||
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 os
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
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."""
|
||||
elapsed_suffix = ""
|
||||
if started_at_raw:
|
||||
try:
|
||||
with suppress(ValueError):
|
||||
elapsed_s = max(0.0, time.time() - float(started_at_raw))
|
||||
elapsed_suffix = f" in {elapsed_s:.1f}s"
|
||||
except ValueError:
|
||||
pass
|
||||
return f"Restart completed{elapsed_suffix}."
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -10,6 +12,9 @@ from nanobot.utils.helpers import stringify_text_blocks
|
||||
|
||||
_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 = (
|
||||
"I completed the tool steps but couldn't produce a final answer. "
|
||||
"Please try again or narrow the task."
|
||||
@@ -95,3 +100,71 @@ def repeated_external_lookup_error(
|
||||
"Error: repeated external lookup blocked. "
|
||||
"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."""
|
||||
if not tool_calls:
|
||||
return ""
|
||||
@@ -36,11 +36,11 @@ def format_tool_hints(tool_calls: list) -> str:
|
||||
for tc in tool_calls:
|
||||
fmt = _TOOL_FORMATS.get(tc.name)
|
||||
if fmt:
|
||||
formatted.append(_fmt_known(tc, fmt))
|
||||
formatted.append(_fmt_known(tc, fmt, max_length))
|
||||
elif tc.name.startswith("mcp_"):
|
||||
formatted.append(_fmt_mcp(tc))
|
||||
formatted.append(_fmt_mcp(tc, max_length))
|
||||
else:
|
||||
formatted.append(_fmt_fallback(tc))
|
||||
formatted.append(_fmt_fallback(tc, max_length))
|
||||
|
||||
hints = []
|
||||
for hint in formatted:
|
||||
@@ -80,26 +80,28 @@ def _extract_arg(tc, key_args: list[str]) -> str | 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."""
|
||||
val = _extract_arg(tc, fmt[0])
|
||||
if val is None:
|
||||
return tc.name
|
||||
if fmt[2]: # is_path
|
||||
val = abbreviate_path(val)
|
||||
val = abbreviate_path(val, max_len=max_length)
|
||||
elif fmt[3]: # is_command
|
||||
val = _abbreviate_command(val)
|
||||
val = _abbreviate_command(val, max_len=max_length)
|
||||
return fmt[1].format(val)
|
||||
|
||||
|
||||
def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
|
||||
"""Abbreviate paths in a command string, then truncate."""
|
||||
path_max = max(max_len // 2, 25)
|
||||
|
||||
def _replace_path(match: re.Match[str]) -> str:
|
||||
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:
|
||||
return f"'{abbreviate_path(match.group('single'), max_len=25)}'"
|
||||
return abbreviate_path(match.group("bare"), max_len=25)
|
||||
return f"'{abbreviate_path(match.group('single'), max_len=path_max)}'"
|
||||
return abbreviate_path(match.group("bare"), max_len=path_max)
|
||||
|
||||
abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd)
|
||||
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"
|
||||
|
||||
|
||||
def _fmt_mcp(tc) -> str:
|
||||
def _fmt_mcp(tc, max_length: int = 40) -> str:
|
||||
"""Format MCP tool as server::tool."""
|
||||
name = tc.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)
|
||||
if val is None:
|
||||
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."""
|
||||
args = _get_args(tc)
|
||||
val = next(iter(args.values()), None) if isinstance(args, dict) else None
|
||||
if not isinstance(val, str):
|
||||
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",
|
||||
"python-pptx>=1.0.0,<2.0.0",
|
||||
"filelock>=3.25.2",
|
||||
"boto3>=1.43.0",
|
||||
]
|
||||
|
||||
[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
|
||||
|
||||
|
||||
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:
|
||||
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for structured tool-event progress metadata emitted by AgentLoop."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -130,11 +131,44 @@ class TestToolEventProgress:
|
||||
assert finish["result"] == "file.txt"
|
||||
|
||||
@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,
|
||||
tmp_path: Path,
|
||||
) -> 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()
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
@@ -149,23 +183,34 @@ class TestToolEventProgress:
|
||||
provider.chat_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="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="say hello",
|
||||
metadata={"_wants_stream": True},
|
||||
))
|
||||
|
||||
outbound = []
|
||||
while bus.outbound_size > 0:
|
||||
outbound.append(await bus.consume_outbound())
|
||||
|
||||
progress = [m for m in outbound if m.metadata.get("_progress")]
|
||||
final = [m for m in outbound if not m.metadata.get("_progress")]
|
||||
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
|
||||
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].metadata.get("_streamed") is True
|
||||
assert outbound[-1].metadata.get("_turn_end") is True
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -195,8 +240,12 @@ class TestToolEventProgress:
|
||||
loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
|
||||
streamed: list[str] = []
|
||||
progress: list[tuple[str, bool, list[dict] | None]] = []
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
streamed.append(delta)
|
||||
|
||||
async def on_progress(
|
||||
content: str,
|
||||
*,
|
||||
@@ -205,12 +254,107 @@ class TestToolEventProgress:
|
||||
) -> None:
|
||||
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 [item[0] for item in progress[:3]] == [
|
||||
"I will",
|
||||
" inspect it.",
|
||||
'custom_tool("foo.txt")',
|
||||
]
|
||||
assert streamed == ["I will", " inspect it."]
|
||||
assert progress[0][0] == 'custom_tool("foo.txt")'
|
||||
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.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
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:
|
||||
@@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop:
|
||||
def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
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")
|
||||
|
||||
|
||||
@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:
|
||||
loop = _mk_loop()
|
||||
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(
|
||||
"slack",
|
||||
"C123",
|
||||
message_id="msg-123",
|
||||
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
|
||||
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")
|
||||
assert spawn_tool is not None
|
||||
assert spawn_tool._session_key.get() == "slack:C123:1700.42"
|
||||
assert spawn_tool._origin_message_id.get() == "msg-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -766,14 +821,17 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
|
||||
chat_id="slack:C123",
|
||||
content="subagent result",
|
||||
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.channel == "slack"
|
||||
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"]
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nanobot.cli import onboard as onboard_wizard
|
||||
|
||||
# Import functions to test
|
||||
from nanobot.cli.commands import _merge_missing_defaults
|
||||
from nanobot.cli.onboard import (
|
||||
_BACK_PRESSED,
|
||||
_configure_pydantic_model,
|
||||
_format_value,
|
||||
_get_constraint_hint,
|
||||
_get_field_display_name,
|
||||
_get_field_type_info,
|
||||
_get_constraint_hint,
|
||||
_input_text,
|
||||
_validate_field_constraint,
|
||||
run_onboard,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
@@ -640,8 +635,8 @@ class TestValidateFieldConstraint:
|
||||
|
||||
def test_real_send_max_retries_field(self):
|
||||
"""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.config.schema import ChannelsConfig
|
||||
|
||||
field_info = ChannelsConfig.model_fields["send_max_retries"]
|
||||
assert _validate_field_constraint(3, field_info) is None
|
||||
@@ -833,12 +828,11 @@ class TestMainMenuUpdate:
|
||||
|
||||
def test_main_menu_dispatch_includes_channel_common(self):
|
||||
"""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
|
||||
# The menu items are defined inline in run_onboard, so we test
|
||||
# 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_GETTER
|
||||
@@ -846,7 +840,7 @@ class TestMainMenuUpdate:
|
||||
|
||||
def test_main_menu_dispatch_includes_api_server(self):
|
||||
"""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_GETTER
|
||||
@@ -960,3 +954,464 @@ class TestMainMenuUpdate:
|
||||
|
||||
assert result.should_save is True
|
||||
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
|
||||
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
|
||||
|
||||
provider = MagicMock()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})],
|
||||
content="trying outside",
|
||||
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.get_definitions.return_value = []
|
||||
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)
|
||||
@@ -336,20 +348,202 @@ async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert provider.chat_with_retry.await_count == 1
|
||||
assert result.stop_reason == "tool_error"
|
||||
assert "outside allowed directory" in (result.error or "")
|
||||
assert result.tool_events == [
|
||||
{
|
||||
"name": "read_file",
|
||||
"status": "error",
|
||||
"detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace",
|
||||
}
|
||||
assert provider.chat_with_retry.await_count == 2, (
|
||||
"workspace violation must NOT short-circuit the loop"
|
||||
)
|
||||
assert result.stop_reason != "tool_error"
|
||||
assert result.error is None
|
||||
assert result.final_content == "ok, telling the user instead"
|
||||
assert result.tool_events and result.tool_events[0]["status"] == "error"
|
||||
# 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
|
||||
@@ -449,7 +643,7 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path):
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.helpers.logger.warning",
|
||||
"nanobot.utils.helpers.logger.exception",
|
||||
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={}),
|
||||
],
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
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={}),
|
||||
],
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
|
||||
@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
|
||||
async def test_loop_retries_think_only_final_response(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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
|
||||
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:
|
||||
@@ -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) ---
|
||||
|
||||
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",
|
||||
"content": "done",
|
||||
"reasoning_content": "hidden chain of thought",
|
||||
"thinking_blocks": [{"type": "thinking", "thinking": "hidden chain of thought", "signature": "sig"}],
|
||||
})
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
@@ -190,6 +203,11 @@ def test_get_history_preserves_reasoning_content():
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"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)
|
||||
|
||||
|
||||
def _hint(calls):
|
||||
def _hint(calls, max_length=40):
|
||||
"""Shortcut for format_tool_hints."""
|
||||
return format_tool_hints(calls)
|
||||
return format_tool_hints(calls, max_length=max_length)
|
||||
|
||||
|
||||
class TestToolHintKnownTools:
|
||||
@@ -254,3 +254,59 @@ class TestToolHintMixedFolding:
|
||||
assert "\u00d7" not in result
|
||||
parts = result.split(", ")
|
||||
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
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
@@ -35,6 +35,7 @@ def _make_mock_loop(**overrides):
|
||||
loop._concurrency_gate = None
|
||||
loop._unified_session = False
|
||||
loop._extra_hooks = []
|
||||
loop.model_preset = None
|
||||
|
||||
# web_config mock — needed for check tests
|
||||
loop.web_config = MagicMock()
|
||||
@@ -76,7 +77,7 @@ class TestInspectSummary:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="check")
|
||||
assert "max_iterations: 40" in result
|
||||
assert "context_window_tokens: 65536" in result
|
||||
assert "model_preset" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_includes_runtime_vars(self):
|
||||
@@ -92,8 +93,7 @@ class TestInspectSummary:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="check")
|
||||
assert "max_iterations" in result
|
||||
assert "context_window_tokens" in result
|
||||
assert "model" in result
|
||||
assert "model_preset" in result
|
||||
assert "workspace" in result
|
||||
assert "provider_retry_mode" in result
|
||||
assert "max_tool_result_chars" in result
|
||||
@@ -231,13 +231,13 @@ class TestModifyRestricted:
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_string_int_coerced(self):
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_context_window_valid(self):
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -337,13 +337,13 @@ class TestModifyFree:
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_allows_list(self):
|
||||
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]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_allows_dict(self):
|
||||
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}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -392,6 +392,26 @@ class TestModifyFree:
|
||||
assert "Error" in result
|
||||
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
|
||||
@@ -689,8 +709,8 @@ class TestSubagentHookStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_updates_status(self):
|
||||
"""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.subagent import SubagentStatus, _SubagentHook
|
||||
|
||||
status = SubagentStatus(
|
||||
task_id="test",
|
||||
@@ -716,8 +736,8 @@ class TestSubagentHookStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_with_error(self):
|
||||
"""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.subagent import SubagentStatus, _SubagentHook
|
||||
|
||||
status = SubagentStatus(
|
||||
task_id="test",
|
||||
@@ -739,8 +759,8 @@ class TestSubagentHookStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_no_status_is_noop(self):
|
||||
"""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.subagent import _SubagentHook
|
||||
|
||||
hook = _SubagentHook("test")
|
||||
context = AgentHookContext(iteration=1, messages=[])
|
||||
@@ -757,7 +777,6 @@ class TestCheckpointCallback:
|
||||
async def test_checkpoint_updates_phase_and_iteration(self):
|
||||
"""The _on_checkpoint callback should update status.phase and iteration."""
|
||||
from nanobot.agent.subagent import SubagentStatus
|
||||
import asyncio
|
||||
|
||||
status = SubagentStatus(
|
||||
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()
|
||||
|
||||
|
||||
@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
|
||||
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."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import Session
|
||||
@@ -74,8 +261,6 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
injection_callback = None
|
||||
|
||||
# Capture the injection_callback that _run_agent_loop creates
|
||||
original_run = loop.runner.run
|
||||
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal 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.manager import ChannelManager
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -338,11 +340,10 @@ async def test_base_channel_passes_language_to_groq_transcription_provider():
|
||||
# Transcription provider HTTP tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider
|
||||
from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider
|
||||
|
||||
|
||||
class _StubResponse:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
@@ -791,6 +792,50 @@ async def test_send_with_retry_skips_send_when_streamed():
|
||||
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
|
||||
async def test_send_with_retry_propagates_cancelled_error():
|
||||
"""_send_with_retry should re-raise CancelledError for graceful shutdown."""
|
||||
|
||||
@@ -2,7 +2,6 @@ import asyncio
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -17,19 +16,27 @@ except ImportError:
|
||||
if not DINGTALK_AVAILABLE:
|
||||
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
|
||||
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
|
||||
from nanobot.channels.dingtalk import DingTalkConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.dingtalk import DingTalkChannel, DingTalkConfig, NanobotDingTalkHandler
|
||||
|
||||
|
||||
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._json_body = json_body or {}
|
||||
self.text = "{}"
|
||||
self.content = b""
|
||||
self.headers = {"content-type": "application/json"}
|
||||
self.text = content.decode("utf-8", errors="replace") if content else "{}"
|
||||
self.content = content
|
||||
self.headers = headers or {"content-type": "application/json"}
|
||||
self.url = httpx.URL(url)
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._json_body
|
||||
@@ -46,11 +53,13 @@ class _FakeHttp:
|
||||
return _FakeResponse()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -242,6 +251,245 @@ async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
||||
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:
|
||||
channel = DingTalkChannel(
|
||||
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
|
||||
from datetime import date
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.email import EmailChannel
|
||||
from nanobot.channels.email import EmailConfig
|
||||
from nanobot.channels.email import EmailChannel, EmailConfig
|
||||
|
||||
|
||||
def _make_config(**overrides) -> EmailConfig:
|
||||
@@ -24,6 +23,7 @@ def _make_config(**overrides) -> EmailConfig:
|
||||
smtp_username="bot@example.com",
|
||||
smtp_password="secret",
|
||||
mark_seen=True,
|
||||
allow_from=["*"],
|
||||
# Disable auth verification by default so existing tests are unaffected
|
||||
verify_dkim=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:
|
||||
"""Unit test for the _check_authentication_results static method."""
|
||||
from email.parser import BytesParser
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
|
||||
# No Authentication-Results header
|
||||
msg_no_auth = EmailMessage()
|
||||
@@ -788,6 +788,32 @@ def _make_raw_email_with_attachment(
|
||||
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:
|
||||
"""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)
|
||||
|
||||
@@ -445,6 +445,58 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -580,6 +632,32 @@ async def test_reply_without_reply_in_thread_when_disabled() -> None:
|
||||
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
|
||||
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
||||
"""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()
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _make_channel(streaming: bool = True) -> FeishuChannel:
|
||||
def _make_channel(streaming: bool = True, reply_to_message: bool = False) -> FeishuChannel:
|
||||
config = FeishuConfig(
|
||||
enabled=True,
|
||||
app_id="cli_test",
|
||||
app_secret="secret",
|
||||
allow_from=["*"],
|
||||
streaming=streaming,
|
||||
reply_to_message=reply_to_message,
|
||||
)
|
||||
ch = FeishuChannel(config, MessageBus())
|
||||
ch._client = MagicMock()
|
||||
@@ -148,6 +149,62 @@ class TestSendDelta:
|
||||
ch._client.im.v1.message.create.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
|
||||
async def test_second_delta_within_interval_skips_update(self):
|
||||
ch = _make_channel()
|
||||
@@ -204,6 +261,70 @@ class TestSendDelta:
|
||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||
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
|
||||
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."""
|
||||
@@ -316,6 +437,64 @@ class TestToolHintInlineStreaming:
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
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
|
||||
async def test_consecutive_tool_hints_append(self):
|
||||
"""When multiple tool hints arrive consecutively, each appends to the card."""
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
pytest.importorskip("nio")
|
||||
pytest.importorskip("nh3")
|
||||
pytest.importorskip("mistune")
|
||||
from nio import RoomSendResponse
|
||||
from nio import RoomSendResponse, SyncError
|
||||
|
||||
from nanobot.channels.matrix import _build_matrix_text_content
|
||||
|
||||
@@ -266,6 +266,61 @@ async def test_start_disables_e2ee_when_configured(
|
||||
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
|
||||
async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None:
|
||||
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 == []
|
||||
|
||||
|
||||
@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
|
||||
async def test_on_message_skips_typing_for_denied_sender() -> None:
|
||||
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()
|
||||
|
||||
|
||||
@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
|
||||
async def test_send_clears_typing_when_send_fails() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for QQ channel media support: helpers, send, inbound, and upload."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@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 ────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -643,6 +643,14 @@ def test_slack_download_rejects_login_html() -> None:
|
||||
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:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
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]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.logger.warning",
|
||||
channel.logger,
|
||||
"warning",
|
||||
lambda message, error: recorded.append(("warning", message.format(error))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.logger.error",
|
||||
channel.logger,
|
||||
"error",
|
||||
lambda message, error: recorded.append(("error", message.format(error))),
|
||||
)
|
||||
|
||||
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
|
||||
@@ -330,13 +332,14 @@ async def test_on_error_summarizes_empty_network_error(monkeypatch) -> None:
|
||||
recorded: list[tuple[str, str]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.logger.warning",
|
||||
channel.logger,
|
||||
"warning",
|
||||
lambda message, error: recorded.append(("warning", message.format(error))),
|
||||
)
|
||||
|
||||
await channel._on_error(object(), SimpleNamespace(error=NetworkError("")))
|
||||
|
||||
assert recorded == [("warning", "Telegram network issue: NetworkError")]
|
||||
assert recorded == [("warning", "network issue: NetworkError")]
|
||||
|
||||
|
||||
@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]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.logger.warning",
|
||||
channel.logger,
|
||||
"warning",
|
||||
lambda message, error: recorded.append(("warning", message.format(error))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.logger.error",
|
||||
channel.logger,
|
||||
"error",
|
||||
lambda message, error: recorded.append(("error", message.format(error))),
|
||||
)
|
||||
|
||||
await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom")))
|
||||
|
||||
assert recorded == [("error", "Telegram error: boom")]
|
||||
assert recorded == [("error", "error: boom")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1309,6 +1314,58 @@ async def test_on_help_includes_restart_command() -> None:
|
||||
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
|
||||
async def test_on_message_location_content() -> None:
|
||||
"""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]
|
||||
assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup)
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -287,6 +321,44 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
||||
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
|
||||
async def test_send_non_connection_closed_exception_is_raised() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -491,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
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(
|
||||
bus: MagicMock,
|
||||
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())
|
||||
assert end["event"] == "stream_end"
|
||||
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:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -379,3 +379,111 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) ->
|
||||
headers = {"Authorization": "Bearer live"}
|
||||
|
||||
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 tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -451,6 +450,39 @@ async def test_process_text_message() -> None:
|
||||
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
|
||||
async def test_process_image_message() -> None:
|
||||
"""Image message: download success → media_paths non-empty."""
|
||||
|
||||
@@ -5,8 +5,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import nanobot.channels.weixin as weixin_mod
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -15,10 +15,10 @@ from nanobot.channels.weixin import (
|
||||
ITEM_TEXT,
|
||||
MESSAGE_TYPE_BOT,
|
||||
WEIXIN_CHANNEL_VERSION,
|
||||
_decrypt_aes_ecb,
|
||||
_encrypt_aes_ecb,
|
||||
WeixinChannel,
|
||||
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["SKRouteTag"] == "123"
|
||||
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:
|
||||
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:
|
||||
@@ -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")
|
||||
|
||||
|
||||
@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
|
||||
async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None:
|
||||
bus = MessageBus()
|
||||
@@ -291,21 +319,22 @@ async def test_process_message_does_not_fallback_when_top_level_media_exists_but
|
||||
|
||||
|
||||
@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._client = object()
|
||||
channel._token = "token"
|
||||
channel._send_text = AsyncMock()
|
||||
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="context_token missing"):
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
|
||||
channel._send_text.assert_not_awaited()
|
||||
|
||||
|
||||
@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._client = object()
|
||||
channel._token = "token"
|
||||
@@ -313,9 +342,10 @@ async def test_send_does_not_send_when_session_is_paused() -> None:
|
||||
channel._pause_session(60)
|
||||
channel._send_text = AsyncMock()
|
||||
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="session paused"):
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
|
||||
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
|
||||
channel._send_media_file.assert_awaited_once()
|
||||
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
|
||||
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()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
@@ -139,7 +139,7 @@ async def test_group_policy_mention_skips_unmentioned_group_message():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
@@ -166,7 +166,7 @@ async def test_group_policy_mention_accepts_mentioned_group_message():
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_id_prefers_phone_jid_over_lid():
|
||||
"""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()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
@@ -187,7 +187,7 @@ async def test_sender_id_prefers_phone_jid_over_lid():
|
||||
@pytest.mark.asyncio
|
||||
async def test_lid_to_phone_cache_resolves_lid_only_messages():
|
||||
"""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()
|
||||
|
||||
# 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
|
||||
async def test_voice_message_transcription_uses_media_path():
|
||||
"""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_api_key = "sk-test"
|
||||
ch._handle_message = AsyncMock()
|
||||
@@ -243,10 +243,32 @@ async def test_voice_message_transcription_uses_media_path():
|
||||
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
|
||||
async def test_voice_message_no_media_shows_not_available():
|
||||
"""Voice messages without media produce a fallback placeholder."""
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
|
||||
+181
-26
@@ -9,7 +9,7 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
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.cron.types import CronJob, CronPayload
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
@@ -220,6 +220,89 @@ def test_config_dump_excludes_oauth_provider_blocks():
|
||||
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():
|
||||
config = Config()
|
||||
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("github-copilot") is not None
|
||||
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():
|
||||
@@ -371,8 +488,8 @@ def test_openai_compat_provider_passes_model_through():
|
||||
|
||||
|
||||
def test_make_provider_uses_github_copilot_backend():
|
||||
from nanobot.cli.commands import _make_provider
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.providers.factory import build_provider_for_preset
|
||||
|
||||
config = Config.model_validate(
|
||||
{
|
||||
@@ -386,7 +503,7 @@ def test_make_provider_uses_github_copilot_backend():
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
@@ -445,6 +562,8 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
||||
|
||||
|
||||
def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||
from nanobot.providers.factory import build_provider_for_preset
|
||||
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"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:
|
||||
_make_provider(config)
|
||||
build_provider_for_preset(config, config.resolve_preset())
|
||||
|
||||
kwargs = mock_async_openai.call_args.kwargs
|
||||
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, \
|
||||
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._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.bus.queue.MessageBus"), \
|
||||
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.channels_config = None
|
||||
agent_loop.process_direct = AsyncMock(
|
||||
@@ -492,6 +611,7 @@ def mock_agent_runtime(tmp_path):
|
||||
)
|
||||
agent_loop.close_mcp = AsyncMock(return_value=None)
|
||||
mock_agent_loop_cls.return_value = agent_loop
|
||||
mock_agent_loop_cls.from_config.return_value = agent_loop
|
||||
|
||||
yield {
|
||||
"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 == (
|
||||
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["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.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.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:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, *args, **kwargs):
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||
|
||||
async def close_mcp(self) -> 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)
|
||||
|
||||
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.load_config", lambda _path=None: config)
|
||||
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())
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, *args, **kwargs):
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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.load_config", lambda _path=None: config)
|
||||
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.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:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, *args, **kwargs):
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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.load_config", lambda _path=None: config)
|
||||
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.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:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, *args, **kwargs):
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
@@ -729,7 +865,7 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
|
||||
assert result.exit_code == 0
|
||||
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["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):
|
||||
@@ -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["config"].agents.defaults.workspace == str(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):
|
||||
@@ -811,8 +947,8 @@ def _patch_cli_command_runtime(
|
||||
sync_templates or (lambda _path: None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
provider_factory,
|
||||
"nanobot.providers.factory.build_provider_for_preset",
|
||||
lambda *_a, **_k: provider_factory(Config()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"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:
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -868,7 +1008,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
||||
message_bus=lambda: 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("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.load_config", lambda _path=None: config)
|
||||
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(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
@@ -1000,8 +1140,13 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
class _FakeAgentLoop:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = object()
|
||||
self.tools = {}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, *args, **kwargs):
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
return OutboundMessage(
|
||||
channel="telegram",
|
||||
@@ -1035,7 +1180,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
return True
|
||||
|
||||
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.utils.evaluator.evaluate_response",
|
||||
@@ -1064,7 +1209,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
|
||||
assert 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["task_context"] == (
|
||||
"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.load_config", lambda _path=None: config)
|
||||
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.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(object(), _config),
|
||||
@@ -1131,8 +1276,13 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
class _FakeAgentLoop:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = object()
|
||||
self.tools = {}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, *args, **kwargs):
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
async def process_direct(self, *_args, on_progress=None, **_kwargs):
|
||||
seen["on_progress"] = on_progress
|
||||
return OutboundMessage(
|
||||
@@ -1158,7 +1308,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
return False
|
||||
|
||||
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.utils.evaluator.evaluate_response",
|
||||
@@ -1363,9 +1513,14 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
class _FakeAgentLoop:
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = object()
|
||||
self.dream = _FakeDream()
|
||||
self.sessions = _FakeSessionManager()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, *args, **kwargs):
|
||||
return cls(**kwargs)
|
||||
|
||||
async def run(self) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
@@ -1454,7 +1609,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
message_bus=lambda: 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.cron.service.CronService", _FakeCronService)
|
||||
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