mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f602fbc8c | ||
|
|
d6f6bbddbf | ||
|
|
ac7b8cf4b4 | ||
|
|
88cb22dd79 | ||
|
|
5328a95add | ||
|
|
c6dbeb97d8 | ||
|
|
0bbb74b1ee | ||
|
|
944de867a0 | ||
|
|
6e0eb46705 | ||
|
|
e260d9b31c | ||
|
|
51f11a8548 | ||
|
|
7e15c4c447 | ||
|
|
3a400e0207 | ||
|
|
8e4fe9cfaf | ||
|
|
07a81d70be | ||
|
|
d3e4b35f2b | ||
|
|
5be176a6a0 | ||
|
|
9aab94c766 | ||
|
|
9957de5226 | ||
|
|
cad368f585 | ||
|
|
0b38c48399 | ||
|
|
6a9157f477 | ||
|
|
8bcab8885e | ||
|
|
aae259c790 | ||
|
|
d993c81f08 | ||
|
|
4490f8cfe4 | ||
|
|
78f4c132d9 | ||
|
|
7e9426d9bd | ||
|
|
98d661775e | ||
|
|
017a4946e2 | ||
|
|
648fc92673 | ||
|
|
274613f064 | ||
|
|
754f457a94 | ||
|
|
6c0f151f6e | ||
|
|
4b1547db7d | ||
|
|
c3ec2e665f | ||
|
|
911a7e3a82 | ||
|
|
fc9d17eb7b | ||
|
|
60ab580f8b | ||
|
|
96eb965aae | ||
|
|
4188ffc88d | ||
|
|
089216f9c7 | ||
|
|
464f71b488 | ||
|
|
15de6be0af | ||
|
|
01cdfc8100 | ||
|
|
3647875aba | ||
|
|
299bcf491b | ||
|
|
0191c0db73 | ||
|
|
5851bd432a | ||
|
|
8195181783 | ||
|
|
9cf2fb19c2 | ||
|
|
f3099286ea | ||
|
|
5f054c0e74 | ||
|
|
536e8db324 | ||
|
|
2f4f00bb9f | ||
|
|
8bd951a06f | ||
|
|
e875f29185 | ||
|
|
1616fa9f14 | ||
|
|
c7393c785e | ||
|
|
c22efb5f7a | ||
|
|
66690fdb0c | ||
|
|
aa8387fb4d | ||
|
|
b189a37648 | ||
|
|
4cd6eb6c38 | ||
|
|
80085085d9 | ||
|
|
ebf1ef5cab | ||
|
|
7b1d81a868 | ||
|
|
254497c02e | ||
|
|
96abb4d2c4 | ||
|
|
63bc6e98a7 | ||
|
|
3748f664b2 | ||
|
|
7bf7469d90 | ||
|
|
79d9455313 | ||
|
|
a9867a5a4e | ||
|
|
c6a4d46a2a | ||
|
|
be1cc769d5 | ||
|
|
9abad4746e | ||
|
|
b32d673ead | ||
|
|
79b89f4f4c | ||
|
|
89d8c055a8 | ||
|
|
b81c05581f | ||
|
|
1d7bad3909 | ||
|
|
b46e7f4377 | ||
|
|
4cfc99f4b3 | ||
|
|
b2cf37da4a | ||
|
|
28102382af | ||
|
|
93571149db | ||
|
|
052f671b3c | ||
|
|
cdb2df4982 | ||
|
|
d5658dbc91 | ||
|
|
ab6ceef1a1 | ||
|
|
12c52c11d3 | ||
|
|
f4a7079e65 | ||
|
|
bbca32fea9 | ||
|
|
8981995474 | ||
|
|
7cf3c71e3a | ||
|
|
fde55d06e2 | ||
|
|
b6156fdd79 | ||
|
|
0b1b02f187 | ||
|
|
dfc3919b52 | ||
|
|
afc65c086e | ||
|
|
4a79cbb6e7 | ||
|
|
9db0d9f3c9 | ||
|
|
b67f4b1371 | ||
|
|
ab0d28103b |
@@ -18,8 +18,39 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
changes:
|
||||||
|
name: Detect changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
python_required: ${{ steps.paths.outputs.python_required }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect Python-relevant changes
|
||||||
|
id: paths
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||||
|
HEAD_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
python_required=true
|
||||||
|
|
||||||
|
if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
|
||||||
|
changed_files="$(git diff --name-only --no-renames "$BASE_SHA" "$HEAD_SHA")" &&
|
||||||
|
[[ -n "$changed_files" ]] &&
|
||||||
|
! grep -qvE '^(webui/|nanobot/channels/[^/]+/webui/|docs/)' <<< "$changed_files"; then
|
||||||
|
python_required=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "python_required=$python_required" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python (${{ matrix.name }})
|
name: Python (${{ matrix.name }})
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.python_required == 'true'
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.svg">
|
||||||
<img alt="nanobot README cover" src="./images/readme-cover-light.png">
|
<img alt="nanobot README cover" src="./images/readme-cover-light.svg">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -46,15 +46,7 @@
|
|||||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
||||||
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
||||||
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
||||||
| Deploy to the cloud in one click | [Deploy to Render](#deploy-to-render) |
|
| Deploy to the cloud or keep nanobot running as a service | [Deployment](./docs/deployment.md), including [one-click Render setup](./docs/deployment.md#render) |
|
||||||
|
|
||||||
## Deploy to Render
|
|
||||||
|
|
||||||
Deploy nanobot's gateway and bundled WebUI as a single web service with persistent memory. Render reads [`render.yaml`](./render.yaml) and prompts for two secrets on deploy: `ANTHROPIC_API_KEY` and `NANOBOT_WEB_TOKEN` (the password that gates the public WebUI — generate a strong random value, e.g. `openssl rand -hex 32`).
|
|
||||||
|
|
||||||
> **Note:** The blueprint attaches a persistent disk so sessions, memory, and WebUI history survive restarts. Persistent disks require a paid service (they are not available on Render's free tier).
|
|
||||||
|
|
||||||
[](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
|
|
||||||
|
|
||||||
## What can nanobot do?
|
## What can nanobot do?
|
||||||
|
|
||||||
@@ -68,19 +60,20 @@ nanobot is a self-hosted personal AI agent runtime. It can:
|
|||||||
- expose a Python SDK and OpenAI-compatible API for integrations
|
- expose a Python SDK and OpenAI-compatible API for integrations
|
||||||
- deploy as a long-running local or server-side agent gateway
|
- deploy as a long-running local or server-side agent gateway
|
||||||
|
|
||||||
## Latest Release
|
## Releases
|
||||||
|
|
||||||
**v0.2.2 - Durability Release**
|
**Coming next: v0.3.0 - The Agency Release**
|
||||||
|
|
||||||
Highlights:
|
The Agency Release turns nanobot from a durable workbench into an agent runtime that can coordinate helpers, switch models per session, and carry authorized work through to completion.
|
||||||
|
|
||||||
- Segmented WebUI transcripts
|
- Consult inline subagents without leaving the current task
|
||||||
- Python SDK runtime controls
|
- Switch model presets per session directly from the composer
|
||||||
- Automation management
|
- Start from a guided WebUI setup with clearer execution controls
|
||||||
- Search/STT provider improvements
|
- Apply configuration changes live across a more reliable provider, channel, and tool runtime
|
||||||
- Gateway/session/provider reliability
|
|
||||||
|
|
||||||
[See full changelog](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
|
[Follow the v0.3.0 release candidate](https://github.com/HKUDS/nanobot/pull/5081)
|
||||||
|
|
||||||
|
**Current stable:** [v0.2.2 - The Durability Release](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
|
||||||
|
|
||||||
## Open Source Partners
|
## Open Source Partners
|
||||||
|
|
||||||
@@ -91,11 +84,11 @@ Highlights:
|
|||||||
|
|
||||||
## Recent Updates
|
## Recent Updates
|
||||||
|
|
||||||
- **2026-07-12** Explicit `/goal` activation, safer runtime and workspace access.
|
- **2026-07-24** Guided first-run setup, inline subagents, and model switching from the composer.
|
||||||
- **2026-07-11** Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
- **2026-07-23** Grok OAuth with hosted X Search, live image settings, and clearer fallback models.
|
||||||
- **2026-07-10** Stable model routing, multiline CLI input, new automation guide.
|
- **2026-07-22** Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI.
|
||||||
- **2026-07-09** Live file-edit diffs, safer localhost setup, Matrix image fixes.
|
- **2026-07-21** Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup.
|
||||||
- **2026-07-08** Safer WebUI/API setup, onboard refresh, responsive prompt rail.
|
- **2026-07-20** Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects.
|
||||||
|
|
||||||
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
|
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
|
||||||
|
|
||||||
@@ -295,7 +288,7 @@ Need help with `PATH`, API keys, provider/model matching, or JSON errors? See th
|
|||||||
|
|
||||||
## 🌐 WebUI
|
## 🌐 WebUI
|
||||||
|
|
||||||
The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for chat sessions, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md).
|
The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for topics, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md).
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||||
|
|||||||
+10
-5
@@ -21,6 +21,11 @@ We aim to respond to security reports within 48 hours.
|
|||||||
**CRITICAL**: Never commit API keys to version control.
|
**CRITICAL**: Never commit API keys to version control.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ✅ Best: Use environment variable references in config (never writes the key to disk)
|
||||||
|
# In ~/.nanobot/config.json:
|
||||||
|
# "apiKey": "${ANTHROPIC_API_KEY}"
|
||||||
|
# Then supply the key at runtime via env var or Docker secret.
|
||||||
|
|
||||||
# ✅ Good: Store in config file with restricted permissions
|
# ✅ Good: Store in config file with restricted permissions
|
||||||
chmod 600 ~/.nanobot/config.json
|
chmod 600 ~/.nanobot/config.json
|
||||||
|
|
||||||
@@ -28,9 +33,9 @@ chmod 600 ~/.nanobot/config.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Recommendations:**
|
**Recommendations:**
|
||||||
- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600`
|
- **Prefer environment variable references** (`${VAR}`) in config — the config file stores the `${VAR}` placeholder, and the plaintext value only exists in memory at runtime. See [Configuration: Environment Variables for Secrets](https://nanobot.wiki/docs/latest/use-nanobot/configuration/#environment-variables-for-secrets) for details.
|
||||||
- Consider using environment variables for sensitive keys
|
- When plaintext keys are stored in `~/.nanobot/config.json`, set file permissions to `0600` (`chmod 600`)
|
||||||
- Use OS keyring/credential manager for production deployments
|
- Consider using an OS keyring/credential manager for production deployments
|
||||||
- Rotate API keys regularly
|
- Rotate API keys regularly
|
||||||
- Use separate API keys for development and production
|
- Use separate API keys for development and production
|
||||||
|
|
||||||
@@ -237,7 +242,7 @@ If you suspect a security breach:
|
|||||||
⚠️ **Current Security Limitations:**
|
⚠️ **Current Security Limitations:**
|
||||||
|
|
||||||
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
|
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
|
||||||
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
|
2. **Plain Text Config** - API keys stored in plain text in `config.json` (prefer `${VAR}` env references when possible, or use keyring for production)
|
||||||
3. **No Session Management** - No automatic session expiry
|
3. **No Session Management** - No automatic session expiry
|
||||||
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux)
|
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux)
|
||||||
5. **No Audit Trail** - Limited security event logging (enhance as needed)
|
5. **No Audit Trail** - Limited security event logging (enhance as needed)
|
||||||
@@ -260,7 +265,7 @@ Before deploying nanobot:
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
**Last Updated**: 2026-04-05
|
**Last Updated**: 2026-07-21
|
||||||
|
|
||||||
For the latest security updates and announcements, check:
|
For the latest security updates and announcements, check:
|
||||||
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
|
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
|
||||||
|
|||||||
@@ -149,6 +149,24 @@ Defaults:
|
|||||||
|
|
||||||
The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
|
The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
|
||||||
|
|
||||||
|
### Agent-Owned State vs Effective Project Context
|
||||||
|
|
||||||
|
Runtime code distinguishes the configured agent workspace from the effective
|
||||||
|
project workspace carried by a session scope. They are often the same path, but
|
||||||
|
a WebUI chat may select a separate project:
|
||||||
|
|
||||||
|
| Concern | Path owner |
|
||||||
|
|---|---|
|
||||||
|
| Sessions, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace |
|
||||||
|
| Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace |
|
||||||
|
| Workspace access mode and project metadata | Session workspace scope |
|
||||||
|
|
||||||
|
`ContextBuilder` combines project instructions with agent-owned profile and
|
||||||
|
memory. Filesystem and search tools use the project as their ordinary boundary
|
||||||
|
and receive only capability-specific read access to built-in/agent skills and
|
||||||
|
the exact agent history file. Keep those cross-root capabilities read-only and
|
||||||
|
explicit; do not treat the entire agent workspace as an allowed root.
|
||||||
|
|
||||||
## Memory and Sessions
|
## Memory and Sessions
|
||||||
|
|
||||||
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
|
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
|
||||||
|
|||||||
+16
-16
@@ -2,21 +2,21 @@
|
|||||||
|
|
||||||
<!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. -->
|
<!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. -->
|
||||||
|
|
||||||
Automations are agent turns that run later in a linked chat/session. Use them
|
Automations are agent turns that run later in a linked topic. Use them
|
||||||
when nanobot should do work without someone actively typing: reminders,
|
when nanobot should do work without someone actively typing: reminders,
|
||||||
recurring checks, nightly summaries, CI follow-ups, local script reports, or
|
recurring checks, nightly summaries, CI follow-ups, local script reports, or
|
||||||
webhook-driven events.
|
webhook-driven events.
|
||||||
|
|
||||||
Create automations from the chat, channel, or WebUI session where the result
|
Create automations from the chat channel or WebUI topic where the
|
||||||
should appear. That lets nanobot keep the right session history, workspace, and
|
result should appear. That lets nanobot keep the right session history,
|
||||||
reply target.
|
workspace, and reply target.
|
||||||
|
|
||||||
## Choose an Automation Type
|
## Choose an Automation Type
|
||||||
|
|
||||||
| Type | Starts from | Best for | Created with |
|
| Type | Starts from | Best for | Created with |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target session to schedule it with the `cron` tool |
|
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target topic to schedule it with the `cron` tool |
|
||||||
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target session |
|
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target topic |
|
||||||
| Heartbeat | Protected system schedule | Quiet recurring checks that should only report useful results | Edit `<workspace>/HEARTBEAT.md` |
|
| Heartbeat | Protected system schedule | Quiet recurring checks that should only report useful results | Edit `<workspace>/HEARTBEAT.md` |
|
||||||
|
|
||||||
The two user-created automation types are scheduled automations and local
|
The two user-created automation types are scheduled automations and local
|
||||||
@@ -26,21 +26,21 @@ protected from normal automation edits.
|
|||||||
## Before You Create One
|
## Before You Create One
|
||||||
|
|
||||||
Keep `nanobot gateway` running. The gateway owns background delivery for chat
|
Keep `nanobot gateway` running. The gateway owns background delivery for chat
|
||||||
apps, WebUI sessions, scheduled automations, local triggers, heartbeat, and
|
apps, WebUI topics, scheduled automations, local triggers, heartbeat, and
|
||||||
Dream jobs.
|
Dream jobs.
|
||||||
|
|
||||||
Use the same workspace and config for the gateway and any process that sends
|
Use the same workspace and config for the gateway and any process that sends
|
||||||
local trigger messages. If you run multiple nanobot instances, pass the matching
|
local trigger messages. If you run multiple nanobot instances, pass the matching
|
||||||
`--config` or `--workspace` option to `nanobot trigger`.
|
`--config` or `--workspace` option to `nanobot trigger`.
|
||||||
|
|
||||||
Create each automation from the target session. An automation without a linked
|
Create each automation from the target topic. An automation without a linked
|
||||||
chat/session cannot be enabled or run from the WebUI because nanobot would not
|
topic cannot be enabled or run from the WebUI because nanobot would not know
|
||||||
know where to deliver the turn.
|
where to deliver the turn.
|
||||||
|
|
||||||
## Scheduled Automations
|
## Scheduled Automations
|
||||||
|
|
||||||
Scheduled automations are created by the agent's `cron` tool. In practice, ask
|
Scheduled automations are created by the agent's `cron` tool. In practice, ask
|
||||||
nanobot from the target chat or WebUI session:
|
nanobot from the target chat or WebUI topic:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Every weekday at 9am, check open pull requests and summarize blockers here.
|
Every weekday at 9am, check open pull requests and summarize blockers here.
|
||||||
@@ -68,7 +68,7 @@ report, use heartbeat instead of a user-created scheduled automation.
|
|||||||
Local triggers let a local script or external service send a message into a
|
Local triggers let a local script or external service send a message into a
|
||||||
specific nanobot session later.
|
specific nanobot session later.
|
||||||
|
|
||||||
Create the trigger from the chat or WebUI session where future messages should
|
Create the trigger from the chat or WebUI topic where future messages should
|
||||||
arrive:
|
arrive:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -120,7 +120,7 @@ Heartbeat is enabled by default when `nanobot gateway` starts. Configure it in
|
|||||||
Use the WebUI Automations view to:
|
Use the WebUI Automations view to:
|
||||||
|
|
||||||
- filter by all, active, paused, needs-attention, or system jobs;
|
- filter by all, active, paused, needs-attention, or system jobs;
|
||||||
- search by task name, message, trigger command, linked chat, schedule, or
|
- search by task name, message, trigger command, linked topic, schedule, or
|
||||||
status;
|
status;
|
||||||
- sort by next run, last run, updated time, or name;
|
- sort by next run, last run, updated time, or name;
|
||||||
- run scheduled automations now;
|
- run scheduled automations now;
|
||||||
@@ -138,7 +138,7 @@ Automation delivery is workspace-local. Scheduled jobs and local trigger
|
|||||||
deliveries use the same workspace as the gateway.
|
deliveries use the same workspace as the gateway.
|
||||||
|
|
||||||
Local trigger messages are written to a durable queue. If the gateway is not
|
Local trigger messages are written to a durable queue. If the gateway is not
|
||||||
running yet, the message waits in that workspace. If the linked session is
|
running yet, the message waits in that workspace. If the linked topic is
|
||||||
already running a turn, the trigger waits until the session becomes idle instead
|
already running a turn, the trigger waits until the session becomes idle instead
|
||||||
of being injected into the active turn.
|
of being injected into the active turn.
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ queue is not a distributed multi-consumer queue.
|
|||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
|
|
||||||
For a nightly report, ask from the target session:
|
For a nightly report, ask from the target topic:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow.
|
Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow.
|
||||||
@@ -181,7 +181,7 @@ generate-report | nanobot trigger <trigger-id>
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If an automation does not run, check that `nanobot gateway` is running, the
|
If an automation does not run, check that `nanobot gateway` is running, the
|
||||||
automation is enabled, and it was created from a linked chat/session.
|
automation is enabled, and it was created from a linked topic.
|
||||||
|
|
||||||
If a local trigger waits forever, confirm the command uses the same workspace or
|
If a local trigger waits forever, confirm the command uses the same workspace or
|
||||||
config as the gateway.
|
config as the gateway.
|
||||||
|
|||||||
+33
-1
@@ -109,7 +109,24 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b></summary>
|
<summary><b>Telegram</b></summary>
|
||||||
|
|
||||||
**Install the optional channel dependency**
|
**Recommended WebUI setup**
|
||||||
|
|
||||||
|
1. Create a bot with `@BotFather` and copy its token.
|
||||||
|
2. Run `nanobot webui`, then open **Settings → Channels → Telegram**.
|
||||||
|
3. Paste the token. If the gateway cannot reach Telegram directly, expand
|
||||||
|
**Advanced** and add an HTTP or SOCKS proxy.
|
||||||
|
4. Save and enable Telegram, then send the bot a direct message.
|
||||||
|
|
||||||
|
The configuration badge means nanobot found a saved token. The live connection
|
||||||
|
check is separate, so a temporary Telegram or proxy outage does not make an
|
||||||
|
existing configuration disappear. Saved tokens and proxy URLs remain masked.
|
||||||
|
|
||||||
|
See the [step-by-step Telegram guide](./guides/telegram-ai-agent.md) for pairing
|
||||||
|
and troubleshooting.
|
||||||
|
|
||||||
|
**Manual setup**
|
||||||
|
|
||||||
|
Install the optional channel dependency:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot plugins enable telegram
|
nanobot plugins enable telegram
|
||||||
@@ -134,6 +151,21 @@ nanobot plugins enable telegram
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the gateway cannot reach Telegram directly, add a proxy to the same section:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"proxy": "http://127.0.0.1:7890"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP, HTTPS, SOCKS5, and SOCKS5H proxy URLs are accepted. Treat a proxy URL
|
||||||
|
containing a username or password as a secret.
|
||||||
|
|
||||||
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
|
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
|
||||||
>
|
>
|
||||||
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
|
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ These commands work inside chat channels and interactive agent sessions:
|
|||||||
| `/restart` | Restart the bot |
|
| `/restart` | Restart the bot |
|
||||||
| `/status` | Show bot status |
|
| `/status` | Show bot status |
|
||||||
| `/model` | Show the current model and available model presets |
|
| `/model` | Show the current model and available model presets |
|
||||||
| `/model <preset>` | Switch the runtime model preset for future turns |
|
| `/model <preset>` | Switch and persist the model preset for the current session |
|
||||||
| `/dream` | Run Dream memory consolidation now |
|
| `/dream` | Run Dream memory consolidation now |
|
||||||
| `/dream-log` | Show the latest Dream memory change |
|
| `/dream-log` | Show the latest Dream memory change |
|
||||||
| `/dream-log <sha>` | Show a specific Dream memory change |
|
| `/dream-log <sha>` | Show a specific Dream memory change |
|
||||||
@@ -47,7 +47,7 @@ Use `/model` to inspect the current runtime model:
|
|||||||
/model
|
/model
|
||||||
```
|
```
|
||||||
|
|
||||||
The response shows the current model, the current preset, and the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
|
The response shows the current session's model and preset, plus the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
|
||||||
|
|
||||||
To switch presets for future turns:
|
To switch presets for future turns:
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ To switch presets for future turns:
|
|||||||
/model default
|
/model default
|
||||||
```
|
```
|
||||||
|
|
||||||
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
|
Preset names come from the top-level `modelPresets` config. Switching affects only the current session and persists the selection in that session, so later turns keep using it across process restarts. It does not rewrite `config.json`, does not change other sessions, and does not alter an in-progress turn's captured model. Sessions without a saved selection follow `agents.defaults.modelPreset` (or the implicit `default` preset when it is omitted). See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
|
||||||
|
|
||||||
## Local triggers
|
## Local triggers
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Use this page when you know what you want to run and need the command shape. For
|
|||||||
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
||||||
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
|
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
|
||||||
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
||||||
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
|
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers |
|
||||||
|
|
||||||
## Global
|
## Global
|
||||||
|
|
||||||
@@ -287,8 +287,10 @@ remain accepted as no-op compatibility aliases.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
||||||
|
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support |
|
||||||
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
||||||
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
||||||
|
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
|
||||||
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
|
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
|
||||||
|
|
||||||
See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection.
|
See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection.
|
||||||
|
|||||||
+18
-1
@@ -38,6 +38,23 @@ nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
|
|||||||
|
|
||||||
The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance.
|
The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance.
|
||||||
|
|
||||||
|
### Agent Workspace and Project Workspace
|
||||||
|
|
||||||
|
The configured workspace is the **agent workspace**. A WebUI chat can also select
|
||||||
|
a different **project workspace** for repository-specific work without moving the
|
||||||
|
agent's identity or durable state.
|
||||||
|
|
||||||
|
| Resource | Owner when a project is selected |
|
||||||
|
|---|---|
|
||||||
|
| Project instructions | `AGENTS.md` from the selected project; there is no fallback to the agent workspace's `AGENTS.md` |
|
||||||
|
| Agent profile | `SOUL.md` and `USER.md` from the agent workspace; project-local files with those names are ignored |
|
||||||
|
| Memory and custom skills | `memory/` and `skills/` from the agent workspace |
|
||||||
|
| Relative file paths and shell working directory | The selected project workspace |
|
||||||
|
|
||||||
|
When no separate project is selected, one directory normally serves both roles.
|
||||||
|
Selecting a project changes the working context for that chat; it does not create
|
||||||
|
a second agent or relocate the configured agent workspace.
|
||||||
|
|
||||||
## Config Format
|
## Config Format
|
||||||
|
|
||||||
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
|
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
|
||||||
@@ -49,7 +66,7 @@ Most examples are partial snippets. Merge them into the existing file created by
|
|||||||
A normal turn follows this flow:
|
A normal turn follows this flow:
|
||||||
|
|
||||||
1. A channel receives a user message and publishes it to the message bus.
|
1. A channel receives a user message and publishes it to the message bus.
|
||||||
2. The agent loop chooses a session key and builds context from the workspace, skills, memory, recent messages, channel metadata, and runtime settings.
|
2. The agent loop chooses a session key and builds context from the effective project workspace, agent-owned profile/skills/memory, recent messages, channel metadata, and runtime settings.
|
||||||
3. The provider receives the model request.
|
3. The provider receives the model request.
|
||||||
4. If the model asks for tools, the runner executes them and feeds results back to the model.
|
4. If the model asks for tools, the runner executes them and feeds results back to the model.
|
||||||
5. The final reply is saved to the session and sent back through the channel.
|
5. The final reply is saved to the session and sent back through the channel.
|
||||||
|
|||||||
+81
-4
@@ -254,12 +254,13 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
> - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
|
> - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
|
||||||
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
||||||
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
||||||
|
> - **ModelScope**: If you're using ModelScope's OpenAI-compatible endpoint, set `"apiBase": "https://api-inference.modelscope.cn/v1"` in your modelscope provider config.
|
||||||
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
||||||
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
|
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
|
||||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||||
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||||
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
||||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers, `openai_codex`, and `xai_grok`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
@@ -288,6 +289,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||||
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
|
| `modelscope` | LLM (ModelScope/魔搭社区) + Image generation | [modelscope.cn](https://modelscope.cn) |
|
||||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||||
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||||
@@ -303,6 +305,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
||||||
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
|
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
|
||||||
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` |
|
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` |
|
||||||
|
| `xai_grok` | LLM (Grok, OAuth) | `nanobot provider login xai-grok --set-main` |
|
||||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||||
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
||||||
|
|
||||||
@@ -676,11 +679,75 @@ Then run:
|
|||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To opt in to Codex Fast mode, merge this provider setting into `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"openaiCodex": {
|
||||||
|
"extraBody": {
|
||||||
|
"service_tier": "priority"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`priority` is the Responses API request value used by Codex Fast mode. The setting only works
|
||||||
|
for models and accounts that support Fast mode; remove `service_tier` to return to standard
|
||||||
|
processing. Fast mode consumes Codex credits at a higher rate. See the
|
||||||
|
[OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details.
|
||||||
|
|
||||||
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
|
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>xAI Grok (OAuth)</b></summary>
|
||||||
|
|
||||||
|
Use an eligible X Premium / Grok subscription without putting an API key in
|
||||||
|
`config.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot provider login xai-grok --set-main
|
||||||
|
nanobot agent -m "Hello from Grok."
|
||||||
|
```
|
||||||
|
|
||||||
|
The default model is `xai-grok/grok-4.5` with a 500,000-token context window.
|
||||||
|
The provider reads xAI's model catalog and includes the server-hosted `x_search`
|
||||||
|
tool only when the selected model advertises `supportsBackendSearch`. Models
|
||||||
|
without that capability continue normally without hosted X Search. When enabled,
|
||||||
|
searches run inside xAI's Responses API and citations arrive as inline links.
|
||||||
|
|
||||||
|
This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the
|
||||||
|
public OAuth client and proxy contract used by
|
||||||
|
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md).
|
||||||
|
The browser flow uses a random loopback callback and PKCE. The resulting token
|
||||||
|
is stored in the active instance's `auth/xai.json` (normally
|
||||||
|
`~/.nanobot/auth/xai.json`), separately from Grok Build so rotating refresh
|
||||||
|
tokens cannot invalidate one another.
|
||||||
|
|
||||||
|
To use a provider-specific proxy, merge this into `config.json` before login:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"xaiGrok": {
|
||||||
|
"proxy": "http://127.0.0.1:7890"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The proxy applies to OAuth discovery, token exchange/refresh, model-catalog
|
||||||
|
lookups, and subscription model requests. Because this integration depends on
|
||||||
|
xAI's public Grok Build client contract, an upstream contract change may require
|
||||||
|
a nanobot update.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
||||||
|
|
||||||
@@ -1277,7 +1344,7 @@ Contributor notes for adding new providers live in [`development.md`](./developm
|
|||||||
|
|
||||||
## Model Presets
|
## Model Presets
|
||||||
|
|
||||||
Model presets let you name a complete model configuration and switch it at runtime with `/model <preset>`. They are the recommended way to configure models because the same names can be reused for startup selection, chat-command switching, and fallback chains.
|
Model presets let you name a complete model configuration and select one per session with `/model <preset>`. They are the recommended way to configure models because the same names can be reused for new-session defaults, chat-command switching, and fallback chains.
|
||||||
|
|
||||||
Existing configs do not need to change. Direct `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and `reasoningEffort` fields still define the implicit `default` preset. For new configs, prefer top-level `modelPresets` plus `agents.defaults.modelPreset`.
|
Existing configs do not need to change. Direct `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and `reasoningEffort` fields still define the implicit `default` preset. For new configs, prefer top-level `modelPresets` plus `agents.defaults.modelPreset`.
|
||||||
|
|
||||||
@@ -1341,7 +1408,7 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
|
|||||||
|
|
||||||
`default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config.
|
`default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config.
|
||||||
|
|
||||||
Set `agents.defaults.modelPreset` to choose the startup preset. When `modelPreset` is `null` or omitted, startup uses the implicit `default` preset from direct `agents.defaults.*` fields. Runtime changes made with `/model <preset>` are not written back to `config.json`; they affect future turns until the process restarts or another model/config change replaces them.
|
Set `agents.defaults.modelPreset` to choose the preset followed by sessions that have no saved model selection. When `modelPreset` is `null` or omitted, such sessions follow the implicit `default` preset from direct `agents.defaults.*` fields. `/model <preset>` saves an override in the current session, so its future turns keep that preset across process restarts while other sessions remain unchanged. The command does not write the selection back to `config.json`.
|
||||||
|
|
||||||
### Model Fallbacks
|
### Model Fallbacks
|
||||||
|
|
||||||
@@ -1419,7 +1486,7 @@ Inline fallback object:
|
|||||||
|
|
||||||
Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries.
|
Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries.
|
||||||
|
|
||||||
Failover normally runs when the primary provider returns a retryable model/provider error before any answer text has been streamed. Stream-stall timeouts are the recovery exception: if the provider already emitted partial answer text and then stalls, nanobot closes the current stream segment and retries/fails over in a new segment. Typical fallback cases include timeouts, connection errors, 5xx server errors, 429 rate limits, overloads, and quota/balance exhaustion. It does not run for malformed requests, authentication/permission errors, content filtering/refusals, or context-length/message-format errors.
|
Failover normally runs when the primary provider returns a fallbackable model/provider error before any answer text has been streamed. Stream-stall timeouts are the recovery exception: if the provider already emitted partial answer text and then stalls, nanobot closes the current stream segment and retries/fails over in a new segment. Typical fallback cases include timeouts, connection errors, 5xx server errors, 429 rate limits, overloads, authentication/permission failures such as invalid or expired credentials, and quota/balance exhaustion. It does not run for malformed requests, content filtering/refusals, or context-length/message-format errors.
|
||||||
|
|
||||||
If fallback candidates use smaller `contextWindowTokens` values, nanobot builds context using the smallest window in the active chain so every candidate can receive the same prompt.
|
If fallback candidates use smaller `contextWindowTokens` values, nanobot builds context using the smallest window in the active chain so every candidate can receive the same prompt.
|
||||||
|
|
||||||
@@ -1909,6 +1976,16 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
|
|
||||||
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
|
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> When a restricted WebUI chat selects a project outside the configured agent
|
||||||
|
> workspace, that project becomes the normal file and shell boundary. Nanobot
|
||||||
|
> adds capability-specific, read-only access for built-in skills, the agent
|
||||||
|
> workspace's `skills/` directory, and the exact agent
|
||||||
|
> `memory/history.jsonl` file. Neighboring memory/profile files and all
|
||||||
|
> cross-workspace writes remain denied. Agent-owned `SOUL.md` and `USER.md` are
|
||||||
|
> assembled into model context directly; this does not grant file tools broader
|
||||||
|
> access to the agent workspace.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. |
|
| `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. |
|
||||||
|
|||||||
+13
-1
@@ -4,7 +4,7 @@ Use this page after `nanobot agent -m "Hello!"` works locally. Deployment keeps
|
|||||||
|
|
||||||
## Before You Deploy
|
## Before You Deploy
|
||||||
|
|
||||||
Check these once before Docker, systemd, or LaunchAgent:
|
Check these once before Render, Docker, systemd, or LaunchAgent:
|
||||||
|
|
||||||
| Check | Why it matters |
|
| Check | Why it matters |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -22,11 +22,23 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
|||||||
|
|
||||||
| Runtime | Use it for | State location | Useful first command |
|
| Runtime | Use it for | State location | Useful first command |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| Render | One-click hosted gateway and WebUI | Persistent disk at `/home/nanobot/.nanobot` | [Deploy to Render](#render) |
|
||||||
| Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` |
|
| Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` |
|
||||||
| Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` |
|
| Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` |
|
||||||
| systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` |
|
| systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` |
|
||||||
| macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` |
|
| macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` |
|
||||||
|
|
||||||
|
## Render
|
||||||
|
|
||||||
|
Run nanobot online without managing a server. The blueprint deploys the gateway and bundled WebUI together, with a persistent disk so sessions, memory, and chat history survive restarts.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This setup requires a paid Render service because persistent disks are not available on the free tier. During setup, provide `ANTHROPIC_API_KEY` and set `NANOBOT_WEB_TOKEN` to a strong private password (for example, generate one with `openssl rand -hex 32`).
|
||||||
|
|
||||||
|
[](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
|
||||||
|
|
||||||
|
[Review the deployment blueprint](../render.yaml)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Build a Telegram AI Agent with nanobot
|
# Connect Telegram to nanobot
|
||||||
|
|
||||||
This guide connects nanobot to Telegram so a paired Telegram user can message a
|
This guide connects one Telegram bot to nanobot. Messages sent to that bot use
|
||||||
self-hosted AI agent backed by your normal nanobot config, tools, memory, and
|
your normal nanobot model, tools, memory, and workspace.
|
||||||
workspace.
|
|
||||||
|
|
||||||
## What this guide builds
|
## What this guide builds
|
||||||
|
|
||||||
@@ -29,27 +28,55 @@ python -m pip install nanobot-ai
|
|||||||
nanobot onboard --wizard
|
nanobot onboard --wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
## Enable the Telegram channel
|
## Connect Telegram in the WebUI
|
||||||
|
|
||||||
Install the optional channel dependency:
|
Start the WebUI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot webui
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **Settings → Channels → Telegram**:
|
||||||
|
|
||||||
|
1. If Telegram support is not installed, turn on its switch and confirm the
|
||||||
|
installation.
|
||||||
|
2. Paste the token from BotFather.
|
||||||
|
3. If the gateway cannot reach Telegram directly, expand **Advanced** and enter
|
||||||
|
an HTTP or SOCKS proxy such as `http://127.0.0.1:7890`.
|
||||||
|
4. Save and enable Telegram.
|
||||||
|
|
||||||
|
The configuration badge appears as soon as a bot token is saved. A connection
|
||||||
|
check is separate: if Telegram is temporarily unreachable, the saved
|
||||||
|
configuration remains valid and the bot can continue working in environments
|
||||||
|
where the gateway has network access.
|
||||||
|
|
||||||
|
Saved tokens and proxy URLs are masked. A proxy entered here is used both for
|
||||||
|
the connection check and for normal Telegram traffic.
|
||||||
|
|
||||||
|
## Manual setup
|
||||||
|
|
||||||
|
For a headless installation, install Telegram support:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot plugins enable telegram
|
nanobot plugins enable telegram
|
||||||
```
|
```
|
||||||
|
|
||||||
Merge this snippet into `~/.nanobot/config.json`:
|
Then merge this snippet into `~/.nanobot/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN"
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"proxy": "http://127.0.0.1:7890"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Omit `proxy` when the gateway can reach Telegram directly.
|
||||||
|
|
||||||
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
|
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
|
||||||
gets a pairing code instead of agent access.
|
gets a pairing code instead of agent access.
|
||||||
|
|
||||||
@@ -95,8 +122,13 @@ workspace as your local CLI check.
|
|||||||
|
|
||||||
- If the channel is not listed, run `nanobot plugins enable telegram` again in
|
- If the channel is not listed, run `nanobot plugins enable telegram` again in
|
||||||
the same Python environment.
|
the same Python environment.
|
||||||
- If messages do not arrive, run `nanobot gateway --verbose` and check the bot
|
- If the WebUI shows a saved configuration but the live check cannot reach Telegram,
|
||||||
token.
|
the token is still saved. Confirm the gateway can reach `api.telegram.org`,
|
||||||
|
or open **Advanced → Network proxy** and enter a proxy.
|
||||||
|
- If Telegram rejects the token, copy the current token from BotFather or
|
||||||
|
regenerate it.
|
||||||
|
- If messages do not arrive, run `nanobot gateway --verbose` and confirm the
|
||||||
|
Telegram channel is enabled.
|
||||||
- If a first DM returns a pairing code, that is expected. Approve the code before
|
- If a first DM returns a pairing code, that is expected. Approve the code before
|
||||||
testing normal agent replies.
|
testing normal agent replies.
|
||||||
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
|
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
|
nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
|
||||||
|
|
||||||
The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, save, and restart when prompted. If that screen is not available in your installed version, use the manual config below.
|
The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, and save. The running gateway applies the change immediately. If that screen is not available in your installed version, use the manual config below.
|
||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ The feature is disabled by default. Open **Settings → Image**, choose a config
|
|||||||
1. Add the image provider credential under **Settings → Models** if it is not already configured.
|
1. Add the image provider credential under **Settings → Models** if it is not already configured.
|
||||||
2. Open **Settings → Image**.
|
2. Open **Settings → Image**.
|
||||||
3. Select the provider and image model, then enable image generation.
|
3. Select the provider and image model, then enable image generation.
|
||||||
4. Save, restart when prompted, and ask for a simple test image.
|
4. Save and ask for a simple test image. If the gateway cannot apply the change live, WebUI will prompt you to restart it.
|
||||||
|
|
||||||
**Manual config**
|
**Manual config**
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ This snippet uses the current built-in image-generation default so the JSON has
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
|
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, Zhipu, and ModelScope configuration examples.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
||||||
@@ -55,7 +55,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
|
|||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
||||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
|
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, `modelscope` |
|
||||||
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
||||||
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
|
||||||
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
|
||||||
@@ -319,6 +319,29 @@ Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be speci
|
|||||||
|
|
||||||
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
|
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
|
||||||
|
|
||||||
|
### ModelScope
|
||||||
|
|
||||||
|
ModelScope (魔搭社区) API-Inference supports text-to-image generation and image editing via an async task pattern.
|
||||||
|
|
||||||
|
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1664x928`) or using aspect ratio presets.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"modelscope": {
|
||||||
|
"apiKey": "${MODELSCOPE_API_KEY}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"imageGeneration": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "modelscope",
|
||||||
|
"model": "Qwen/Qwen-Image-2512"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Artifacts
|
## Artifacts
|
||||||
|
|
||||||
Generated images are stored under the active nanobot instance's media directory:
|
Generated images are stored under the active nanobot instance's media directory:
|
||||||
@@ -371,9 +394,9 @@ Use the reference image. Keep the same robot and composition, change the palette
|
|||||||
|
|
||||||
| Symptom | Check |
|
| Symptom | Check |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
| `generate_image` is not available | Enable image generation in **Settings → Image** and save. For manual config changes, restart the gateway |
|
||||||
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
||||||
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
|
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, or `modelscope` |
|
||||||
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
||||||
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
|
||||||
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ This is why nanobot's memory is not just archival. It is interpretive.
|
|||||||
|
|
||||||
## The Files
|
## The Files
|
||||||
|
|
||||||
|
In this page, `workspace` means the configured **agent workspace** (the default
|
||||||
|
is `~/.nanobot/workspace/`, or the path passed with `--workspace`). Selecting a
|
||||||
|
different project in the WebUI changes that chat's project context and tool
|
||||||
|
working directory; it does not relocate the files below.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
workspace/
|
workspace/
|
||||||
├── SOUL.md # The bot's long-term voice and communication style
|
├── SOUL.md # The bot's long-term voice and communication style
|
||||||
@@ -79,6 +84,11 @@ workspace/
|
|||||||
└── .git/ # Version history for long-term memory files
|
└── .git/ # Version history for long-term memory files
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A selected project may provide its own `AGENTS.md`, but project-local `SOUL.md`,
|
||||||
|
`USER.md`, and `memory/` do not replace the agent-owned files above. This keeps
|
||||||
|
one agent's profile and memory continuous while it works across projects. Use a
|
||||||
|
separate configured agent workspace when identity or memory must be isolated.
|
||||||
|
|
||||||
These files play different roles:
|
These files play different roles:
|
||||||
|
|
||||||
- `SOUL.md` remembers how nanobot should sound.
|
- `SOUL.md` remembers how nanobot should sound.
|
||||||
|
|||||||
+16
-15
@@ -27,7 +27,8 @@ To allow the agent to set its configuration (e.g. switch models, adjust paramete
|
|||||||
|
|
||||||
Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and rewritten in-place the next time `nanobot onboard` refreshes the config.
|
Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and rewritten in-place the next time `nanobot onboard` refreshes the config.
|
||||||
|
|
||||||
All modifications are held in memory only — restart restores defaults.
|
Most modifications are held in memory only. `model_preset` is the exception: it is
|
||||||
|
stored in the current session so the selection survives a restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,20 +78,18 @@ my(action="check", key="web_config.enable")
|
|||||||
|
|
||||||
## set — Runtime tuning
|
## set — Runtime tuning
|
||||||
|
|
||||||
Changes take effect immediately, no restart required.
|
Changes do not require a restart. `model_preset` is saved for the current session and
|
||||||
|
applies to its next turn; other writable runtime tuning takes effect immediately.
|
||||||
|
Direct `model` and `context_window_tokens` writes are rejected during an active session
|
||||||
|
because those setters change the shared instance default. Configure a named preset for
|
||||||
|
model or context-window changes instead.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
my(action="set", key="max_iterations", value=80)
|
my(action="set", key="max_iterations", value=80)
|
||||||
# → Bump iteration limit from 40 to 80
|
# → Bump iteration limit from 40 to 80
|
||||||
|
|
||||||
my(action="set", key="model_preset", value="fast")
|
my(action="set", key="model_preset", value="fast")
|
||||||
# → Switch to a configured model preset
|
# → Use a configured model preset for this session's next turn
|
||||||
|
|
||||||
my(action="set", key="model", value="fast-model")
|
|
||||||
# → Switch to a raw model and clear the active preset
|
|
||||||
|
|
||||||
my(action="set", key="context_window_tokens", value=262144)
|
|
||||||
# → Expand context window for long documents
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also store custom state in your scratchpad:
|
You can also store custom state in your scratchpad:
|
||||||
@@ -109,9 +108,9 @@ These parameters have type and range validation — invalid values are rejected:
|
|||||||
| Parameter | Type | Range | Purpose |
|
| Parameter | Type | Range | Purpose |
|
||||||
|-----------|------|-------|---------|
|
|-----------|------|-------|---------|
|
||||||
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
||||||
| `context_window_tokens` | int | 4,096–1,000,000 | Context window size |
|
| `context_window_tokens` | int | 4,096–1,000,000 | Instance default; during a session, select through a preset |
|
||||||
| `model` | str | non-empty | LLM model to use |
|
| `model` | str | non-empty | Instance default; during a session, select through a preset |
|
||||||
| `model_preset` | str | configured preset name | Named preset to use |
|
| `model_preset` | str | configured preset name | Current session's preset for its next turn |
|
||||||
|
|
||||||
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. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
|
||||||
|
|
||||||
@@ -122,8 +121,8 @@ Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_char
|
|||||||
### "This task is complex, I need more room"
|
### "This task is complex, I need more room"
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Agent: This codebase is large, let me expand my context window to handle it.
|
Agent: This codebase is large, let me switch this session to the configured deep preset.
|
||||||
→ my(action="set", key="context_window_tokens", value=262144)
|
→ my(action="set", key="model_preset", value="deep")
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Simple question, don't waste compute"
|
### "Simple question, don't waste compute"
|
||||||
@@ -180,7 +179,9 @@ Agent: The code review is progressing well. The test task hasn't started yet.
|
|||||||
|
|
||||||
## Safety Mechanisms
|
## Safety Mechanisms
|
||||||
|
|
||||||
Core design principle: **All modifications live in memory only. Restart restores defaults.** The agent cannot cause persistent damage.
|
Core design principle: **The tool does not rewrite `config.json`.** Instance-wide
|
||||||
|
changes live in memory only, while `model_preset` persists only as the current
|
||||||
|
session's selector.
|
||||||
|
|
||||||
### Off-limits (BLOCKED)
|
### Off-limits (BLOCKED)
|
||||||
|
|
||||||
|
|||||||
@@ -610,7 +610,9 @@ In chat:
|
|||||||
/model fast
|
/model fast
|
||||||
```
|
```
|
||||||
|
|
||||||
`/model` switching is runtime-only. It does not rewrite `config.json`, and an in-progress turn keeps using the model it started with.
|
`/model` stores the selection in the current session without rewriting `config.json`.
|
||||||
|
The selection survives restarts, does not affect other sessions, and an in-progress
|
||||||
|
turn keeps using the model it started with.
|
||||||
|
|
||||||
## Quick Failure Map
|
## Quick Failure Map
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -63,11 +63,11 @@ These fields answer different questions:
|
|||||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
||||||
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
||||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
|
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers, OpenAI Codex, and xAI OAuth. |
|
||||||
|
|
||||||
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
||||||
|
|
||||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex` and `xai_grok`, including OAuth token exchange/refresh and model requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
||||||
|
|
||||||
## Common Provider Patterns
|
## Common Provider Patterns
|
||||||
|
|
||||||
@@ -433,6 +433,25 @@ For OpenAI Codex:
|
|||||||
nanobot provider login openai-codex --set-main
|
nanobot provider login openai-codex --set-main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For an eligible X Premium / Grok subscription:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot provider login xai-grok --set-main
|
||||||
|
```
|
||||||
|
|
||||||
|
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and
|
||||||
|
exposes the hosted `x_search` tool only when the selected model advertises
|
||||||
|
`supportsBackendSearch`; otherwise the model runs without hosted X Search.
|
||||||
|
When enabled, Grok can search current X posts and return inline source links
|
||||||
|
without invoking a local nanobot tool. Credentials are stored under the
|
||||||
|
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
|
||||||
|
`config.json` and not in Grok Build's credential file.
|
||||||
|
|
||||||
|
The login is xAI subscription OAuth, not X Developer OAuth. It follows the
|
||||||
|
public client contract documented and implemented by
|
||||||
|
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md);
|
||||||
|
xAI may change that upstream contract independently of nanobot.
|
||||||
|
|
||||||
For GitHub Copilot:
|
For GitHub Copilot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+7
-5
@@ -494,8 +494,10 @@ Run the agent once and return a `RunResult`.
|
|||||||
| `model` | `str \| None` | `None` | Override the model for this run only. |
|
| `model` | `str \| None` | `None` | Override the model for this run only. |
|
||||||
| `model_preset` | `str \| None` | `None` | Override the model preset for this run only. |
|
| `model_preset` | `str \| None` | `None` | Override the model preset for this run only. |
|
||||||
|
|
||||||
`model` and `model_preset` are per-run overrides and do not change
|
Without an override, a run uses the preset saved in its session, or the configured
|
||||||
`bot.runtime.model` after the run completes. They are mutually exclusive.
|
default when that session has no saved selection. `model` and `model_preset` are
|
||||||
|
mutually exclusive per-run overrides; they do not change the saved session selection
|
||||||
|
or `bot.runtime.model` after the run completes.
|
||||||
|
|
||||||
### `await bot.run_streamed(...)`
|
### `await bot.run_streamed(...)`
|
||||||
|
|
||||||
@@ -531,9 +533,9 @@ async for event in bot.stream("Generate a long answer"):
|
|||||||
| `await cancel()` | Cancel the run and release stream resources. |
|
| `await cancel()` | Cancel the run and release stream resources. |
|
||||||
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
|
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
|
||||||
|
|
||||||
Normal SDK runs with different session keys may overlap. Runs that use per-run
|
SDK runs with different session keys may overlap, including runs with per-run
|
||||||
`model` or `model_preset` overrides are exclusive while the override is active,
|
`model` or `model_preset` overrides. Each run receives an immutable runtime without
|
||||||
because the current `AgentLoop` provider/model state is mutable.
|
mutating the instance default. Runs sharing one session key remain serialized.
|
||||||
|
|
||||||
### `StreamEvent`
|
### `StreamEvent`
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ For tagged releases, see [GitHub Releases](https://github.com/HKUDS/nanobot/rele
|
|||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
|
- **2026-07-24** 🧭 Guided first-run setup, inline subagents, and model switching from the composer.
|
||||||
|
- **2026-07-23** 🔎 Grok OAuth with hosted X Search, live image settings, and clearer fallback models.
|
||||||
|
- **2026-07-22** 🔌 Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI.
|
||||||
|
- **2026-07-21** ⚡ Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup.
|
||||||
|
- **2026-07-20** 💬 Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects.
|
||||||
|
- **2026-07-19** 🔀 Cross-provider failover, safer local triggers, WhatsApp group allowlists, and sturdier workspace staging.
|
||||||
|
- **2026-07-18** 🧰 More resilient automation recovery and UTF-8 CLI App installs.
|
||||||
|
- **2026-07-17** 🌙 Kimi K3 support, more reliable scheduled jobs, and cleaner provider behavior.
|
||||||
|
- **2026-07-16** 📁 Native folder picker bridges, tighter Docker defaults, and bounded session caching.
|
||||||
|
- **2026-07-15** 🔐 Short-lived Render access, safer gateway shutdown, validated file previews, and highlighted app mentions.
|
||||||
|
- **2026-07-14** 📎 Document attachments, one-click Render deployment, clearer workflow docs, and stronger Windows support.
|
||||||
|
- **2026-07-13** 🌍 Guided WebUI setup, Brazilian Portuguese, and steadier Dream, gateway, and Discord behavior.
|
||||||
- **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access.
|
- **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access.
|
||||||
- **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
- **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
||||||
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
|
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
|
||||||
|
|||||||
+50
-2
@@ -135,12 +135,17 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
|
|||||||
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
|
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
|
||||||
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
|
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
|
||||||
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
||||||
| OAuth provider fails | Run `nanobot provider login openai-codex --set-main` or `nanobot provider login github-copilot --set-main`. |
|
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
|
||||||
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
|
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
|
||||||
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
|
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
|
||||||
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
|
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
|
||||||
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
|
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
|
||||||
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
|
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
|
||||||
|
| xAI OAuth needs a proxy | Set `providers.xaiGrok.proxy` before login. It applies to OAuth discovery, token exchange/refresh, and Grok subscription requests. |
|
||||||
|
| xAI login runs on a remote/headless machine | In the WebUI, finish sign-in in your local browser; if the loopback redirect cannot reach the server, copy the final URL from the address bar into the WebUI dialog. From the CLI, run `nanobot provider login xai-grok` interactively, open the printed URL elsewhere, and paste the final callback URL or authorization code when prompted. |
|
||||||
|
| xAI returns 403 or subscription access denied | Confirm the signed-in account has an eligible X Premium / Grok subscription, then run `nanobot provider login xai-grok` again. This provider does not use an xAI API key or X Developer OAuth. |
|
||||||
|
| xAI returns 400 `invalid-argument` | Read the bounded `Response body` appended to the provider error. Hosted `x_search` is sent only when xAI's model catalog advertises `supportsBackendSearch`; the model ID `grok-4.5` itself is valid. |
|
||||||
|
| xAI model or X Search stops working after an upstream release | The integration follows Grok Build's public OAuth/proxy client contract. Update nanobot if xAI changes that contract. |
|
||||||
|
|
||||||
## Langfuse Problems
|
## Langfuse Problems
|
||||||
|
|
||||||
@@ -178,9 +183,50 @@ nanobot gateway --verbose
|
|||||||
| Port already in use | Change `gateway.port`, `channels.websocket.port`, or the `--port` CLI flag for the relevant command. |
|
| Port already in use | Change `gateway.port`, `channels.websocket.port`, or the `--port` CLI flag for the relevant command. |
|
||||||
| WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. |
|
| WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. |
|
||||||
| Config changes ignored | Restart the gateway. |
|
| Config changes ignored | Restart the gateway. |
|
||||||
|
| Startup pauses at `Installing optional feature` | An enabled channel is missing its Python dependencies. See [Slow Optional Channel Dependency Installation](#slow-optional-channel-dependency-installation). |
|
||||||
| Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. |
|
| Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. |
|
||||||
| Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. |
|
| Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. |
|
||||||
|
|
||||||
|
### Slow Optional Channel Dependency Installation
|
||||||
|
|
||||||
|
Before loading enabled channels, the gateway checks the dependencies declared by their
|
||||||
|
channel manifests. The CLI and WebUI normally install these dependencies when a channel is
|
||||||
|
enabled. Installation during startup is a recovery path for an enabled config whose Python
|
||||||
|
environment no longer has the required packages, for example after manually editing the
|
||||||
|
config, upgrading nanobot, or recreating an isolated `uv tool`/`pipx` environment. The
|
||||||
|
gateway waits for the install so an enabled channel is not silently skipped; later starts
|
||||||
|
skip the installation once the dependencies are present.
|
||||||
|
|
||||||
|
If access to PyPI is slow in your region, configure pip to use a trusted package index. The
|
||||||
|
installer honors the standard `PIP_INDEX_URL` environment variable, including when nanobot
|
||||||
|
itself was installed with `uv tool`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PIP_INDEX_URL=https://your-trusted-mirror.example/simple nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
For the systemd user service created by `nanobot gateway install-service`, add a drop-in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user edit nanobot-gateway.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
Environment="PIP_INDEX_URL=https://your-trusted-mirror.example/simple"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reload and restart the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart nanobot-gateway.service
|
||||||
|
```
|
||||||
|
|
||||||
|
For a system-level or custom service, use `sudo systemctl edit <unit>` instead. Prefer an
|
||||||
|
HTTPS index operated by an organization you trust, and do not put index credentials in
|
||||||
|
commands or logs.
|
||||||
|
|
||||||
## WebUI Problems
|
## WebUI Problems
|
||||||
|
|
||||||
The packaged WebUI is served by the WebSocket channel.
|
The packaged WebUI is served by the WebSocket channel.
|
||||||
@@ -229,7 +275,9 @@ Then check:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. |
|
| Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. |
|
||||||
| Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. |
|
| Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. |
|
||||||
| Telegram fails | Confirm the BotFather token and `allowFrom` user ID. |
|
| Telegram shows a saved configuration but cannot complete a live check | The token is saved. Confirm the gateway can reach `api.telegram.org`, or open **Settings → Channels → Telegram → Advanced → Network proxy** and enter an HTTP or SOCKS proxy. |
|
||||||
|
| Telegram rejects the token | Copy the current token from BotFather or regenerate it. |
|
||||||
|
| Telegram receives no messages | Confirm the channel is enabled, the gateway is running, and the sender is paired or listed in `allowFrom`. |
|
||||||
| Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. |
|
| Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. |
|
||||||
| WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. |
|
| WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. |
|
||||||
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |
|
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |
|
||||||
|
|||||||
+6
-2
@@ -152,7 +152,8 @@ All frames are JSON text. Each message has an `event` field.
|
|||||||
|
|
||||||
Reasoning frames only flow when the channel's `showReasoning` is `true` (default) and the model returns reasoning content (DeepSeek-R1 / Kimi / MiMo / OpenAI reasoning models, Anthropic extended thinking, or inline `<think>` / `<thought>` tags). Models without reasoning produce zero `reasoning_delta` frames.
|
Reasoning frames only flow when the channel's `showReasoning` is `true` (default) and the model returns reasoning content (DeepSeek-R1 / Kimi / MiMo / OpenAI reasoning models, Anthropic extended thinking, or inline `<think>` / `<thought>` tags). Models without reasoning produce zero `reasoning_delta` frames.
|
||||||
|
|
||||||
**`runtime_model_updated`** — broadcast when the gateway runtime model changes, for example after `/model <preset>`:
|
**`runtime_model_updated`** — broadcast when the gateway default runtime changes or
|
||||||
|
when a config reload requires clients to refresh their model catalog:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -162,7 +163,10 @@ Reasoning frames only flow when the channel's `showReasoning` is `true` (default
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`model_preset` is omitted when no named preset is active. WebUI clients use this event to keep the displayed model badge in sync across slash commands, config reloads, and settings changes.
|
`model_preset` is omitted when no named preset is active. WebUI clients use this event
|
||||||
|
to refresh model settings after default-runtime and config changes. `/model <preset>`
|
||||||
|
is session-scoped; its selection is reflected through `session_updated` and the
|
||||||
|
session row's `model_preset` field instead of this global event.
|
||||||
|
|
||||||
**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
|
**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
|
||||||
|
|
||||||
|
|||||||
+54
-24
@@ -1,8 +1,8 @@
|
|||||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||||
|
|
||||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent chat sessions, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||||
|
|
||||||
The WebUI is nanobot's browser workbench for persistent chat sessions, visible
|
The WebUI is nanobot's browser workbench for persistent topics, visible
|
||||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||||
one place.
|
one place.
|
||||||
|
|
||||||
@@ -17,12 +17,12 @@ Use the launcher:
|
|||||||
nanobot webui
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
`nanobot webui` creates the config/workspace when needed, checks provider setup,
|
`nanobot webui` creates the config/workspace when needed, enables the local
|
||||||
offers Quick Start when the model provider is not ready, enables the local
|
|
||||||
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
|
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
|
||||||
one is missing, starts the gateway, and opens the browser. The first-run path
|
one is missing, starts the gateway, and opens the browser. With a fresh config,
|
||||||
binds the WebUI to `127.0.0.1` by default, so it is not available from other
|
it can open before a model is configured so you can finish setup in **Settings
|
||||||
devices on your LAN.
|
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
|
||||||
|
it is not available from other devices on your LAN.
|
||||||
|
|
||||||
Run it in the background when you do not want to keep a terminal open:
|
Run it in the background when you do not want to keep a terminal open:
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ Run it in the background when you do not want to keep a terminal open:
|
|||||||
nanobot webui --background
|
nanobot webui --background
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Complete first-time model setup in a foreground `nanobot webui` session before using
|
||||||
|
`--background`.
|
||||||
|
|
||||||
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||||
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||||
|
|
||||||
@@ -55,11 +58,11 @@ gateway health endpoint, `18790` by default, is not the browser UI.
|
|||||||
|
|
||||||
## First 10 Minutes
|
## First 10 Minutes
|
||||||
|
|
||||||
Use the WebUI as the primary setup surface after Quick Start:
|
Use the WebUI as the primary setup surface:
|
||||||
|
|
||||||
1. Send `Hello!` in a new chat to prove the selected model works.
|
1. Open **Settings → Models** and configure a provider, credential, and active model preset.
|
||||||
2. Open **Settings → Models** and confirm the active model preset.
|
2. Send `Hello!` in a new topic to prove the selected model works.
|
||||||
3. Start a separate chat before project work, then choose the intended workspace and access mode.
|
3. Start a separate topic before project work, then choose the intended workspace and access mode.
|
||||||
4. Add only one capability next: a chat channel in **Settings → Channels**, a web/voice/image provider in **Settings**, or an App/MCP integration in **Apps**.
|
4. Add only one capability next: a chat channel in **Settings → Channels**, a web/voice/image provider in **Settings**, or an App/MCP integration in **Apps**.
|
||||||
5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request.
|
5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request.
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
|
|||||||
|
|
||||||
| Area | Use it for |
|
| Area | Use it for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Chat | Start, switch, search, fork, and delete browser sessions |
|
| Topics | Start, switch, search, fork, and delete browser topics |
|
||||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||||
@@ -80,10 +83,10 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
|
|||||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
||||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||||
|
|
||||||
## Chat Workspace
|
## Topic Workspace
|
||||||
|
|
||||||
The sidebar is the session switcher. A session keeps its own history, title,
|
The sidebar is the topic switcher. Each topic keeps its own history, title,
|
||||||
workspace metadata, and linked automations. Use a new session when you want a
|
workspace selection, and linked automations. Use a new topic when you want a
|
||||||
separate context; use fork when you want to continue from an existing point
|
separate context; use fork when you want to continue from an existing point
|
||||||
without changing the original thread.
|
without changing the original thread.
|
||||||
|
|
||||||
@@ -106,12 +109,34 @@ Use the workspace picker before starting project-specific work. This gives the
|
|||||||
agent the right project context for file paths, shell commands, and session
|
agent the right project context for file paths, shell commands, and session
|
||||||
metadata.
|
metadata.
|
||||||
|
|
||||||
|
Selecting a project does not replace the configured agent workspace. The two
|
||||||
|
paths have different responsibilities:
|
||||||
|
|
||||||
|
| Selected project provides | Agent workspace continues to provide |
|
||||||
|
|---|---|
|
||||||
|
| Project `AGENTS.md` | `SOUL.md` and `USER.md` |
|
||||||
|
| Relative file paths and shell working directory | Long-term memory and history |
|
||||||
|
| The normal read/write boundary in Restricted mode | Custom skills and instance state |
|
||||||
|
|
||||||
|
Project-local `SOUL.md` and `USER.md` files are ignored, and the agent workspace's
|
||||||
|
`AGENTS.md` is not inherited by a separately selected project. When the selected
|
||||||
|
project is the configured agent workspace, both roles naturally use the same
|
||||||
|
directory.
|
||||||
|
|
||||||
The access control in the composer controls the local capability level for the
|
The access control in the composer controls the local capability level for the
|
||||||
chat. It does not bypass your gateway, provider, shell sandbox, or operating
|
chat. It does not bypass your gateway, provider, shell sandbox, or operating
|
||||||
system configuration; it only selects among the capabilities that are already
|
system configuration; it only selects among the capabilities that are already
|
||||||
available to this WebUI session.
|
available to the current topic.
|
||||||
|
|
||||||
Remote WebUI sessions may reduce access for the current workspace. Selecting a
|
In Restricted mode, ordinary file and shell work stays inside the selected
|
||||||
|
project. To preserve agent continuity, filesystem/search tools receive narrow,
|
||||||
|
read-only access to built-in skills, custom skills in the agent workspace, and
|
||||||
|
the exact agent `memory/history.jsonl` file. This does not grant access to
|
||||||
|
neighboring memory or profile files, and it does not allow writes outside the
|
||||||
|
selected project. These tool exceptions do not broaden the browser's file
|
||||||
|
preview boundary.
|
||||||
|
|
||||||
|
Remote WebUI connections may reduce access for the current workspace. Selecting a
|
||||||
different workspace or enabling Full Access remains limited to local and native
|
different workspace or enabling Full Access remains limited to local and native
|
||||||
clients.
|
clients.
|
||||||
|
|
||||||
@@ -165,6 +190,11 @@ extraction tools without requiring an API key. This does not replace nanobot's
|
|||||||
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
|
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
|
||||||
turn needs Firecrawl's richer web data tools.
|
turn needs Firecrawl's richer web data tools.
|
||||||
|
|
||||||
|
The Parallel Search preset connects to the free, anonymous Parallel Search MCP
|
||||||
|
endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
|
||||||
|
It is an optional integration and does not replace nanobot's built-in web search
|
||||||
|
provider; mention `@parallel-search` when a turn should use it.
|
||||||
|
|
||||||
After an App or integration is available, mention it from the composer with
|
After an App or integration is available, mention it from the composer with
|
||||||
`@` to attach that tool to the next message.
|
`@` to attach that tool to the next message.
|
||||||
|
|
||||||
@@ -177,10 +207,10 @@ to perform that task.
|
|||||||
|
|
||||||
## Automations
|
## Automations
|
||||||
|
|
||||||
Automations are agent turns that run later in a linked chat/session. They should
|
Automations are agent turns that run later in a linked topic. Create them from
|
||||||
be created from the chat, channel, or session where they are supposed to run so
|
the topic or channel where they are supposed to run so nanobot keeps the
|
||||||
nanobot keeps the correct target context. When an automation runs, it normally
|
correct target context. When an automation runs, it normally delivers the
|
||||||
delivers the result back to that linked chat.
|
result back to that topic.
|
||||||
|
|
||||||
For the full automation model, creation flow, trigger CLI usage, and delivery
|
For the full automation model, creation flow, trigger CLI usage, and delivery
|
||||||
semantics, see [`automations.md`](./automations.md).
|
semantics, see [`automations.md`](./automations.md).
|
||||||
@@ -199,7 +229,7 @@ instead of creating a chat automation.
|
|||||||
Use the Automations view to:
|
Use the Automations view to:
|
||||||
|
|
||||||
- Filter by all, active, paused, needs-attention, or system jobs.
|
- Filter by all, active, paused, needs-attention, or system jobs.
|
||||||
- Search by task name, message, trigger command, linked chat, schedule, or status.
|
- Search by task name, message, trigger command, linked topic, schedule, or status.
|
||||||
- Sort by next run, last run, updated time, or name.
|
- Sort by next run, last run, updated time, or name.
|
||||||
- Run scheduled automations now.
|
- Run scheduled automations now.
|
||||||
- Pause or resume, rename, or delete user-created automations.
|
- Pause or resume, rename, or delete user-created automations.
|
||||||
@@ -210,9 +240,9 @@ Search accepts plain text and field filters such as `name:backup`,
|
|||||||
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
|
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
|
||||||
`status:paused`.
|
`status:paused`.
|
||||||
|
|
||||||
An automation without a linked chat cannot be enabled or run from the WebUI,
|
An automation without a linked topic cannot be enabled or run from the WebUI,
|
||||||
because nanobot would not know where to deliver the scheduled turn. Recreate it
|
because nanobot would not know where to deliver the scheduled turn. Recreate it
|
||||||
from the target chat or channel so the automation has complete context.
|
from the target topic or channel so the automation has complete context.
|
||||||
|
|
||||||
Local triggers do not have a WebUI "Run now" action because each run needs a
|
Local triggers do not have a WebUI "Run now" action because each run needs a
|
||||||
message. Use the copied `nanobot trigger ...` command and replace `"message"`
|
message. Use the copied `nanobot trigger ...` command and replace `"message"`
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<svg
|
||||||
|
width="1060"
|
||||||
|
height="220"
|
||||||
|
viewBox="0 0 1060 220"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>nanobot</title>
|
||||||
|
<g transform="translate(16 20) scale(0.2507)">
|
||||||
|
<path d="M229.029 127.134C308.64 112.113 354.143 106.879 379.029 108.134V716.634L272.029 715.634C251.029 715.634 243.029 702.634 201.529 678.134L54.5291 581.634C30.0291 565.134 23.9802 560.075 13.0291 549.134C3.52914 537.634 -1.97086 526.634 3.52914 481.634L28.0291 340.634L29.5291 27.1337C31.0291 -2.36625 53.0291 -6.86625 77.0291 12.6337L229.029 127.134Z" fill="#F4A949" stroke="#F4A949"/>
|
||||||
|
<path d="M529.842 126.817C450.231 111.796 404.728 106.562 379.842 107.817V716.317L486.842 715.317C509.342 714.317 570.342 661.817 611.842 637.317L704.342 581.317C728.842 564.817 734.891 559.759 745.842 548.817C755.342 537.317 760.842 526.317 755.342 481.317L730.842 340.317L729.342 26.817C727.842 -2.68287 705.842 -7.18287 681.842 12.3171L529.842 126.817Z" fill="#EF8E30" stroke="#EF8E30"/>
|
||||||
|
<path d="M143.342 497.317H1.84164C-6.15857 550.317 22.8417 557.817 56.3419 582.817L143.342 497.317Z" fill="#E27223" stroke="#DF6E22"/>
|
||||||
|
<path d="M615.342 496.817H757.001C765.002 549.817 735.842 557.317 702.342 582.317L615.342 496.817Z" fill="#D96016" stroke="#D45F16"/>
|
||||||
|
<path d="M379.342 716.317V517.817H288.342C239.842 517.817 243.342 531.817 144.842 640.817L233.342 698.817C245.302 707.847 260.342 717.317 275.342 715.817L379.342 716.317Z" fill="#FBCB89" stroke="#FBCB8A"/>
|
||||||
|
<path d="M566.842 382.817C561.842 348.817 509.842 341.317 501.342 382.817V439.317C509.842 477.317 559.342 478.317 566.842 439.317V382.817Z" fill="#B94D0B" stroke="#B5490B"/>
|
||||||
|
<path d="M379.342 716.317V517.817H470.342C518.842 517.817 513.342 528.317 611.842 637.317L522.842 698.817C510.881 707.847 495.342 715.817 483.342 715.817L379.342 716.317Z" fill="#F7B066" stroke="#F8B166"/>
|
||||||
|
<path d="M258.842 383.199C253.842 349.199 201.842 341.699 193.342 383.199V439.699C201.842 477.699 251.342 478.699 258.842 439.699V383.199Z" fill="#B94D0B" stroke="#B94D0B"/>
|
||||||
|
<path d="M439.342 517.817H318.342L379.842 583.317L439.342 517.817Z" fill="#C85513" stroke="#C85513"/>
|
||||||
|
<path d="M379.342 583.317V517.817H438.842L379.342 583.317Z" fill="#BA470A" stroke="#B94D0B"/>
|
||||||
|
<path d="M367.842 304.817L339.842 109.817C369.864 107.082 387.219 106.437 420.842 109.817L391.342 304.817C382.555 322.184 376.628 321.255 367.842 304.817Z" fill="#D35E14" stroke="#D35E14"/>
|
||||||
|
<path d="M446.412 112.822C473.271 116.662 491.893 119.703 529.928 126.325L530.604 126.442L530.284 127.05L529.842 126.817L530.283 127.051C530.283 127.051 530.282 127.054 530.281 127.055C530.279 127.059 530.276 127.064 530.273 127.071C530.265 127.085 530.254 127.107 530.239 127.135C530.209 127.193 530.164 127.279 530.105 127.391C529.986 127.617 529.81 127.951 529.581 128.387C529.122 129.261 528.449 130.543 527.59 132.177C525.872 135.444 523.413 140.12 520.448 145.753C514.519 157.018 506.565 172.113 498.471 187.426C490.377 202.738 482.142 218.27 475.65 230.412C469.165 242.538 464.401 251.316 463.262 253.088C460.97 256.653 457.712 259.067 454.529 259.067C451.263 259.067 448.386 256.547 446.859 250.949C446.467 249.511 446.169 246.271 445.938 241.776C445.705 237.256 445.537 231.406 445.42 224.701C445.186 211.289 445.154 194.441 445.217 177.94C445.279 161.438 445.436 145.281 445.576 133.249C445.647 127.233 445.713 122.248 445.762 118.767C445.786 117.027 445.806 115.662 445.82 114.733C445.827 114.268 445.832 113.912 445.836 113.673C445.838 113.553 445.839 113.462 445.84 113.401C445.84 113.371 445.841 113.348 445.841 113.333C445.841 113.325 445.842 113.319 445.842 113.315C445.842 113.313 445.842 113.311 445.842 113.31C445.845 113.31 445.882 113.31 446.342 113.317L445.842 113.309L445.851 112.742L446.412 112.822Z" fill="#D35E14" stroke="#D35C15"/>
|
||||||
|
<path d="M311.842 251.317C314.842 240.317 313.842 112.817 313.842 112.817C281.05 117.181 262.657 120.321 229.842 126.817C229.842 126.817 291.842 246.317 296.342 253.317C300.842 260.317 308.842 262.317 311.842 251.317Z" fill="#DF6E23" stroke="#DA6D1F"/>
|
||||||
|
<path d="M562.842 166.317L686.842 67.8171V278.317L562.842 166.317Z" fill="#D66114" stroke="#D86116"/>
|
||||||
|
<path d="M196.342 166.317L72.3416 67.8171V278.317L196.342 166.317Z" fill="#E17125" stroke="#E27326"/>
|
||||||
|
<path d="M752.342 465.817L625.342 432.817L737.497 377.487L752.342 465.817Z" fill="#D66015"/>
|
||||||
|
<path d="M737.842 377.317L737.497 377.487M737.497 377.487L625.342 432.817L752.342 465.817L737.497 377.487Z" stroke="#D66115"/>
|
||||||
|
<path d="M6.34164 464.817L134.342 432.004L21.3031 376.986L6.34164 464.817Z" fill="#E06B1F"/>
|
||||||
|
<path d="M20.9558 376.817L21.3031 376.986M21.3031 376.986L134.342 432.004L6.34164 464.817L21.3031 376.986Z" stroke="#DF6E1E"/>
|
||||||
|
<path d="M379.842 317.775C376.246 317.475 372.636 313.145 368.342 305.112L340.342 110.112C355.495 108.732 367.422 107.884 379.842 107.817V317.775Z" fill="#E16D22" stroke="#E27225"/>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="#B94D0B"
|
||||||
|
stroke-width="26"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M260 164V78M260 118C260 91 276 77 299 77C323 77 339 93 339 119V164"/>
|
||||||
|
<path d="M450 164V78M450 121C450 95 433 77 408 77C383 77 366 95 366 121C366 146 383 164 408 164C433 164 450 146 450 121"/>
|
||||||
|
<path d="M490 164V78M490 118C490 91 506 77 529 77C553 77 569 93 569 119V164"/>
|
||||||
|
<path d="M686 121C686 147 670 164 644 164C618 164 602 147 602 121C602 94 618 77 644 77C670 77 686 94 686 121Z"/>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="#D96016"
|
||||||
|
stroke-width="26"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M730 34V164M731 121C731 94 747 77 773 77C799 77 815 94 815 121C815 147 799 164 773 164C747 164 731 147 731 121Z"/>
|
||||||
|
<path d="M934 121C934 147 918 164 892 164C866 164 850 147 850 121C850 94 866 77 892 77C918 77 934 94 934 121Z"/>
|
||||||
|
<path d="M1000 47V138C1000 156 1011 164 1028 164M969 78H1028"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<svg width="759" height="718" viewBox="0 0 759 718" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>nanobot mark</title>
|
||||||
|
<path d="M229.029 127.134C308.64 112.113 354.143 106.879 379.029 108.134V716.634L272.029 715.634C251.029 715.634 243.029 702.634 201.529 678.134L54.5291 581.634C30.0291 565.134 23.9802 560.075 13.0291 549.134C3.52914 537.634 -1.97086 526.634 3.52914 481.634L28.0291 340.634L29.5291 27.1337C31.0291 -2.36625 53.0291 -6.86625 77.0291 12.6337L229.029 127.134Z" fill="#F4A949" stroke="#F4A949"/>
|
||||||
|
<path d="M529.842 126.817C450.231 111.796 404.728 106.562 379.842 107.817V716.317L486.842 715.317C509.342 714.317 570.342 661.817 611.842 637.317L704.342 581.317C728.842 564.817 734.891 559.759 745.842 548.817C755.342 537.317 760.842 526.317 755.342 481.317L730.842 340.317L729.342 26.817C727.842 -2.68287 705.842 -7.18287 681.842 12.3171L529.842 126.817Z" fill="#EF8E30" stroke="#EF8E30"/>
|
||||||
|
<path d="M143.342 497.317H1.84164C-6.15857 550.317 22.8417 557.817 56.3419 582.817L143.342 497.317Z" fill="#E27223" stroke="#DF6E22"/>
|
||||||
|
<path d="M615.342 496.817H757.001C765.002 549.817 735.842 557.317 702.342 582.317L615.342 496.817Z" fill="#D96016" stroke="#D45F16"/>
|
||||||
|
<path d="M379.342 716.317V517.817H288.342C239.842 517.817 243.342 531.817 144.842 640.817L233.342 698.817C245.302 707.847 260.342 717.317 275.342 715.817L379.342 716.317Z" fill="#FBCB89" stroke="#FBCB8A"/>
|
||||||
|
<path d="M566.842 382.817C561.842 348.817 509.842 341.317 501.342 382.817V439.317C509.842 477.317 559.342 478.317 566.842 439.317V382.817Z" fill="#B94D0B" stroke="#B5490B"/>
|
||||||
|
<path d="M379.342 716.317V517.817H470.342C518.842 517.817 513.342 528.317 611.842 637.317L522.842 698.817C510.881 707.847 495.342 715.817 483.342 715.817L379.342 716.317Z" fill="#F7B066" stroke="#F8B166"/>
|
||||||
|
<path d="M258.842 383.199C253.842 349.199 201.842 341.699 193.342 383.199V439.699C201.842 477.699 251.342 478.699 258.842 439.699V383.199Z" fill="#B94D0B" stroke="#B94D0B"/>
|
||||||
|
<path d="M439.342 517.817H318.342L379.842 583.317L439.342 517.817Z" fill="#C85513" stroke="#C85513"/>
|
||||||
|
<path d="M379.342 583.317V517.817H438.842L379.342 583.317Z" fill="#BA470A" stroke="#B94D0B"/>
|
||||||
|
<path d="M367.842 304.817L339.842 109.817C369.864 107.082 387.219 106.437 420.842 109.817L391.342 304.817C382.555 322.184 376.628 321.255 367.842 304.817Z" fill="#D35E14" stroke="#D35E14"/>
|
||||||
|
<path d="M446.412 112.822C473.271 116.662 491.893 119.703 529.928 126.325L530.604 126.442L530.284 127.05L529.842 126.817L530.283 127.051C530.283 127.051 530.282 127.054 530.281 127.055C530.279 127.059 530.276 127.064 530.273 127.071C530.265 127.085 530.254 127.107 530.239 127.135C530.209 127.193 530.164 127.279 530.105 127.391C529.986 127.617 529.81 127.951 529.581 128.387C529.122 129.261 528.449 130.543 527.59 132.177C525.872 135.444 523.413 140.12 520.448 145.753C514.519 157.018 506.565 172.113 498.471 187.426C490.377 202.738 482.142 218.27 475.65 230.412C469.165 242.538 464.401 251.316 463.262 253.088C460.97 256.653 457.712 259.067 454.529 259.067C451.263 259.067 448.386 256.547 446.859 250.949C446.467 249.511 446.169 246.271 445.938 241.776C445.705 237.256 445.537 231.406 445.42 224.701C445.186 211.289 445.154 194.441 445.217 177.94C445.279 161.438 445.436 145.281 445.576 133.249C445.647 127.233 445.713 122.248 445.762 118.767C445.786 117.027 445.806 115.662 445.82 114.733C445.827 114.268 445.832 113.912 445.836 113.673C445.838 113.553 445.839 113.462 445.84 113.401C445.84 113.371 445.841 113.348 445.841 113.333C445.841 113.325 445.842 113.319 445.842 113.315C445.842 113.313 445.842 113.311 445.842 113.31C445.845 113.31 445.882 113.31 446.342 113.317L445.842 113.309L445.851 112.742L446.412 112.822Z" fill="#D35E14" stroke="#D35C15"/>
|
||||||
|
<path d="M311.842 251.317C314.842 240.317 313.842 112.817 313.842 112.817C281.05 117.181 262.657 120.321 229.842 126.817C229.842 126.817 291.842 246.317 296.342 253.317C300.842 260.317 308.842 262.317 311.842 251.317Z" fill="#DF6E23" stroke="#DA6D1F"/>
|
||||||
|
<path d="M562.842 166.317L686.842 67.8171V278.317L562.842 166.317Z" fill="#D66114" stroke="#D86116"/>
|
||||||
|
<path d="M196.342 166.317L72.3416 67.8171V278.317L196.342 166.317Z" fill="#E17125" stroke="#E27326"/>
|
||||||
|
<path d="M752.342 465.817L625.342 432.817L737.497 377.487L752.342 465.817Z" fill="#D66015"/>
|
||||||
|
<path d="M737.842 377.317L737.497 377.487M737.497 377.487L625.342 432.817L752.342 465.817L737.497 377.487Z" stroke="#D66115"/>
|
||||||
|
<path d="M6.34164 464.817L134.342 432.004L21.3031 376.986L6.34164 464.817Z" fill="#E06B1F"/>
|
||||||
|
<path d="M20.9558 376.817L21.3031 376.986M21.3031 376.986L134.342 432.004L6.34164 464.817L21.3031 376.986Z" stroke="#DF6E1E"/>
|
||||||
|
<path d="M379.842 317.775C376.246 317.475 372.636 313.145 368.342 305.112L340.342 110.112C355.495 108.732 367.422 107.884 379.842 107.817V317.775Z" fill="#E16D22" stroke="#E27225"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
+1
-1
@@ -22,7 +22,7 @@ def _resolve_version() -> str:
|
|||||||
return _pkg_version("nanobot-ai")
|
return _pkg_version("nanobot-ai")
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
# Source checkouts often import nanobot without installed dist-info.
|
# Source checkouts often import nanobot without installed dist-info.
|
||||||
return _read_pyproject_version() or "0.2.2"
|
return _read_pyproject_version() or "0.3.0"
|
||||||
|
|
||||||
|
|
||||||
__version__ = _resolve_version()
|
__version__ = _resolve_version()
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class AutoCompact:
|
|||||||
def check_expired(
|
def check_expired(
|
||||||
self,
|
self,
|
||||||
schedule_background: Callable[[Coroutine], None],
|
schedule_background: Callable[[Coroutine], None],
|
||||||
resolve_runtime: Callable[[], LLMRuntime],
|
resolve_runtime: Callable[[Session], LLMRuntime],
|
||||||
active_session_keys: Collection[str] = (),
|
active_session_keys: Collection[str] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
||||||
@@ -79,7 +79,12 @@ class AutoCompact:
|
|||||||
continue
|
continue
|
||||||
updated_at = info.get("updated_at")
|
updated_at = info.get("updated_at")
|
||||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||||
runtime = resolve_runtime()
|
session = self.sessions.get_or_create(key)
|
||||||
|
try:
|
||||||
|
runtime = resolve_runtime(session)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
# Invalid session selections remain recoverable through /model.
|
||||||
|
continue
|
||||||
self._archiving.add(key)
|
self._archiving.add(key)
|
||||||
schedule_background(self._archive(key, runtime=runtime))
|
schedule_background(self._archive(key, runtime=runtime))
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any, Mapping, Sequence
|
|||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.agent.tools import image_generation as image_generation_tools
|
||||||
from nanobot.agent.tools import mcp as mcp_tools
|
from nanobot.agent.tools import mcp as mcp_tools
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.cli import utils as cli_app_utils
|
from nanobot.apps.cli import utils as cli_app_utils
|
||||||
@@ -41,13 +42,20 @@ async def close_mcp(state: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||||
return await mcp_tools.handle_runtime_control(state, msg, tools)
|
for handler in (
|
||||||
|
image_generation_tools.handle_runtime_control,
|
||||||
|
mcp_tools.handle_runtime_control,
|
||||||
|
):
|
||||||
|
if await handler(state, msg, tools):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ContextBuilder:
|
class ContextBuilder:
|
||||||
"""Builds the context (system prompt + messages) for the agent."""
|
"""Builds the context (system prompt + messages) for the agent."""
|
||||||
|
|
||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||||
|
_SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
|
||||||
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
@@ -116,12 +124,14 @@ class ContextBuilder:
|
|||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
workspace_path = str(root.expanduser().resolve())
|
workspace_path = str(root.expanduser().resolve())
|
||||||
|
agent_workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/identity.md",
|
"agent/identity.md",
|
||||||
workspace_path=workspace_path,
|
workspace_path=workspace_path,
|
||||||
|
agent_workspace_path=agent_workspace_path,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
platform_policy=render_template("agent/platform_policy.md", system=system),
|
platform_policy=render_template("agent/platform_policy.md", system=system),
|
||||||
channel=channel or "",
|
channel=channel or "",
|
||||||
@@ -146,14 +156,30 @@ class ContextBuilder:
|
|||||||
return _to_blocks(left) + _to_blocks(right)
|
return _to_blocks(left) + _to_blocks(right)
|
||||||
|
|
||||||
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
|
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
|
||||||
"""Load all bootstrap files from workspace."""
|
"""Load project instructions plus the agent's global profile files."""
|
||||||
parts = []
|
parts = []
|
||||||
root = workspace or self.workspace
|
project_root = workspace or self.workspace
|
||||||
|
sources = [
|
||||||
|
("AGENTS.md", project_root),
|
||||||
|
("SOUL.md", self.workspace),
|
||||||
|
("USER.md", self.workspace),
|
||||||
|
]
|
||||||
|
|
||||||
for filename in self.BOOTSTRAP_FILES:
|
for filename, root in sources:
|
||||||
file_path = root / filename
|
file_path = root / filename
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
content = file_path.read_text(encoding="utf-8")
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
if filename == "SOUL.md" and self._is_template_content(
|
||||||
|
content,
|
||||||
|
"legacy/SOUL.md",
|
||||||
|
):
|
||||||
|
content = load_bundled_template("SOUL.md") or content
|
||||||
|
if not content.strip():
|
||||||
|
continue
|
||||||
|
if filename in self._SKIPPABLE_DEFAULTS and self._is_template_content(
|
||||||
|
content, filename
|
||||||
|
):
|
||||||
|
continue
|
||||||
parts.append(f"## {filename}\n\n{content}")
|
parts.append(f"## {filename}\n\n{content}")
|
||||||
|
|
||||||
return "\n\n".join(parts) if parts else ""
|
return "\n\n".join(parts) if parts else ""
|
||||||
|
|||||||
@@ -232,8 +232,9 @@ class ContextGovernor:
|
|||||||
def drop_orphan_tool_results(
|
def drop_orphan_tool_results(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Drop tool results that have no matching assistant tool_call earlier in history."""
|
"""Drop invalid tool results before history is sent back to providers."""
|
||||||
declared: set[str] = set()
|
declared: set[str] = set()
|
||||||
|
fulfilled: set[str] = set()
|
||||||
updated: list[dict[str, Any]] | None = None
|
updated: list[dict[str, Any]] | None = None
|
||||||
for idx, msg in enumerate(messages):
|
for idx, msg in enumerate(messages):
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
@@ -243,10 +244,12 @@ class ContextGovernor:
|
|||||||
declared.add(str(tc["id"]))
|
declared.add(str(tc["id"]))
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
tid = msg.get("tool_call_id")
|
tid = msg.get("tool_call_id")
|
||||||
if tid and str(tid) not in declared:
|
tid_str = str(tid) if tid else ""
|
||||||
|
if not tid_str or tid_str not in declared or tid_str in fulfilled:
|
||||||
if updated is None:
|
if updated is None:
|
||||||
updated = [dict(m) for m in messages[:idx]]
|
updated = [dict(m) for m in messages[:idx]]
|
||||||
continue
|
continue
|
||||||
|
fulfilled.add(tid_str)
|
||||||
if updated is not None:
|
if updated is not None:
|
||||||
updated.append(dict(msg))
|
updated.append(dict(msg))
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ class AgentHook:
|
|||||||
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def on_provider_tool_event(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
event: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Observe a provider-hosted tool lifecycle event."""
|
||||||
|
pass
|
||||||
|
|
||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -192,6 +200,13 @@ class CompositeHook(AgentHook):
|
|||||||
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||||
await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
|
await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
|
||||||
|
|
||||||
|
async def on_provider_tool_event(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
event: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await self._for_each_hook_safe("on_provider_tool_event", context, event)
|
||||||
|
|
||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
await self._for_each_hook_safe("before_execute_tools", context)
|
await self._for_each_hook_safe("before_execute_tools", context)
|
||||||
|
|
||||||
|
|||||||
+270
-307
@@ -33,16 +33,14 @@ from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, res
|
|||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
|
from nanobot.agent.turn_delivery import (
|
||||||
|
TurnDelivery,
|
||||||
|
TurnDeliveryFactory,
|
||||||
|
)
|
||||||
|
from nanobot.agent.turn_delivery import TurnRoute as TurnRoute
|
||||||
from nanobot.agent.turn_hooks import AgentTurnHookSpec, build_agent_turn_hook
|
from nanobot.agent.turn_hooks import AgentTurnHookSpec, build_agent_turn_hook
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import StreamedResponseEvent
|
||||||
RetryWaitEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
outbound_message_for_event,
|
|
||||||
)
|
|
||||||
from nanobot.bus.progress import build_bus_progress_callback
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import (
|
from nanobot.bus.runtime_events import (
|
||||||
RuntimeEventBus,
|
RuntimeEventBus,
|
||||||
@@ -60,6 +58,7 @@ from nanobot.runtime_context import (
|
|||||||
RuntimeContextProvider,
|
RuntimeContextProvider,
|
||||||
append_runtime_context,
|
append_runtime_context,
|
||||||
resolve_runtime_context,
|
resolve_runtime_context,
|
||||||
|
runtime_context_blocks_from_metadata,
|
||||||
)
|
)
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WorkspaceScopeResolver,
|
WorkspaceScopeResolver,
|
||||||
@@ -80,6 +79,10 @@ from nanobot.session.manager import (
|
|||||||
SessionManager,
|
SessionManager,
|
||||||
replay_max_messages_for_context,
|
replay_max_messages_for_context,
|
||||||
)
|
)
|
||||||
|
from nanobot.session.model_selection import (
|
||||||
|
SESSION_MODEL_PRESET_METADATA_KEY,
|
||||||
|
model_preset_from_metadata,
|
||||||
|
)
|
||||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
||||||
from nanobot.utils.cancellation import task_is_cancelling
|
from nanobot.utils.cancellation import task_is_cancelling
|
||||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||||
@@ -110,6 +113,11 @@ class TurnState(Enum):
|
|||||||
DONE = auto()
|
DONE = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class TurnKind(Enum):
|
||||||
|
USER = auto()
|
||||||
|
SYSTEM = auto()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StateTraceEntry:
|
class StateTraceEntry:
|
||||||
state: TurnState
|
state: TurnState
|
||||||
@@ -125,7 +133,9 @@ class TurnContext:
|
|||||||
session_key: str
|
session_key: str
|
||||||
state: TurnState
|
state: TurnState
|
||||||
turn_id: str
|
turn_id: str
|
||||||
runtime: LLMRuntime
|
runtime: LLMRuntime | None
|
||||||
|
kind: TurnKind
|
||||||
|
delivery: TurnDelivery
|
||||||
original_user_text: str | None = None
|
original_user_text: str | None = None
|
||||||
session: Session | None = None
|
session: Session | None = None
|
||||||
|
|
||||||
@@ -139,8 +149,9 @@ class TurnContext:
|
|||||||
all_messages: list[dict[str, Any]] = field(default_factory=list)
|
all_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
stop_reason: str = ""
|
stop_reason: str = ""
|
||||||
had_injections: bool = False
|
had_injections: bool = False
|
||||||
|
streamed_content: bool = False
|
||||||
|
|
||||||
user_persisted_early: bool = False
|
input_persisted_early: bool = False
|
||||||
save_skip: int = 0
|
save_skip: int = 0
|
||||||
|
|
||||||
outbound: OutboundMessage | None = None
|
outbound: OutboundMessage | None = None
|
||||||
@@ -149,6 +160,7 @@ class TurnContext:
|
|||||||
on_progress: Callable[..., Awaitable[None]] | None = None
|
on_progress: Callable[..., Awaitable[None]] | None = None
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None = None
|
on_stream: Callable[[str], Awaitable[None]] | None = None
|
||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None
|
on_stream_end: Callable[..., Awaitable[None]] | None = None
|
||||||
|
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None
|
||||||
|
|
||||||
pending_queue: asyncio.Queue | None = None
|
pending_queue: asyncio.Queue | None = None
|
||||||
@@ -219,11 +231,7 @@ class AgentLoop:
|
|||||||
def llm_runtime(self) -> LLMRuntime:
|
def llm_runtime(self) -> LLMRuntime:
|
||||||
"""Resolve the immutable default used to admit the next turn."""
|
"""Resolve the immutable default used to admit the next turn."""
|
||||||
previous = self.runtime_resolver.runtime
|
previous = self.runtime_resolver.runtime
|
||||||
try:
|
runtime = self.runtime_resolver.admit()
|
||||||
runtime = self.runtime_resolver.current(refresh=True)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to refresh model runtime")
|
|
||||||
return previous
|
|
||||||
if (
|
if (
|
||||||
runtime.model != previous.model
|
runtime.model != previous.model
|
||||||
or runtime.model_preset != previous.model_preset
|
or runtime.model_preset != previous.model_preset
|
||||||
@@ -280,9 +288,11 @@ class AgentLoop:
|
|||||||
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None = None,
|
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None = None,
|
||||||
provider_signature: tuple[object, ...] | None = None,
|
provider_signature: tuple[object, ...] | None = None,
|
||||||
model_presets: dict[str, ModelPresetConfig] | None = None,
|
model_presets: dict[str, ModelPresetConfig] | None = None,
|
||||||
|
preset_catalog_loader: preset_helpers.PresetCatalogLoader | None = None,
|
||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||||
runtime_events: RuntimeEventBus | None = None,
|
runtime_events: RuntimeEventBus | None = None,
|
||||||
|
turn_delivery_factory: TurnDeliveryFactory | None = None,
|
||||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||||
restart_mode: str = "auto",
|
restart_mode: str = "auto",
|
||||||
local_trigger_store: Any | None = None,
|
local_trigger_store: Any | None = None,
|
||||||
@@ -292,8 +302,20 @@ class AgentLoop:
|
|||||||
_tc = tools_config or ToolsConfig()
|
_tc = tools_config or ToolsConfig()
|
||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
|
if turn_delivery_factory is not None:
|
||||||
|
if turn_delivery_factory.bus is not bus:
|
||||||
|
raise ValueError("turn delivery factory must use the agent message bus")
|
||||||
|
if (
|
||||||
|
runtime_events is not None
|
||||||
|
and turn_delivery_factory.runtime_events is not runtime_events
|
||||||
|
):
|
||||||
|
raise ValueError("turn delivery factory must use the agent runtime event bus")
|
||||||
|
self.turn_delivery_factory = turn_delivery_factory
|
||||||
|
self.runtime_events = turn_delivery_factory.runtime_events
|
||||||
|
else:
|
||||||
self.runtime_events = runtime_events or RuntimeEventBus()
|
self.runtime_events = runtime_events or RuntimeEventBus()
|
||||||
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
self.turn_delivery_factory = TurnDeliveryFactory(bus, self.runtime_events)
|
||||||
|
self.runtime_event_publisher = self.turn_delivery_factory.runtime_event_publisher
|
||||||
self.channels_config = channels_config
|
self.channels_config = channels_config
|
||||||
self.restart_mode = restart_mode
|
self.restart_mode = restart_mode
|
||||||
self._runtime_model_publisher = runtime_model_publisher
|
self._runtime_model_publisher = runtime_model_publisher
|
||||||
@@ -316,6 +338,8 @@ class AgentLoop:
|
|||||||
snapshot_signature=provider_signature,
|
snapshot_signature=provider_signature,
|
||||||
),
|
),
|
||||||
model_presets=configured_presets,
|
model_presets=configured_presets,
|
||||||
|
preset_catalog_loader=preset_catalog_loader,
|
||||||
|
configured_default_preset=model_preset,
|
||||||
provider_snapshot_loader=provider_snapshot_loader,
|
provider_snapshot_loader=provider_snapshot_loader,
|
||||||
preset_snapshot_loader=preset_snapshot_loader,
|
preset_snapshot_loader=preset_snapshot_loader,
|
||||||
)
|
)
|
||||||
@@ -353,6 +377,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
self.sessions = session_manager or SessionManager(workspace)
|
||||||
|
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
|
||||||
self.tools = ToolRegistry()
|
self.tools = ToolRegistry()
|
||||||
# One file-read/write tracker per logical session. The tool registry is
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
# shared by this loop, so tools resolve the active state via contextvars.
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
@@ -488,6 +513,47 @@ class AgentLoop:
|
|||||||
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
||||||
self.subagents.max_iterations = self.max_iterations
|
self.subagents.max_iterations = self.max_iterations
|
||||||
|
|
||||||
|
def invalidate_runtime_config(self) -> None:
|
||||||
|
"""Invalidate runtime config and notify clients to refresh its catalog."""
|
||||||
|
self.runtime_resolver.invalidate()
|
||||||
|
self._publish_runtime_selection(self.runtime_resolver.runtime)
|
||||||
|
|
||||||
|
def runtime_for_session(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
recover_removed: bool = True,
|
||||||
|
) -> LLMRuntime:
|
||||||
|
"""Resolve the immutable runtime selected by one session."""
|
||||||
|
name = model_preset_from_metadata(session.metadata)
|
||||||
|
if name is None:
|
||||||
|
return self.llm_runtime()
|
||||||
|
try:
|
||||||
|
return self.runtime_resolver.resolve_preset(name)
|
||||||
|
except KeyError:
|
||||||
|
if not recover_removed or name in self.runtime_resolver.model_presets:
|
||||||
|
raise
|
||||||
|
logger.warning(
|
||||||
|
"Session '{}' references removed model preset '{}'; falling back to default",
|
||||||
|
session.key,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
session.metadata.pop(SESSION_MODEL_PRESET_METADATA_KEY, None)
|
||||||
|
self.sessions.save(session)
|
||||||
|
return self.llm_runtime()
|
||||||
|
|
||||||
|
def set_session_model_preset(
|
||||||
|
self,
|
||||||
|
session_key: str,
|
||||||
|
name: str,
|
||||||
|
) -> LLMRuntime:
|
||||||
|
"""Validate and persist one session's preset selection."""
|
||||||
|
runtime = self.runtime_resolver.resolve_preset(name)
|
||||||
|
session = self.sessions.get_or_create(session_key)
|
||||||
|
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = runtime.model_preset
|
||||||
|
self.sessions.save(session)
|
||||||
|
return runtime
|
||||||
|
|
||||||
def _publish_runtime_selection(
|
def _publish_runtime_selection(
|
||||||
self,
|
self,
|
||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
@@ -575,34 +641,6 @@ class AgentLoop:
|
|||||||
if provider not in self._runtime_context_providers:
|
if provider not in self._runtime_context_providers:
|
||||||
self._runtime_context_providers.append(provider)
|
self._runtime_context_providers.append(provider)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _runtime_chat_id(msg: InboundMessage) -> str:
|
|
||||||
"""Return the chat id shown in runtime metadata for the model."""
|
|
||||||
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
|
||||||
|
|
||||||
async def _build_bus_progress_callback(
|
|
||||||
self, msg: InboundMessage
|
|
||||||
) -> Callable[..., Awaitable[None]]:
|
|
||||||
"""Build a progress callback that publishes to the message bus."""
|
|
||||||
return build_bus_progress_callback(self.bus, msg)
|
|
||||||
|
|
||||||
async def _build_retry_wait_callback(
|
|
||||||
self, msg: InboundMessage
|
|
||||||
) -> Callable[[str], Awaitable[None]]:
|
|
||||||
"""Build a retry-wait callback that publishes to the message bus."""
|
|
||||||
|
|
||||||
async def _on_retry_wait(content: str) -> None:
|
|
||||||
await self.bus.publish_outbound(
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel=msg.channel,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
event=RetryWaitEvent(content=content),
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return _on_retry_wait
|
|
||||||
|
|
||||||
def _runtime_events(self) -> RuntimeEventPublisher:
|
def _runtime_events(self) -> RuntimeEventPublisher:
|
||||||
return ensure_runtime_event_publisher(self)
|
return ensure_runtime_event_publisher(self)
|
||||||
|
|
||||||
@@ -660,38 +698,39 @@ class AgentLoop:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _build_initial_messages(
|
def _build_initial_messages(self, ctx: TurnContext) -> list[dict[str, Any]]:
|
||||||
self,
|
|
||||||
msg: InboundMessage,
|
|
||||||
session: Session,
|
|
||||||
history: list[dict[str, Any]],
|
|
||||||
pending_summary: str | None,
|
|
||||||
include_memory_recent_history: bool = True,
|
|
||||||
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Build the initial message list for the LLM turn."""
|
"""Build the initial message list for the LLM turn."""
|
||||||
scope = self.workspace_scopes.for_message(msg, session.metadata)
|
assert ctx.session is not None
|
||||||
|
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
||||||
return self.context.build_messages(
|
return self.context.build_messages(
|
||||||
history=history,
|
history=ctx.history,
|
||||||
current_message=msg.content,
|
current_message=ctx.msg.content,
|
||||||
media=msg.media if msg.media else None,
|
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
||||||
channel=msg.channel,
|
channel=ctx.delivery.route.channel,
|
||||||
chat_id=self._runtime_chat_id(msg),
|
chat_id=str(
|
||||||
sender_id=msg.sender_id,
|
ctx.msg.metadata.get("context_chat_id") or ctx.delivery.route.chat_id
|
||||||
session_summary=pending_summary,
|
),
|
||||||
session_metadata=session.metadata,
|
current_role="user",
|
||||||
|
sender_id=ctx.msg.sender_id,
|
||||||
|
session_summary=ctx.pending_summary,
|
||||||
|
session_metadata=ctx.session.metadata,
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_context_blocks=runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
include_memory_recent_history=include_memory_recent_history,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
session_key=session.key,
|
session_key=ctx.session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||||
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
assert ctx.session is not None
|
||||||
|
scope = self.workspace_scopes.for_turn(
|
||||||
|
channel=ctx.delivery.route.channel,
|
||||||
|
message_metadata=ctx.msg.metadata,
|
||||||
|
session_metadata=ctx.session.metadata,
|
||||||
|
)
|
||||||
return RequestContext(
|
return RequestContext(
|
||||||
channel=ctx.msg.channel,
|
channel=ctx.delivery.route.channel,
|
||||||
chat_id=ctx.msg.chat_id,
|
chat_id=ctx.delivery.route.chat_id,
|
||||||
message_id=ctx.msg.metadata.get("message_id"),
|
message_id=ctx.msg.metadata.get("message_id"),
|
||||||
session_key=ctx.session_key,
|
session_key=ctx.session_key,
|
||||||
original_user_text=ctx.original_user_text,
|
original_user_text=ctx.original_user_text,
|
||||||
@@ -712,7 +751,9 @@ class AgentLoop:
|
|||||||
*self._runtime_context_providers,
|
*self._runtime_context_providers,
|
||||||
]
|
]
|
||||||
assert ctx.request_context is not None
|
assert ctx.request_context is not None
|
||||||
return await resolve_runtime_context(providers, ctx.request_context)
|
blocks = runtime_context_blocks_from_metadata(ctx.request_context.metadata)
|
||||||
|
blocks.extend(await resolve_runtime_context(providers, ctx.request_context))
|
||||||
|
return blocks
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
self,
|
self,
|
||||||
@@ -992,7 +1033,7 @@ class AgentLoop:
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
self.auto_compact.check_expired(
|
self.auto_compact.check_expired(
|
||||||
self._schedule_background,
|
self._schedule_background,
|
||||||
self.llm_runtime,
|
self.runtime_for_session,
|
||||||
active_session_keys=self._pending_queues.keys(),
|
active_session_keys=self._pending_queues.keys(),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -1088,6 +1129,7 @@ class AgentLoop:
|
|||||||
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
gate = self._concurrency_gate or nullcontext()
|
gate = self._concurrency_gate or nullcontext()
|
||||||
|
|
||||||
|
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
|
||||||
pending: asyncio.Queue | None = None
|
pending: asyncio.Queue | None = None
|
||||||
try:
|
try:
|
||||||
async with lock, gate:
|
async with lock, gate:
|
||||||
@@ -1096,65 +1138,22 @@ class AgentLoop:
|
|||||||
pending = asyncio.Queue(maxsize=20)
|
pending = asyncio.Queue(maxsize=20)
|
||||||
self._pending_queues[session_key] = pending
|
self._pending_queues[session_key] = pending
|
||||||
try:
|
try:
|
||||||
on_stream = on_stream_end = None
|
delivery = self.turn_delivery_factory.create(
|
||||||
if msg.metadata.get("_wants_stream"):
|
msg,
|
||||||
# Split one answer into distinct stream segments.
|
session_key,
|
||||||
stream_base_id = f"{msg.session_key}:{time.time_ns()}"
|
enable_stream=True,
|
||||||
stream_segment = 0
|
|
||||||
|
|
||||||
def _current_stream_id() -> str:
|
|
||||||
return f"{stream_base_id}:{stream_segment}"
|
|
||||||
|
|
||||||
async def on_stream(delta: str) -> None:
|
|
||||||
await self.bus.publish_outbound(
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel=msg.channel,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
event=StreamDeltaEvent(
|
|
||||||
content=delta,
|
|
||||||
stream_id=_current_stream_id(),
|
|
||||||
),
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
async def on_stream_end(*, resuming: bool = False) -> None:
|
|
||||||
nonlocal stream_segment
|
|
||||||
await self.bus.publish_outbound(
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel=msg.channel,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
event=StreamEndEvent(
|
|
||||||
stream_id=_current_stream_id(),
|
|
||||||
resuming=resuming,
|
|
||||||
),
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stream_segment += 1
|
|
||||||
|
|
||||||
response = await self._process_message(
|
response = await self._process_message(
|
||||||
msg, on_stream=on_stream, on_stream_end=on_stream_end,
|
msg,
|
||||||
|
on_stream=delivery.on_stream,
|
||||||
|
on_stream_end=delivery.on_stream_end,
|
||||||
pending_queue=pending,
|
pending_queue=pending,
|
||||||
|
delivery=delivery,
|
||||||
)
|
)
|
||||||
completed_channel = msg.channel
|
|
||||||
completed_chat_id = msg.chat_id
|
|
||||||
if response is not None:
|
|
||||||
await self.bus.publish_outbound(response)
|
|
||||||
completed_channel = response.channel
|
|
||||||
completed_chat_id = response.chat_id
|
|
||||||
elif msg.channel == "cli":
|
|
||||||
await self.bus.publish_outbound(OutboundMessage(
|
|
||||||
channel=msg.channel, chat_id=msg.chat_id,
|
|
||||||
content="", metadata=msg.metadata or {},
|
|
||||||
))
|
|
||||||
continuing = turn_continuation.internal_continuation_pending(msg.metadata)
|
continuing = turn_continuation.internal_continuation_pending(msg.metadata)
|
||||||
if not continuing:
|
await delivery.complete(
|
||||||
await self._runtime_events().turn_completed(
|
response,
|
||||||
channel=completed_channel,
|
publish_completion=not continuing,
|
||||||
chat_id=completed_chat_id,
|
|
||||||
session_key=session_key,
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
)
|
||||||
for _, coordinator in self._automation_turn_coordinators:
|
for _, coordinator in self._automation_turn_coordinators:
|
||||||
coordinator.complete(msg, response=response)
|
coordinator.complete(msg, response=response)
|
||||||
@@ -1188,16 +1187,10 @@ class AgentLoop:
|
|||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Error processing message for session {}", session_key)
|
logger.exception("Error processing message for session {}", session_key)
|
||||||
await self.bus.publish_outbound(OutboundMessage(
|
await delivery.fail(
|
||||||
channel=msg.channel, chat_id=msg.chat_id,
|
publish_completion=not turn_continuation.internal_continuation_pending(
|
||||||
content="Sorry, I encountered an error.",
|
msg.metadata
|
||||||
))
|
)
|
||||||
if not turn_continuation.internal_continuation_pending(msg.metadata):
|
|
||||||
await self._runtime_events().turn_completed(
|
|
||||||
channel=msg.channel,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
session_key=session_key,
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
)
|
||||||
for _, coordinator in self._automation_turn_coordinators:
|
for _, coordinator in self._automation_turn_coordinators:
|
||||||
coordinator.complete(msg, error=exc)
|
coordinator.complete(msg, error=exc)
|
||||||
@@ -1227,25 +1220,33 @@ class AgentLoop:
|
|||||||
leftover, session_key,
|
leftover, session_key,
|
||||||
)
|
)
|
||||||
if not turn_continuation.internal_continuation_pending(msg.metadata):
|
if not turn_continuation.internal_continuation_pending(msg.metadata):
|
||||||
await self._runtime_events().run_status_changed(
|
await delivery.idle()
|
||||||
msg, session_key, "idle"
|
|
||||||
)
|
|
||||||
self._runtime_events().clear_turn(session_key)
|
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
finally:
|
finally:
|
||||||
if pending is None:
|
if pending is None:
|
||||||
await self._runtime_events().run_status_changed(
|
await delivery.idle()
|
||||||
msg, session_key, "idle"
|
|
||||||
)
|
|
||||||
self._runtime_events().clear_turn(session_key)
|
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Drain pending background archives, then close MCP connections."""
|
"""Drain background work, stop exec sessions, then close MCP connections."""
|
||||||
if self._background_tasks:
|
if self._background_tasks:
|
||||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||||
self._background_tasks.clear()
|
self._background_tasks.clear()
|
||||||
await agent_context.close_mcp(self)
|
errors: list[BaseException] = []
|
||||||
|
cleanup_steps = (
|
||||||
|
self.subagents.close,
|
||||||
|
self._exec_session_manager.close_all,
|
||||||
|
lambda: agent_context.close_mcp(self),
|
||||||
|
)
|
||||||
|
for cleanup in cleanup_steps:
|
||||||
|
try:
|
||||||
|
await cleanup()
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
if len(errors) == 1:
|
||||||
|
raise errors[0]
|
||||||
|
if errors:
|
||||||
|
raise BaseExceptionGroup("failed to close agent resources", errors)
|
||||||
|
|
||||||
def _schedule_background(self, coro) -> None:
|
def _schedule_background(self, coro) -> None:
|
||||||
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
||||||
@@ -1258,110 +1259,6 @@ class AgentLoop:
|
|||||||
self._running = False
|
self._running = False
|
||||||
logger.info("Agent loop stopping")
|
logger.info("Agent loop stopping")
|
||||||
|
|
||||||
async def _process_system_message(
|
|
||||||
self,
|
|
||||||
msg: InboundMessage,
|
|
||||||
*,
|
|
||||||
runtime: LLMRuntime,
|
|
||||||
session_key: str | None = None,
|
|
||||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
|
||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
|
||||||
pending_queue: asyncio.Queue | None = None,
|
|
||||||
hook_factories: list[AgentTurnHookFactory] | None = None,
|
|
||||||
) -> OutboundMessage | None:
|
|
||||||
"""Process a system inbound message (e.g. subagent announce)."""
|
|
||||||
channel, chat_id = (
|
|
||||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
|
||||||
)
|
|
||||||
logger.info("Processing system message from {}", msg.sender_id)
|
|
||||||
key = msg.session_key_override or f"{channel}:{chat_id}"
|
|
||||||
session = self.sessions.get_or_create(key)
|
|
||||||
self._runtime_events().record_turn_runtime(key, runtime)
|
|
||||||
if self._restore_runtime_checkpoint(session):
|
|
||||||
self.sessions.save(session)
|
|
||||||
if self._restore_pending_user_turn(session):
|
|
||||||
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,
|
|
||||||
runtime=runtime,
|
|
||||||
replay_max_messages=replay_max_messages_for_context(
|
|
||||||
runtime.context_window_tokens
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
current_role = "assistant" if is_subagent else "user"
|
|
||||||
_hist_kwargs: dict[str, Any] = {
|
|
||||||
"max_messages": replay_max_messages_for_context(runtime.context_window_tokens),
|
|
||||||
"max_tokens": self._replay_token_budget(runtime),
|
|
||||||
"extend_to_user": is_subagent,
|
|
||||||
}
|
|
||||||
history = session.get_history(**_hist_kwargs)
|
|
||||||
workspace_scope = self.workspace_scopes.for_message(msg, session.metadata)
|
|
||||||
|
|
||||||
messages = self.context.build_messages(
|
|
||||||
history=history,
|
|
||||||
current_message="" if is_subagent else msg.content,
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
current_role=current_role,
|
|
||||||
sender_id=msg.sender_id,
|
|
||||||
session_summary=pending,
|
|
||||||
session_metadata=session.metadata,
|
|
||||||
workspace=workspace_scope.project_path,
|
|
||||||
session_key=key,
|
|
||||||
unified_session=self._unified_session,
|
|
||||||
)
|
|
||||||
t_wall = time.time()
|
|
||||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
|
||||||
messages, session=session, channel=channel, chat_id=chat_id,
|
|
||||||
runtime=runtime,
|
|
||||||
message_id=msg.metadata.get("message_id"),
|
|
||||||
metadata=msg.metadata,
|
|
||||||
session_key=key,
|
|
||||||
original_user_text=None,
|
|
||||||
pending_queue=pending_queue,
|
|
||||||
hook_factories=hook_factories,
|
|
||||||
)
|
|
||||||
wall_done = time.time()
|
|
||||||
latency_ms = max(0, int((wall_done - t_wall) * 1000))
|
|
||||||
self._save_turn(session, all_msgs, 1 + len(history), turn_latency_ms=latency_ms)
|
|
||||||
self._runtime_events().record_turn_latency(key, latency_ms)
|
|
||||||
session.enforce_file_cap(
|
|
||||||
on_archive=partial(self.context.memory.raw_archive, session_key=key)
|
|
||||||
)
|
|
||||||
self._clear_runtime_checkpoint(session)
|
|
||||||
self.sessions.save(session)
|
|
||||||
self._schedule_background(
|
|
||||||
self.consolidator.maybe_consolidate_by_tokens(
|
|
||||||
session,
|
|
||||||
runtime=runtime,
|
|
||||||
replay_max_messages=replay_max_messages_for_context(
|
|
||||||
runtime.context_window_tokens
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
content = final_content or "Background task completed."
|
|
||||||
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,
|
|
||||||
content=content,
|
|
||||||
metadata=outbound_metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _process_message(
|
async def _process_message(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
@@ -1376,24 +1273,26 @@ class AgentLoop:
|
|||||||
hook_factories: list[AgentTurnHookFactory] | None = None,
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
runtime: LLMRuntime | None = None,
|
runtime: LLMRuntime | None = None,
|
||||||
|
delivery: TurnDelivery | None = None,
|
||||||
|
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Process a single inbound message and return the response."""
|
"""Process a single inbound message and return the response."""
|
||||||
if runtime is None:
|
kind = TurnKind.SYSTEM if msg.channel == "system" else TurnKind.USER
|
||||||
runtime = self.llm_runtime()
|
if kind is TurnKind.SYSTEM:
|
||||||
|
destination = (
|
||||||
if msg.channel == "system":
|
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||||
return await self._process_system_message(
|
|
||||||
msg,
|
|
||||||
runtime=runtime,
|
|
||||||
session_key=session_key,
|
|
||||||
on_progress=on_progress,
|
|
||||||
on_stream=on_stream,
|
|
||||||
on_stream_end=on_stream_end,
|
|
||||||
pending_queue=pending_queue,
|
|
||||||
hook_factories=hook_factories,
|
|
||||||
)
|
)
|
||||||
|
key = session_key or msg.session_key_override or f"{destination[0]}:{destination[1]}"
|
||||||
|
else:
|
||||||
key = session_key or msg.session_key
|
key = session_key or msg.session_key
|
||||||
|
if delivery is None:
|
||||||
|
delivery = self.turn_delivery_factory.create(msg, key)
|
||||||
|
elif delivery.session_key != key:
|
||||||
|
raise ValueError("turn delivery session does not match the processing session")
|
||||||
|
if on_stream is None:
|
||||||
|
on_stream = delivery.on_stream
|
||||||
|
if on_stream_end is None:
|
||||||
|
on_stream_end = delivery.on_stream_end
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
ctx = TurnContext(
|
ctx = TurnContext(
|
||||||
msg=msg,
|
msg=msg,
|
||||||
@@ -1402,9 +1301,12 @@ class AgentLoop:
|
|||||||
state=TurnState.RESTORE,
|
state=TurnState.RESTORE,
|
||||||
turn_id=f"{key}:{time.time_ns()}",
|
turn_id=f"{key}:{time.time_ns()}",
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
|
kind=kind,
|
||||||
|
delivery=delivery,
|
||||||
original_user_text=(
|
original_user_text=(
|
||||||
None
|
None
|
||||||
if turn_continuation.internal_continuation_inbound(msg.metadata)
|
if kind is TurnKind.SYSTEM
|
||||||
|
or turn_continuation.internal_continuation_inbound(msg.metadata)
|
||||||
else msg.content
|
else msg.content
|
||||||
),
|
),
|
||||||
turn_wall_started_at=t0,
|
turn_wall_started_at=t0,
|
||||||
@@ -1414,6 +1316,7 @@ class AgentLoop:
|
|||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
on_stream=on_stream,
|
on_stream=on_stream,
|
||||||
on_stream_end=on_stream_end,
|
on_stream_end=on_stream_end,
|
||||||
|
on_runtime_admitted=on_runtime_admitted,
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
ephemeral=ephemeral,
|
ephemeral=ephemeral,
|
||||||
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
||||||
@@ -1421,6 +1324,29 @@ class AgentLoop:
|
|||||||
hook_factories=list(hook_factories or []),
|
hook_factories=list(hook_factories or []),
|
||||||
tools=tools,
|
tools=tools,
|
||||||
)
|
)
|
||||||
|
# A streaming callback may be present even when the final text comes from a
|
||||||
|
# non-streaming recovery. Only the last completed segment can suppress the
|
||||||
|
# regular outbound message.
|
||||||
|
if ctx.on_stream is not None:
|
||||||
|
stream_callback = ctx.on_stream
|
||||||
|
stream_end_callback = ctx.on_stream_end
|
||||||
|
segment_streamed_content = False
|
||||||
|
|
||||||
|
async def _tracked_stream(delta: str) -> None:
|
||||||
|
nonlocal segment_streamed_content
|
||||||
|
if delta:
|
||||||
|
segment_streamed_content = True
|
||||||
|
await stream_callback(delta)
|
||||||
|
|
||||||
|
async def _tracked_stream_end(*, resuming: bool = False) -> None:
|
||||||
|
nonlocal segment_streamed_content
|
||||||
|
ctx.streamed_content = segment_streamed_content
|
||||||
|
segment_streamed_content = False
|
||||||
|
if stream_end_callback is not None:
|
||||||
|
await stream_end_callback(resuming=resuming)
|
||||||
|
|
||||||
|
ctx.on_stream = _tracked_stream
|
||||||
|
ctx.on_stream_end = _tracked_stream_end
|
||||||
|
|
||||||
while ctx.state is not TurnState.DONE:
|
while ctx.state is not TurnState.DONE:
|
||||||
handler_name = f"_state_{ctx.state.name.lower()}"
|
handler_name = f"_state_{ctx.state.name.lower()}"
|
||||||
@@ -1483,7 +1409,7 @@ class AgentLoop:
|
|||||||
all_msgs: list[dict[str, Any]],
|
all_msgs: list[dict[str, Any]],
|
||||||
stop_reason: str,
|
stop_reason: str,
|
||||||
had_injections: bool,
|
had_injections: bool,
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None,
|
streamed_content: bool,
|
||||||
*,
|
*,
|
||||||
turn_latency_ms: int | None = None,
|
turn_latency_ms: int | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
@@ -1498,7 +1424,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
event = None
|
event = None
|
||||||
meta = dict(msg.metadata or {})
|
meta = dict(msg.metadata or {})
|
||||||
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
|
if streamed_content and stop_reason not in {"error", "tool_error"}:
|
||||||
event = StreamedResponseEvent()
|
event = StreamedResponseEvent()
|
||||||
if turn_latency_ms is not None:
|
if turn_latency_ms is not None:
|
||||||
meta["latency_ms"] = int(turn_latency_ms)
|
meta["latency_ms"] = int(turn_latency_ms)
|
||||||
@@ -1515,19 +1441,23 @@ class AgentLoop:
|
|||||||
"""Restore checkpoint / pending user turn; extract documents."""
|
"""Restore checkpoint / pending user turn; extract documents."""
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
if msg.media:
|
if ctx.kind is TurnKind.USER and msg.media:
|
||||||
new_content, image_only = self._prepare_message_media(msg.content, msg.media)
|
new_content, image_only = self._prepare_message_media(msg.content, msg.media)
|
||||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only)
|
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only)
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||||
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
|
logger.info("Processing system message from {}", msg.sender_id)
|
||||||
|
else:
|
||||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
|
|
||||||
# Session is already fetched by the caller (_process_message) but
|
# Session is already fetched by the caller (_process_message) but
|
||||||
# ensure it exists in case this handler is invoked independently.
|
# ensure it exists in case this handler is invoked independently.
|
||||||
if ctx.session is None:
|
if ctx.session is None:
|
||||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||||
await self._runtime_events().session_turn_started(msg, ctx.session_key)
|
await ctx.delivery.started()
|
||||||
|
if ctx.kind is TurnKind.USER:
|
||||||
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
||||||
|
|
||||||
if self._restore_runtime_checkpoint(ctx.session):
|
if self._restore_runtime_checkpoint(ctx.session):
|
||||||
@@ -1553,6 +1483,8 @@ class AgentLoop:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_command(self, ctx: TurnContext) -> str:
|
async def _state_command(self, ctx: TurnContext) -> str:
|
||||||
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
|
return "dispatch"
|
||||||
raw = ctx.msg.content.strip()
|
raw = ctx.msg.content.strip()
|
||||||
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
||||||
is_user_turn = (
|
is_user_turn = (
|
||||||
@@ -1580,7 +1512,7 @@ class AgentLoop:
|
|||||||
# them out of LLM context. /new is excluded because it
|
# them out of LLM context. /new is excluded because it
|
||||||
# intentionally clears the session.
|
# intentionally clears the session.
|
||||||
if cmd_ctx.raw.lower() != "/new":
|
if cmd_ctx.raw.lower() != "/new":
|
||||||
ctx.user_persisted_early = self._persist_user_message_early(
|
ctx.input_persisted_early = self._persist_user_message_early(
|
||||||
ctx.msg, ctx.session, _command=True
|
ctx.msg, ctx.session, _command=True
|
||||||
)
|
)
|
||||||
ctx.session.add_message(
|
ctx.session.add_message(
|
||||||
@@ -1592,62 +1524,67 @@ class AgentLoop:
|
|||||||
return "dispatch"
|
return "dispatch"
|
||||||
|
|
||||||
async def _state_build(self, ctx: TurnContext) -> str:
|
async def _state_build(self, ctx: TurnContext) -> str:
|
||||||
|
runtime = ctx.runtime
|
||||||
|
if runtime is None:
|
||||||
|
runtime = self.runtime_for_session(ctx.session)
|
||||||
|
ctx.runtime = runtime
|
||||||
|
if ctx.on_runtime_admitted is not None:
|
||||||
|
await ctx.on_runtime_admitted(runtime)
|
||||||
replay_max_messages = replay_max_messages_for_context(
|
replay_max_messages = replay_max_messages_for_context(
|
||||||
ctx.runtime.context_window_tokens
|
runtime.context_window_tokens
|
||||||
)
|
)
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
await self.consolidator.maybe_consolidate_by_tokens(
|
await self.consolidator.maybe_consolidate_by_tokens(
|
||||||
ctx.session,
|
ctx.session,
|
||||||
runtime=ctx.runtime,
|
runtime=runtime,
|
||||||
replay_max_messages=replay_max_messages,
|
replay_max_messages=replay_max_messages,
|
||||||
)
|
)
|
||||||
if message_tool := self.tools.get("message"):
|
is_subagent = ctx.kind is TurnKind.SYSTEM and ctx.msg.sender_id == "subagent"
|
||||||
|
|
||||||
|
if ctx.kind is TurnKind.USER and (message_tool := self.tools.get("message")):
|
||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_tool.start_turn()
|
message_tool.start_turn()
|
||||||
|
|
||||||
_hist_kwargs: dict[str, Any] = {
|
_hist_kwargs: dict[str, Any] = {
|
||||||
"max_messages": replay_max_messages,
|
"max_messages": replay_max_messages,
|
||||||
"max_tokens": self._replay_token_budget(ctx.runtime),
|
"max_tokens": self._replay_token_budget(runtime),
|
||||||
"extend_to_user": False,
|
"extend_to_user": is_subagent,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||||
self._runtime_events().record_turn_runtime(
|
if is_subagent:
|
||||||
ctx.session_key,
|
# Keep the durable internal delivery as an assistant record, but
|
||||||
ctx.runtime,
|
# present this completion to the model as fresh follow-up input.
|
||||||
)
|
# Providers without assistant-prefill support drop trailing
|
||||||
|
# assistant messages, so using the persisted record as the current
|
||||||
|
# prompt would hide an independently dispatched subagent result.
|
||||||
|
if self._persist_subagent_followup(ctx.session, ctx.msg):
|
||||||
|
logger.debug("Subagent result persisted for session {}", ctx.session_key)
|
||||||
|
self.sessions.save(ctx.session)
|
||||||
|
ctx.input_persisted_early = True
|
||||||
|
ctx.delivery.record_runtime(ctx.runtime)
|
||||||
|
|
||||||
ctx.request_context = self._request_context_for_turn(ctx)
|
ctx.request_context = self._request_context_for_turn(ctx)
|
||||||
|
if ctx.kind is TurnKind.USER:
|
||||||
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
||||||
ctx.initial_messages = self._build_initial_messages(
|
ctx.initial_messages = self._build_initial_messages(ctx)
|
||||||
ctx.msg,
|
if ctx.kind is TurnKind.USER:
|
||||||
ctx.session,
|
ctx.input_persisted_early = self._persist_user_message_early(
|
||||||
ctx.history,
|
|
||||||
ctx.pending_summary,
|
|
||||||
include_memory_recent_history=not ctx.ephemeral,
|
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
|
||||||
)
|
|
||||||
ctx.user_persisted_early = self._persist_user_message_early(
|
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.session,
|
ctx.session,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
)
|
)
|
||||||
|
|
||||||
if ctx.on_progress is None:
|
if ctx.on_progress is None:
|
||||||
ctx.on_progress = await self._build_bus_progress_callback(ctx.msg)
|
ctx.on_progress = ctx.delivery.progress_callback()
|
||||||
if ctx.on_retry_wait is None:
|
if ctx.on_retry_wait is None:
|
||||||
ctx.on_retry_wait = await self._build_retry_wait_callback(ctx.msg)
|
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
||||||
|
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_run(self, ctx: TurnContext) -> str:
|
async def _state_run(self, ctx: TurnContext) -> str:
|
||||||
if ctx.visible_run_started_at is None:
|
if ctx.visible_run_started_at is None:
|
||||||
ctx.visible_run_started_at = time.time()
|
ctx.visible_run_started_at = time.time()
|
||||||
await self._runtime_events().run_status_changed(
|
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
||||||
ctx.msg,
|
|
||||||
ctx.session_key,
|
|
||||||
"running",
|
|
||||||
started_at=ctx.visible_run_started_at,
|
|
||||||
)
|
|
||||||
result = await self._run_agent_loop(
|
result = await self._run_agent_loop(
|
||||||
ctx.initial_messages,
|
ctx.initial_messages,
|
||||||
runtime=ctx.runtime,
|
runtime=ctx.runtime,
|
||||||
@@ -1656,8 +1593,8 @@ class AgentLoop:
|
|||||||
on_stream_end=ctx.on_stream_end,
|
on_stream_end=ctx.on_stream_end,
|
||||||
on_retry_wait=ctx.on_retry_wait,
|
on_retry_wait=ctx.on_retry_wait,
|
||||||
session=ctx.session,
|
session=ctx.session,
|
||||||
channel=ctx.msg.channel,
|
channel=ctx.delivery.route.channel,
|
||||||
chat_id=ctx.msg.chat_id,
|
chat_id=ctx.delivery.route.chat_id,
|
||||||
message_id=ctx.msg.metadata.get("message_id"),
|
message_id=ctx.msg.metadata.get("message_id"),
|
||||||
metadata=ctx.msg.metadata,
|
metadata=ctx.msg.metadata,
|
||||||
session_key=ctx.session_key,
|
session_key=ctx.session_key,
|
||||||
@@ -1677,6 +1614,7 @@ class AgentLoop:
|
|||||||
ctx.all_messages = all_msgs
|
ctx.all_messages = all_msgs
|
||||||
ctx.stop_reason = stop_reason
|
ctx.stop_reason = stop_reason
|
||||||
ctx.had_injections = had_injections
|
ctx.had_injections = had_injections
|
||||||
|
if ctx.kind is TurnKind.USER:
|
||||||
await turn_continuation.maybe_continue_turn(ctx)
|
await turn_continuation.maybe_continue_turn(ctx)
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
@@ -1684,14 +1622,18 @@ class AgentLoop:
|
|||||||
turn_continuation.prepare_save_boundary(ctx)
|
turn_continuation.prepare_save_boundary(ctx)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(ctx.final_content is None or not ctx.final_content.strip())
|
ctx.kind is TurnKind.USER
|
||||||
|
and (ctx.final_content is None or not ctx.final_content.strip())
|
||||||
and not ctx.suppress_response
|
and not ctx.suppress_response
|
||||||
):
|
):
|
||||||
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
latency_started_at = (
|
latency_started_at = (
|
||||||
ctx.visible_run_started_at
|
ctx.visible_run_started_at
|
||||||
if turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
|
if (
|
||||||
|
ctx.kind is TurnKind.SYSTEM
|
||||||
|
or turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
|
||||||
|
)
|
||||||
and ctx.visible_run_started_at is not None
|
and ctx.visible_run_started_at is not None
|
||||||
else ctx.turn_wall_started_at
|
else ctx.turn_wall_started_at
|
||||||
)
|
)
|
||||||
@@ -1700,10 +1642,7 @@ class AgentLoop:
|
|||||||
ctx.session, ctx.all_messages, ctx.save_skip,
|
ctx.session, ctx.all_messages, ctx.save_skip,
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
self._runtime_events().record_turn_latency(
|
ctx.delivery.record_latency(ctx.turn_latency_ms)
|
||||||
ctx.session_key,
|
|
||||||
ctx.turn_latency_ms,
|
|
||||||
)
|
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
ctx.session.enforce_file_cap(
|
ctx.session.enforce_file_cap(
|
||||||
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
||||||
@@ -1726,13 +1665,21 @@ class AgentLoop:
|
|||||||
if ctx.suppress_response:
|
if ctx.suppress_response:
|
||||||
ctx.outbound = None
|
ctx.outbound = None
|
||||||
return "ok"
|
return "ok"
|
||||||
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
|
ctx.outbound = ctx.delivery.background_response(
|
||||||
|
ctx.final_content,
|
||||||
|
stop_reason=ctx.stop_reason,
|
||||||
|
streamed=ctx.streamed_content,
|
||||||
|
latency_ms=ctx.turn_latency_ms,
|
||||||
|
)
|
||||||
|
return "ok"
|
||||||
ctx.outbound = self._assemble_outbound(
|
ctx.outbound = self._assemble_outbound(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.final_content,
|
ctx.final_content,
|
||||||
ctx.all_messages,
|
ctx.all_messages,
|
||||||
ctx.stop_reason,
|
ctx.stop_reason,
|
||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.on_stream,
|
ctx.streamed_content,
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
if ctx.ephemeral and ctx.outbound is not None:
|
if ctx.ephemeral and ctx.outbound is not None:
|
||||||
@@ -1788,6 +1735,11 @@ class AgentLoop:
|
|||||||
for tc in m.get("tool_calls") or []
|
for tc in m.get("tool_calls") or []
|
||||||
if isinstance(tc, dict) and tc.get("id")
|
if isinstance(tc, dict) and tc.get("id")
|
||||||
}
|
}
|
||||||
|
fulfilled_tool_call_ids = {
|
||||||
|
str(m["tool_call_id"])
|
||||||
|
for m in session.messages
|
||||||
|
if m.get("role") == "tool" and m.get("tool_call_id")
|
||||||
|
}
|
||||||
last_assistant_idx: int | None = None
|
last_assistant_idx: int | None = None
|
||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
@@ -1802,14 +1754,20 @@ class AgentLoop:
|
|||||||
continue # skip empty assistant messages — they poison session context
|
continue # skip empty assistant messages — they poison session context
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
tool_call_id = entry.get("tool_call_id")
|
tool_call_id = entry.get("tool_call_id")
|
||||||
if not tool_call_id or str(tool_call_id) not in declared_tool_call_ids:
|
tool_call_id_str = str(tool_call_id) if tool_call_id else ""
|
||||||
|
if (
|
||||||
|
not tool_call_id_str
|
||||||
|
or tool_call_id_str not in declared_tool_call_ids
|
||||||
|
or tool_call_id_str in fulfilled_tool_call_ids
|
||||||
|
):
|
||||||
# Undeclared tool results corrupt future provider requests.
|
# Undeclared tool results corrupt future provider requests.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Dropping orphaned tool result {} from session {} during persistence",
|
"Dropping invalid tool result {} from session {} during persistence",
|
||||||
tool_call_id or "(missing id)",
|
tool_call_id_str or "(missing id)",
|
||||||
session.key,
|
session.key,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
fulfilled_tool_call_ids.add(tool_call_id_str)
|
||||||
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
|
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
|
||||||
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
|
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
@@ -1984,8 +1942,11 @@ class AgentLoop:
|
|||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
persist_user_message: bool = True,
|
persist_user_message: bool = True,
|
||||||
runtime: LLMRuntime | None = None,
|
runtime: LLMRuntime | None = None,
|
||||||
|
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Process a message directly and return the outbound payload."""
|
"""Process an external message directly and return the outbound payload."""
|
||||||
|
if channel == "system":
|
||||||
|
raise ValueError("channel 'system' is reserved for internal messages")
|
||||||
await self._connect_mcp()
|
await self._connect_mcp()
|
||||||
metadata: dict[str, Any] = {}
|
metadata: dict[str, Any] = {}
|
||||||
if not persist_user_message:
|
if not persist_user_message:
|
||||||
@@ -2015,6 +1976,8 @@ class AgentLoop:
|
|||||||
kwargs["tools"] = tools
|
kwargs["tools"] = tools
|
||||||
if runtime is not None:
|
if runtime is not None:
|
||||||
kwargs["runtime"] = runtime
|
kwargs["runtime"] = runtime
|
||||||
|
if on_runtime_admitted is not None:
|
||||||
|
kwargs["on_runtime_admitted"] = on_runtime_admitted
|
||||||
return await self._process_message(
|
return await self._process_message(
|
||||||
msg,
|
msg,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|||||||
+13
-18
@@ -941,18 +941,19 @@ class Consolidator:
|
|||||||
messages_to_summarize = public_history_messages(
|
messages_to_summarize = public_history_messages(
|
||||||
summary_messages if summary_messages is not None else messages
|
summary_messages if summary_messages is not None else messages
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
formatted = MemoryStore._format_messages(messages_to_summarize)
|
formatted = MemoryStore._format_messages(messages_to_summarize)
|
||||||
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
||||||
|
system_prompt = render_template(
|
||||||
|
"agent/consolidator_archive.md",
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
response = await runtime.provider.chat_with_retry(
|
response = await runtime.provider.chat_with_retry(
|
||||||
model=runtime.model,
|
model=runtime.model,
|
||||||
messages=[
|
messages=[
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": render_template(
|
"content": system_prompt,
|
||||||
"agent/consolidator_archive.md",
|
|
||||||
strip=True,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{"role": "user", "content": formatted},
|
{"role": "user", "content": formatted},
|
||||||
],
|
],
|
||||||
@@ -962,8 +963,14 @@ class Consolidator:
|
|||||||
max_tokens=runtime.generation.max_tokens,
|
max_tokens=runtime.generation.max_tokens,
|
||||||
reasoning_effort=runtime.generation.reasoning_effort,
|
reasoning_effort=runtime.generation.reasoning_effort,
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Consolidation provider call failed, raw-dumping to history")
|
||||||
|
self.store.raw_archive(messages, session_key=session_key)
|
||||||
|
return None
|
||||||
if response.finish_reason == "error":
|
if response.finish_reason == "error":
|
||||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
logger.warning("Consolidation provider returned an error, raw-dumping to history")
|
||||||
|
self.store.raw_archive(messages, session_key=session_key)
|
||||||
|
return None
|
||||||
summary = response.content or "[no summary]"
|
summary = response.content or "[no summary]"
|
||||||
self.store.append_history(
|
self.store.append_history(
|
||||||
summary,
|
summary,
|
||||||
@@ -971,10 +978,6 @@ class Consolidator:
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
except Exception:
|
|
||||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
|
||||||
self.store.raw_archive(messages, session_key=session_key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def maybe_consolidate_by_tokens(
|
async def maybe_consolidate_by_tokens(
|
||||||
self,
|
self,
|
||||||
@@ -1007,14 +1010,10 @@ class Consolidator:
|
|||||||
replay_max_messages,
|
replay_max_messages,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
estimated, source = self.estimate_session_prompt_tokens(
|
estimated, source = self.estimate_session_prompt_tokens(
|
||||||
session,
|
session,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
|
||||||
estimated, source = 0, "error"
|
|
||||||
if estimated <= 0:
|
if estimated <= 0:
|
||||||
self._persist_last_summary(session, last_summary)
|
self._persist_last_summary(session, last_summary)
|
||||||
return
|
return
|
||||||
@@ -1077,14 +1076,10 @@ class Consolidator:
|
|||||||
# the next invocation can retry a fresh chunk.
|
# the next invocation can retry a fresh chunk.
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
|
||||||
estimated, source = self.estimate_session_prompt_tokens(
|
estimated, source = self.estimate_session_prompt_tokens(
|
||||||
session,
|
session,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
|
||||||
estimated, source = 0, "error"
|
|
||||||
if estimated <= 0:
|
if estimated <= 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
@@ -10,16 +12,31 @@ from nanobot.providers.base import LLMProvider
|
|||||||
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
|
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
|
||||||
|
|
||||||
PresetSnapshotLoader = Callable[[str], ProviderSnapshot]
|
PresetSnapshotLoader = Callable[[str], ProviderSnapshot]
|
||||||
|
PresetCatalogLoader = Callable[[], Mapping[str, ModelPresetConfig]]
|
||||||
|
|
||||||
|
|
||||||
def default_selection_signature(signature: tuple[object, ...] | None) -> tuple[object, ...] | None:
|
def default_selection_signature(
|
||||||
return signature[:2] if signature else None
|
signature: tuple[object, ...] | None,
|
||||||
|
model_preset: str | None = None,
|
||||||
|
) -> tuple[object, ...] | None:
|
||||||
|
return (model_preset, *signature[:2]) if signature else None
|
||||||
|
|
||||||
|
|
||||||
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
|
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
|
||||||
return {**config.model_presets, "default": config.resolve_default_preset()}
|
return {**config.model_presets, "default": config.resolve_default_preset()}
|
||||||
|
|
||||||
|
|
||||||
|
def load_model_preset_catalog(
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, ModelPresetConfig]:
|
||||||
|
"""Load the current preset catalog from the configured file."""
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
|
return configured_model_presets(
|
||||||
|
resolve_config_env_vars(load_config(config_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_preset_snapshot_loader(
|
def make_preset_snapshot_loader(
|
||||||
config: Any,
|
config: Any,
|
||||||
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
|
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
|
||||||
@@ -40,6 +57,7 @@ def build_static_preset_snapshot(
|
|||||||
context_window_tokens=preset.context_window_tokens,
|
context_window_tokens=preset.context_window_tokens,
|
||||||
signature=("model_preset", name, preset.model_dump_json()),
|
signature=("model_preset", name, preset.model_dump_json()),
|
||||||
generation=preset.to_generation_settings(),
|
generation=preset.to_generation_settings(),
|
||||||
|
model_preset=name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +69,7 @@ def build_runtime_preset_snapshot(
|
|||||||
loader: PresetSnapshotLoader | None,
|
loader: PresetSnapshotLoader | None,
|
||||||
) -> ProviderSnapshot:
|
) -> ProviderSnapshot:
|
||||||
if loader is not None:
|
if loader is not None:
|
||||||
return loader(name)
|
return replace(loader(name), model_preset=name)
|
||||||
return build_static_preset_snapshot(provider, name, presets[name])
|
return build_static_preset_snapshot(provider, name, presets[name])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
from nanobot.agent import model_presets as preset_helpers
|
from nanobot.agent import model_presets as preset_helpers
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
@@ -24,16 +25,23 @@ class ModelRuntimeResolver:
|
|||||||
initial_runtime: LLMRuntime,
|
initial_runtime: LLMRuntime,
|
||||||
*,
|
*,
|
||||||
model_presets: Mapping[str, ModelPresetConfig] | None = None,
|
model_presets: Mapping[str, ModelPresetConfig] | None = None,
|
||||||
|
preset_catalog_loader: preset_helpers.PresetCatalogLoader | None = None,
|
||||||
|
configured_default_preset: str | None = None,
|
||||||
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
||||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._runtime = initial_runtime
|
self._runtime = initial_runtime
|
||||||
self._model_presets = dict(model_presets or {})
|
self._model_presets = dict(model_presets or {})
|
||||||
|
self._preset_catalog_loader = preset_catalog_loader
|
||||||
|
self._preset_catalog_refresh_required = False
|
||||||
self._provider_snapshot_loader = provider_snapshot_loader
|
self._provider_snapshot_loader = provider_snapshot_loader
|
||||||
self._preset_snapshot_loader = preset_snapshot_loader
|
self._preset_snapshot_loader = preset_snapshot_loader
|
||||||
|
self._refresh_required = False
|
||||||
|
self._resolved_presets: dict[str, LLMRuntime] = {}
|
||||||
self._tracks_provider_generation = initial_runtime.model_preset is None
|
self._tracks_provider_generation = initial_runtime.model_preset is None
|
||||||
self._default_selection_signature = preset_helpers.default_selection_signature(
|
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||||
initial_runtime.snapshot_signature
|
initial_runtime.snapshot_signature,
|
||||||
|
configured_default_preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -43,7 +51,11 @@ class ModelRuntimeResolver:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
|
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
|
||||||
return self._model_presets
|
self._refresh_preset_catalog()
|
||||||
|
return MappingProxyType({
|
||||||
|
name: preset.model_copy(deep=True)
|
||||||
|
for name, preset in self._model_presets.items()
|
||||||
|
})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_preset(self) -> str | None:
|
def model_preset(self) -> str | None:
|
||||||
@@ -60,40 +72,63 @@ class ModelRuntimeResolver:
|
|||||||
self._refresh_provider_generation()
|
self._refresh_provider_generation()
|
||||||
return self._runtime
|
return self._runtime
|
||||||
|
|
||||||
|
def admit(self) -> LLMRuntime:
|
||||||
|
"""Resolve the immutable runtime for the next turn admission."""
|
||||||
|
if self._refresh_required:
|
||||||
|
self.refresh()
|
||||||
|
self._refresh_provider_generation()
|
||||||
|
return self._runtime
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
"""Refresh configured runtime state on the next admission."""
|
||||||
|
self._refresh_required = True
|
||||||
|
self._preset_catalog_refresh_required = True
|
||||||
|
self._resolved_presets.clear()
|
||||||
|
|
||||||
|
def _refresh_preset_catalog(self) -> None:
|
||||||
|
if not self._preset_catalog_refresh_required:
|
||||||
|
return
|
||||||
|
if self._preset_catalog_loader is not None:
|
||||||
|
self._model_presets = dict(self._preset_catalog_loader())
|
||||||
|
self._preset_catalog_refresh_required = False
|
||||||
|
|
||||||
def resolve_snapshot(
|
def resolve_snapshot(
|
||||||
self,
|
self,
|
||||||
snapshot: ProviderSnapshot,
|
snapshot: ProviderSnapshot,
|
||||||
*,
|
|
||||||
model_preset: str | None = None,
|
|
||||||
) -> LLMRuntime:
|
) -> LLMRuntime:
|
||||||
"""Resolve a factory snapshot without changing the selected default."""
|
"""Resolve a factory snapshot without changing the selected default."""
|
||||||
return runtime_from_provider_snapshot(snapshot, model_preset=model_preset)
|
return runtime_from_provider_snapshot(snapshot)
|
||||||
|
|
||||||
def adopt_snapshot(
|
def adopt_snapshot(
|
||||||
self,
|
self,
|
||||||
snapshot: ProviderSnapshot,
|
snapshot: ProviderSnapshot,
|
||||||
*,
|
|
||||||
model_preset: str | None = None,
|
|
||||||
) -> LLMRuntime:
|
) -> LLMRuntime:
|
||||||
"""Select a snapshot as the default for future turns."""
|
"""Select a snapshot as the default for future turns."""
|
||||||
runtime = self.resolve_snapshot(snapshot, model_preset=model_preset)
|
runtime = self.resolve_snapshot(snapshot)
|
||||||
self._runtime = runtime
|
self._runtime = runtime
|
||||||
self._tracks_provider_generation = model_preset is None
|
self._tracks_provider_generation = runtime.model_preset is None
|
||||||
self._default_selection_signature = preset_helpers.default_selection_signature(
|
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||||
runtime.snapshot_signature
|
runtime.snapshot_signature,
|
||||||
|
runtime.model_preset,
|
||||||
)
|
)
|
||||||
return runtime
|
return runtime
|
||||||
|
|
||||||
def resolve_preset(self, name: str | None) -> LLMRuntime:
|
def resolve_preset(self, name: str | None) -> LLMRuntime:
|
||||||
"""Resolve a named preset without changing the selected default."""
|
"""Resolve a named preset without changing the selected default."""
|
||||||
|
self._refresh_preset_catalog()
|
||||||
normalized = preset_helpers.normalize_preset_name(name, self._model_presets)
|
normalized = preset_helpers.normalize_preset_name(name, self._model_presets)
|
||||||
|
cached = self._resolved_presets.get(normalized)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
snapshot = preset_helpers.build_runtime_preset_snapshot(
|
snapshot = preset_helpers.build_runtime_preset_snapshot(
|
||||||
name=normalized,
|
name=normalized,
|
||||||
presets=self._model_presets,
|
presets=self._model_presets,
|
||||||
provider=self._runtime.provider,
|
provider=self._runtime.provider,
|
||||||
loader=self._preset_snapshot_loader,
|
loader=self._preset_snapshot_loader,
|
||||||
)
|
)
|
||||||
return self.resolve_snapshot(snapshot, model_preset=normalized)
|
runtime = self.resolve_snapshot(snapshot)
|
||||||
|
self._resolved_presets[normalized] = runtime
|
||||||
|
return runtime
|
||||||
|
|
||||||
def select_preset(self, name: str | None) -> LLMRuntime:
|
def select_preset(self, name: str | None) -> LLMRuntime:
|
||||||
"""Select a named preset as the default for future turns."""
|
"""Select a named preset as the default for future turns."""
|
||||||
@@ -146,21 +181,26 @@ class ModelRuntimeResolver:
|
|||||||
def refresh(self) -> LLMRuntime | None:
|
def refresh(self) -> LLMRuntime | None:
|
||||||
"""Refresh configured defaults and return the replacement when changed."""
|
"""Refresh configured defaults and return the replacement when changed."""
|
||||||
if self._provider_snapshot_loader is None:
|
if self._provider_snapshot_loader is None:
|
||||||
|
self._refresh_required = False
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
self._resolved_presets.clear()
|
||||||
snapshot = self._provider_snapshot_loader()
|
snapshot = self._provider_snapshot_loader()
|
||||||
default_selection = preset_helpers.default_selection_signature(snapshot.signature)
|
default_selection = preset_helpers.default_selection_signature(
|
||||||
|
snapshot.signature,
|
||||||
|
snapshot.model_preset,
|
||||||
|
)
|
||||||
active_preset = self._runtime.model_preset
|
active_preset = self._runtime.model_preset
|
||||||
if active_preset and self._default_selection_signature in (None, default_selection):
|
if active_preset and self._default_selection_signature in (None, default_selection):
|
||||||
runtime = self.resolve_preset(active_preset)
|
runtime = self.resolve_preset(active_preset)
|
||||||
else:
|
else:
|
||||||
active_preset = None
|
|
||||||
runtime = self.resolve_snapshot(snapshot)
|
runtime = self.resolve_snapshot(snapshot)
|
||||||
|
|
||||||
unchanged = (
|
unchanged = (
|
||||||
runtime.snapshot_signature == self._runtime.snapshot_signature
|
runtime.snapshot_signature == self._runtime.snapshot_signature
|
||||||
and runtime.model_preset == self._runtime.model_preset
|
and runtime.model_preset == self._runtime.model_preset
|
||||||
)
|
)
|
||||||
|
self._refresh_required = False
|
||||||
if unchanged:
|
if unchanged:
|
||||||
self._default_selection_signature = default_selection
|
self._default_selection_signature = default_selection
|
||||||
return None
|
return None
|
||||||
@@ -170,7 +210,7 @@ class ModelRuntimeResolver:
|
|||||||
self._default_selection_signature,
|
self._default_selection_signature,
|
||||||
) = (
|
) = (
|
||||||
runtime,
|
runtime,
|
||||||
active_preset is None,
|
runtime.model_preset is None,
|
||||||
default_selection,
|
default_selection,
|
||||||
)
|
)
|
||||||
return runtime
|
return runtime
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any, Awaitable, Callable
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
|
from nanobot.providers.base import ToolCallRequest
|
||||||
from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think
|
from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think
|
||||||
from nanobot.utils.progress_events import (
|
from nanobot.utils.progress_events import (
|
||||||
build_tool_event_finish_payloads,
|
build_tool_event_finish_payloads,
|
||||||
@@ -97,6 +98,61 @@ class AgentProgressHook(AgentHook):
|
|||||||
self._session_key,
|
self._session_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def on_provider_tool_event(
|
||||||
|
self,
|
||||||
|
context: AgentHookContext,
|
||||||
|
event: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if not self._on_progress:
|
||||||
|
return
|
||||||
|
phase = event.get("phase")
|
||||||
|
name = event.get("name")
|
||||||
|
call_id = event.get("call_id")
|
||||||
|
if (
|
||||||
|
phase not in {"start", "end", "error"}
|
||||||
|
or not isinstance(name, str)
|
||||||
|
or not name
|
||||||
|
or not call_id
|
||||||
|
):
|
||||||
|
return
|
||||||
|
arguments = event.get("arguments")
|
||||||
|
if not isinstance(arguments, dict):
|
||||||
|
arguments = {}
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"phase": phase,
|
||||||
|
"call_id": str(call_id),
|
||||||
|
"name": name,
|
||||||
|
"arguments": arguments,
|
||||||
|
"result": event.get("result") if phase == "end" else None,
|
||||||
|
"error": event.get("error") if phase == "error" else None,
|
||||||
|
"files": [],
|
||||||
|
"embeds": [],
|
||||||
|
}
|
||||||
|
if phase == "start":
|
||||||
|
await self.emit_reasoning_end()
|
||||||
|
tool_call = ToolCallRequest(id=str(call_id), name=name, arguments=arguments)
|
||||||
|
tool_hint = self._strip_think(self._tool_hint([tool_call])) or name
|
||||||
|
await invoke_on_progress(
|
||||||
|
self._on_progress,
|
||||||
|
tool_hint,
|
||||||
|
tool_hint=True,
|
||||||
|
tool_events=[payload],
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Provider-hosted tool call: {}({})",
|
||||||
|
name,
|
||||||
|
json.dumps(arguments, ensure_ascii=False)[:200],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if on_progress_accepts_tool_events(self._on_progress):
|
||||||
|
await invoke_on_progress(
|
||||||
|
self._on_progress,
|
||||||
|
"",
|
||||||
|
tool_hint=False,
|
||||||
|
tool_events=[payload],
|
||||||
|
)
|
||||||
|
|
||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
if self._on_progress:
|
if self._on_progress:
|
||||||
if not self._on_stream and not context.streamed_content:
|
if not self._on_stream and not context.streamed_content:
|
||||||
@@ -114,6 +170,7 @@ class AgentProgressHook(AgentHook):
|
|||||||
for tc in context.tool_calls:
|
for tc in context.tool_calls:
|
||||||
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
||||||
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
||||||
|
|
||||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||||
"""Publish a reasoning chunk; channel plugins decide whether to render."""
|
"""Publish a reasoning chunk; channel plugins decide whether to render."""
|
||||||
if (
|
if (
|
||||||
|
|||||||
+30
-26
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from contextlib import suppress
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -353,37 +352,16 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for iteration in range(spec.max_iterations):
|
for iteration in range(spec.max_iterations):
|
||||||
try:
|
|
||||||
# Keep the persisted conversation untouched. Context governance
|
# Keep the persisted conversation untouched. Context governance
|
||||||
# may repair or compact historical messages for the model, but
|
# may repair or compact historical messages for the model, but
|
||||||
# those synthetic edits must not shift the append boundary used
|
# those synthetic edits must not shift the append boundary used
|
||||||
# later when the caller saves only the new turn.
|
# later when the caller saves only the new turn. A governance
|
||||||
|
# failure must stop the run instead of sending an ungoverned copy.
|
||||||
messages_for_model = self.context_governor.prepare_for_model(
|
messages_for_model = self.context_governor.prepare_for_model(
|
||||||
governance_config,
|
governance_config,
|
||||||
messages,
|
messages,
|
||||||
compacted_tool_call_ids,
|
compacted_tool_call_ids,
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Context governance failed on turn {} for {}; applying minimal repair",
|
|
||||||
iteration,
|
|
||||||
spec.session_key or "default",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
|
|
||||||
messages
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.drop_orphan_tool_results(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
messages_for_model = messages
|
|
||||||
context = AgentHookContext(
|
context = AgentHookContext(
|
||||||
iteration=iteration,
|
iteration=iteration,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -744,6 +722,20 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
progress_state: dict[str, bool] | None = None
|
progress_state: dict[str, bool] | None = None
|
||||||
|
active_hosted_tools: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def _provider_tool_event(event: dict[str, Any]) -> None:
|
||||||
|
if event.get("kind") != "hosted_tool":
|
||||||
|
return
|
||||||
|
await hook.on_provider_tool_event(context, event)
|
||||||
|
call_id = event.get("call_id")
|
||||||
|
if not call_id:
|
||||||
|
return
|
||||||
|
call_id = str(call_id)
|
||||||
|
if event.get("phase") == "start":
|
||||||
|
active_hosted_tools[call_id] = dict(event)
|
||||||
|
elif event.get("phase") in {"end", "error"}:
|
||||||
|
active_hosted_tools.pop(call_id, None)
|
||||||
|
|
||||||
if wants_streaming:
|
if wants_streaming:
|
||||||
thinking_buf = ""
|
thinking_buf = ""
|
||||||
@@ -772,6 +764,7 @@ class AgentRunner:
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
on_content_delta=_stream,
|
on_content_delta=_stream,
|
||||||
on_thinking_delta=_thinking,
|
on_thinking_delta=_thinking,
|
||||||
|
on_tool_call_delta=_provider_tool_event,
|
||||||
on_stream_recover=_stream_recover,
|
on_stream_recover=_stream_recover,
|
||||||
)
|
)
|
||||||
elif wants_progress_streaming:
|
elif wants_progress_streaming:
|
||||||
@@ -802,6 +795,7 @@ class AgentRunner:
|
|||||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
on_content_delta=_stream_progress,
|
on_content_delta=_stream_progress,
|
||||||
|
on_tool_call_delta=_provider_tool_event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
||||||
@@ -835,6 +829,17 @@ class AgentRunner:
|
|||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
error_kind="timeout",
|
error_kind="timeout",
|
||||||
)
|
)
|
||||||
|
# chat_stream_with_retry may recover internally, so only fail unfinished
|
||||||
|
# hosted calls after the provider returns its final error response.
|
||||||
|
if response.finish_reason == "error":
|
||||||
|
for event in list(active_hosted_tools.values()):
|
||||||
|
await _provider_tool_event({
|
||||||
|
**event,
|
||||||
|
"phase": "error",
|
||||||
|
"result": None,
|
||||||
|
"error": response.content
|
||||||
|
or "Model request failed before the provider-hosted tool completed.",
|
||||||
|
})
|
||||||
if progress_state and progress_state.get("reasoning_open"):
|
if progress_state and progress_state.get("reasoning_open"):
|
||||||
await hook.emit_reasoning_end()
|
await hook.emit_reasoning_end()
|
||||||
dropped, all_dropped, original_finish_reason = (
|
dropped, all_dropped, original_finish_reason = (
|
||||||
@@ -1167,7 +1172,6 @@ class AgentRunner:
|
|||||||
prepare_call = getattr(spec.tools, "prepare_call", None)
|
prepare_call = getattr(spec.tools, "prepare_call", None)
|
||||||
tool, params, prep_error = None, tool_call.arguments, None
|
tool, params, prep_error = None, tool_call.arguments, None
|
||||||
if callable(prepare_call):
|
if callable(prepare_call):
|
||||||
with suppress(Exception):
|
|
||||||
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
||||||
if isinstance(prepared, tuple) and len(prepared) == 3:
|
if isinstance(prepared, tuple) and len(prepared) == 3:
|
||||||
tool, params, prep_error = prepared
|
tool, params, prep_error = prepared
|
||||||
@@ -1197,7 +1201,7 @@ class AgentRunner:
|
|||||||
result = await spec.tools.execute(tool_call.name, params)
|
result = await spec.tools.execute(tool_call.name, params)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except BaseException as exc:
|
except Exception as exc:
|
||||||
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
|
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
|
|||||||
+22
-9
@@ -125,21 +125,34 @@ class SkillsLoader:
|
|||||||
if not all_skills:
|
if not all_skills:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
lines: list[str] = []
|
sections: list[str] = []
|
||||||
for entry in all_skills:
|
groups = (
|
||||||
skill_name = entry["name"]
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
if exclude and skill_name in exclude:
|
("Built-in skills", "builtin", self.builtin_skills),
|
||||||
|
)
|
||||||
|
for label, source, root in groups:
|
||||||
|
entries = [
|
||||||
|
entry
|
||||||
|
for entry in all_skills
|
||||||
|
if entry["source"] == source and (not exclude or entry["name"] not in exclude)
|
||||||
|
]
|
||||||
|
if not entries:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
lines = [f"### {label} (`{root.expanduser().resolve()}`)"]
|
||||||
|
for entry in entries:
|
||||||
|
skill_name = entry["name"]
|
||||||
meta = self._get_skill_meta(skill_name)
|
meta = self._get_skill_meta(skill_name)
|
||||||
available = self._check_requirements(meta)
|
available = self._check_requirements(meta)
|
||||||
desc = self._get_skill_description(skill_name)
|
desc = self._get_skill_description(skill_name)
|
||||||
if available:
|
suffix = ""
|
||||||
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
|
if not available:
|
||||||
else:
|
|
||||||
missing = self._get_missing_requirements(meta)
|
missing = self._get_missing_requirements(meta)
|
||||||
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
|
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
|
||||||
lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
|
relative_path = Path(entry["path"]).relative_to(root).as_posix()
|
||||||
return "\n".join(lines)
|
lines.append(f"- **{skill_name}** — {desc}{suffix} `{relative_path}`")
|
||||||
|
sections.append("\n".join(lines))
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
def _get_missing_requirements(self, skill_meta: dict) -> str:
|
def _get_missing_requirements(self, skill_meta: dict) -> str:
|
||||||
"""Get a description of missing requirements."""
|
"""Get a description of missing requirements."""
|
||||||
|
|||||||
+113
-18
@@ -13,6 +13,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
from nanobot.agent.tools.base import ToolResult
|
||||||
from nanobot.agent.tools.context import (
|
from nanobot.agent.tools.context import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
ToolContext,
|
ToolContext,
|
||||||
@@ -146,7 +147,7 @@ class SubagentManager:
|
|||||||
self.runner = AgentRunner()
|
self.runner = AgentRunner()
|
||||||
self._exec_session_manager = ExecSessionManager()
|
self._exec_session_manager = ExecSessionManager()
|
||||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[str]] = {}
|
||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||||
|
|
||||||
@@ -275,6 +276,68 @@ class SubagentManager:
|
|||||||
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
|
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
|
||||||
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
||||||
|
|
||||||
|
async def run_inline(
|
||||||
|
self,
|
||||||
|
task: str,
|
||||||
|
label: str | None = None,
|
||||||
|
origin_channel: str = "cli",
|
||||||
|
origin_chat_id: str = "direct",
|
||||||
|
session_key: str | None = None,
|
||||||
|
origin_message_id: str | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
workspace_scope: WorkspaceScope | None = None,
|
||||||
|
*,
|
||||||
|
runtime: LLMRuntime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Run a subagent synchronously and return its result to the caller."""
|
||||||
|
if runtime is None:
|
||||||
|
runtime = self._compat_spawn_runtime()
|
||||||
|
if temperature is not None:
|
||||||
|
runtime = runtime.with_generation_overrides(temperature=temperature)
|
||||||
|
task_id = str(uuid.uuid4())[:8]
|
||||||
|
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||||
|
origin = {
|
||||||
|
"channel": origin_channel,
|
||||||
|
"chat_id": origin_chat_id,
|
||||||
|
"session_key": session_key,
|
||||||
|
}
|
||||||
|
status = SubagentStatus(
|
||||||
|
task_id=task_id,
|
||||||
|
label=display_label,
|
||||||
|
task_description=task,
|
||||||
|
started_at=time.monotonic(),
|
||||||
|
)
|
||||||
|
self._task_statuses[task_id] = status
|
||||||
|
logger.info("Running inline subagent [{}]: {}", task_id, display_label)
|
||||||
|
inline_task = asyncio.create_task(
|
||||||
|
self._run_subagent(
|
||||||
|
task_id,
|
||||||
|
task,
|
||||||
|
display_label,
|
||||||
|
origin,
|
||||||
|
status,
|
||||||
|
runtime,
|
||||||
|
origin_message_id,
|
||||||
|
workspace_scope,
|
||||||
|
announce=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._running_tasks[task_id] = inline_task
|
||||||
|
if session_key:
|
||||||
|
self._session_tasks.setdefault(session_key, set()).add(task_id)
|
||||||
|
try:
|
||||||
|
result = await inline_task
|
||||||
|
if status.phase == "error" or status.stop_reason in {"error", "tool_error"}:
|
||||||
|
return ToolResult.error(result)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
self._running_tasks.pop(task_id, None)
|
||||||
|
self._task_statuses.pop(task_id, None)
|
||||||
|
if session_key and (ids := self._session_tasks.get(session_key)):
|
||||||
|
ids.discard(task_id)
|
||||||
|
if not ids:
|
||||||
|
del self._session_tasks[session_key]
|
||||||
|
|
||||||
async def _run_subagent(
|
async def _run_subagent(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
@@ -285,7 +348,9 @@ class SubagentManager:
|
|||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
workspace_scope: WorkspaceScope | None = None,
|
workspace_scope: WorkspaceScope | None = None,
|
||||||
) -> None:
|
*,
|
||||||
|
announce: bool = True,
|
||||||
|
) -> str:
|
||||||
"""Execute the subagent task and announce the result."""
|
"""Execute the subagent task and announce the result."""
|
||||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||||
|
|
||||||
@@ -299,7 +364,8 @@ class SubagentManager:
|
|||||||
if workspace_scope is not None:
|
if workspace_scope is not None:
|
||||||
cfg = self._subagent_tools_config()
|
cfg = self._subagent_tools_config()
|
||||||
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
|
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
|
||||||
tools = self._build_tools(workspace=root, tools_config=cfg)
|
# Construct from the agent workspace; the bound scope below supplies the project cwd.
|
||||||
|
tools = self._build_tools(tools_config=cfg)
|
||||||
system_prompt = self._build_subagent_prompt(workspace=root)
|
system_prompt = self._build_subagent_prompt(workspace=root)
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
@@ -346,27 +412,43 @@ class SubagentManager:
|
|||||||
|
|
||||||
if result.stop_reason == "tool_error":
|
if result.stop_reason == "tool_error":
|
||||||
status.tool_events = list(result.tool_events)
|
status.tool_events = list(result.tool_events)
|
||||||
await self._announce_result(
|
final_result = self._format_partial_progress(result)
|
||||||
task_id, label, task,
|
final_status = "error"
|
||||||
self._format_partial_progress(result),
|
|
||||||
origin, "error", origin_message_id,
|
|
||||||
)
|
|
||||||
elif result.stop_reason == "error":
|
elif result.stop_reason == "error":
|
||||||
await self._announce_result(
|
final_result = result.error or "Error: subagent execution failed."
|
||||||
task_id, label, task,
|
final_status = "error"
|
||||||
result.error or "Error: subagent execution failed.",
|
|
||||||
origin, "error", origin_message_id,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
final_result = result.final_content or "Task completed but no final response was generated."
|
final_result = result.final_content or "Task completed but no final response was generated."
|
||||||
|
final_status = "ok"
|
||||||
logger.info("Subagent [{}] completed successfully", task_id)
|
logger.info("Subagent [{}] completed successfully", task_id)
|
||||||
await self._announce_result(task_id, label, task, final_result, origin, "ok", origin_message_id)
|
if announce:
|
||||||
|
await self._announce_result(
|
||||||
|
task_id,
|
||||||
|
label,
|
||||||
|
task,
|
||||||
|
final_result,
|
||||||
|
origin,
|
||||||
|
final_status,
|
||||||
|
origin_message_id,
|
||||||
|
)
|
||||||
|
return final_result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status.phase = "error"
|
status.phase = "error"
|
||||||
status.error = str(e)
|
status.error = str(e)
|
||||||
logger.exception("Subagent [{}] failed", task_id)
|
logger.exception("Subagent [{}] failed", task_id)
|
||||||
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
|
final_result = f"Error: {e}"
|
||||||
|
if announce:
|
||||||
|
await self._announce_result(
|
||||||
|
task_id,
|
||||||
|
label,
|
||||||
|
task,
|
||||||
|
final_result,
|
||||||
|
origin,
|
||||||
|
"error",
|
||||||
|
origin_message_id,
|
||||||
|
)
|
||||||
|
return final_result
|
||||||
|
|
||||||
async def _announce_result(
|
async def _announce_result(
|
||||||
self,
|
self,
|
||||||
@@ -438,14 +520,17 @@ class SubagentManager:
|
|||||||
"""Build a focused system prompt for the subagent."""
|
"""Build a focused system prompt for the subagent."""
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
root = workspace or self.workspace
|
agent_workspace = self.workspace.expanduser().resolve()
|
||||||
|
project_workspace = workspace.expanduser().resolve() if workspace else agent_workspace
|
||||||
skills_summary = SkillsLoader(
|
skills_summary = SkillsLoader(
|
||||||
root,
|
self.workspace,
|
||||||
disabled_skills=self.disabled_skills,
|
disabled_skills=self.disabled_skills,
|
||||||
).build_skills_summary()
|
).build_skills_summary()
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/subagent_system.md",
|
"agent/subagent_system.md",
|
||||||
workspace=str(root),
|
workspace=str(project_workspace),
|
||||||
|
agent_workspace=str(agent_workspace),
|
||||||
|
history_log=str(agent_workspace / "memory" / "history.jsonl"),
|
||||||
skills_summary=skills_summary or "",
|
skills_summary=skills_summary or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -457,8 +542,18 @@ class SubagentManager:
|
|||||||
t.cancel()
|
t.cancel()
|
||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
await self._exec_session_manager.terminate_by_owner(session_key)
|
||||||
return len(tasks)
|
return len(tasks)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Cancel running subagents and close their shared exec sessions."""
|
||||||
|
tasks = [task for task in self._running_tasks.values() if not task.done()]
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
await self._exec_session_manager.close_all()
|
||||||
|
|
||||||
def get_running_count(self) -> int:
|
def get_running_count(self) -> int:
|
||||||
"""Return the number of currently running subagents."""
|
"""Return the number of currently running subagents."""
|
||||||
return len(self._running_tasks)
|
return len(self._running_tasks)
|
||||||
|
|||||||
@@ -61,12 +61,14 @@ class _ExecSession:
|
|||||||
cwd: str,
|
cwd: str,
|
||||||
timeout: int | None,
|
timeout: int | None,
|
||||||
owner_session_key: str | None = None,
|
owner_session_key: str | None = None,
|
||||||
|
process_tree: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.process = process
|
self.process = process
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.owner_session_key = owner_session_key
|
self.owner_session_key = owner_session_key
|
||||||
|
self._process_tree = process_tree
|
||||||
self.started_at = time.monotonic()
|
self.started_at = time.monotonic()
|
||||||
# timeout None/0 means no limit; an infinite deadline is never reached.
|
# timeout None/0 means no limit; an infinite deadline is never reached.
|
||||||
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
||||||
@@ -171,17 +173,23 @@ class _ExecSession:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def kill(self) -> None:
|
async def kill(self) -> None:
|
||||||
if self.process.returncode is not None:
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
return
|
|
||||||
self.process.kill()
|
|
||||||
try:
|
try:
|
||||||
with suppress(asyncio.TimeoutError):
|
if self._process_tree:
|
||||||
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
await ExecTool._kill_process_tree(self.process)
|
||||||
|
else:
|
||||||
|
await ExecTool._kill_process(self.process)
|
||||||
finally:
|
finally:
|
||||||
# Safety-net waitpid — prevent zombie if asyncio's child watcher
|
with suppress(asyncio.TimeoutError):
|
||||||
# did not reap the process (common in containers).
|
await asyncio.wait_for(
|
||||||
from nanobot.agent.tools.shell import _reap_pid
|
asyncio.gather(
|
||||||
_reap_pid(self.process.pid)
|
self._stdout_task,
|
||||||
|
self._stderr_task,
|
||||||
|
return_exceptions=True,
|
||||||
|
),
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
async def _wait_for_buffered_output(self) -> None:
|
async def _wait_for_buffered_output(self) -> None:
|
||||||
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
||||||
@@ -198,6 +206,7 @@ class ExecSessionManager:
|
|||||||
self.idle_timeout = idle_timeout
|
self.idle_timeout = idle_timeout
|
||||||
self._sessions: dict[str, _ExecSession] = {}
|
self._sessions: dict[str, _ExecSession] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
self,
|
self,
|
||||||
@@ -213,6 +222,8 @@ class ExecSessionManager:
|
|||||||
owner_session_key: str | None = None,
|
owner_session_key: str | None = None,
|
||||||
) -> tuple[str, _SessionPoll]:
|
) -> tuple[str, _SessionPoll]:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
if self._closed:
|
||||||
|
raise RuntimeError("exec session manager is closed")
|
||||||
await self._cleanup_locked()
|
await self._cleanup_locked()
|
||||||
if len(self._sessions) >= self.max_sessions:
|
if len(self._sessions) >= self.max_sessions:
|
||||||
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
|
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
|
||||||
@@ -225,6 +236,7 @@ class ExecSessionManager:
|
|||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
owner_session_key=owner_session_key,
|
owner_session_key=owner_session_key,
|
||||||
|
process_tree=True,
|
||||||
)
|
)
|
||||||
self._sessions[session_id] = session
|
self._sessions[session_id] = session
|
||||||
|
|
||||||
@@ -295,6 +307,61 @@ class ExecSessionManager:
|
|||||||
if session.owner_session_key == owner_session_key
|
if session.owner_session_key == owner_session_key
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def close_all(self) -> int:
|
||||||
|
"""Terminate and remove all active sessions during shutdown."""
|
||||||
|
async with self._lock:
|
||||||
|
self._closed = True
|
||||||
|
sessions = list(self._sessions.values())
|
||||||
|
self._sessions.clear()
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(session.kill() for session in sessions),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
failures = [
|
||||||
|
(session, result)
|
||||||
|
for session, result in zip(sessions, results, strict=True)
|
||||||
|
if isinstance(result, BaseException)
|
||||||
|
]
|
||||||
|
if failures:
|
||||||
|
async with self._lock:
|
||||||
|
for session, _ in failures:
|
||||||
|
self._sessions[session.session_id] = session
|
||||||
|
if len(failures) == 1:
|
||||||
|
raise failures[0][1]
|
||||||
|
raise BaseExceptionGroup(
|
||||||
|
"failed to close exec sessions",
|
||||||
|
[result for _, result in failures],
|
||||||
|
)
|
||||||
|
return len(sessions)
|
||||||
|
|
||||||
|
async def terminate_by_owner(self, owner_session_key: str) -> int:
|
||||||
|
"""Terminate all sessions owned by owner_session_key. Returns count."""
|
||||||
|
async with self._lock:
|
||||||
|
victims = []
|
||||||
|
for sid, s in list(self._sessions.items()):
|
||||||
|
if s.owner_session_key == owner_session_key:
|
||||||
|
victims.append(self._sessions.pop(sid))
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(s.kill() for s in victims),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
failures = [
|
||||||
|
(session, result)
|
||||||
|
for session, result in zip(victims, results, strict=True)
|
||||||
|
if isinstance(result, BaseException)
|
||||||
|
]
|
||||||
|
if failures:
|
||||||
|
async with self._lock:
|
||||||
|
for session, _ in failures:
|
||||||
|
self._sessions[session.session_id] = session
|
||||||
|
if len(failures) == 1:
|
||||||
|
raise failures[0][1]
|
||||||
|
raise BaseExceptionGroup(
|
||||||
|
"failed to terminate exec sessions by owner",
|
||||||
|
[result for _, result in failures],
|
||||||
|
)
|
||||||
|
return len(victims)
|
||||||
|
|
||||||
async def _cleanup_locked(self) -> None:
|
async def _cleanup_locked(self) -> None:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
stale = [
|
stale = [
|
||||||
@@ -303,8 +370,9 @@ class ExecSessionManager:
|
|||||||
if now - session.last_access > self.idle_timeout
|
if now - session.last_access > self.idle_timeout
|
||||||
]
|
]
|
||||||
for session_id in stale:
|
for session_id in stale:
|
||||||
session = self._sessions.pop(session_id)
|
session = self._sessions[session_id]
|
||||||
await session.kill()
|
await session.kill()
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
|
||||||
async def _spawn(
|
async def _spawn(
|
||||||
self,
|
self,
|
||||||
@@ -319,6 +387,7 @@ class ExecSessionManager:
|
|||||||
return await ExecTool._spawn(
|
return await ExecTool._spawn(
|
||||||
command, cwd, env, shell_program, login,
|
command, cwd, env, shell_program, login,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
process_tree=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class _FsTool(Tool):
|
|||||||
file_states: FileStates | None = None,
|
file_states: FileStates | None = None,
|
||||||
restrict_to_workspace: bool | None = None,
|
restrict_to_workspace: bool | None = None,
|
||||||
sandbox_restricts_workspace: bool = False,
|
sandbox_restricts_workspace: bool = False,
|
||||||
|
extra_read_allowed_files: list[Path] | None = None,
|
||||||
):
|
):
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
self._allowed_dir = allowed_dir
|
self._allowed_dir = allowed_dir
|
||||||
@@ -60,6 +61,7 @@ class _FsTool(Tool):
|
|||||||
*(extra_allowed_dirs or []),
|
*(extra_allowed_dirs or []),
|
||||||
*(extra_read_allowed_dirs or []),
|
*(extra_read_allowed_dirs or []),
|
||||||
]
|
]
|
||||||
|
self._extra_read_allowed_files = list(extra_read_allowed_files or [])
|
||||||
self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or [])
|
self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or [])
|
||||||
self._extra_write_allowed_files = list(extra_write_allowed_files or [])
|
self._extra_write_allowed_files = list(extra_write_allowed_files or [])
|
||||||
self._restrict_to_workspace = (
|
self._restrict_to_workspace = (
|
||||||
@@ -78,17 +80,21 @@ class _FsTool(Tool):
|
|||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
|
||||||
|
agent_workspace = Path(ctx.workspace)
|
||||||
|
resolved_agent_workspace = agent_workspace.expanduser().resolve(strict=False)
|
||||||
restrict = (
|
restrict = (
|
||||||
ctx.config.restrict_to_workspace
|
ctx.config.restrict_to_workspace
|
||||||
or ctx.config.exec.sandbox
|
or ctx.config.exec.sandbox
|
||||||
)
|
)
|
||||||
sandbox_restricts = bool(ctx.config.exec.sandbox)
|
sandbox_restricts = bool(ctx.config.exec.sandbox)
|
||||||
allowed_dir = Path(ctx.workspace) if restrict else None
|
allowed_dir = agent_workspace if restrict else None
|
||||||
extra_read = [BUILTIN_SKILLS_DIR]
|
# Agent-owned skills stay available from project scopes. History is a narrower
|
||||||
|
# capability: expose only the append-only log, not the surrounding memory directory.
|
||||||
return cls(
|
return cls(
|
||||||
workspace=Path(ctx.workspace),
|
workspace=agent_workspace,
|
||||||
allowed_dir=allowed_dir,
|
allowed_dir=allowed_dir,
|
||||||
extra_read_allowed_dirs=extra_read,
|
extra_read_allowed_dirs=[BUILTIN_SKILLS_DIR, resolved_agent_workspace / "skills"],
|
||||||
|
extra_read_allowed_files=[resolved_agent_workspace / "memory" / "history.jsonl"],
|
||||||
file_states=ctx.file_state_store,
|
file_states=ctx.file_state_store,
|
||||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
sandbox_restricts_workspace=sandbox_restricts,
|
sandbox_restricts_workspace=sandbox_restricts,
|
||||||
@@ -119,16 +125,20 @@ class _FsTool(Tool):
|
|||||||
extra_allowed_files: list[Path] | None,
|
extra_allowed_files: list[Path] | None,
|
||||||
*,
|
*,
|
||||||
include_media_dir: bool,
|
include_media_dir: bool,
|
||||||
|
extra_files_require_allowed_root: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
access = current_tool_workspace(
|
access = current_tool_workspace(
|
||||||
self._workspace,
|
self._workspace,
|
||||||
restrict_to_workspace=self._restrict_to_workspace,
|
restrict_to_workspace=self._restrict_to_workspace,
|
||||||
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
|
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
|
||||||
)
|
)
|
||||||
|
allowed_root = self._effective_allowed_root(access.allowed_root)
|
||||||
|
if extra_files_require_allowed_root and allowed_root is None:
|
||||||
|
extra_allowed_files = None
|
||||||
return resolve_workspace_path(
|
return resolve_workspace_path(
|
||||||
path,
|
path,
|
||||||
access.project_path,
|
access.project_path,
|
||||||
self._effective_allowed_root(access.allowed_root),
|
allowed_root,
|
||||||
extra_allowed_dirs,
|
extra_allowed_dirs,
|
||||||
extra_allowed_files,
|
extra_allowed_files,
|
||||||
include_media_dir=include_media_dir,
|
include_media_dir=include_media_dir,
|
||||||
@@ -138,8 +148,9 @@ class _FsTool(Tool):
|
|||||||
return self._resolve_with_extra(
|
return self._resolve_with_extra(
|
||||||
path,
|
path,
|
||||||
self._extra_read_allowed_dirs,
|
self._extra_read_allowed_dirs,
|
||||||
None,
|
self._extra_read_allowed_files,
|
||||||
include_media_dir=True,
|
include_media_dir=True,
|
||||||
|
extra_files_require_allowed_root=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_write(self, path: str) -> Path:
|
def _resolve_write(self, path: str) -> Path:
|
||||||
@@ -237,6 +248,7 @@ class ReadFileTool(_FsTool):
|
|||||||
_scopes = {"core", "subagent", "memory"}
|
_scopes = {"core", "subagent", "memory"}
|
||||||
|
|
||||||
_MAX_CHARS = 128_000
|
_MAX_CHARS = 128_000
|
||||||
|
_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024
|
||||||
_DEFAULT_LIMIT = 2000
|
_DEFAULT_LIMIT = 2000
|
||||||
_MAX_PDF_PAGES = 20
|
_MAX_PDF_PAGES = 20
|
||||||
|
|
||||||
@@ -290,6 +302,15 @@ class ReadFileTool(_FsTool):
|
|||||||
if not fp.is_file():
|
if not fp.is_file():
|
||||||
return ToolResult.error(f"Error: Not a file: {path}")
|
return ToolResult.error(f"Error: Not a file: {path}")
|
||||||
|
|
||||||
|
file_size = fp.stat().st_size
|
||||||
|
if file_size > self._MAX_FILE_SIZE_BYTES:
|
||||||
|
size_mib = file_size / (1024 * 1024)
|
||||||
|
max_mib = self._MAX_FILE_SIZE_BYTES // (1024 * 1024)
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: File too large to read ({size_mib:.1f} MiB). "
|
||||||
|
f"Maximum is {max_mib} MiB."
|
||||||
|
)
|
||||||
|
|
||||||
# PDF support
|
# PDF support
|
||||||
if fp.suffix.lower() == ".pdf":
|
if fp.suffix.lower() == ".pdf":
|
||||||
return self._read_pdf(fp, pages)
|
return self._read_pdf(fp, pages)
|
||||||
|
|||||||
@@ -2,24 +2,34 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
StringSchema,
|
StringSchema,
|
||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
|
from nanobot.bus.events import (
|
||||||
|
INBOUND_META_RUNTIME_CONTROL,
|
||||||
|
RUNTIME_CONTROL_ACK,
|
||||||
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
|
||||||
|
InboundMessage,
|
||||||
|
)
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
ImageGenerationError,
|
ImageGenerationError,
|
||||||
ImageGenerationProvider,
|
ImageGenerationProvider,
|
||||||
get_image_gen_provider,
|
get_image_gen_provider,
|
||||||
|
image_gen_provider_configs,
|
||||||
)
|
)
|
||||||
from nanobot.security.workspace_access import current_tool_workspace
|
from nanobot.security.workspace_access import current_tool_workspace
|
||||||
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
|
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
|
||||||
@@ -208,3 +218,114 @@ class ImageGenerationTool(Tool):
|
|||||||
return generated_image_tool_result(artifacts)
|
return generated_image_tool_result(artifacts)
|
||||||
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
||||||
return ToolResult.error(f"Error: {exc}")
|
return ToolResult.error(f"Error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||||
|
"""Apply the persisted image configuration to the running agent."""
|
||||||
|
try:
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
|
config = resolve_config_env_vars(load_config())
|
||||||
|
tool_config = config.tools.image_generation
|
||||||
|
provider_configs = image_gen_provider_configs(config)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Image generation hot reload could not read config: {}", exc)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Could not reload image generation config.",
|
||||||
|
"requires_restart": True,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
next_tool = (
|
||||||
|
ImageGenerationTool(
|
||||||
|
workspace=state.workspace,
|
||||||
|
config=tool_config,
|
||||||
|
provider_configs=provider_configs,
|
||||||
|
)
|
||||||
|
if tool_config.enabled
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
state.tools_config.image_generation = tool_config
|
||||||
|
state._image_generation_provider_configs = provider_configs
|
||||||
|
if next_tool is not None:
|
||||||
|
registry.register(next_tool)
|
||||||
|
else:
|
||||||
|
registry.unregister("generate_image")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Image generation config reloaded: enabled={} provider={} model={}",
|
||||||
|
tool_config.enabled,
|
||||||
|
tool_config.provider,
|
||||||
|
tool_config.model,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Image generation settings applied without restarting nanobot.",
|
||||||
|
"enabled": tool_config.enabled,
|
||||||
|
"provider": tool_config.provider,
|
||||||
|
"model": tool_config.model,
|
||||||
|
"requires_restart": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def request_image_generation_reload(
|
||||||
|
bus: Any,
|
||||||
|
*,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Ask the running agent loop to refresh its image generation tool."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||||
|
await bus.publish_inbound(
|
||||||
|
InboundMessage(
|
||||||
|
channel="system",
|
||||||
|
sender_id="webui-settings",
|
||||||
|
chat_id="runtime",
|
||||||
|
content=RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
|
||||||
|
metadata={
|
||||||
|
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
|
||||||
|
RUNTIME_CONTROL_ACK: ack,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(ack, timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Image generation hot reload timed out.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
return result if isinstance(result, dict) else {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Image generation hot reload returned an unexpected response.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_runtime_control(
|
||||||
|
state: Any,
|
||||||
|
msg: InboundMessage,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
) -> bool:
|
||||||
|
"""Handle an in-process image generation reload request."""
|
||||||
|
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
|
||||||
|
if metadata.get(INBOUND_META_RUNTIME_CONTROL) != RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ack = metadata.get(RUNTIME_CONTROL_ACK)
|
||||||
|
try:
|
||||||
|
result = await reload_image_generation_tool(state, registry)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Image generation hot reload failed")
|
||||||
|
result = {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Image generation hot reload failed.",
|
||||||
|
"requires_restart": True,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
if isinstance(ack, asyncio.Future) and not ack.done():
|
||||||
|
ack.set_result(result)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -60,5 +60,11 @@ class RuntimeState(Protocol):
|
|||||||
|
|
||||||
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
|
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
|
||||||
|
|
||||||
|
def set_session_model_preset(
|
||||||
|
self,
|
||||||
|
session_key: str,
|
||||||
|
name: str,
|
||||||
|
) -> Any: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_preset(self) -> str | None: ...
|
def model_preset(self) -> str | None: ...
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ class GrepTool(_SearchTool):
|
|||||||
|
|
||||||
_MAX_RESULT_CHARS = 128_000
|
_MAX_RESULT_CHARS = 128_000
|
||||||
_MAX_FILE_BYTES = 2_000_000
|
_MAX_FILE_BYTES = 2_000_000
|
||||||
|
_MAX_EXPLICIT_FILE_BYTES = 100_000_000
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -295,7 +296,8 @@ class GrepTool(_SearchTool):
|
|||||||
"Default output_mode is files_with_matches (file paths only); "
|
"Default output_mode is files_with_matches (file paths only); "
|
||||||
"use content mode for matching lines with context. Prefer this "
|
"use content mode for matching lines with context. Prefer this "
|
||||||
"over shell grep for ordinary workspace searches. "
|
"over shell grep for ordinary workspace searches. "
|
||||||
"Skips binary and files >2 MB. Supports glob/type filtering."
|
"Binary and file-size limits are enforced by the tool; explicit file paths "
|
||||||
|
"use a larger bounded limit than directory searches. Supports glob/type filtering."
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -456,6 +458,9 @@ class GrepTool(_SearchTool):
|
|||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
file_mtimes: dict[str, float] = {}
|
file_mtimes: dict[str, float] = {}
|
||||||
root = target if target.is_dir() else target.parent
|
root = target if target.is_dir() else target.parent
|
||||||
|
max_file_bytes = (
|
||||||
|
self._MAX_EXPLICIT_FILE_BYTES if target.is_file() else self._MAX_FILE_BYTES
|
||||||
|
)
|
||||||
|
|
||||||
for file_path in self._iter_files(target):
|
for file_path in self._iter_files(target):
|
||||||
rel_path = file_path.relative_to(root).as_posix()
|
rel_path = file_path.relative_to(root).as_posix()
|
||||||
@@ -464,8 +469,9 @@ class GrepTool(_SearchTool):
|
|||||||
if not _matches_type(file_path.name, type):
|
if not _matches_type(file_path.name, type):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raw = file_path.read_bytes()
|
with file_path.open("rb") as file:
|
||||||
if len(raw) > self._MAX_FILE_BYTES:
|
raw = file.read(max_file_bytes + 1)
|
||||||
|
if len(raw) > max_file_bytes:
|
||||||
skipped_large += 1
|
skipped_large += 1
|
||||||
continue
|
continue
|
||||||
if _is_binary(raw):
|
if _is_binary(raw):
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.context import current_request_context
|
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
||||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ class MyTool(Tool):
|
|||||||
"_current_iteration", # updated by runner only
|
"_current_iteration", # updated by runner only
|
||||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||||
|
"model_presets", # config-derived catalog; changes require config reload
|
||||||
"workspace_sandbox", # read-only view of workspace enforcement level
|
"workspace_sandbox", # read-only view of workspace enforcement level
|
||||||
"request", # current message routing metadata
|
"request", # current message routing metadata
|
||||||
})
|
})
|
||||||
@@ -146,6 +148,8 @@ class MyTool(Tool):
|
|||||||
"max_iterations - _current_iteration = remaining iterations.\n"
|
"max_iterations - _current_iteration = remaining iterations.\n"
|
||||||
"Current routing metadata is available read-only via request.channel, "
|
"Current routing metadata is available read-only via request.channel, "
|
||||||
"request.chat_id, and request.sender_id.\n"
|
"request.chat_id, and request.sender_id.\n"
|
||||||
|
"Use model_preset for session-scoped model or context changes; direct "
|
||||||
|
"model/context_window_tokens writes are disabled during active sessions.\n"
|
||||||
"Note: web_config and exec_config are readable but read-only.\n"
|
"Note: web_config and exec_config are readable but read-only.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"When to use:\n"
|
"When to use:\n"
|
||||||
@@ -210,11 +214,11 @@ class MyTool(Tool):
|
|||||||
if part.lower() in self._SENSITIVE_NAMES:
|
if part.lower() in self._SENSITIVE_NAMES:
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
try:
|
try:
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, Mapping):
|
||||||
if part in obj:
|
if part in obj:
|
||||||
obj = obj[part]
|
obj = obj[part]
|
||||||
else:
|
else:
|
||||||
return None, f"'{part}' not found in dict"
|
return None, f"'{part}' not found in mapping"
|
||||||
else:
|
else:
|
||||||
obj = getattr(obj, part)
|
obj = getattr(obj, part)
|
||||||
except (KeyError, AttributeError) as e:
|
except (KeyError, AttributeError) as e:
|
||||||
@@ -257,7 +261,7 @@ class MyTool(Tool):
|
|||||||
# SubagentManager: delegate to its _task_statuses dict
|
# SubagentManager: delegate to its _task_statuses dict
|
||||||
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
|
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
|
||||||
return MyTool._format_value(val._task_statuses, key)
|
return MyTool._format_value(val._task_statuses, key)
|
||||||
if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
|
if isinstance(val, Mapping) and val and _is_subagent_status(next(iter(val.values()))):
|
||||||
prefix = f"{key}: " if key else ""
|
prefix = f"{key}: " if key else ""
|
||||||
lines = [f"{prefix}{len(val)} subagent(s):"]
|
lines = [f"{prefix}{len(val)} subagent(s):"]
|
||||||
for tid, st in val.items():
|
for tid, st in val.items():
|
||||||
@@ -270,8 +274,8 @@ class MyTool(Tool):
|
|||||||
if isinstance(val, (str, int, float, bool, type(None))):
|
if isinstance(val, (str, int, float, bool, type(None))):
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
# Dict — small: show content; large: show keys for dot-path navigation
|
# Mapping — small: show content; large: show keys for dot-path navigation
|
||||||
if isinstance(val, dict):
|
if isinstance(val, Mapping):
|
||||||
ks = list(val.keys())
|
ks = list(val.keys())
|
||||||
if not ks:
|
if not ks:
|
||||||
return f"{key}: {{}}" if key else "{}"
|
return f"{key}: {{}}" if key else "{}"
|
||||||
@@ -447,6 +451,23 @@ class MyTool(Tool):
|
|||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||||
name = value.strip()
|
name = value.strip()
|
||||||
|
session_key = current_request_session_key()
|
||||||
|
if session_key:
|
||||||
|
try:
|
||||||
|
runtime = self._runtime_state.set_session_model_preset(
|
||||||
|
session_key,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
message = str(exc.args[0]) if exc.args else str(exc)
|
||||||
|
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||||
|
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||||
|
self._audit("modify", f"model_preset = {name!r}")
|
||||||
|
return (
|
||||||
|
f"Set model_preset = {name!r} for the next turn; "
|
||||||
|
f"model will be {runtime.model!r}; "
|
||||||
|
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||||
|
)
|
||||||
result = self._modify_free("model_preset", name)
|
result = self._modify_free("model_preset", name)
|
||||||
if isinstance(result, ToolResult) and result.is_error:
|
if isinstance(result, ToolResult) and result.is_error:
|
||||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||||
@@ -472,6 +493,11 @@ class MyTool(Tool):
|
|||||||
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
|
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
|
||||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
|
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
|
||||||
|
if key in {"model", "context_window_tokens"} and current_request_session_key():
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: direct '{key}' changes are instance-wide and disabled "
|
||||||
|
"during an active session; use a configured model_preset"
|
||||||
|
)
|
||||||
if key == "model":
|
if key == "model":
|
||||||
self._runtime_state.set_runtime_model(value)
|
self._runtime_state.set_runtime_model(value)
|
||||||
elif key == "context_window_tokens":
|
elif key == "context_window_tokens":
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -516,6 +518,7 @@ class ExecTool(Tool):
|
|||||||
login: bool = False,
|
login: bool = False,
|
||||||
*,
|
*,
|
||||||
stdin: int = asyncio.subprocess.DEVNULL,
|
stdin: int = asyncio.subprocess.DEVNULL,
|
||||||
|
process_tree: bool = False,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Launch *command* in a platform-appropriate shell."""
|
"""Launch *command* in a platform-appropriate shell."""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
@@ -563,6 +566,7 @@ class ExecTool(Tool):
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
|
**({"start_new_session": True} if process_tree else {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -655,6 +659,39 @@ class ExecTool(Tool):
|
|||||||
finally:
|
finally:
|
||||||
_reap_pid(process.pid)
|
_reap_pid(process.pid)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
|
||||||
|
"""Kill a session process and descendants, then reap the root process."""
|
||||||
|
if process.returncode is not None:
|
||||||
|
_reap_pid(process.pid)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
with suppress(OSError, asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
),
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if process.returncode is None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
process.kill()
|
||||||
|
with suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||||
|
finally:
|
||||||
|
_reap_pid(process.pid)
|
||||||
|
|
||||||
def _build_env(self) -> dict[str, str]:
|
def _build_env(self) -> dict[str, str]:
|
||||||
"""Build a minimal environment for subprocess execution.
|
"""Build a minimal environment for subprocess execution.
|
||||||
|
|
||||||
@@ -718,9 +755,12 @@ class ExecTool(Tool):
|
|||||||
|
|
||||||
# allow_patterns take priority over deny_patterns so that users can
|
# allow_patterns take priority over deny_patterns so that users can
|
||||||
# exempt specific commands (e.g. "rm -rf" inside a build directory)
|
# exempt specific commands (e.g. "rm -rf" inside a build directory)
|
||||||
# from the hardcoded deny list via configuration.
|
# from the hardcoded deny list via configuration. A chained command is
|
||||||
explicitly_allowed = bool(self.allow_patterns) and any(
|
# only explicitly allowed when every top-level shell segment matches.
|
||||||
re.fullmatch(p, lower) for p in self.allow_patterns
|
segments = self._split_shell_segments(lower)
|
||||||
|
explicitly_allowed = bool(self.allow_patterns) and bool(segments) and all(
|
||||||
|
any(re.fullmatch(pattern, segment) for pattern in self.allow_patterns)
|
||||||
|
for segment in segments
|
||||||
)
|
)
|
||||||
if not explicitly_allowed:
|
if not explicitly_allowed:
|
||||||
for pattern in self.deny_patterns:
|
for pattern in self.deny_patterns:
|
||||||
@@ -785,6 +825,84 @@ class ExecTool(Tool):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_shell_segments(command: str) -> list[str]:
|
||||||
|
"""Split shell commands on top-level chaining operators."""
|
||||||
|
segments: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
quote: str | None = None
|
||||||
|
escaped = False
|
||||||
|
paren_depth = 0
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(command):
|
||||||
|
ch = command[i]
|
||||||
|
|
||||||
|
if escaped:
|
||||||
|
current.append(ch)
|
||||||
|
escaped = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "\\" and quote != "'":
|
||||||
|
current.append(ch)
|
||||||
|
escaped = True
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if quote is not None:
|
||||||
|
current.append(ch)
|
||||||
|
if ch == quote:
|
||||||
|
quote = None
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch in {"'", '"', "`"}:
|
||||||
|
current.append(ch)
|
||||||
|
quote = ch
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "(":
|
||||||
|
paren_depth += 1
|
||||||
|
current.append(ch)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == ")" and paren_depth > 0:
|
||||||
|
paren_depth -= 1
|
||||||
|
current.append(ch)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
operator_len = 0
|
||||||
|
if paren_depth == 0:
|
||||||
|
if command.startswith(("&&", "||"), i):
|
||||||
|
operator_len = 2
|
||||||
|
elif ch == "&" and not (
|
||||||
|
(i > 0 and command[i - 1] in "<>") or command.startswith("&>", i)
|
||||||
|
):
|
||||||
|
current.append(ch)
|
||||||
|
operator_len = 1
|
||||||
|
elif ch in {";", "|"}:
|
||||||
|
operator_len = 1
|
||||||
|
|
||||||
|
if operator_len:
|
||||||
|
segment = "".join(current).strip()
|
||||||
|
if segment:
|
||||||
|
segments.append(segment)
|
||||||
|
current = []
|
||||||
|
i += operator_len
|
||||||
|
continue
|
||||||
|
|
||||||
|
current.append(ch)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
segment = "".join(current).strip()
|
||||||
|
if segment:
|
||||||
|
segments.append(segment)
|
||||||
|
return segments
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _is_benign_device_path(cls, path: str) -> bool:
|
def _is_benign_device_path(cls, path: str) -> bool:
|
||||||
"""Return True for kernel device files that should never be workspace-blocked."""
|
"""Return True for kernel device files that should never be workspace-blocked."""
|
||||||
@@ -800,6 +918,6 @@ class ExecTool(Tool):
|
|||||||
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||||
command
|
command
|
||||||
)
|
)
|
||||||
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||||
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
|
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+
|
||||||
return win_paths + posix_paths + home_paths
|
return win_paths + posix_paths + home_paths
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_context
|
from nanobot.agent.tools.context import current_request_context
|
||||||
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import (
|
||||||
|
BooleanSchema,
|
||||||
|
NumberSchema,
|
||||||
|
StringSchema,
|
||||||
|
tool_parameters_schema,
|
||||||
|
)
|
||||||
from nanobot.security.workspace_access import current_workspace_scope
|
from nanobot.security.workspace_access import current_workspace_scope
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -26,6 +31,14 @@ if TYPE_CHECKING:
|
|||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
maximum=2.0,
|
maximum=2.0,
|
||||||
),
|
),
|
||||||
|
wait=BooleanSchema(
|
||||||
|
description=(
|
||||||
|
"Wait for the subagent and return its result directly. Use this for a "
|
||||||
|
"blocking consultation that must inform the current turn. Defaults to "
|
||||||
|
"false for background execution."
|
||||||
|
),
|
||||||
|
default=False,
|
||||||
|
),
|
||||||
required=["task"],
|
required=["task"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -48,6 +61,7 @@ class SpawnTool(Tool):
|
|||||||
return (
|
return (
|
||||||
"Spawn a subagent to handle a task in the background. "
|
"Spawn a subagent to handle a task in the background. "
|
||||||
"Use this for complex or time-consuming tasks that can run independently. "
|
"Use this for complex or time-consuming tasks that can run independently. "
|
||||||
|
"Set wait=true for a consultation whose result must inform the current turn. "
|
||||||
"The subagent will complete the task and report back when done. "
|
"The subagent will complete the task and report back when done. "
|
||||||
"For deliverables or existing projects, inspect the workspace first "
|
"For deliverables or existing projects, inspect the workspace first "
|
||||||
"and use a dedicated subdirectory when helpful."
|
"and use a dedicated subdirectory when helpful."
|
||||||
@@ -58,6 +72,7 @@ class SpawnTool(Tool):
|
|||||||
task: str,
|
task: str,
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
|
wait: bool = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Spawn a subagent to execute the given task."""
|
"""Spawn a subagent to execute the given task."""
|
||||||
@@ -75,7 +90,8 @@ class SpawnTool(Tool):
|
|||||||
origin_channel = request_ctx.channel
|
origin_channel = request_ctx.channel
|
||||||
origin_chat_id = request_ctx.chat_id
|
origin_chat_id = request_ctx.chat_id
|
||||||
session_key = request_ctx.session_key or f"{origin_channel}:{origin_chat_id}"
|
session_key = request_ctx.session_key or f"{origin_channel}:{origin_chat_id}"
|
||||||
return await self._manager.spawn(
|
method = self._manager.run_inline if wait else self._manager.spawn
|
||||||
|
return await method(
|
||||||
task=task,
|
task=task,
|
||||||
runtime=request_ctx.runtime,
|
runtime=request_ctx.runtime,
|
||||||
label=label,
|
label=label,
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Route and publish the user-visible lifecycle of an agent turn."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.bus.outbound_events import (
|
||||||
|
RetryWaitEvent,
|
||||||
|
StreamDeltaEvent,
|
||||||
|
StreamedResponseEvent,
|
||||||
|
StreamEndEvent,
|
||||||
|
outbound_message_for_event,
|
||||||
|
)
|
||||||
|
from nanobot.bus.progress import build_bus_progress_callback
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TurnRoute:
|
||||||
|
"""Turn delivery destination and lifecycle policy, separate from execution input."""
|
||||||
|
|
||||||
|
channel: str
|
||||||
|
chat_id: str
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
publish_lifecycle: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
TurnRoutePolicy = Callable[[InboundMessage, str, TurnRoute], TurnRoute]
|
||||||
|
ProgressCallback = Callable[..., Awaitable[None]]
|
||||||
|
StreamCallback = Callable[[str], Awaitable[None]]
|
||||||
|
StreamEndCallback = Callable[..., Awaitable[None]]
|
||||||
|
RetryWaitCallback = Callable[[str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
class TurnDeliveryFactory:
|
||||||
|
"""Create per-turn delivery objects from an optional edge-owned route policy."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bus: MessageBus,
|
||||||
|
runtime_events: RuntimeEventBus,
|
||||||
|
route_policy: TurnRoutePolicy | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.runtime_events = runtime_events
|
||||||
|
self.runtime_event_publisher = RuntimeEventPublisher(runtime_events)
|
||||||
|
self.route_policy = route_policy
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
msg: InboundMessage,
|
||||||
|
session_key: str,
|
||||||
|
*,
|
||||||
|
enable_stream: bool = False,
|
||||||
|
) -> TurnDelivery:
|
||||||
|
route = self._default_route(msg, session_key)
|
||||||
|
if self.route_policy is not None:
|
||||||
|
route = self.route_policy(msg, session_key, route)
|
||||||
|
if not isinstance(route, TurnRoute):
|
||||||
|
raise TypeError("turn route policy must return TurnRoute")
|
||||||
|
return TurnDelivery(
|
||||||
|
bus=self.bus,
|
||||||
|
runtime_event_publisher=self.runtime_event_publisher,
|
||||||
|
input_message=msg,
|
||||||
|
session_key=session_key,
|
||||||
|
route=route,
|
||||||
|
enable_stream=enable_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
def unrouted(self, msg: InboundMessage, session_key: str) -> TurnDelivery:
|
||||||
|
"""Create a lifecycle fallback without invoking edge routing policy."""
|
||||||
|
return TurnDelivery(
|
||||||
|
bus=self.bus,
|
||||||
|
runtime_event_publisher=self.runtime_event_publisher,
|
||||||
|
input_message=msg,
|
||||||
|
session_key=session_key,
|
||||||
|
route=TurnRoute(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
metadata=dict(msg.metadata or {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_route(msg: InboundMessage, session_key: str) -> TurnRoute:
|
||||||
|
if msg.channel != "system":
|
||||||
|
return TurnRoute(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
metadata=dict(msg.metadata or {}),
|
||||||
|
publish_lifecycle=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
channel, chat_id = (
|
||||||
|
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||||
|
)
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
if (
|
||||||
|
channel == "slack"
|
||||||
|
and session_key.startswith("slack:")
|
||||||
|
and session_key.count(":") >= 2
|
||||||
|
):
|
||||||
|
metadata["slack"] = {"thread_ts": session_key.split(":", 2)[2]}
|
||||||
|
if origin_message_id := msg.metadata.get("origin_message_id"):
|
||||||
|
metadata["origin_message_id"] = origin_message_id
|
||||||
|
return TurnRoute(channel=channel, chat_id=chat_id, metadata=metadata)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TurnDelivery:
|
||||||
|
"""Own routing, callbacks, and lifecycle publication for one turn."""
|
||||||
|
|
||||||
|
bus: MessageBus
|
||||||
|
runtime_event_publisher: RuntimeEventPublisher
|
||||||
|
input_message: InboundMessage
|
||||||
|
session_key: str
|
||||||
|
route: TurnRoute
|
||||||
|
enable_stream: bool = False
|
||||||
|
delivery_message: InboundMessage = field(init=False)
|
||||||
|
lifecycle_message: InboundMessage = field(init=False)
|
||||||
|
_stream_base_id: str | None = field(init=False, default=None)
|
||||||
|
_stream_segment: int = field(init=False, default=0)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.delivery_message = dataclasses.replace(
|
||||||
|
self.input_message,
|
||||||
|
channel=self.route.channel,
|
||||||
|
chat_id=self.route.chat_id,
|
||||||
|
metadata=dict(self.route.metadata),
|
||||||
|
)
|
||||||
|
self.lifecycle_message = (
|
||||||
|
self.delivery_message if self.route.publish_lifecycle else self.input_message
|
||||||
|
)
|
||||||
|
if self.enable_stream and self.delivery_message.metadata.get("_wants_stream"):
|
||||||
|
self._stream_base_id = f"{self.session_key}:{time.time_ns()}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_stream(self) -> StreamCallback | None:
|
||||||
|
return self._publish_stream if self._stream_base_id is not None else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_stream_end(self) -> StreamEndCallback | None:
|
||||||
|
return self._publish_stream_end if self._stream_base_id is not None else None
|
||||||
|
|
||||||
|
def progress_callback(self) -> ProgressCallback | None:
|
||||||
|
if not self.route.publish_lifecycle:
|
||||||
|
return None
|
||||||
|
return build_bus_progress_callback(self.bus, self.delivery_message)
|
||||||
|
|
||||||
|
def retry_wait_callback(self) -> RetryWaitCallback | None:
|
||||||
|
if not self.route.publish_lifecycle:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _on_retry_wait(content: str) -> None:
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel=self.delivery_message.channel,
|
||||||
|
chat_id=self.delivery_message.chat_id,
|
||||||
|
event=RetryWaitEvent(content=content),
|
||||||
|
metadata=self.delivery_message.metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _on_retry_wait
|
||||||
|
|
||||||
|
async def started(self) -> None:
|
||||||
|
if self.route.publish_lifecycle:
|
||||||
|
await self.runtime_event_publisher.session_turn_started(
|
||||||
|
self.delivery_message,
|
||||||
|
self.session_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def running(self, *, started_at: float) -> None:
|
||||||
|
if self.route.publish_lifecycle:
|
||||||
|
await self.runtime_event_publisher.run_status_changed(
|
||||||
|
self.delivery_message,
|
||||||
|
self.session_key,
|
||||||
|
"running",
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_runtime(self, runtime: Any) -> None:
|
||||||
|
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
|
||||||
|
|
||||||
|
def record_latency(self, latency_ms: int | None) -> None:
|
||||||
|
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
|
||||||
|
|
||||||
|
def background_response(
|
||||||
|
self,
|
||||||
|
content: str | None,
|
||||||
|
*,
|
||||||
|
stop_reason: str,
|
||||||
|
streamed: bool,
|
||||||
|
latency_ms: int | None,
|
||||||
|
) -> OutboundMessage:
|
||||||
|
metadata = dict(self.route.metadata)
|
||||||
|
if self.route.publish_lifecycle and latency_ms is not None:
|
||||||
|
metadata["latency_ms"] = int(latency_ms)
|
||||||
|
event = (
|
||||||
|
StreamedResponseEvent()
|
||||||
|
if self.route.publish_lifecycle
|
||||||
|
and streamed
|
||||||
|
and stop_reason not in {"error", "tool_error"}
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return OutboundMessage(
|
||||||
|
channel=self.route.channel,
|
||||||
|
chat_id=self.route.chat_id,
|
||||||
|
content=content or "Background task completed.",
|
||||||
|
metadata=metadata,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
response: OutboundMessage | None,
|
||||||
|
*,
|
||||||
|
publish_completion: bool,
|
||||||
|
) -> None:
|
||||||
|
completed_channel = self.lifecycle_message.channel
|
||||||
|
completed_chat_id = self.lifecycle_message.chat_id
|
||||||
|
if response is not None:
|
||||||
|
await self.bus.publish_outbound(response)
|
||||||
|
completed_channel = response.channel
|
||||||
|
completed_chat_id = response.chat_id
|
||||||
|
elif self.lifecycle_message.channel == "cli":
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
OutboundMessage(
|
||||||
|
channel=self.lifecycle_message.channel,
|
||||||
|
chat_id=self.lifecycle_message.chat_id,
|
||||||
|
content="",
|
||||||
|
metadata=dict(self.lifecycle_message.metadata or {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if publish_completion:
|
||||||
|
await self.runtime_event_publisher.turn_completed(
|
||||||
|
channel=completed_channel,
|
||||||
|
chat_id=completed_chat_id,
|
||||||
|
session_key=self.session_key,
|
||||||
|
metadata=self.lifecycle_message.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fail(self, *, publish_completion: bool) -> None:
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
OutboundMessage(
|
||||||
|
channel=self.lifecycle_message.channel,
|
||||||
|
chat_id=self.lifecycle_message.chat_id,
|
||||||
|
content="Sorry, I encountered an error.",
|
||||||
|
metadata=dict(self.lifecycle_message.metadata or {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if publish_completion:
|
||||||
|
await self.runtime_event_publisher.turn_completed(
|
||||||
|
channel=self.lifecycle_message.channel,
|
||||||
|
chat_id=self.lifecycle_message.chat_id,
|
||||||
|
session_key=self.session_key,
|
||||||
|
metadata=self.lifecycle_message.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def idle(self) -> None:
|
||||||
|
await self.runtime_event_publisher.run_status_changed(
|
||||||
|
self.lifecycle_message,
|
||||||
|
self.session_key,
|
||||||
|
"idle",
|
||||||
|
)
|
||||||
|
self.runtime_event_publisher.clear_turn(self.session_key)
|
||||||
|
|
||||||
|
def _stream_id(self) -> str:
|
||||||
|
assert self._stream_base_id is not None
|
||||||
|
return f"{self._stream_base_id}:{self._stream_segment}"
|
||||||
|
|
||||||
|
async def _publish_stream(self, delta: str) -> None:
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel=self.delivery_message.channel,
|
||||||
|
chat_id=self.delivery_message.chat_id,
|
||||||
|
event=StreamDeltaEvent(content=delta, stream_id=self._stream_id()),
|
||||||
|
metadata=self.delivery_message.metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _publish_stream_end(self, *, resuming: bool = False) -> None:
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel=self.delivery_message.channel,
|
||||||
|
chat_id=self.delivery_message.chat_id,
|
||||||
|
event=StreamEndEvent(
|
||||||
|
stream_id=self._stream_id(),
|
||||||
|
resuming=resuming,
|
||||||
|
),
|
||||||
|
metadata=self.delivery_message.metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._stream_segment += 1
|
||||||
+2
-19
@@ -344,8 +344,6 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
# -- non-streaming path (original logic) --
|
# -- non-streaming path (original logic) --
|
||||||
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session_lock:
|
async with session_lock:
|
||||||
try:
|
try:
|
||||||
@@ -360,24 +358,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
timeout=timeout_s,
|
timeout=timeout_s,
|
||||||
)
|
)
|
||||||
response_text = _response_text(response)
|
response_text = _response_text(response)
|
||||||
|
|
||||||
if not response_text or not response_text.strip():
|
if not response_text or not response_text.strip():
|
||||||
logger.warning("Empty response for session {}, retrying", session_key)
|
logger.warning("Empty response for session {}, using fallback", session_key)
|
||||||
retry_response = await asyncio.wait_for(
|
response_text = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
agent_loop.process_direct(
|
|
||||||
content=text,
|
|
||||||
media=media_paths if media_paths else None,
|
|
||||||
session_key=session_key,
|
|
||||||
channel="api",
|
|
||||||
chat_id=API_CHAT_ID,
|
|
||||||
persist_user_message=False,
|
|
||||||
),
|
|
||||||
timeout=timeout_s,
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return _error_json(504, f"Request timed out after {timeout_s}s")
|
return _error_json(504, f"Request timed out after {timeout_s}s")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from nanobot.audio.transcription_registry import (
|
|||||||
get_transcription_provider,
|
get_transcription_provider,
|
||||||
resolve_transcription_provider,
|
resolve_transcription_provider,
|
||||||
)
|
)
|
||||||
|
from nanobot.config.loader import resolve_env_refs
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
|
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
|
||||||
@@ -82,7 +83,7 @@ def _provider_default_api_base(provider: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
|
def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
|
||||||
api_key = getattr(provider_cfg, "api_key", None) if provider_cfg else None
|
api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else ""
|
||||||
if api_key:
|
if api_key:
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str:
|
def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str:
|
||||||
api_base = getattr(provider_cfg, "api_base", None) if provider_cfg else None
|
api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else ""
|
||||||
if api_base:
|
if api_base:
|
||||||
return api_base
|
return api_base
|
||||||
return _provider_default_api_base(provider) or ""
|
return _provider_default_api_base(provider) or ""
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
|
|||||||
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||||
RUNTIME_CONTROL_ACK = "_ack"
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||||
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
|
|||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TurnModelUpdatedEvent(OutboundEvent):
|
||||||
|
"""The fallback model currently handling one chat turn."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
|
||||||
|
|
||||||
def outbound_message_for_event(
|
def outbound_message_for_event(
|
||||||
*,
|
*,
|
||||||
channel: str,
|
channel: str,
|
||||||
|
|||||||
@@ -1388,18 +1388,30 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
def _build_card_elements(self, content: str) -> list[dict]:
|
def _build_card_elements(self, content: str) -> list[dict]:
|
||||||
"""Split content into div/markdown + table elements for Feishu card."""
|
"""Split content into div/markdown + table elements for Feishu card."""
|
||||||
|
protected = content
|
||||||
|
code_blocks: list[str] = []
|
||||||
|
for m in self._CODE_BLOCK_RE.finditer(content):
|
||||||
|
code_blocks.append(m.group(1))
|
||||||
|
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
|
||||||
|
|
||||||
elements, last_end = [], 0
|
elements, last_end = [], 0
|
||||||
for m in self._TABLE_RE.finditer(content):
|
for m in self._TABLE_RE.finditer(protected):
|
||||||
before = content[last_end : m.start()]
|
before = protected[last_end : m.start()]
|
||||||
if before.strip():
|
if before.strip():
|
||||||
elements.extend(self._split_headings(before))
|
elements.extend(self._split_headings(before))
|
||||||
elements.append(
|
elements.append(
|
||||||
self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}
|
self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}
|
||||||
)
|
)
|
||||||
last_end = m.end()
|
last_end = m.end()
|
||||||
remaining = content[last_end:]
|
remaining = protected[last_end:]
|
||||||
if remaining.strip():
|
if remaining.strip():
|
||||||
elements.extend(self._split_headings(remaining))
|
elements.extend(self._split_headings(remaining))
|
||||||
|
|
||||||
|
for i, cb in enumerate(code_blocks):
|
||||||
|
for el in elements:
|
||||||
|
if el.get("tag") == "markdown":
|
||||||
|
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
|
||||||
|
|
||||||
return elements or [{"tag": "markdown", "content": content}]
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Check optional Feishu dependencies before running tests
|
# Check optional Feishu dependencies before running tests
|
||||||
try:
|
try:
|
||||||
from nanobot.channels import feishu
|
from nanobot.channels.feishu.runtime import FEISHU_AVAILABLE
|
||||||
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
FEISHU_AVAILABLE = False
|
FEISHU_AVAILABLE = False
|
||||||
|
|
||||||
@@ -66,3 +65,23 @@ def test_split_headings_keeps_markdown_body_and_code_blocks_intact() -> None:
|
|||||||
assert elements[1]["tag"] == "markdown"
|
assert elements[1]["tag"] == "markdown"
|
||||||
assert "Body with **bold** text." in elements[1]["content"]
|
assert "Body with **bold** text." in elements[1]["content"]
|
||||||
assert "```python\nprint('hi')\n```" in elements[1]["content"]
|
assert "```python\nprint('hi')\n```" in elements[1]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_card_elements_keeps_fenced_markdown_tables_intact() -> None:
|
||||||
|
channel = FeishuChannel.__new__(FeishuChannel)
|
||||||
|
text = "Before\n\n```\n| a | b |\n| - | - |\n| 1 | 2 |\n```\n\nAfter"
|
||||||
|
|
||||||
|
elements = channel._build_card_elements(text)
|
||||||
|
|
||||||
|
assert all(el.get("tag") != "table" for el in elements)
|
||||||
|
joined = "\n".join(el["content"] for el in elements if el.get("tag") == "markdown")
|
||||||
|
assert "```\n| a | b |\n| - | - |\n| 1 | 2 |\n```" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_card_elements_still_parses_unfenced_markdown_tables() -> None:
|
||||||
|
channel = FeishuChannel.__new__(FeishuChannel)
|
||||||
|
text = "Before\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter"
|
||||||
|
|
||||||
|
elements = channel._build_card_elements(text)
|
||||||
|
|
||||||
|
assert any(el.get("tag") == "table" for el in elements)
|
||||||
|
|||||||
@@ -48,12 +48,14 @@ except Exception: # pragma: no cover
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import botpy
|
import botpy
|
||||||
|
from botpy.gateway import BotWebSocket
|
||||||
from botpy.http import Route
|
from botpy.http import Route
|
||||||
|
|
||||||
QQ_AVAILABLE = True
|
QQ_AVAILABLE = True
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
QQ_AVAILABLE = False
|
QQ_AVAILABLE = False
|
||||||
botpy = None
|
botpy = None
|
||||||
|
BotWebSocket = None
|
||||||
Route = None
|
Route = None
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -104,14 +106,28 @@ def _guess_send_file_type(filename: str) -> int:
|
|||||||
return QQ_FILE_TYPE_FILE
|
return QQ_FILE_TYPE_FILE
|
||||||
|
|
||||||
|
|
||||||
|
_RECONNECT_BACKOFF_START = 5
|
||||||
|
_RECONNECT_BACKOFF_MAX = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _is_network_error(exc: BaseException) -> bool:
|
||||||
|
"""Check whether an exception is a transient network/DNS error."""
|
||||||
|
return isinstance(
|
||||||
|
exc,
|
||||||
|
(aiohttp.ClientConnectorError, OSError),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
|
def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
|
||||||
"""Create a botpy Client subclass bound to the given channel."""
|
"""Create a botpy client with per-session reconnect backoff."""
|
||||||
intents = botpy.Intents(public_messages=True, direct_message=True)
|
intents = botpy.Intents(public_messages=True, direct_message=True)
|
||||||
|
|
||||||
class _Bot(botpy.Client):
|
class _Bot(botpy.Client):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
|
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
|
||||||
super().__init__(intents=intents, ext_handlers=False)
|
super().__init__(intents=intents, ext_handlers=False)
|
||||||
|
self._ws_backoff: dict[int, int] = {}
|
||||||
|
self._ws_retry_at: dict[int, float] = {}
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
logger.info("QQ bot ready: {}", self.robot.name)
|
logger.info("QQ bot ready: {}", self.robot.name)
|
||||||
@@ -125,6 +141,35 @@ def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
|
|||||||
async def on_direct_message_create(self, message):
|
async def on_direct_message_create(self, message):
|
||||||
await channel._on_message(message, is_group=False)
|
await channel._on_message(message, is_group=False)
|
||||||
|
|
||||||
|
async def bot_connect(self, session):
|
||||||
|
"""Connect a botpy session with exponential retry backoff."""
|
||||||
|
session_id = id(session)
|
||||||
|
retry_at = self._ws_retry_at.pop(session_id, None)
|
||||||
|
if retry_at is not None:
|
||||||
|
remaining = retry_at - time.monotonic()
|
||||||
|
if remaining > 0:
|
||||||
|
await asyncio.sleep(remaining)
|
||||||
|
|
||||||
|
client = BotWebSocket(session, self._connection)
|
||||||
|
backoff = self._ws_backoff.get(session_id, _RECONNECT_BACKOFF_START)
|
||||||
|
try:
|
||||||
|
await client.ws_connect()
|
||||||
|
self._ws_backoff.pop(session_id, None)
|
||||||
|
except (Exception, KeyboardInterrupt, SystemExit) as e:
|
||||||
|
if _is_network_error(e):
|
||||||
|
channel.logger.warning(
|
||||||
|
"QQ bot network error (retry in {}s): {}",
|
||||||
|
backoff,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
# Count botpy's post-connect pacing toward the retry delay.
|
||||||
|
self._ws_retry_at[session_id] = time.monotonic() + backoff
|
||||||
|
self._ws_backoff[session_id] = min(backoff * 2, _RECONNECT_BACKOFF_MAX)
|
||||||
|
else:
|
||||||
|
channel.logger.exception("QQ bot WebSocket error: {}", e)
|
||||||
|
|
||||||
|
self._connection.add(session)
|
||||||
|
|
||||||
return _Bot
|
return _Bot
|
||||||
|
|
||||||
|
|
||||||
@@ -210,15 +255,24 @@ class QQChannel(BaseChannel):
|
|||||||
await self._run_bot()
|
await self._run_bot()
|
||||||
|
|
||||||
async def _run_bot(self) -> None:
|
async def _run_bot(self) -> None:
|
||||||
"""Run the bot connection with auto-reconnect."""
|
"""Run botpy with fallback backoff for errors escaping start()."""
|
||||||
|
backoff = 5
|
||||||
|
max_backoff = 300
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||||
|
backoff = 5
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if _is_network_error(e):
|
||||||
|
self.logger.warning(
|
||||||
|
"QQ bot network error (retry in {}s): {}", backoff, e
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.logger.warning("bot error: {}", e)
|
self.logger.warning("bot error: {}", e)
|
||||||
if self._running:
|
if self._running:
|
||||||
self.logger.info("Reconnecting bot in 5 seconds...")
|
self.logger.info("Reconnecting bot in {} seconds...", backoff)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(backoff)
|
||||||
|
backoff = min(backoff * 2, max_backoff)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop bot and cleanup resources."""
|
"""Stop bot and cleanup resources."""
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
@cache
|
@cache
|
||||||
def _warn_legacy_channel_entry_points() -> None:
|
def _warn_legacy_channel_entry_points() -> None:
|
||||||
# TODO: Remove this legacy entry-point detection and warning after the migration window.
|
# TODO(v0.2.4): Remove this detection and warning. v0.2.3 is the final
|
||||||
|
# migration window for installed legacy channel entry points.
|
||||||
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
|
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
|
||||||
if not names:
|
if not names:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -701,7 +701,16 @@ class SlackChannel(BaseChannel):
|
|||||||
"""Convert Markdown to Slack mrkdwn, including tables."""
|
"""Convert Markdown to Slack mrkdwn, including tables."""
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
|
code_blocks: list[str] = []
|
||||||
|
|
||||||
|
def _save_fence(m: re.Match) -> str:
|
||||||
|
code_blocks.append(m.group(0))
|
||||||
|
return f"\x00CB{len(code_blocks) - 1}\x00"
|
||||||
|
|
||||||
|
text = cls._CODE_FENCE_RE.sub(_save_fence, text)
|
||||||
text = cls._TABLE_RE.sub(cls._convert_table, text)
|
text = cls._TABLE_RE.sub(cls._convert_table, text)
|
||||||
|
for i, block in enumerate(code_blocks):
|
||||||
|
text = text.replace(f"\x00CB{i}\x00", block)
|
||||||
return cls._fixup_mrkdwn(slackify_markdown(text)).rstrip("\n")
|
return cls._fixup_mrkdwn(slackify_markdown(text)).rstrip("\n")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -714,3 +714,19 @@ def test_group_require_mention_accepts_camel_case_alias() -> None:
|
|||||||
)
|
)
|
||||||
assert config.group_require_mention is True
|
assert config.group_require_mention is True
|
||||||
assert config.group_allow_from == ["C_OK"]
|
assert config.group_allow_from == ["C_OK"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_mrkdwn_keeps_fenced_markdown_tables_intact() -> None:
|
||||||
|
text = "Intro\n\n```\n| a | b |\n| - | - |\n| 1 | 2 |\n```\n\nOutro"
|
||||||
|
out = SlackChannel._to_mrkdwn(text)
|
||||||
|
|
||||||
|
assert "```\n| a | b |\n| - | - |\n| 1 | 2 |\n```" in out
|
||||||
|
assert "**a**: 1" not in out
|
||||||
|
assert "*a*: 1" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_mrkdwn_still_converts_unfenced_markdown_tables() -> None:
|
||||||
|
out = SlackChannel._to_mrkdwn("| a | b |\n| - | - |\n| 1 | 2 |")
|
||||||
|
|
||||||
|
assert "| a | b |" not in out
|
||||||
|
assert "a" in out and "1" in out and "b" in out and "2" in out
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from nanobot.channels.telegram.validation import validate
|
|||||||
SETUP_SPEC = ChannelSetupSpec(
|
SETUP_SPEC = ChannelSetupSpec(
|
||||||
fields={
|
fields={
|
||||||
"token": field("secret"),
|
"token": field("secret"),
|
||||||
|
"proxy": field("secret"),
|
||||||
"allowFrom": field("list"),
|
"allowFrom": field("list"),
|
||||||
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"),
|
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -90,21 +90,34 @@ def _split_telegram_markdown(content: str, max_len: int) -> list[str]:
|
|||||||
min_code_pos = len(fence)
|
min_code_pos = len(fence)
|
||||||
if content.startswith(fence + "\n"):
|
if content.startswith(fence + "\n"):
|
||||||
min_code_pos += 1
|
min_code_pos += 1
|
||||||
if pos < min_code_pos and min_code_pos + len(closing) > max_len:
|
# When the only break in range is the opening fence newline,
|
||||||
|
# cutting there re-emits the same fence and never advances.
|
||||||
|
if pos < min_code_pos:
|
||||||
|
if min_code_pos + len(closing) >= max_len:
|
||||||
chunks.append(content[:max_len])
|
chunks.append(content[:max_len])
|
||||||
content = content[max_len:].lstrip()
|
content = content[max_len:].lstrip()
|
||||||
continue
|
continue
|
||||||
if pos + len(closing) > max_len:
|
|
||||||
budget = max_len - len(closing)
|
budget = max_len - len(closing)
|
||||||
if budget > 0:
|
|
||||||
recut = content[:budget]
|
recut = content[:budget]
|
||||||
adjusted = recut.rfind("\n")
|
adjusted = recut.rfind("\n", min_code_pos)
|
||||||
if adjusted <= 0:
|
if adjusted < min_code_pos:
|
||||||
adjusted = recut.rfind(" ")
|
adjusted = recut.rfind(" ", min_code_pos)
|
||||||
pos = adjusted if adjusted > 0 else budget
|
pos = adjusted if adjusted > min_code_pos else budget
|
||||||
else:
|
elif pos + len(closing) > max_len:
|
||||||
closing = "```"
|
budget = max_len - len(closing)
|
||||||
pos = max_len - len(closing)
|
if budget <= min_code_pos:
|
||||||
|
chunks.append(content[:max_len])
|
||||||
|
content = content[max_len:].lstrip()
|
||||||
|
continue
|
||||||
|
recut = content[:budget]
|
||||||
|
adjusted = recut.rfind("\n", min_code_pos)
|
||||||
|
if adjusted < min_code_pos:
|
||||||
|
adjusted = recut.rfind(" ", min_code_pos)
|
||||||
|
pos = adjusted if adjusted > min_code_pos else budget
|
||||||
|
if pos <= min_code_pos:
|
||||||
|
chunks.append(content[:max_len])
|
||||||
|
content = content[max_len:].lstrip()
|
||||||
|
continue
|
||||||
chunks.append(content[:pos] + closing)
|
chunks.append(content[:pos] + closing)
|
||||||
remainder = content[pos:]
|
remainder = content[pos:]
|
||||||
if remainder.startswith("\n"):
|
if remainder.startswith("\n"):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from nanobot.bus.events import OutboundMessage
|
|||||||
from nanobot.bus.outbound_events import ProgressEvent
|
from nanobot.bus.outbound_events import ProgressEvent
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.telegram.runtime import (
|
from nanobot.channels.telegram.runtime import (
|
||||||
|
TELEGRAM_MAX_MESSAGE_LEN,
|
||||||
TELEGRAM_REPLY_CONTEXT_MAX_LEN,
|
TELEGRAM_REPLY_CONTEXT_MAX_LEN,
|
||||||
TelegramChannel,
|
TelegramChannel,
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
@@ -243,6 +244,69 @@ def test_split_telegram_markdown_leading_whitespace_before_fence() -> None:
|
|||||||
_assert_code_blocks_render_balanced(chunks)
|
_assert_code_blocks_render_balanced(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_telegram_markdown_long_single_line_code_body() -> None:
|
||||||
|
"""Long fence bodies with no interior newlines must still advance."""
|
||||||
|
body = "a" * 4500
|
||||||
|
content = f"```\n{body}\n```"
|
||||||
|
|
||||||
|
chunks = _split_telegram_markdown(content, TELEGRAM_MAX_MESSAGE_LEN)
|
||||||
|
|
||||||
|
assert len(chunks) > 1
|
||||||
|
assert all(len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN for chunk in chunks)
|
||||||
|
assert chunks[0].startswith("```\n")
|
||||||
|
assert chunks[0].endswith("\n```")
|
||||||
|
assert chunks[1].startswith("```\n")
|
||||||
|
reassembled = []
|
||||||
|
for chunk in chunks:
|
||||||
|
part = chunk.split("\n", 1)[1]
|
||||||
|
if part.endswith("\n```"):
|
||||||
|
part = part[:-4]
|
||||||
|
elif part.endswith("```"):
|
||||||
|
part = part[:-3]
|
||||||
|
reassembled.append(part)
|
||||||
|
assert "".join(reassembled) == body
|
||||||
|
_assert_code_blocks_render_balanced(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_telegram_markdown_tiny_limit_hard_cuts_fence_prefix() -> None:
|
||||||
|
"""Adaptive HTML limits can shrink max_len to the fence+closer size."""
|
||||||
|
body = "a" * 100
|
||||||
|
content = f"```\n{body}"
|
||||||
|
|
||||||
|
chunks = _split_telegram_markdown(content, max_len=8)
|
||||||
|
|
||||||
|
assert chunks
|
||||||
|
assert all(len(chunk) <= 8 for chunk in chunks)
|
||||||
|
assert "".join(chunks).replace("```", "").replace("\n", "") == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_telegram_markdown_tiny_limit_with_early_body_newline() -> None:
|
||||||
|
body = "a" * 100
|
||||||
|
content = f"```\na\n{body}"
|
||||||
|
|
||||||
|
chunks = _split_telegram_markdown(content, max_len=8)
|
||||||
|
|
||||||
|
assert chunks
|
||||||
|
assert all(len(chunk) <= 8 for chunk in chunks)
|
||||||
|
plain = "".join(chunks).replace("```", "")
|
||||||
|
assert "a" in plain
|
||||||
|
assert plain.count("a") >= 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_telegram_markdown_leading_space_in_fence_body() -> None:
|
||||||
|
body = "a" * 4500
|
||||||
|
content = f"```\n {body}"
|
||||||
|
|
||||||
|
chunks = _split_telegram_markdown(content, TELEGRAM_MAX_MESSAGE_LEN)
|
||||||
|
|
||||||
|
assert chunks
|
||||||
|
assert all(len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN for chunk in chunks)
|
||||||
|
plain = "".join(chunks).replace("```", "").replace("\n", "")
|
||||||
|
assert plain.count("a") == 4500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None:
|
async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None:
|
||||||
_FakeHTTPXRequest.clear()
|
_FakeHTTPXRequest.clear()
|
||||||
|
|||||||
@@ -4,11 +4,60 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.channels.telegram import validation as telegram_validation
|
from nanobot.channels.telegram import validation as telegram_validation
|
||||||
|
from nanobot.channels.telegram.manifest import SETUP_SPEC
|
||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
from nanobot.config.loader import save_config
|
from nanobot.config.loader import save_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_setup_exposes_proxy_as_an_optional_secret() -> None:
|
||||||
|
proxy = SETUP_SPEC.fields["proxy"]
|
||||||
|
|
||||||
|
assert proxy.kind == "secret"
|
||||||
|
assert "proxy" not in SETUP_SPEC.simple_required_fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_me_builds_http_client_with_explicit_proxy(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
proxy = "socks5://proxy-user:proxy-pass@127.0.0.1:1080"
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return {"ok": True, "result": {"id": 42}}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get(self, url: str) -> FakeResponse:
|
||||||
|
captured["url"] = url
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation.httpx, "Client", FakeClient)
|
||||||
|
|
||||||
|
result = telegram_validation._get_me(token, proxy)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert captured["kwargs"] == {
|
||||||
|
"timeout": 4.0,
|
||||||
|
"proxy": proxy,
|
||||||
|
"trust_env": False,
|
||||||
|
}
|
||||||
|
assert captured["url"] == f"https://api.telegram.org/bot{token}/getMe"
|
||||||
|
|
||||||
|
|
||||||
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -21,7 +70,38 @@ def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.Mo
|
|||||||
assert result["missing_fields"] == []
|
assert result["missing_fields"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
|
@pytest.mark.parametrize("status_code", [401, 404])
|
||||||
|
def test_validate_telegram_rejects_denied_tokens_without_exposing_them(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
status_code: int,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(
|
||||||
|
Config.model_validate({"channels": {"telegram": {"token": token}}}),
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
def raise_http_error(token_value: str, _proxy: str | None) -> dict:
|
||||||
|
request = httpx.Request("GET", f"https://api.telegram.org/bot{token_value}/getMe")
|
||||||
|
response = httpx.Response(status_code, request=request)
|
||||||
|
raise httpx.HTTPStatusError("rejected", request=request, response=response)
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation, "_get_me", raise_http_error)
|
||||||
|
|
||||||
|
result = validate_channel_config("telegram", {"channels.telegram.token": ""})
|
||||||
|
|
||||||
|
assert result["status"] == "invalid"
|
||||||
|
assert result["can_enable"] is False
|
||||||
|
assert token not in str(result)
|
||||||
|
assert any(
|
||||||
|
f"HTTP {status_code}" in check.get("message", "") for check in result["checks"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_telegram_keeps_transient_http_failures_retryable(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -33,14 +113,197 @@ def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
def raise_http_error(url: str, **_kwargs) -> dict:
|
def raise_http_error(token_value: str, _proxy: str | None) -> dict:
|
||||||
request = httpx.Request("GET", url)
|
request = httpx.Request("GET", f"https://api.telegram.org/bot{token_value}/getMe")
|
||||||
response = httpx.Response(401, request=request)
|
response = httpx.Response(503, request=request)
|
||||||
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
|
raise httpx.HTTPStatusError("unavailable", request=request, response=response)
|
||||||
|
|
||||||
monkeypatch.setattr(telegram_validation, "http_get", raise_http_error)
|
monkeypatch.setattr(telegram_validation, "_get_me", raise_http_error)
|
||||||
|
|
||||||
result = validate_channel_config("telegram", {"channels.telegram.token": ""})
|
result = validate_channel_config("telegram", {"channels.telegram.token": ""})
|
||||||
|
|
||||||
|
assert result["status"] == "configured"
|
||||||
|
assert result["can_enable"] is True
|
||||||
assert token not in str(result)
|
assert token not in str(result)
|
||||||
assert any("HTTP 401" in check.get("message", "") for check in result["checks"])
|
assert any("HTTP 503" in check.get("message", "") for check in result["checks"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_telegram_marks_proxy_transport_failures_without_exposing_proxy(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
proxy = "http://proxy-user:proxy-pass@127.0.0.1:7890"
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(
|
||||||
|
Config.model_validate(
|
||||||
|
{"channels": {"telegram": {"token": token, "proxy": proxy}}}
|
||||||
|
),
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
def raise_proxy_error(_token: str, _proxy: str | None) -> dict:
|
||||||
|
raise httpx.ProxyError("proxy credentials rejected")
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation, "_get_me", raise_proxy_error)
|
||||||
|
|
||||||
|
result = validate_channel_config("telegram")
|
||||||
|
|
||||||
|
assert result["status"] == "configured"
|
||||||
|
assert result["can_enable"] is True
|
||||||
|
assert proxy not in str(result)
|
||||||
|
assert any(check["id"] == "proxy_connection" for check in result["checks"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_telegram_uses_saved_proxy_without_exposing_it(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
proxy = "socks5://proxy-user:proxy-pass@127.0.0.1:1080"
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(
|
||||||
|
Config.model_validate(
|
||||||
|
{"channels": {"telegram": {"token": token, "proxy": proxy}}}
|
||||||
|
),
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
captured: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
|
||||||
|
captured.update(token=token_value, proxy=proxy_value)
|
||||||
|
return {"ok": True, "result": {"id": 42, "username": "working_bot"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
|
||||||
|
|
||||||
|
result = validate_channel_config("telegram")
|
||||||
|
|
||||||
|
assert result["status"] == "connected"
|
||||||
|
assert captured == {"token": token, "proxy": proxy}
|
||||||
|
assert proxy not in str(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_telegram_resolves_saved_secret_env_refs_without_exposing_them(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
token_ref = "${TELEGRAM_TOKEN_TEST}"
|
||||||
|
proxy_ref = "${TELEGRAM_PROXY_TEST}"
|
||||||
|
proxy = "http://proxy-user:proxy-pass@127.0.0.1:7890"
|
||||||
|
monkeypatch.setenv("TELEGRAM_TOKEN_TEST", token)
|
||||||
|
monkeypatch.setenv("TELEGRAM_PROXY_TEST", proxy)
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(
|
||||||
|
Config.model_validate(
|
||||||
|
{"channels": {"telegram": {"token": token_ref, "proxy": proxy_ref}}}
|
||||||
|
),
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
captured: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
|
||||||
|
captured.update(token=token_value, proxy=proxy_value)
|
||||||
|
return {"ok": True, "result": {"id": 42, "username": "working_bot"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
|
||||||
|
|
||||||
|
result = validate_channel_config("telegram")
|
||||||
|
|
||||||
|
assert result["status"] == "connected"
|
||||||
|
assert captured == {"token": token, "proxy": proxy}
|
||||||
|
assert token_ref not in str(result)
|
||||||
|
assert token not in str(result)
|
||||||
|
assert proxy_ref not in str(result)
|
||||||
|
assert proxy not in str(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_telegram_rejects_unset_proxy_env_ref_without_connecting(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
proxy_ref = "${TELEGRAM_MISSING_PROXY_TEST}"
|
||||||
|
monkeypatch.delenv("TELEGRAM_MISSING_PROXY_TEST", raising=False)
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(
|
||||||
|
Config.model_validate(
|
||||||
|
{"channels": {"telegram": {"token": token, "proxy": proxy_ref}}}
|
||||||
|
),
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
def fail_get_me(*_args) -> dict:
|
||||||
|
pytest.fail("an unresolved proxy reference must not fall back to direct access")
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation, "_get_me", fail_get_me)
|
||||||
|
|
||||||
|
result = validate_channel_config("telegram")
|
||||||
|
|
||||||
|
assert result["status"] == "invalid"
|
||||||
|
assert result["can_enable"] is False
|
||||||
|
assert proxy_ref not in str(result)
|
||||||
|
assert any(check["id"] == "proxy_env" for check in result["checks"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_telegram_uses_proxy_submitted_with_new_token(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
proxy = "http://127.0.0.1:7890"
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
captured: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
|
||||||
|
captured.update(token=token_value, proxy=proxy_value)
|
||||||
|
return {"ok": True, "result": {"id": 42, "username": "new_bot"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
|
||||||
|
|
||||||
|
result = validate_channel_config(
|
||||||
|
"telegram",
|
||||||
|
{
|
||||||
|
"channels.telegram.token": token,
|
||||||
|
"channels.telegram.proxy": proxy,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "connected"
|
||||||
|
assert captured == {"token": token, "proxy": proxy}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("proxy", ["127.0.0.1:7890", "http://[", "http://localhost:not-a-port"])
|
||||||
|
def test_validate_telegram_rejects_invalid_proxy_without_trying_token(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
proxy: str,
|
||||||
|
) -> None:
|
||||||
|
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
def fail_get_me(*_args) -> dict:
|
||||||
|
pytest.fail("invalid proxy must stop before getMe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(telegram_validation, "_get_me", fail_get_me)
|
||||||
|
|
||||||
|
result = validate_channel_config(
|
||||||
|
"telegram",
|
||||||
|
{
|
||||||
|
"channels.telegram.token": token,
|
||||||
|
"channels.telegram.proxy": proxy,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "invalid"
|
||||||
|
assert result["can_enable"] is False
|
||||||
|
assert proxy not in str(result)
|
||||||
|
assert any(check["id"] == "proxy_format" for check in result["checks"])
|
||||||
|
|||||||
@@ -2,24 +2,82 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nanobot.channels.contracts import ChannelValidationContext
|
from nanobot.channels.contracts import ChannelValidationContext
|
||||||
from nanobot.channels.validation import (
|
from nanobot.channels.validation import (
|
||||||
check,
|
check,
|
||||||
http_get,
|
|
||||||
message_from_response,
|
message_from_response,
|
||||||
payload,
|
payload,
|
||||||
required_checks,
|
required_checks,
|
||||||
status_from_checks,
|
status_from_checks,
|
||||||
string_value,
|
string_value,
|
||||||
)
|
)
|
||||||
|
from nanobot.config.loader import resolve_env_refs
|
||||||
|
|
||||||
|
_TIMEOUT_SECONDS = 4.0
|
||||||
|
_SUPPORTED_PROXY_SCHEMES = {"http", "https", "socks5", "socks5h"}
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_url_is_valid(proxy: str) -> bool:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(proxy)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
parsed.port
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return parsed.scheme.lower() in _SUPPORTED_PROXY_SCHEMES and bool(hostname)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_me(token: str, proxy: str | None) -> dict[str, Any]:
|
||||||
|
client_kwargs: dict[str, Any] = {"timeout": _TIMEOUT_SECONDS}
|
||||||
|
if proxy:
|
||||||
|
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||||
|
with httpx.Client(**client_kwargs) as client:
|
||||||
|
response = client.get(f"https://api.telegram.org/bot{token}/getMe")
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||||
checks, missing = required_checks("telegram", values)
|
checks, missing = required_checks("telegram", values)
|
||||||
token = string_value(values.get("token"))
|
raw_token = string_value(values.get("token"))
|
||||||
|
raw_proxy = string_value(values.get("proxy"))
|
||||||
|
token = string_value(resolve_env_refs(raw_token))
|
||||||
|
proxy = string_value(resolve_env_refs(raw_proxy))
|
||||||
|
if raw_token and not token:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
"token_env",
|
||||||
|
"Token environment variable",
|
||||||
|
"fail",
|
||||||
|
"Set every environment variable referenced by the bot token.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if raw_proxy and not proxy:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
"proxy_env",
|
||||||
|
"Proxy environment variable",
|
||||||
|
"fail",
|
||||||
|
"Set every environment variable referenced by the network proxy.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (raw_token and not token) or (raw_proxy and not proxy):
|
||||||
|
return status_from_checks("telegram", checks, missing)
|
||||||
|
if proxy and not _proxy_url_is_valid(proxy):
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
"proxy_format",
|
||||||
|
"Network proxy",
|
||||||
|
"fail",
|
||||||
|
"Enter a full HTTP or SOCKS proxy URL.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return status_from_checks("telegram", checks, missing)
|
||||||
if token:
|
if token:
|
||||||
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
|
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
|
||||||
checks.append(
|
checks.append(
|
||||||
@@ -35,7 +93,7 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
|
|||||||
check("token_format", "Token format", "pass", "Looks like a BotFather token.")
|
check("token_format", "Token format", "pass", "Looks like a BotFather token.")
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
data = http_get(f"https://api.telegram.org/bot{token}/getMe")
|
data = _get_me(token, proxy or None)
|
||||||
if data.get("ok") and isinstance(data.get("result"), dict):
|
if data.get("ok") and isinstance(data.get("result"), dict):
|
||||||
bot = data["result"]
|
bot = data["result"]
|
||||||
identity = {
|
identity = {
|
||||||
@@ -61,12 +119,31 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
rejected = status_code in {400, 401, 403, 404}
|
||||||
checks.append(
|
checks.append(
|
||||||
check(
|
check(
|
||||||
"get_me",
|
"get_me",
|
||||||
"Bot identity",
|
"Bot identity",
|
||||||
|
"fail" if rejected else "warn",
|
||||||
|
(
|
||||||
|
f"Telegram rejected the token: HTTP {status_code}."
|
||||||
|
if rejected
|
||||||
|
else f"Telegram could not verify the token: HTTP {status_code}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except httpx.TransportError:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
"proxy_connection" if proxy else "get_me",
|
||||||
|
"Network proxy" if proxy else "Bot identity",
|
||||||
"warn",
|
"warn",
|
||||||
f"Telegram could not verify the token: HTTP {exc.response.status_code}.",
|
(
|
||||||
|
"Could not reach Telegram through the network proxy."
|
||||||
|
if proxy
|
||||||
|
else "Could not reach Telegram now. Try again later."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -75,7 +152,7 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
|
|||||||
"get_me",
|
"get_me",
|
||||||
"Bot identity",
|
"Bot identity",
|
||||||
"warn",
|
"warn",
|
||||||
"Could not reach Telegram now. Try again later.",
|
"Could not verify Telegram now. Try again later.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return status_from_checks("telegram", checks, missing)
|
return status_from_checks("telegram", checks, missing)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
docsUrl: chatAppGuideUrl("telegram"),
|
docsUrl: chatAppGuideUrl("telegram"),
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "channels.telegram.token" },
|
{ key: "channels.telegram.token" },
|
||||||
|
{ key: "channels.telegram.proxy" },
|
||||||
{ key: "channels.telegram.allowFrom" },
|
{ key: "channels.telegram.allowFrom" },
|
||||||
{ key: "channels.telegram.groupPolicy" },
|
{ key: "channels.telegram.groupPolicy" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Create it with BotFather."
|
"help": "Create it with BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Network proxy",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Allowed users",
|
"label": "Allowed users",
|
||||||
"placeholder": "* or Telegram user IDs",
|
"placeholder": "* or Telegram user IDs",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Créalo con BotFather."
|
"help": "Créalo con BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy de red",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Usuarios permitidos",
|
"label": "Usuarios permitidos",
|
||||||
"placeholder": "* o ID de usuario de Telegram",
|
"placeholder": "* o ID de usuario de Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Créez-le avec BotFather."
|
"help": "Créez-le avec BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy réseau",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Utilisateurs autorisés",
|
"label": "Utilisateurs autorisés",
|
||||||
"placeholder": "* ou ID utilisateur Telegram",
|
"placeholder": "* ou ID utilisateur Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Buat dengan BotFather."
|
"help": "Buat dengan BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy jaringan",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Pengguna yang diizinkan",
|
"label": "Pengguna yang diizinkan",
|
||||||
"placeholder": "* atau ID pengguna Telegram",
|
"placeholder": "* atau ID pengguna Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "BotFather で作成します。"
|
"help": "BotFather で作成します。"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "ネットワークプロキシ",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "許可するユーザー",
|
"label": "許可するユーザー",
|
||||||
"placeholder": "* または Telegram ユーザー ID",
|
"placeholder": "* または Telegram ユーザー ID",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "BotFather에서 생성하세요."
|
"help": "BotFather에서 생성하세요."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "네트워크 프록시",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "허용된 사용자",
|
"label": "허용된 사용자",
|
||||||
"placeholder": "* 또는 Telegram 사용자 ID",
|
"placeholder": "* 또는 Telegram 사용자 ID",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Crie-o com o BotFather."
|
"help": "Crie-o com o BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy de rede",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Usuários permitidos",
|
"label": "Usuários permitidos",
|
||||||
"placeholder": "* ou IDs de usuário do Telegram",
|
"placeholder": "* ou IDs de usuário do Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "Tạo bằng BotFather."
|
"help": "Tạo bằng BotFather."
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "Proxy mạng",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "Người dùng được phép",
|
"label": "Người dùng được phép",
|
||||||
"placeholder": "* hoặc ID người dùng Telegram",
|
"placeholder": "* hoặc ID người dùng Telegram",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "使用 BotFather 创建。"
|
"help": "使用 BotFather 创建。"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "网络代理",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "允许的用户",
|
"label": "允许的用户",
|
||||||
"placeholder": "* 或 Telegram 用户 ID",
|
"placeholder": "* 或 Telegram 用户 ID",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"placeholder": "123456:ABC...",
|
"placeholder": "123456:ABC...",
|
||||||
"help": "使用 BotFather 建立。"
|
"help": "使用 BotFather 建立。"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"label": "網路代理",
|
||||||
|
"placeholder": "http://127.0.0.1:7890"
|
||||||
|
},
|
||||||
"allowFrom": {
|
"allowFrom": {
|
||||||
"label": "允許的使用者",
|
"label": "允許的使用者",
|
||||||
"placeholder": "* 或 Telegram 使用者 ID",
|
"placeholder": "* 或 Telegram 使用者 ID",
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ from nanobot.bus.outbound_events import (
|
|||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
|
TurnModelUpdatedEvent,
|
||||||
outbound_event_from_message,
|
outbound_event_from_message,
|
||||||
outbound_message_for_event,
|
outbound_message_for_event,
|
||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_INPUT_META,
|
||||||
|
WEBUI_QUOTE_METADATA,
|
||||||
|
webui_quote_runtime_context,
|
||||||
|
)
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WORKSPACE_SCOPE_METADATA_KEY,
|
WORKSPACE_SCOPE_METADATA_KEY,
|
||||||
WorkspaceScopeError,
|
WorkspaceScopeError,
|
||||||
@@ -250,6 +256,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_chats: dict[Any, set[str]] = {}
|
self._conn_chats: dict[Any, set[str]] = {}
|
||||||
# connection -> default chat_id for legacy frames that omit routing.
|
# connection -> default chat_id for legacy frames that omit routing.
|
||||||
self._conn_default: dict[Any, str] = {}
|
self._conn_default: dict[Any, str] = {}
|
||||||
|
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||||
|
self._webui_connections: set[Any] = set()
|
||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | None = None
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@@ -284,6 +292,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not subs:
|
if not subs:
|
||||||
self._subs.pop(cid, None)
|
self._subs.pop(cid, None)
|
||||||
self._conn_default.pop(connection, None)
|
self._conn_default.pop(connection, None)
|
||||||
|
self._webui_connections.discard(connection)
|
||||||
|
|
||||||
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
|
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
|
||||||
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
|
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
|
||||||
@@ -311,7 +320,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
await self.send_goal_status(chat_id, "running", started_at=t0)
|
await self.send_goal_status(chat_id, "running", started_at=t0)
|
||||||
|
|
||||||
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
||||||
"""Replay goal/run strip state after subscribe (same-process refresh)."""
|
"""Replay persisted or actively running per-chat state after subscribe."""
|
||||||
await self._maybe_push_active_goal_state(chat_id)
|
await self._maybe_push_active_goal_state(chat_id)
|
||||||
await self._maybe_push_turn_run_wall_clock(chat_id)
|
await self._maybe_push_turn_run_wall_clock(chat_id)
|
||||||
|
|
||||||
@@ -374,19 +383,25 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if static_token:
|
if static_token:
|
||||||
if supplied and hmac.compare_digest(supplied, static_token):
|
if supplied and hmac.compare_digest(supplied, static_token):
|
||||||
return None
|
return None
|
||||||
if supplied and self._tokens.take_issued_token_if_valid(supplied):
|
if supplied and self._consume_issued_token(connection, supplied):
|
||||||
return None
|
return None
|
||||||
return connection.respond(401, "Unauthorized")
|
return connection.respond(401, "Unauthorized")
|
||||||
|
|
||||||
if self.config.websocket_requires_token:
|
if self.config.websocket_requires_token:
|
||||||
if supplied and self._tokens.take_issued_token_if_valid(supplied):
|
if supplied and self._consume_issued_token(connection, supplied):
|
||||||
return None
|
return None
|
||||||
return connection.respond(401, "Unauthorized")
|
return connection.respond(401, "Unauthorized")
|
||||||
|
|
||||||
if supplied:
|
if supplied:
|
||||||
self._tokens.take_issued_token_if_valid(supplied)
|
self._consume_issued_token(connection, supplied)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _consume_issued_token(self, connection: Any, token: str) -> bool:
|
||||||
|
audience = self._tokens.take_issued_token_audience(token)
|
||||||
|
if audience == "webui":
|
||||||
|
self._webui_connections.add(connection)
|
||||||
|
return audience is not None
|
||||||
|
|
||||||
# -- Server lifecycle and connection ingress ---------------------------
|
# -- Server lifecycle and connection ingress ---------------------------
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -696,6 +711,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
cli_apps=cli_apps or None,
|
cli_apps=cli_apps or None,
|
||||||
mcp_presets=mcp_presets or None,
|
mcp_presets=mcp_presets or None,
|
||||||
)
|
)
|
||||||
|
if metadata.get("webui") is True and connection in self._webui_connections:
|
||||||
|
quote = webui_quote_runtime_context({
|
||||||
|
WEBUI_QUOTE_METADATA: envelope.get("quoted_context"),
|
||||||
|
})
|
||||||
|
if quote is not None:
|
||||||
|
metadata[RUNTIME_CONTEXT_INPUT_META] = [quote]
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=client_id,
|
sender_id=client_id,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
@@ -747,6 +768,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
|
self._webui_connections.clear()
|
||||||
self._tokens.clear()
|
self._tokens.clear()
|
||||||
|
|
||||||
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
|
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
|
||||||
@@ -784,6 +806,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
else:
|
else:
|
||||||
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
|
if isinstance(event, TurnModelUpdatedEvent):
|
||||||
|
if conns:
|
||||||
|
await self.send_turn_model_updated(
|
||||||
|
msg.chat_id,
|
||||||
|
model_name=event.model,
|
||||||
|
)
|
||||||
|
return
|
||||||
if isinstance(event, GoalStateSyncEvent):
|
if isinstance(event, GoalStateSyncEvent):
|
||||||
if conns:
|
if conns:
|
||||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||||
@@ -988,6 +1017,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
|
if stream_end and resuming:
|
||||||
|
body["resuming"] = True
|
||||||
self._transcripts.prepare_and_append(
|
self._transcripts.prepare_and_append(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
@@ -1090,3 +1121,26 @@ class WebSocketChannel(BaseChannel):
|
|||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" runtime_model_updated ")
|
await self._safe_send_to(connection, raw, label=" runtime_model_updated ")
|
||||||
|
|
||||||
|
async def send_turn_model_updated(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
*,
|
||||||
|
model_name: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Notify one chat's subscribers which model is handling its current request."""
|
||||||
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
|
if (
|
||||||
|
not conns
|
||||||
|
or not isinstance(model_name, str)
|
||||||
|
or not model_name.strip()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"event": "turn_model_updated",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"model_name": model_name.strip(),
|
||||||
|
}
|
||||||
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
|
for connection in conns:
|
||||||
|
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
|
TurnModelUpdatedEvent,
|
||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.websocket.runtime import (
|
from nanobot.channels.websocket.runtime import (
|
||||||
@@ -32,6 +33,7 @@ from nanobot.channels.websocket.runtime import (
|
|||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
|
||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session import webui_turns as wth
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||||
@@ -502,11 +504,83 @@ async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> No
|
|||||||
await channel._dispatch_envelope(
|
await channel._dispatch_envelope(
|
||||||
conn,
|
conn,
|
||||||
"custom-client",
|
"custom-client",
|
||||||
{"type": "message", "chat_id": "chat-1", "content": "hello"},
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": "chat-1",
|
||||||
|
"content": "hello",
|
||||||
|
"quoted_context": "must be ignored",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
msg = bus.publish_inbound.await_args.args[0]
|
msg = bus.publish_inbound.await_args.args[0]
|
||||||
assert "webui" not in msg.metadata
|
assert "webui" not in msg.metadata
|
||||||
|
assert RUNTIME_CONTEXT_INPUT_META not in msg.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
webui_connection = MagicMock()
|
||||||
|
client_connection = MagicMock()
|
||||||
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
|
client_token = channel.gateway.tokens.issue_token(300)
|
||||||
|
|
||||||
|
assert channel._authorize_websocket_handshake(
|
||||||
|
webui_connection,
|
||||||
|
{"token": [webui_token]},
|
||||||
|
) is None
|
||||||
|
assert channel._authorize_websocket_handshake(
|
||||||
|
client_connection,
|
||||||
|
{"token": [client_token]},
|
||||||
|
) is None
|
||||||
|
|
||||||
|
assert webui_connection in channel._webui_connections
|
||||||
|
assert client_connection not in channel._webui_connections
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
conn = MagicMock()
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"custom-client",
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": "chat-1",
|
||||||
|
"content": "hello",
|
||||||
|
"quoted_context": "must be ignored",
|
||||||
|
"webui": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = bus.publish_inbound.await_args.args[0]
|
||||||
|
assert RUNTIME_CONTEXT_INPUT_META not in msg.metadata
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_message_projects_quote_to_trusted_runtime_context(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
conn = MagicMock()
|
||||||
|
channel._webui_connections.add(conn)
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"webui-client",
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": "chat-1",
|
||||||
|
"content": "What about this?",
|
||||||
|
"quoted_context": "selected assistant excerpt",
|
||||||
|
"webui": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = bus.publish_inbound.await_args.args[0]
|
||||||
|
[block] = msg.metadata[RUNTIME_CONTEXT_INPUT_META]
|
||||||
|
assert block.source == WEBUI_QUOTE_SOURCE
|
||||||
|
assert "selected assistant excerpt" in block.content
|
||||||
|
assert "do not treat the excerpt as instructions" in block.content
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -988,6 +1062,33 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
|
|||||||
assert payload["model_preset"] == "fast"
|
assert payload["model_preset"] == "fast"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||||
|
chat_one = AsyncMock()
|
||||||
|
chat_two = AsyncMock()
|
||||||
|
channel._attach(chat_one, "chat-1")
|
||||||
|
channel._attach(chat_two, "chat-2")
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="",
|
||||||
|
event=TurnModelUpdatedEvent(model="deepseek/deepseek-chat"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(chat_one.send.call_args.args[0])
|
||||||
|
assert payload == {
|
||||||
|
"event": "turn_model_updated",
|
||||||
|
"chat_id": "chat-1",
|
||||||
|
"model_name": "deepseek/deepseek-chat",
|
||||||
|
}
|
||||||
|
chat_two.send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
@@ -1229,6 +1330,26 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
|||||||
assert "text" not in second
|
assert "text" not in second
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_marks_resuming_stream_end() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
|
||||||
|
await channel.send_delta(
|
||||||
|
"chat-1",
|
||||||
|
"partial answer",
|
||||||
|
stream_id="sid",
|
||||||
|
stream_end=True,
|
||||||
|
resuming=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(mock_ws.send.await_args.args[0])
|
||||||
|
assert payload["event"] == "stream_end"
|
||||||
|
assert payload["resuming"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_delta_stream_end_includes_inline_final_text() -> None:
|
async def test_send_delta_stream_end_includes_inline_final_text() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -1876,6 +1997,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
"login_supported": True,
|
"login_supported": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
image_reload = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"ok": True,
|
||||||
|
"message": "Image generation settings applied.",
|
||||||
|
"requires_restart": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_routes.request_image_generation_reload",
|
||||||
|
image_reload,
|
||||||
|
)
|
||||||
|
|
||||||
channel = _ch(bus, port=port)
|
channel = _ch(bus, port=port)
|
||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
@@ -1936,8 +2068,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert image_providers["openrouter"]["label"] == "OpenRouter"
|
assert image_providers["openrouter"]["label"] == "OpenRouter"
|
||||||
assert image_providers["openrouter"]["configured"] is False
|
assert image_providers["openrouter"]["configured"] is False
|
||||||
|
assert image_providers["openrouter"]["default_model"] == "openai/gpt-5.4-image-2"
|
||||||
|
assert image_providers["openrouter"]["models"] == ["openai/gpt-5.4-image-2"]
|
||||||
assert image_providers["openai_codex"]["auth_type"] == "oauth"
|
assert image_providers["openai_codex"]["auth_type"] == "oauth"
|
||||||
assert image_providers["openai_codex"]["configured"] is False
|
assert image_providers["openai_codex"]["configured"] is False
|
||||||
|
assert image_providers["gemini"]["models"] == [
|
||||||
|
"gemini-2.5-flash-image",
|
||||||
|
"imagen-4.0-generate-001",
|
||||||
|
]
|
||||||
assert image_providers["gemini"]["label"] == "Gemini"
|
assert image_providers["gemini"]["label"] == "Gemini"
|
||||||
assert body["runtime"]["config_path"] == str(config_path)
|
assert body["runtime"]["config_path"] == str(config_path)
|
||||||
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
|
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
|
||||||
@@ -1973,6 +2111,36 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert provider_body["image_generation"]["provider_configured"] is True
|
assert provider_body["image_generation"]["provider_configured"] is True
|
||||||
assert "sk-or-test" not in provider_updated.text
|
assert "sk-or-test" not in provider_updated.text
|
||||||
|
|
||||||
|
custom_provider_created = await _http_get(
|
||||||
|
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer tok",
|
||||||
|
"X-Nanobot-Provider-Values": json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Company Gateway",
|
||||||
|
"apiBase": "https://gateway.example/v1",
|
||||||
|
"apiKey": "sk-company",
|
||||||
|
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||||
|
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||||
|
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||||
|
"proxy": "http://127.0.0.1:7890",
|
||||||
|
"thinkingStyle": "enable_thinking",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert custom_provider_created.status_code == 200
|
||||||
|
custom_provider_body = custom_provider_created.json()
|
||||||
|
custom_provider_name = custom_provider_body["created_provider"]
|
||||||
|
custom_provider_rows = {
|
||||||
|
provider["name"]: provider for provider in custom_provider_body["providers"]
|
||||||
|
}
|
||||||
|
assert custom_provider_rows[custom_provider_name]["label"] == "Company Gateway"
|
||||||
|
assert custom_provider_rows[custom_provider_name]["extra_headers"] == {
|
||||||
|
"X-Tenant": "engineering"
|
||||||
|
}
|
||||||
|
assert "sk-company" not in custom_provider_created.text
|
||||||
|
|
||||||
local_provider_updated = await _http_get(
|
local_provider_updated = await _http_get(
|
||||||
"http://127.0.0.1:"
|
"http://127.0.0.1:"
|
||||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||||
@@ -2022,8 +2190,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
)
|
)
|
||||||
assert created_preset.status_code == 200
|
assert created_preset.status_code == 200
|
||||||
created_body = created_preset.json()
|
created_body = created_preset.json()
|
||||||
assert created_body["agent"]["model_preset"] == "fast-writing"
|
assert created_body["created_model_preset"] == "fast-writing"
|
||||||
assert created_body["agent"]["model"] == "openai/gpt-4.1-mini"
|
assert created_body["agent"]["model_preset"] == "deep"
|
||||||
|
assert created_body["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
|
assert created_body["model_call_order"] == ["deep"]
|
||||||
created_presets = {
|
created_presets = {
|
||||||
preset["name"]: preset for preset in created_body["model_presets"]
|
preset["name"]: preset for preset in created_body["model_presets"]
|
||||||
}
|
}
|
||||||
@@ -2038,13 +2208,25 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
)
|
)
|
||||||
assert updated_preset.status_code == 200
|
assert updated_preset.status_code == 200
|
||||||
updated_preset_body = updated_preset.json()
|
updated_preset_body = updated_preset.json()
|
||||||
assert updated_preset_body["agent"]["model_preset"] == "fast-writing"
|
assert updated_preset_body["agent"]["model_preset"] == "deep"
|
||||||
assert updated_preset_body["agent"]["model"] == "openai/gpt-5.5"
|
assert updated_preset_body["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
updated_presets = {
|
updated_presets = {
|
||||||
preset["name"]: preset for preset in updated_preset_body["model_presets"]
|
preset["name"]: preset for preset in updated_preset_body["model_presets"]
|
||||||
}
|
}
|
||||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||||
|
|
||||||
|
call_order_updated = await _http_get(
|
||||||
|
"http://127.0.0.1:"
|
||||||
|
f"{port}/api/settings/model-call-order/update"
|
||||||
|
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
|
)
|
||||||
|
assert call_order_updated.status_code == 200
|
||||||
|
call_order_body = call_order_updated.json()
|
||||||
|
assert call_order_body["agent"]["model_preset"] == "fast-writing"
|
||||||
|
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||||
|
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||||
|
|
||||||
duplicate_preset = await _http_get(
|
duplicate_preset = await _http_get(
|
||||||
"http://127.0.0.1:"
|
"http://127.0.0.1:"
|
||||||
f"{port}/api/settings/model-configurations/create"
|
f"{port}/api/settings/model-configurations/create"
|
||||||
@@ -2094,7 +2276,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert image_updated.status_code == 200
|
assert image_updated.status_code == 200
|
||||||
image_body = image_updated.json()
|
image_body = image_updated.json()
|
||||||
assert image_body["requires_restart"] is True
|
assert image_body["requires_restart"] is True
|
||||||
assert image_body["restart_required_sections"] == ["browser", "image", "runtime"]
|
assert image_body["restart_required_sections"] == ["browser", "runtime"]
|
||||||
assert image_body["image_generation"]["enabled"] is True
|
assert image_body["image_generation"]["enabled"] is True
|
||||||
assert image_body["image_generation"]["model"] == "openai/gpt-image-1"
|
assert image_body["image_generation"]["model"] == "openai/gpt-image-1"
|
||||||
assert image_body["image_generation"]["default_aspect_ratio"] == "16:9"
|
assert image_body["image_generation"]["default_aspect_ratio"] == "16:9"
|
||||||
@@ -2109,12 +2291,9 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
)
|
)
|
||||||
assert image_provider_updated.status_code == 200
|
assert image_provider_updated.status_code == 200
|
||||||
assert image_provider_updated.json()["requires_restart"] is True
|
assert image_provider_updated.json()["requires_restart"] is True
|
||||||
assert image_provider_updated.json()["restart_required_sections"] == [
|
assert image_provider_updated.json()["restart_required_sections"] == ["browser", "runtime"]
|
||||||
"browser",
|
|
||||||
"image",
|
|
||||||
"runtime",
|
|
||||||
]
|
|
||||||
assert "sk-or-next" not in image_provider_updated.text
|
assert "sk-or-next" not in image_provider_updated.text
|
||||||
|
assert image_reload.await_count == 2
|
||||||
|
|
||||||
bad_web = await _http_get(
|
bad_web = await _http_get(
|
||||||
"http://127.0.0.1:"
|
"http://127.0.0.1:"
|
||||||
@@ -2134,6 +2313,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "atomic_chat"
|
assert saved.agents.defaults.provider == "atomic_chat"
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||||
|
assert saved.agents.defaults.fallback_models == ["deep"]
|
||||||
assert saved.model_presets["fast-writing"].label == "Codex"
|
assert saved.model_presets["fast-writing"].label == "Codex"
|
||||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
||||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||||
@@ -2144,6 +2324,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert saved.providers.openrouter.api_key == "sk-or-next"
|
assert saved.providers.openrouter.api_key == "sk-or-next"
|
||||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
||||||
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
|
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
|
||||||
|
custom_provider = saved.providers.model_extra[custom_provider_name]
|
||||||
|
assert custom_provider.display_name == "Company Gateway"
|
||||||
|
assert custom_provider.api_base == "https://gateway.example/v1"
|
||||||
|
assert custom_provider.extra_body == {"service_tier": "priority"}
|
||||||
assert saved.tools.web.search.provider == "searxng"
|
assert saved.tools.web.search.provider == "searxng"
|
||||||
assert saved.tools.web.search.api_key == ""
|
assert saved.tools.web.search.api_key == ""
|
||||||
assert saved.tools.web.search.base_url == "https://search.example.com"
|
assert saved.tools.web.search.base_url == "https://search.example.com"
|
||||||
@@ -2162,6 +2346,92 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_settings_hot_reload_without_restart(
|
||||||
|
bus: MagicMock,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
port = 29935
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.providers.openrouter.api_key = "image-key"
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
image_reload = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"ok": True,
|
||||||
|
"message": "Image generation settings applied.",
|
||||||
|
"requires_restart": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_routes.request_image_generation_reload",
|
||||||
|
image_reload,
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = _ch(bus, port=port)
|
||||||
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
try:
|
||||||
|
response = await _http_get(
|
||||||
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["requires_restart"] is False
|
||||||
|
assert response.json()["restart_required_sections"] == []
|
||||||
|
image_reload.assert_awaited_once_with(bus)
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
||||||
|
bus: MagicMock,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
port = 29936
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.providers.openrouter.api_key = "image-key"
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_routes.request_image_generation_reload",
|
||||||
|
AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"ok": False,
|
||||||
|
"message": "Image generation hot reload timed out.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = _ch(bus, port=port)
|
||||||
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
try:
|
||||||
|
response = await _http_get(
|
||||||
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["requires_restart"] is True
|
||||||
|
assert response.json()["restart_required_sections"] == ["image"]
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
|
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
|
||||||
port = 29892
|
port = 29892
|
||||||
@@ -2849,6 +3119,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
|||||||
"updated_at": "2026-05-19T10:01:00Z",
|
"updated_at": "2026-05-19T10:01:00Z",
|
||||||
"title": "Running",
|
"title": "Running",
|
||||||
"preview": "work",
|
"preview": "work",
|
||||||
|
"model_preset": "fast",
|
||||||
"path": "/private/path",
|
"path": "/private/path",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2885,6 +3156,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
|||||||
"updated_at": "2026-05-19T10:01:00Z",
|
"updated_at": "2026-05-19T10:01:00Z",
|
||||||
"title": "Running",
|
"title": "Running",
|
||||||
"preview": "work",
|
"preview": "work",
|
||||||
|
"model_preset": "fast",
|
||||||
"run_started_at": 1_700_000_000.0,
|
"run_started_at": 1_700_000_000.0,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ async def test_bootstrap_returns_token_for_localhost(
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["token"].startswith("nbwt_")
|
assert body["token"].startswith("nbwt_")
|
||||||
|
assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui"
|
||||||
assert body["api_token"].startswith("nbwt_")
|
assert body["api_token"].startswith("nbwt_")
|
||||||
assert body["api_token"] != body["token"]
|
assert body["api_token"] != body["token"]
|
||||||
assert body["ws_path"] == "/"
|
assert body["ws_path"] == "/"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
|
|||||||
channel.gateway = MagicMock()
|
channel.gateway = MagicMock()
|
||||||
channel.gateway.session_manager = MagicMock()
|
channel.gateway.session_manager = MagicMock()
|
||||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||||
|
channel._turn_models = {}
|
||||||
|
|
||||||
sent_events = []
|
sent_events = []
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
|
|||||||
channel.gateway = MagicMock()
|
channel.gateway = MagicMock()
|
||||||
channel.gateway.session_manager = MagicMock()
|
channel.gateway.session_manager = MagicMock()
|
||||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||||
|
channel._turn_models = {}
|
||||||
|
|
||||||
sent_events = []
|
sent_events = []
|
||||||
|
|
||||||
|
|||||||
+154
-25
@@ -78,7 +78,12 @@ from nanobot.config.paths import get_workspace_path, is_default_workspace # noq
|
|||||||
from nanobot.config.schema import Config # noqa: E402
|
from nanobot.config.schema import Config # noqa: E402
|
||||||
from nanobot.security.network import is_loopback_host # noqa: E402
|
from nanobot.security.network import is_loopback_host # noqa: E402
|
||||||
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt # noqa: E402
|
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt # noqa: E402
|
||||||
from nanobot.utils.helpers import sync_workspace_templates # noqa: E402
|
from nanobot.utils.helpers import ( # noqa: E402
|
||||||
|
sanitize_surrogates as _sanitize_surrogates,
|
||||||
|
)
|
||||||
|
from nanobot.utils.helpers import ( # noqa: E402
|
||||||
|
sync_workspace_templates,
|
||||||
|
)
|
||||||
from nanobot.utils.restart import ( # noqa: E402
|
from nanobot.utils.restart import ( # noqa: E402
|
||||||
consume_restart_notice_from_env,
|
consume_restart_notice_from_env,
|
||||||
format_restart_completed_message,
|
format_restart_completed_message,
|
||||||
@@ -92,17 +97,6 @@ from nanobot.webui.build import ( # noqa: E402
|
|||||||
from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
|
from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_surrogates(text: str) -> str:
|
|
||||||
"""Reconstruct surrogate pairs into real characters; replace lone surrogates.
|
|
||||||
|
|
||||||
On Windows, console input may produce lone surrogate code points (e.g.
|
|
||||||
``\\ud83d\\udc08`` for U+1F408). Round-tripping through UTF-16 reconstructs
|
|
||||||
paired surrogates into their actual characters and replaces unpaired ones
|
|
||||||
with U+FFFD.
|
|
||||||
"""
|
|
||||||
return text.encode("utf-16-le", errors="surrogatepass").decode("utf-16-le", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def _signal_name(signum: int) -> str:
|
def _signal_name(signum: int) -> str:
|
||||||
with suppress(ValueError):
|
with suppress(ValueError):
|
||||||
return signal.Signals(signum).name
|
return signal.Signals(signum).name
|
||||||
@@ -926,11 +920,10 @@ def _load_webui_setup_config(config_path: Path) -> Config:
|
|||||||
|
|
||||||
def _provider_setup_error(config: Config) -> str | None:
|
def _provider_setup_error(config: Config) -> str | None:
|
||||||
"""Return the provider setup error, or None when the current model can start."""
|
"""Return the provider setup error, or None when the current model can start."""
|
||||||
from nanobot.config.loader import resolve_config_env_vars
|
|
||||||
from nanobot.providers.factory import build_provider_snapshot
|
from nanobot.providers.factory import build_provider_snapshot
|
||||||
|
|
||||||
try:
|
try:
|
||||||
build_provider_snapshot(resolve_config_env_vars(config.model_copy(deep=True)))
|
build_provider_snapshot(config)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return str(exc)
|
return str(exc)
|
||||||
return None
|
return None
|
||||||
@@ -1433,7 +1426,7 @@ def webui(
|
|||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
|
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
|
||||||
from nanobot.config.loader import save_config
|
from nanobot.config.loader import resolve_config_env_vars, save_config
|
||||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||||
|
|
||||||
_ensure_interactive_tty_mode()
|
_ensure_interactive_tty_mode()
|
||||||
@@ -1447,8 +1440,24 @@ def webui(
|
|||||||
if workspace:
|
if workspace:
|
||||||
setup_config.agents.defaults.workspace = workspace
|
setup_config.agents.defaults.workspace = workspace
|
||||||
|
|
||||||
provider_error = _provider_setup_error(setup_config)
|
try:
|
||||||
if provider_error:
|
resolved_setup_config = resolve_config_env_vars(setup_config.model_copy(deep=True))
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
provider_error = _provider_setup_error(resolved_setup_config)
|
||||||
|
settings_setup_error = provider_error if provider_error and created_config else None
|
||||||
|
if settings_setup_error:
|
||||||
|
console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]")
|
||||||
|
console.print("Configure a provider and model in WebUI Settings → Models.")
|
||||||
|
if background:
|
||||||
|
console.print(
|
||||||
|
"[red]First-time WebUI setup must run in the foreground. "
|
||||||
|
"Run `nanobot webui` without --background.[/red]"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
elif provider_error:
|
||||||
console.print(f"[dim]Provider check: {provider_error}[/dim]")
|
console.print(f"[dim]Provider check: {provider_error}[/dim]")
|
||||||
setup_config = _run_quick_start_for_webui(setup_config, yes=yes)
|
setup_config = _run_quick_start_for_webui(setup_config, yes=yes)
|
||||||
if workspace:
|
if workspace:
|
||||||
@@ -1591,6 +1600,7 @@ def webui(
|
|||||||
port=effective_gateway_port,
|
port=effective_gateway_port,
|
||||||
open_browser_url=None if no_open else webui_url,
|
open_browser_url=None if no_open else webui_url,
|
||||||
webui_bundle_mode=webui_bundle_mode,
|
webui_bundle_mode=webui_bundle_mode,
|
||||||
|
unconfigured_provider_error=settings_setup_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1609,20 +1619,33 @@ def _run_gateway(
|
|||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
health_server_enabled: bool = True,
|
health_server_enabled: bool = True,
|
||||||
|
unconfigured_provider_error: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||||
|
from nanobot.agent.model_presets import load_model_preset_catalog
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
|
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
|
from nanobot.config.watcher import watch_config_file
|
||||||
from nanobot.cron.bound_runner import run_bound_cron_job
|
from nanobot.cron.bound_runner import run_bound_cron_job
|
||||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
from nanobot.providers.factory import (
|
||||||
|
build_provider_snapshot,
|
||||||
|
build_unconfigured_provider_snapshot,
|
||||||
|
load_provider_snapshot,
|
||||||
|
)
|
||||||
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
from nanobot.session.webui_turns import (
|
||||||
|
WebuiTurnCoordinator,
|
||||||
|
WebuiTurnRoutePolicy,
|
||||||
|
build_webui_fallback_model_observer,
|
||||||
|
)
|
||||||
from nanobot.triggers.local_runner import run_local_trigger_queue
|
from nanobot.triggers.local_runner import run_local_trigger_queue
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
from nanobot.webui.token_usage import TokenUsageHook
|
from nanobot.webui.token_usage import TokenUsageHook
|
||||||
@@ -1654,8 +1677,29 @@ def _run_gateway(
|
|||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
runtime_events = RuntimeEventBus()
|
runtime_events = RuntimeEventBus()
|
||||||
|
fallback_model_observer = build_webui_fallback_model_observer(bus)
|
||||||
|
|
||||||
|
def _observe_fallback_models(snapshot):
|
||||||
|
if isinstance(snapshot.provider, FallbackProvider):
|
||||||
|
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def _load_gateway_provider_snapshot(*args: Any, **kwargs: Any):
|
||||||
try:
|
try:
|
||||||
provider_snapshot = build_provider_snapshot(config)
|
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
|
||||||
|
except ValueError as exc:
|
||||||
|
if unconfigured_provider_error is None:
|
||||||
|
raise
|
||||||
|
return build_unconfigured_provider_snapshot(config, str(exc))
|
||||||
|
|
||||||
|
if unconfigured_provider_error is not None:
|
||||||
|
provider_snapshot = build_unconfigured_provider_snapshot(
|
||||||
|
config,
|
||||||
|
unconfigured_provider_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
@@ -1684,6 +1728,12 @@ def _run_gateway(
|
|||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
trigger_store = LocalTriggerStore(config.workspace_path)
|
trigger_store = LocalTriggerStore(config.workspace_path)
|
||||||
|
|
||||||
|
turn_delivery_factory = TurnDeliveryFactory(
|
||||||
|
bus,
|
||||||
|
runtime_events,
|
||||||
|
route_policy=WebuiTurnRoutePolicy(session_manager),
|
||||||
|
)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop.from_config(
|
agent = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
@@ -1693,18 +1743,21 @@ def _run_gateway(
|
|||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
provider_snapshot_loader=load_provider_snapshot,
|
provider_snapshot_loader=_load_gateway_provider_snapshot,
|
||||||
|
preset_catalog_loader=load_model_preset_catalog,
|
||||||
runtime_events=runtime_events,
|
runtime_events=runtime_events,
|
||||||
|
turn_delivery_factory=turn_delivery_factory,
|
||||||
provider_signature=provider_snapshot.signature,
|
provider_signature=provider_snapshot.signature,
|
||||||
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
)
|
)
|
||||||
WebuiTurnCoordinator(
|
webui_turn_coordinator = WebuiTurnCoordinator(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=session_manager,
|
sessions=session_manager,
|
||||||
schedule_background=lambda coro: agent._schedule_background(coro),
|
schedule_background=lambda coro: agent._schedule_background(coro),
|
||||||
).subscribe(runtime_events)
|
)
|
||||||
|
webui_turn_coordinator.subscribe(runtime_events)
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.session.keys import session_key_for_channel
|
from nanobot.session.keys import session_key_for_channel
|
||||||
|
|
||||||
@@ -2076,7 +2129,16 @@ def _run_gateway(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await cron.start()
|
await cron.start()
|
||||||
|
# Re-read once on first admission to close the watcher subscription window.
|
||||||
|
agent.runtime_resolver.invalidate()
|
||||||
tasks = [
|
tasks = [
|
||||||
|
asyncio.create_task(
|
||||||
|
watch_config_file(
|
||||||
|
Path(config_path),
|
||||||
|
lambda: agent.invalidate_runtime_config(),
|
||||||
|
),
|
||||||
|
name="nanobot-config-watcher",
|
||||||
|
),
|
||||||
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
|
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
|
||||||
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
|
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
@@ -2647,11 +2709,13 @@ _LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {}
|
|||||||
|
|
||||||
_PROVIDER_DISPLAY: dict[str, str] = {
|
_PROVIDER_DISPLAY: dict[str, str] = {
|
||||||
"openai_codex": "OpenAI Codex",
|
"openai_codex": "OpenAI Codex",
|
||||||
|
"xai_grok": "xAI Grok",
|
||||||
"github_copilot": "GitHub Copilot",
|
"github_copilot": "GitHub Copilot",
|
||||||
}
|
}
|
||||||
|
|
||||||
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||||
"openai_codex": "openai-codex/gpt-5.6-sol",
|
"openai_codex": "openai-codex/gpt-5.6-sol",
|
||||||
|
"xai_grok": "xai-grok/grok-4.5",
|
||||||
"github_copilot": "github-copilot/gpt-5.4-mini",
|
"github_copilot": "github-copilot/gpt-5.4-mini",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2705,6 +2769,8 @@ def _set_oauth_provider_as_main(
|
|||||||
config.agents.defaults.model_preset = None
|
config.agents.defaults.model_preset = None
|
||||||
config.agents.defaults.provider = provider_name
|
config.agents.defaults.provider = provider_name
|
||||||
config.agents.defaults.model = selected_model
|
config.agents.defaults.model = selected_model
|
||||||
|
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
|
||||||
|
config.agents.defaults.context_window_tokens = 500_000
|
||||||
save_config(config, resolved_config_path)
|
save_config(config, resolved_config_path)
|
||||||
|
|
||||||
saved_path = resolved_config_path or get_config_path()
|
saved_path = resolved_config_path or get_config_path()
|
||||||
@@ -2717,7 +2783,10 @@ def _set_oauth_provider_as_main(
|
|||||||
|
|
||||||
@provider_app.command("login")
|
@provider_app.command("login")
|
||||||
def provider_login(
|
def provider_login(
|
||||||
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
provider: str = typer.Argument(
|
||||||
|
...,
|
||||||
|
help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
|
||||||
|
),
|
||||||
set_main: bool = typer.Option(
|
set_main: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
"--set-main",
|
"--set-main",
|
||||||
@@ -2755,7 +2824,11 @@ def provider_login(
|
|||||||
|
|
||||||
@provider_app.command("logout")
|
@provider_app.command("logout")
|
||||||
def provider_logout(
|
def provider_logout(
|
||||||
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
provider: str = typer.Argument(
|
||||||
|
...,
|
||||||
|
help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
|
||||||
|
),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
):
|
):
|
||||||
"""Log out from an OAuth provider."""
|
"""Log out from an OAuth provider."""
|
||||||
spec = _resolve_oauth_provider(provider)
|
spec = _resolve_oauth_provider(provider)
|
||||||
@@ -2765,6 +2838,13 @@ def provider_logout(
|
|||||||
console.print(f"[red]Logout not implemented for {spec.label}[/red]")
|
console.print(f"[red]Logout not implemented for {spec.label}[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if config:
|
||||||
|
from nanobot.config.loader import set_config_path
|
||||||
|
|
||||||
|
resolved_config_path = Path(config).expanduser().resolve()
|
||||||
|
set_config_path(resolved_config_path)
|
||||||
|
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
||||||
|
|
||||||
console.print(f"{__logo__} OAuth Logout - {spec.label}\n")
|
console.print(f"{__logo__} OAuth Logout - {spec.label}\n")
|
||||||
handler()
|
handler()
|
||||||
|
|
||||||
@@ -2815,6 +2895,55 @@ def _logout_openai_codex() -> None:
|
|||||||
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
|
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
|
||||||
|
|
||||||
|
|
||||||
|
@_register_login("xai_grok")
|
||||||
|
def _login_xai_grok() -> None:
|
||||||
|
"""Authenticate with xAI using the Grok subscription OAuth contract."""
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
from nanobot.providers.xai_oauth import get_xai_oauth_token, login_xai_oauth
|
||||||
|
|
||||||
|
try:
|
||||||
|
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]{exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
token = None
|
||||||
|
with suppress(Exception):
|
||||||
|
token = get_xai_oauth_token(proxy=proxy)
|
||||||
|
if not (token and token.access):
|
||||||
|
console.print(
|
||||||
|
"[cyan]Starting xAI browser sign-in for your X Premium / Grok subscription...[/cyan]\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
token = login_xai_oauth(
|
||||||
|
print_fn=lambda message: console.print(message),
|
||||||
|
prompt_fn=lambda prompt: typer.prompt(prompt),
|
||||||
|
proxy=proxy,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f"[red]Authentication error: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
account = token.account_id or "xAI account"
|
||||||
|
console.print(f"[green]✓ Authenticated with xAI[/green] [dim]{account}[/dim]")
|
||||||
|
console.print(
|
||||||
|
"[dim]Hosted X Search is enabled automatically when the selected model supports it.[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_register_logout("xai_grok")
|
||||||
|
def _logout_xai_grok() -> None:
|
||||||
|
"""Clear local xAI OAuth credentials for this nanobot instance."""
|
||||||
|
from nanobot.providers.xai_oauth import get_xai_oauth_storage_path, logout_xai_oauth
|
||||||
|
|
||||||
|
token_path = get_xai_oauth_storage_path()
|
||||||
|
provider_label = _PROVIDER_DISPLAY["xai_grok"]
|
||||||
|
if logout_xai_oauth():
|
||||||
|
console.print(f"[green]✓ Logged out from {provider_label}[/green]")
|
||||||
|
console.print(f"[dim]Removed: {token_path}[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
@_register_logout("github_copilot")
|
@_register_logout("github_copilot")
|
||||||
def _logout_github_copilot() -> None:
|
def _logout_github_copilot() -> None:
|
||||||
"""Clear local OAuth credentials for GitHub Copilot."""
|
"""Clear local OAuth credentials for GitHub Copilot."""
|
||||||
|
|||||||
+19
-11
@@ -237,7 +237,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
"""Build an outbound status message for a session."""
|
"""Build an outbound status message for a session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
runtime = ctx.runtime or loop.llm_runtime()
|
runtime = ctx.runtime or loop.runtime_for_session(session)
|
||||||
ctx_est = 0
|
ctx_est = 0
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(
|
ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(
|
||||||
@@ -286,11 +286,12 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
|||||||
await loop._cancel_active_tasks(ctx.key)
|
await loop._cancel_active_tasks(ctx.key)
|
||||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
snapshot = session.messages[session.last_consolidated:]
|
snapshot = session.messages[session.last_consolidated:]
|
||||||
|
if snapshot:
|
||||||
|
runtime = ctx.runtime or loop.runtime_for_session(session)
|
||||||
session.clear()
|
session.clear()
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
loop.sessions.invalidate(session.key)
|
loop.sessions.invalidate(session.key)
|
||||||
if snapshot:
|
if snapshot:
|
||||||
runtime = ctx.runtime or loop.llm_runtime()
|
|
||||||
loop._schedule_background(
|
loop._schedule_background(
|
||||||
loop.consolidator.archive(
|
loop.consolidator.archive(
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -315,20 +316,25 @@ def _model_preset_names(loop) -> list[str]:
|
|||||||
return ["default", *sorted(name for name in names if name != "default")]
|
return ["default", *sorted(name for name in names if name != "default")]
|
||||||
|
|
||||||
|
|
||||||
def _active_model_preset_name(loop) -> str:
|
|
||||||
return loop.model_preset or "default"
|
|
||||||
|
|
||||||
|
|
||||||
def _command_error_message(exc: Exception) -> str:
|
def _command_error_message(exc: Exception) -> str:
|
||||||
return str(exc.args[0]) if isinstance(exc, KeyError) and exc.args else str(exc)
|
return str(exc.args[0]) if isinstance(exc, KeyError) and exc.args else str(exc)
|
||||||
|
|
||||||
|
|
||||||
def _model_command_status(loop) -> str:
|
def _model_command_status(loop, session) -> str:
|
||||||
names = _model_preset_names(loop)
|
names = _model_preset_names(loop)
|
||||||
active = _active_model_preset_name(loop)
|
try:
|
||||||
|
runtime = loop.runtime_for_session(session, recover_removed=False)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
return "\n".join([
|
return "\n".join([
|
||||||
"## Model",
|
"## Model",
|
||||||
f"- Current model: `{loop.model}`",
|
f"- Current selection error: {_command_error_message(exc)}",
|
||||||
|
f"- Available presets: {_format_preset_names(names)}",
|
||||||
|
"- Switch with `/model <preset>`.",
|
||||||
|
])
|
||||||
|
active = runtime.model_preset or "default"
|
||||||
|
return "\n".join([
|
||||||
|
"## Model",
|
||||||
|
f"- Current model: `{runtime.model}`",
|
||||||
f"- Current preset: `{active}`",
|
f"- Current preset: `{active}`",
|
||||||
f"- Available presets: {_format_preset_names(names)}",
|
f"- Available presets: {_format_preset_names(names)}",
|
||||||
])
|
])
|
||||||
@@ -341,10 +347,11 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
|
|||||||
metadata = {**dict(ctx.msg.metadata or {}), "render_as": "text"}
|
metadata = {**dict(ctx.msg.metadata or {}), "render_as": "text"}
|
||||||
|
|
||||||
if not args:
|
if not args:
|
||||||
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=ctx.msg.channel,
|
channel=ctx.msg.channel,
|
||||||
chat_id=ctx.msg.chat_id,
|
chat_id=ctx.msg.chat_id,
|
||||||
content=_model_command_status(loop),
|
content=_model_command_status(loop, session),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -359,7 +366,7 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
name = parts[0]
|
name = parts[0]
|
||||||
try:
|
try:
|
||||||
runtime = loop.set_model_preset(name)
|
runtime = loop.set_session_model_preset(ctx.key, name)
|
||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
names = _model_preset_names(loop)
|
names = _model_preset_names(loop)
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@@ -375,6 +382,7 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
|
|||||||
max_tokens = runtime.generation.max_tokens
|
max_tokens = runtime.generation.max_tokens
|
||||||
lines = [
|
lines = [
|
||||||
f"Switched model preset to `{runtime.model_preset}`.",
|
f"Switched model preset to `{runtime.model_preset}`.",
|
||||||
|
"- Scope: current session",
|
||||||
f"- Model: `{runtime.model}`",
|
f"- Model: `{runtime.model}`",
|
||||||
f"- Context window: {runtime.context_window_tokens}",
|
f"- Context window: {runtime.context_window_tokens}",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from loguru import logger
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||||
|
from nanobot.utils.helpers import _write_text_atomic
|
||||||
|
|
||||||
# Global variable to store current config path (for multi-instance support)
|
# Global variable to store current config path (for multi-instance support)
|
||||||
_current_config_path: Path | None = None
|
_current_config_path: Path | None = None
|
||||||
@@ -80,13 +81,23 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
|||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
data = config.model_dump(mode="json", by_alias=True)
|
data = config.model_dump(mode="json", by_alias=True)
|
||||||
if config.providers.openai_codex.proxy is not None:
|
# OAuth credentials live in dedicated token stores. Persist only the
|
||||||
data.setdefault("providers", {})["openaiCodex"] = {
|
# non-credential request settings consumed by these provider backends.
|
||||||
"proxy": config.providers.openai_codex.proxy,
|
for alias, provider in (
|
||||||
}
|
("openaiCodex", config.providers.openai_codex),
|
||||||
|
("xaiGrok", config.providers.xai_grok),
|
||||||
|
):
|
||||||
|
settings = provider.model_dump(
|
||||||
|
mode="json",
|
||||||
|
by_alias=True,
|
||||||
|
include={"proxy", "extra_body"},
|
||||||
|
exclude_none=True,
|
||||||
|
)
|
||||||
|
if settings:
|
||||||
|
data.setdefault("providers", {})[alias] = settings
|
||||||
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
# Temp + replace so a crash mid-write cannot leave a truncated config.json.
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
||||||
@@ -116,6 +127,24 @@ def resolve_config_env_vars(config: Config) -> Config:
|
|||||||
return _resolve_in_place(config)
|
return _resolve_in_place(config)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_env_refs(value: str) -> str:
|
||||||
|
"""Resolve ``${VAR}`` references in a single string, leniently.
|
||||||
|
|
||||||
|
Unlike :func:`resolve_config_env_vars` (which walks a whole ``Config`` and
|
||||||
|
raises on a missing variable), this resolves one value and returns an empty
|
||||||
|
string if any reference is unset. It is meant for individual, lazily consumed
|
||||||
|
fields — e.g. a transcription provider's ``api_key`` or ``api_base`` — so a
|
||||||
|
missing variable degrades to "not configured" instead of producing a partial
|
||||||
|
value. Non-string input is returned unchanged.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
names = _ENV_REF_PATTERN.findall(value)
|
||||||
|
if any(name not in os.environ for name in names):
|
||||||
|
return ""
|
||||||
|
return _ENV_REF_PATTERN.sub(lambda m: os.environ[m.group(1)], value)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_in_place(obj: Any) -> Any:
|
def _resolve_in_place(obj: Any) -> Any:
|
||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
new = _ENV_REF_PATTERN.sub(_env_replace, obj)
|
new = _ENV_REF_PATTERN.sub(_env_replace, obj)
|
||||||
@@ -180,8 +209,8 @@ def _migrate_config(data: dict) -> dict:
|
|||||||
defaults.pop("maxMessages", None)
|
defaults.pop("maxMessages", None)
|
||||||
defaults.pop("max_messages", None)
|
defaults.pop("max_messages", None)
|
||||||
if had_legacy_max_messages:
|
if had_legacy_max_messages:
|
||||||
# TODO(next version): Remove this legacy cleanup branch; the schema
|
# TODO(v0.2.4): Remove this legacy cleanup branch. v0.2.3 is the
|
||||||
# will silently ignore this field once the warning grace period ends.
|
# final release that warns before the schema silently ignores the field.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
||||||
"replay max messages is now an internal safety cap. Remove it from "
|
"replay max messages is now an internal safety cap. Remove it from "
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ class AgentDefaults(Base):
|
|||||||
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
|
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
|
||||||
dream: DreamConfig = Field(default_factory=DreamConfig)
|
dream: DreamConfig = Field(default_factory=DreamConfig)
|
||||||
|
|
||||||
|
@field_validator("timezone")
|
||||||
|
@classmethod
|
||||||
|
def validate_timezone(cls, value: str) -> str:
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
try:
|
||||||
|
ZoneInfo(value)
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
raise ValueError(f"unknown timezone {value!r}") from None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class AgentsConfig(Base):
|
class AgentsConfig(Base):
|
||||||
"""Agent configuration."""
|
"""Agent configuration."""
|
||||||
@@ -173,6 +184,11 @@ class AgentsConfig(Base):
|
|||||||
class ProviderConfig(Base):
|
class ProviderConfig(Base):
|
||||||
"""LLM provider configuration."""
|
"""LLM provider configuration."""
|
||||||
|
|
||||||
|
# User-facing name for dynamic custom providers.
|
||||||
|
display_name: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
exclude_if=lambda value: value is None,
|
||||||
|
)
|
||||||
api_key: str | None = Field(default=None, repr=False)
|
api_key: str | None = Field(default=None, repr=False)
|
||||||
api_base: str | None = None
|
api_base: str | None = None
|
||||||
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface
|
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface
|
||||||
@@ -234,6 +250,7 @@ class ProvidersConfig(Base):
|
|||||||
groq: ProviderConfig = Field(default_factory=ProviderConfig)
|
groq: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
|
dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
|
modelscope: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
|
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
|
ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
|
||||||
lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models
|
lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models
|
||||||
@@ -257,6 +274,7 @@ class ProvidersConfig(Base):
|
|||||||
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
||||||
byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan
|
byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan
|
||||||
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth)
|
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth)
|
||||||
|
xai_grok: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # xAI Grok (OAuth)
|
||||||
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth)
|
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth)
|
||||||
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
||||||
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
|
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""System-level notification for config file changes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from watchfiles import Change, awatch
|
||||||
|
|
||||||
|
|
||||||
|
async def watch_config_file(config_path: Path, on_change: Callable[[], None]) -> None:
|
||||||
|
"""Notify ``on_change`` after the configured file changes."""
|
||||||
|
target = config_path.resolve(strict=False)
|
||||||
|
|
||||||
|
def is_config_file(_change: Change, changed_path: str) -> bool:
|
||||||
|
return Path(changed_path).resolve(strict=False) == target
|
||||||
|
|
||||||
|
async for _changes in awatch(
|
||||||
|
target.parent,
|
||||||
|
watch_filter=is_config_file,
|
||||||
|
recursive=False,
|
||||||
|
):
|
||||||
|
on_change()
|
||||||
+20
-8
@@ -8,6 +8,13 @@ from typing import Any, Literal
|
|||||||
from nanobot.utils.dict_keys import get_camel_snake
|
from nanobot.utils.dict_keys import get_camel_snake
|
||||||
|
|
||||||
|
|
||||||
|
def _store_int(value: Any, default: int | None = 0) -> int | None:
|
||||||
|
"""Coerce JSON numerics to int; treat null/blank like a missing key."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CronSchedule:
|
class CronSchedule:
|
||||||
"""Schedule definition for a cron job."""
|
"""Schedule definition for a cron job."""
|
||||||
@@ -25,8 +32,8 @@ class CronSchedule:
|
|||||||
def from_store_dict(cls, data: dict[str, Any]) -> CronSchedule:
|
def from_store_dict(cls, data: dict[str, Any]) -> CronSchedule:
|
||||||
return cls(
|
return cls(
|
||||||
kind=data["kind"],
|
kind=data["kind"],
|
||||||
at_ms=get_camel_snake(data, "atMs", "at_ms"),
|
at_ms=_store_int(get_camel_snake(data, "atMs", "at_ms"), None),
|
||||||
every_ms=get_camel_snake(data, "everyMs", "every_ms"),
|
every_ms=_store_int(get_camel_snake(data, "everyMs", "every_ms"), None),
|
||||||
expr=data.get("expr"),
|
expr=data.get("expr"),
|
||||||
tz=data.get("tz"),
|
tz=data.get("tz"),
|
||||||
)
|
)
|
||||||
@@ -78,9 +85,9 @@ class CronRunRecord:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_store_dict(cls, data: dict[str, Any]) -> CronRunRecord:
|
def from_store_dict(cls, data: dict[str, Any]) -> CronRunRecord:
|
||||||
return cls(
|
return cls(
|
||||||
run_at_ms=int(get_camel_snake(data, "runAtMs", "run_at_ms", 0)),
|
run_at_ms=_store_int(get_camel_snake(data, "runAtMs", "run_at_ms", 0)),
|
||||||
status=data["status"],
|
status=data["status"],
|
||||||
duration_ms=int(get_camel_snake(data, "durationMs", "duration_ms", 0)),
|
duration_ms=_store_int(get_camel_snake(data, "durationMs", "duration_ms", 0)),
|
||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,8 +105,12 @@ class CronJobState:
|
|||||||
def from_store_dict(cls, data: dict[str, Any]) -> CronJobState:
|
def from_store_dict(cls, data: dict[str, Any]) -> CronJobState:
|
||||||
history = get_camel_snake(data, "runHistory", "run_history", []) or []
|
history = get_camel_snake(data, "runHistory", "run_history", []) or []
|
||||||
return cls(
|
return cls(
|
||||||
next_run_at_ms=get_camel_snake(data, "nextRunAtMs", "next_run_at_ms"),
|
next_run_at_ms=_store_int(
|
||||||
last_run_at_ms=get_camel_snake(data, "lastRunAtMs", "last_run_at_ms"),
|
get_camel_snake(data, "nextRunAtMs", "next_run_at_ms"), None
|
||||||
|
),
|
||||||
|
last_run_at_ms=_store_int(
|
||||||
|
get_camel_snake(data, "lastRunAtMs", "last_run_at_ms"), None
|
||||||
|
),
|
||||||
last_status=get_camel_snake(data, "lastStatus", "last_status"),
|
last_status=get_camel_snake(data, "lastStatus", "last_status"),
|
||||||
last_error=get_camel_snake(data, "lastError", "last_error"),
|
last_error=get_camel_snake(data, "lastError", "last_error"),
|
||||||
run_history=[
|
run_history=[
|
||||||
@@ -107,6 +118,7 @@ class CronJobState:
|
|||||||
if isinstance(record, CronRunRecord)
|
if isinstance(record, CronRunRecord)
|
||||||
else CronRunRecord.from_store_dict(record)
|
else CronRunRecord.from_store_dict(record)
|
||||||
for record in history
|
for record in history
|
||||||
|
if isinstance(record, (dict, CronRunRecord))
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,8 +158,8 @@ class CronJob:
|
|||||||
schedule=CronSchedule.from_store_dict(data["schedule"]),
|
schedule=CronSchedule.from_store_dict(data["schedule"]),
|
||||||
payload=CronPayload.from_store_dict(data.get("payload") or {}),
|
payload=CronPayload.from_store_dict(data.get("payload") or {}),
|
||||||
state=CronJobState.from_store_dict(data.get("state") or {}),
|
state=CronJobState.from_store_dict(data.get("state") or {}),
|
||||||
created_at_ms=int(get_camel_snake(data, "createdAtMs", "created_at_ms", 0)),
|
created_at_ms=_store_int(get_camel_snake(data, "createdAtMs", "created_at_ms", 0)),
|
||||||
updated_at_ms=int(get_camel_snake(data, "updatedAtMs", "updated_at_ms", 0)),
|
updated_at_ms=_store_int(get_camel_snake(data, "updatedAtMs", "updated_at_ms", 0)),
|
||||||
delete_after_run=bool(
|
delete_after_run=bool(
|
||||||
get_camel_snake(data, "deleteAfterRun", "delete_after_run", False)
|
get_camel_snake(data, "deleteAfterRun", "delete_after_run", False)
|
||||||
),
|
),
|
||||||
|
|||||||
+32
-14
@@ -37,6 +37,7 @@ from nanobot.sdk.types import (
|
|||||||
StreamEventType,
|
StreamEventType,
|
||||||
result_from_response,
|
result_from_response,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Nanobot",
|
"Nanobot",
|
||||||
@@ -192,16 +193,40 @@ class Nanobot:
|
|||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
) -> RunStream:
|
) -> RunStream:
|
||||||
"""Start a streamed run and return a handle for events and final result."""
|
"""Start a streamed run and return a handle for events and final result."""
|
||||||
runtime = self._loop.runtime_resolver.resolve_override(
|
override_runtime = self._loop.runtime_resolver.resolve_override(
|
||||||
model=model,
|
model=model,
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
config=self._config,
|
config=self._config,
|
||||||
) or self._loop.llm_runtime()
|
)
|
||||||
queue: asyncio.Queue[StreamEvent | object] = asyncio.Queue(maxsize=256)
|
queue: asyncio.Queue[StreamEvent | object] = asyncio.Queue(maxsize=256)
|
||||||
emitter = SDKStreamEmitter(queue)
|
emitter = SDKStreamEmitter(queue)
|
||||||
stream_hook = SDKStreamingHook(emitter)
|
stream_hook = SDKStreamingHook(emitter)
|
||||||
capture = SDKCaptureHook()
|
capture = SDKCaptureHook()
|
||||||
per_run_hooks = [capture, stream_hook, *(hooks or [])]
|
per_run_hooks = [capture, stream_hook, *(hooks or [])]
|
||||||
|
run_started = False
|
||||||
|
|
||||||
|
async def _emit_run_started(runtime: LLMRuntime | None = None) -> None:
|
||||||
|
nonlocal run_started
|
||||||
|
if run_started:
|
||||||
|
return
|
||||||
|
if runtime is None:
|
||||||
|
runtime = override_runtime
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"session_key": session_key,
|
||||||
|
"channel": channel,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"sender_id": sender_id,
|
||||||
|
}
|
||||||
|
if runtime is not None:
|
||||||
|
metadata.update({
|
||||||
|
"model": runtime.model,
|
||||||
|
"model_preset": runtime.model_preset,
|
||||||
|
})
|
||||||
|
await emitter.emit(StreamEvent(
|
||||||
|
type=STREAM_EVENT_RUN_STARTED,
|
||||||
|
metadata=metadata,
|
||||||
|
))
|
||||||
|
run_started = True
|
||||||
|
|
||||||
async def _on_stream(delta: str) -> None:
|
async def _on_stream(delta: str) -> None:
|
||||||
await emitter.text_delta(delta)
|
await emitter.text_delta(delta)
|
||||||
@@ -220,24 +245,16 @@ class Nanobot:
|
|||||||
on_stream=_on_stream,
|
on_stream=_on_stream,
|
||||||
on_stream_end=_on_stream_end,
|
on_stream_end=_on_stream_end,
|
||||||
)
|
)
|
||||||
kwargs["runtime"] = runtime
|
kwargs["on_runtime_admitted"] = _emit_run_started
|
||||||
await emitter.emit(StreamEvent(
|
if override_runtime is not None:
|
||||||
type=STREAM_EVENT_RUN_STARTED,
|
kwargs["runtime"] = override_runtime
|
||||||
metadata={
|
|
||||||
"session_key": session_key,
|
|
||||||
"channel": channel,
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"sender_id": sender_id,
|
|
||||||
"model": runtime.model,
|
|
||||||
"model_preset": runtime.model_preset,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
try:
|
try:
|
||||||
response = await self._loop.process_direct(
|
response = await self._loop.process_direct(
|
||||||
message,
|
message,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
hooks=per_run_hooks,
|
hooks=per_run_hooks,
|
||||||
)
|
)
|
||||||
|
await _emit_run_started()
|
||||||
await emitter.text_completed(resuming=False, force=False)
|
await emitter.text_completed(resuming=False, force=False)
|
||||||
result = result_from_response(response, capture)
|
result = result_from_response(response, capture)
|
||||||
await emitter.emit(StreamEvent(
|
await emitter.emit(StreamEvent(
|
||||||
@@ -249,6 +266,7 @@ class Nanobot:
|
|||||||
))
|
))
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
await _emit_run_started()
|
||||||
await emitter.emit(StreamEvent(
|
await emitter.emit(StreamEvent(
|
||||||
type=STREAM_EVENT_RUN_FAILED,
|
type=STREAM_EVENT_RUN_FAILED,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
|
|||||||
@@ -53,36 +53,32 @@ _BUNDLED_FEATURE_ALIASES = {"documents", "pdf"}
|
|||||||
|
|
||||||
|
|
||||||
def load_pyproject(path: Path) -> dict[str, Any]:
|
def load_pyproject(path: Path) -> dict[str, Any]:
|
||||||
try:
|
|
||||||
import tomllib
|
import tomllib
|
||||||
|
|
||||||
return tomllib.loads(path.read_text(encoding="utf-8"))
|
try:
|
||||||
except Exception:
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
return {}
|
return {}
|
||||||
|
return tomllib.loads(content)
|
||||||
|
|
||||||
|
|
||||||
def optional_dependency_groups_from_metadata() -> dict[str, list[str] | None]:
|
def optional_dependency_groups_from_metadata() -> dict[str, list[str] | None]:
|
||||||
try:
|
|
||||||
from importlib.metadata import metadata, requires
|
from importlib.metadata import metadata, requires
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extras = metadata("nanobot-ai").get_all("Provides-Extra") or []
|
extras = metadata("nanobot-ai").get_all("Provides-Extra") or []
|
||||||
|
raw_requirements = requires("nanobot-ai") or []
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return {}
|
||||||
groups: dict[str, list[str] | None] = {name: [] for name in extras if name != "dev"}
|
groups: dict[str, list[str] | None] = {name: [] for name in extras if name != "dev"}
|
||||||
for raw in requires("nanobot-ai") or []:
|
for raw in raw_requirements:
|
||||||
try:
|
|
||||||
req = Requirement(raw)
|
req = Requirement(raw)
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if not req.marker:
|
if not req.marker:
|
||||||
continue
|
continue
|
||||||
for extra, deps in groups.items():
|
for extra, deps in groups.items():
|
||||||
if deps is not None and req.marker.evaluate({"extra": extra}):
|
if deps is not None and req.marker.evaluate({"extra": extra}):
|
||||||
deps.append(raw)
|
deps.append(raw)
|
||||||
return groups
|
return groups
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def optional_dependency_groups() -> dict[str, list[str] | None]:
|
def optional_dependency_groups() -> dict[str, list[str] | None]:
|
||||||
@@ -105,11 +101,7 @@ def optional_dependency_groups() -> dict[str, list[str] | None]:
|
|||||||
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
|
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
|
||||||
install_args: list[str] = []
|
install_args: list[str] = []
|
||||||
for raw in deps:
|
for raw in deps:
|
||||||
try:
|
|
||||||
req = Requirement(raw)
|
req = Requirement(raw)
|
||||||
except Exception:
|
|
||||||
install_args.append(raw)
|
|
||||||
continue
|
|
||||||
if req.marker and not req.marker.evaluate({"extra": extra}):
|
if req.marker and not req.marker.evaluate({"extra": extra}):
|
||||||
continue
|
continue
|
||||||
req.marker = None
|
req.marker = None
|
||||||
@@ -168,10 +160,7 @@ def _extra_dependencies_installed(
|
|||||||
|
|
||||||
matched = False
|
matched = False
|
||||||
for raw in dist.requires or []:
|
for raw in dist.requires or []:
|
||||||
try:
|
|
||||||
req = Requirement(raw)
|
req = Requirement(raw)
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
|
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
|
||||||
continue
|
continue
|
||||||
matched = True
|
matched = True
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ def _load() -> dict[str, Any]:
|
|||||||
|
|
||||||
# Convert approved lists to str sets for O(1) lookup.
|
# Convert approved lists to str sets for O(1) lookup.
|
||||||
for channel, users in data.get("approved", {}).items():
|
for channel, users in data.get("approved", {}).items():
|
||||||
|
if not isinstance(users, list):
|
||||||
|
users = []
|
||||||
data["approved"][channel] = {str(u) for u in users}
|
data["approved"][channel] = {str(u) for u in users}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ __all__ = [
|
|||||||
"AnthropicProvider",
|
"AnthropicProvider",
|
||||||
"OpenAICompatProvider",
|
"OpenAICompatProvider",
|
||||||
"OpenAICodexProvider",
|
"OpenAICodexProvider",
|
||||||
|
"XAIGrokProvider",
|
||||||
"GitHubCopilotProvider",
|
"GitHubCopilotProvider",
|
||||||
"AzureOpenAIProvider",
|
"AzureOpenAIProvider",
|
||||||
"BedrockProvider",
|
"BedrockProvider",
|
||||||
@@ -22,6 +23,7 @@ _LAZY_IMPORTS = {
|
|||||||
"AnthropicProvider": ".anthropic_provider",
|
"AnthropicProvider": ".anthropic_provider",
|
||||||
"OpenAICompatProvider": ".openai_compat_provider",
|
"OpenAICompatProvider": ".openai_compat_provider",
|
||||||
"OpenAICodexProvider": ".openai_codex_provider",
|
"OpenAICodexProvider": ".openai_codex_provider",
|
||||||
|
"XAIGrokProvider": ".xai_grok_provider",
|
||||||
"GitHubCopilotProvider": ".github_copilot_provider",
|
"GitHubCopilotProvider": ".github_copilot_provider",
|
||||||
"AzureOpenAIProvider": ".azure_openai_provider",
|
"AzureOpenAIProvider": ".azure_openai_provider",
|
||||||
"BedrockProvider": ".bedrock_provider",
|
"BedrockProvider": ".bedrock_provider",
|
||||||
@@ -34,6 +36,7 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
from nanobot.providers.xai_grok_provider import XAIGrokProvider
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ from typing import Any
|
|||||||
import json_repair
|
import json_repair
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.utils.helpers import sanitize_surrogates_deep
|
||||||
|
|
||||||
STREAM_IDLE_TIMEOUT_ENV = "NANOBOT_STREAM_IDLE_TIMEOUT_S"
|
STREAM_IDLE_TIMEOUT_ENV = "NANOBOT_STREAM_IDLE_TIMEOUT_S"
|
||||||
DEFAULT_STREAM_IDLE_TIMEOUT_S = 90.0
|
DEFAULT_STREAM_IDLE_TIMEOUT_S = 90.0
|
||||||
MAX_STREAM_IDLE_TIMEOUT_S = 3600.0
|
MAX_STREAM_IDLE_TIMEOUT_S = 3600.0
|
||||||
|
RETRY_AFTER_BUFFER = 1
|
||||||
|
|
||||||
|
|
||||||
def resolve_stream_idle_timeout_s(
|
def resolve_stream_idle_timeout_s(
|
||||||
@@ -272,7 +275,15 @@ class LLMProvider(ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Sanitize message content: fix empty blocks, strip internal _meta fields."""
|
"""Sanitize message content: fix empty blocks, strip internal _meta fields.
|
||||||
|
|
||||||
|
Also strips unpaired UTF-16 surrogate code points from every string leaf
|
||||||
|
as a defense-in-depth pass before the payload leaves the process. Lone
|
||||||
|
surrogates (e.g. leaking from a Windows console, prompt_toolkit history,
|
||||||
|
or a truncated JSON round-trip) otherwise cause ``UnicodeEncodeError:
|
||||||
|
'utf-8' codec can't encode characters ... surrogates not allowed`` when
|
||||||
|
the HTTP client serializes the request body.
|
||||||
|
"""
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for raw_msg in messages:
|
for raw_msg in messages:
|
||||||
msg = {key: value for key, value in raw_msg.items() if key != "_meta"}
|
msg = {key: value for key, value in raw_msg.items() if key != "_meta"}
|
||||||
@@ -318,7 +329,10 @@ class LLMProvider(ABC):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
result.append(msg)
|
result.append(msg)
|
||||||
return result
|
# Defense-in-depth: scrub lone UTF-16 surrogates from every string leaf.
|
||||||
|
# This is idempotent and no-op when messages are already clean.
|
||||||
|
sanitized = sanitize_surrogates_deep(result)
|
||||||
|
return sanitized if isinstance(sanitized, list) else result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _tool_name(tool: dict[str, Any]) -> str:
|
def _tool_name(tool: dict[str, Any]) -> str:
|
||||||
@@ -952,8 +966,9 @@ class LLMProvider(ABC):
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
retry_after = self._extract_retry_after_from_response(response)
|
||||||
base_delay = delays[min(attempt - 1, len(delays) - 1)]
|
base_delay = delays[min(attempt - 1, len(delays) - 1)]
|
||||||
delay = self._extract_retry_after_from_response(response) or base_delay
|
delay = retry_after + RETRY_AFTER_BUFFER if retry_after else base_delay
|
||||||
if persistent:
|
if persistent:
|
||||||
delay = min(delay, self._PERSISTENT_MAX_DELAY)
|
delay = min(delay, self._PERSISTENT_MAX_DELAY)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ProviderSnapshot:
|
|||||||
context_window_tokens: int
|
context_window_tokens: int
|
||||||
signature: tuple[object, ...]
|
signature: tuple[object, ...]
|
||||||
generation: GenerationSettings | None = None
|
generation: GenerationSettings | None = None
|
||||||
|
model_preset: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model_preset(
|
def _resolve_model_preset(
|
||||||
@@ -55,14 +56,18 @@ def _make_provider_core(
|
|||||||
if provider_name and not spec and p:
|
if provider_name and not spec and p:
|
||||||
if not p.api_base:
|
if not p.api_base:
|
||||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||||
spec = create_dynamic_spec(provider_name, thinking_style=(p.thinking_style or "") if p else "")
|
spec = create_dynamic_spec(
|
||||||
|
provider_name,
|
||||||
|
display_name=(p.display_name or "") if p else "",
|
||||||
|
thinking_style=(p.thinking_style or "") if p else "",
|
||||||
|
)
|
||||||
if spec and spec.is_transcription_only:
|
if spec and spec.is_transcription_only:
|
||||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||||
backend = spec.backend if spec else "openai_compat"
|
backend = spec.backend if spec else "openai_compat"
|
||||||
if p and p.proxy and backend not in {"openai_compat", "openai_codex"}:
|
if p and p.proxy and backend not in {"openai_compat", "openai_codex", "xai_grok"}:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"providers.{provider_name}.proxy is only supported for "
|
f"providers.{provider_name}.proxy is only supported for "
|
||||||
"OpenAI-compatible providers and OpenAI Codex."
|
"OpenAI-compatible providers, OpenAI Codex, and xAI Grok."
|
||||||
)
|
)
|
||||||
|
|
||||||
if backend == "azure_openai":
|
if backend == "azure_openai":
|
||||||
@@ -88,6 +93,15 @@ def _make_provider_core(
|
|||||||
provider = OpenAICodexProvider(
|
provider = OpenAICodexProvider(
|
||||||
default_model=model,
|
default_model=model,
|
||||||
proxy=getattr(p, "proxy", None) if p else None,
|
proxy=getattr(p, "proxy", None) if p else None,
|
||||||
|
extra_body=p.extra_body if p else None,
|
||||||
|
)
|
||||||
|
elif backend == "xai_grok":
|
||||||
|
from nanobot.providers.xai_grok_provider import XAIGrokProvider
|
||||||
|
|
||||||
|
provider = XAIGrokProvider(
|
||||||
|
default_model=model,
|
||||||
|
proxy=getattr(p, "proxy", None) if p else None,
|
||||||
|
extra_body=p.extra_body if p else None,
|
||||||
)
|
)
|
||||||
elif backend == "azure_openai":
|
elif backend == "azure_openai":
|
||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
@@ -198,6 +212,22 @@ def make_provider(
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def build_unconfigured_provider_snapshot(config: Config, setup_error: str) -> ProviderSnapshot:
|
||||||
|
"""Build a non-networking runtime so the WebUI can collect first-time setup."""
|
||||||
|
from nanobot.providers.unconfigured_provider import UnconfiguredProvider
|
||||||
|
|
||||||
|
preset = config.resolve_preset()
|
||||||
|
provider = UnconfiguredProvider(preset.model)
|
||||||
|
provider.generation = preset.to_generation_settings()
|
||||||
|
return ProviderSnapshot(
|
||||||
|
provider=provider,
|
||||||
|
model=preset.model,
|
||||||
|
context_window_tokens=preset.context_window_tokens,
|
||||||
|
signature=("unconfigured", setup_error, preset.model),
|
||||||
|
generation=provider.generation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def provider_signature(
|
def provider_signature(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
@@ -229,6 +259,7 @@ def provider_signature(
|
|||||||
fallback.reasoning_effort,
|
fallback.reasoning_effort,
|
||||||
fallback.context_window_tokens,
|
fallback.context_window_tokens,
|
||||||
getattr(fp, "proxy", None) if fp else None,
|
getattr(fp, "proxy", None) if fp else None,
|
||||||
|
fp.thinking_style if fp else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||||
@@ -249,6 +280,7 @@ def provider_signature(
|
|||||||
resolved.reasoning_effort,
|
resolved.reasoning_effort,
|
||||||
resolved.context_window_tokens,
|
resolved.context_window_tokens,
|
||||||
getattr(p, "proxy", None) if p else None,
|
getattr(p, "proxy", None) if p else None,
|
||||||
|
p.thinking_style if p else None,
|
||||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -260,6 +292,11 @@ def build_provider_snapshot(
|
|||||||
preset: ModelPresetConfig | None = None,
|
preset: ModelPresetConfig | None = None,
|
||||||
) -> ProviderSnapshot:
|
) -> ProviderSnapshot:
|
||||||
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
|
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
|
||||||
|
selected_preset = (
|
||||||
|
config.agents.defaults.model_preset
|
||||||
|
if preset_name is None and preset is None
|
||||||
|
else preset_name
|
||||||
|
)
|
||||||
fallback_windows = [
|
fallback_windows = [
|
||||||
fallback.context_window_tokens
|
fallback.context_window_tokens
|
||||||
for fallback in _resolve_fallback_presets(config, resolved)
|
for fallback in _resolve_fallback_presets(config, resolved)
|
||||||
@@ -270,6 +307,7 @@ def build_provider_snapshot(
|
|||||||
context_window_tokens=min([resolved.context_window_tokens, *fallback_windows]),
|
context_window_tokens=min([resolved.context_window_tokens, *fallback_windows]),
|
||||||
signature=provider_signature(config, preset=resolved),
|
signature=provider_signature(config, preset=resolved),
|
||||||
generation=resolved.to_generation_settings(),
|
generation=resolved.to_generation_settings(),
|
||||||
|
model_preset=selected_preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,36 @@ _FALLBACK_ERROR_KINDS = frozenset({
|
|||||||
"rate_limit",
|
"rate_limit",
|
||||||
"overloaded",
|
"overloaded",
|
||||||
})
|
})
|
||||||
_NON_FALLBACK_ERROR_KINDS = frozenset({
|
_AUTHENTICATION_ERROR_KINDS = frozenset({
|
||||||
"authentication",
|
"authentication",
|
||||||
"auth",
|
"auth",
|
||||||
"permission",
|
"permission",
|
||||||
|
})
|
||||||
|
_AUTHENTICATION_ERROR_TOKENS = (
|
||||||
|
"authentication_error",
|
||||||
|
"authentication error",
|
||||||
|
"invalid_api_key",
|
||||||
|
"invalid api key",
|
||||||
|
"incorrect_api_key",
|
||||||
|
"incorrect api key",
|
||||||
|
"expired_api_key",
|
||||||
|
"expired api key",
|
||||||
|
"invalid credential",
|
||||||
|
"expired credential",
|
||||||
|
"credential has expired",
|
||||||
|
"credentials have expired",
|
||||||
|
"invalid_token",
|
||||||
|
"invalid token",
|
||||||
|
"expired_token",
|
||||||
|
"expired token",
|
||||||
|
"unauthorized",
|
||||||
|
"permission_denied",
|
||||||
|
"permission denied",
|
||||||
|
"access_denied",
|
||||||
|
"account_deactivated",
|
||||||
|
"organization_deactivated",
|
||||||
|
)
|
||||||
|
_NON_FALLBACK_ERROR_KINDS = frozenset({
|
||||||
"content_filter",
|
"content_filter",
|
||||||
"refusal",
|
"refusal",
|
||||||
"context_length",
|
"context_length",
|
||||||
@@ -56,6 +82,9 @@ _FALLBACK_ERROR_TOKENS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FallbackModelObserver = Callable[[str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
class FallbackProvider(LLMProvider):
|
class FallbackProvider(LLMProvider):
|
||||||
"""Wrap a primary provider and transparently failover to fallback models.
|
"""Wrap a primary provider and transparently failover to fallback models.
|
||||||
|
|
||||||
@@ -82,10 +111,12 @@ class FallbackProvider(LLMProvider):
|
|||||||
primary: LLMProvider,
|
primary: LLMProvider,
|
||||||
fallback_presets: list[Any],
|
fallback_presets: list[Any],
|
||||||
provider_factory: Callable[[Any], LLMProvider],
|
provider_factory: Callable[[Any], LLMProvider],
|
||||||
|
fallback_model_observer: FallbackModelObserver | None = None,
|
||||||
):
|
):
|
||||||
self._primary = primary
|
self._primary = primary
|
||||||
self._fallback_presets = list(fallback_presets)
|
self._fallback_presets = list(fallback_presets)
|
||||||
self._provider_factory = provider_factory
|
self._provider_factory = provider_factory
|
||||||
|
self._fallback_model_observer = fallback_model_observer
|
||||||
self._has_fallbacks = bool(fallback_presets)
|
self._has_fallbacks = bool(fallback_presets)
|
||||||
self._primary_failures = 0
|
self._primary_failures = 0
|
||||||
self._primary_tripped_at: float | None = None
|
self._primary_tripped_at: float | None = None
|
||||||
@@ -101,6 +132,10 @@ class FallbackProvider(LLMProvider):
|
|||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
return self._primary.get_default_model()
|
return self._primary.get_default_model()
|
||||||
|
|
||||||
|
def set_fallback_model_observer(self, observer: FallbackModelObserver | None) -> None:
|
||||||
|
"""Attach a process-level observer without changing request call signatures."""
|
||||||
|
self._fallback_model_observer = observer
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_progress_deltas(self) -> bool:
|
def supports_progress_deltas(self) -> bool:
|
||||||
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
||||||
@@ -242,6 +277,8 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
await self._notify_fallback_model(fallback_model)
|
||||||
|
|
||||||
original_values = {
|
original_values = {
|
||||||
name: kwargs.get(name, _MISSING)
|
name: kwargs.get(name, _MISSING)
|
||||||
for name in ("model", "max_tokens", "temperature", "reasoning_effort")
|
for name in ("model", "max_tokens", "temperature", "reasoning_effort")
|
||||||
@@ -289,23 +326,48 @@ class FallbackProvider(LLMProvider):
|
|||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _notify_fallback_model(self, model: str) -> None:
|
||||||
|
if self._fallback_model_observer is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._fallback_model_observer(model)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("fallback model observer failed for '{}'", model)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _should_fallback(response: LLMResponse) -> bool:
|
def _should_fallback(response: LLMResponse) -> bool:
|
||||||
if LLMProvider.is_arrearage_response(response):
|
if LLMProvider.is_arrearage_response(response):
|
||||||
return True
|
return True
|
||||||
if response.error_should_retry is False:
|
|
||||||
return False
|
|
||||||
status = response.error_status_code
|
status = response.error_status_code
|
||||||
kind = (response.error_kind or "").lower()
|
kind = (response.error_kind or "").lower()
|
||||||
error_type = (response.error_type or "").lower()
|
error_type = (response.error_type or "").lower()
|
||||||
code = (response.error_code or "").lower()
|
code = (response.error_code or "").lower()
|
||||||
text = (response.content or "").lower()
|
text = (response.content or "").lower()
|
||||||
|
structured_values = (kind, error_type, code)
|
||||||
|
|
||||||
if status in {400, 401, 403, 404, 422}:
|
if kind in _AUTHENTICATION_ERROR_KINDS:
|
||||||
return False
|
return True
|
||||||
|
if any(
|
||||||
|
token in value
|
||||||
|
for value in structured_values
|
||||||
|
for token in _AUTHENTICATION_ERROR_TOKENS
|
||||||
|
):
|
||||||
|
return True
|
||||||
if kind in _NON_FALLBACK_ERROR_KINDS:
|
if kind in _NON_FALLBACK_ERROR_KINDS:
|
||||||
return False
|
return False
|
||||||
if any(token in value for value in (kind, error_type, code) for token in _NON_FALLBACK_ERROR_KINDS):
|
if any(
|
||||||
|
token in value
|
||||||
|
for value in structured_values
|
||||||
|
for token in _NON_FALLBACK_ERROR_KINDS
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if status in {401, 403}:
|
||||||
|
return True
|
||||||
|
if any(token in text for token in _AUTHENTICATION_ERROR_TOKENS):
|
||||||
|
return True
|
||||||
|
if response.error_should_retry is False:
|
||||||
|
return False
|
||||||
|
if status in {400, 404, 422}:
|
||||||
return False
|
return False
|
||||||
if response.error_should_retry is True:
|
if response.error_should_retry is True:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ class ImageGenerationProvider(ABC):
|
|||||||
"""Base class for image generation provider clients."""
|
"""Base class for image generation provider clients."""
|
||||||
|
|
||||||
provider_name: str = ""
|
provider_name: str = ""
|
||||||
|
model_options: tuple[str, ...] = ()
|
||||||
missing_key_message: str = ""
|
missing_key_message: str = ""
|
||||||
default_timeout: float = _DEFAULT_TIMEOUT_S
|
default_timeout: float = _DEFAULT_TIMEOUT_S
|
||||||
|
|
||||||
@@ -254,6 +255,7 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""Small async client for OpenRouter Chat Completions image generation."""
|
"""Small async client for OpenRouter Chat Completions image generation."""
|
||||||
|
|
||||||
provider_name = "openrouter"
|
provider_name = "openrouter"
|
||||||
|
model_options = ("openai/gpt-5.4-image-2",)
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"OpenRouter API key is not configured. Set providers.openrouter.apiKey."
|
"OpenRouter API key is not configured. Set providers.openrouter.apiKey."
|
||||||
)
|
)
|
||||||
@@ -345,6 +347,7 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""Small async client for AIHubMix unified image generation."""
|
"""Small async client for AIHubMix unified image generation."""
|
||||||
|
|
||||||
provider_name = "aihubmix"
|
provider_name = "aihubmix"
|
||||||
|
model_options = ("gpt-image-2-free",)
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
|
"AIHubMix API key is not configured. Set providers.aihubmix.apiKey."
|
||||||
)
|
)
|
||||||
@@ -515,6 +518,7 @@ class OllamaImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""Async client for Ollama native image generation models."""
|
"""Async client for Ollama native image generation models."""
|
||||||
|
|
||||||
provider_name = "ollama"
|
provider_name = "ollama"
|
||||||
|
model_options = ("x/z-image-turbo",)
|
||||||
default_timeout = 300.0
|
default_timeout = 300.0
|
||||||
|
|
||||||
def _default_base_url(self) -> str:
|
def _default_base_url(self) -> str:
|
||||||
@@ -591,6 +595,7 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
|
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
|
||||||
|
|
||||||
provider_name = "gemini"
|
provider_name = "gemini"
|
||||||
|
model_options = ("gemini-2.5-flash-image", "imagen-4.0-generate-001")
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"Gemini API key is not configured. Set providers.gemini.apiKey."
|
"Gemini API key is not configured. Set providers.gemini.apiKey."
|
||||||
)
|
)
|
||||||
@@ -815,6 +820,7 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""Async client for MiniMax image generation API."""
|
"""Async client for MiniMax image generation API."""
|
||||||
|
|
||||||
provider_name = "minimax"
|
provider_name = "minimax"
|
||||||
|
model_options = ("image-01",)
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"MiniMax API key is not configured. Set providers.minimax.apiKey."
|
"MiniMax API key is not configured. Set providers.minimax.apiKey."
|
||||||
)
|
)
|
||||||
@@ -947,6 +953,7 @@ class OpenAIImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""OpenAI Images API using an API key (``providers.openai.apiKey``)."""
|
"""OpenAI Images API using an API key (``providers.openai.apiKey``)."""
|
||||||
|
|
||||||
provider_name = "openai"
|
provider_name = "openai"
|
||||||
|
model_options = ("gpt-image-2", "gpt-image-1", "dall-e-3", "dall-e-2")
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"OpenAI API key is not configured. Set providers.openai.apiKey."
|
"OpenAI API key is not configured. Set providers.openai.apiKey."
|
||||||
)
|
)
|
||||||
@@ -1210,6 +1217,7 @@ class CodexImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
provider_name = "openai_codex"
|
provider_name = "openai_codex"
|
||||||
|
model_options = ("gpt-5.4",)
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"Codex OAuth token is unavailable. "
|
"Codex OAuth token is unavailable. "
|
||||||
"Log in with Codex subscription first."
|
"Log in with Codex subscription first."
|
||||||
@@ -1508,6 +1516,7 @@ class StepFunImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
provider_name = "stepfun"
|
provider_name = "stepfun"
|
||||||
|
model_options = ("step-image-edit-2", "step-1x-medium")
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"StepFun API key is not configured. Set providers.stepfun.apiKey."
|
"StepFun API key is not configured. Set providers.stepfun.apiKey."
|
||||||
)
|
)
|
||||||
@@ -1634,6 +1643,7 @@ class ZhipuImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
provider_name = "zhipu"
|
provider_name = "zhipu"
|
||||||
|
model_options = ("glm-image", "cogview-4", "cogview-4-250304", "cogview-3-flash")
|
||||||
missing_key_message = "Zhipu API key is not configured. Set providers.zhipu.apiKey."
|
missing_key_message = "Zhipu API key is not configured. Set providers.zhipu.apiKey."
|
||||||
default_timeout = _ZHIPU_TIMEOUT_S
|
default_timeout = _ZHIPU_TIMEOUT_S
|
||||||
|
|
||||||
@@ -1752,6 +1762,193 @@ async def _zhipu_images_from_payload(
|
|||||||
return images
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ModelScope (魔搭) image generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MODELSCOPE_TIMEOUT_S = 300.0
|
||||||
|
_MODELSCOPE_POLL_INTERVAL_S = 5.0
|
||||||
|
_MODELSCOPE_POLL_MAX_ATTEMPTS = 60 # 5 min at 5s intervals
|
||||||
|
_MODELSCOPE_ASPECT_RATIOS = {
|
||||||
|
"1:1": "1328x1328",
|
||||||
|
"16:9": "1664x928",
|
||||||
|
"9:16": "928x1664",
|
||||||
|
"3:4": "1140x1472",
|
||||||
|
"4:3": "1472x1140",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _modelscope_size(
|
||||||
|
aspect_ratio: str | None,
|
||||||
|
image_size: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve aspect ratio / image_size to a ModelScope size string."""
|
||||||
|
if image_size and "x" in image_size.lower():
|
||||||
|
return image_size
|
||||||
|
if aspect_ratio and aspect_ratio in _MODELSCOPE_ASPECT_RATIOS:
|
||||||
|
return _MODELSCOPE_ASPECT_RATIOS[aspect_ratio]
|
||||||
|
return "1024x1024"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelScopeImageGenerationClient(ImageGenerationProvider):
|
||||||
|
"""Async client for ModelScope (魔搭) AIGC image generation.
|
||||||
|
|
||||||
|
ModelScope uses an async task pattern: POST submits the job and returns
|
||||||
|
a task_id, then the client polls GET /tasks/{task_id} until
|
||||||
|
task_status is SUCCEED or FAILED.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider_name = "modelscope"
|
||||||
|
model_options = ("Qwen/Qwen-Image-2512",)
|
||||||
|
missing_key_message = (
|
||||||
|
"ModelScope API key is not configured. Set providers.modelscope.apiKey."
|
||||||
|
)
|
||||||
|
default_timeout = _MODELSCOPE_TIMEOUT_S
|
||||||
|
|
||||||
|
def _default_base_url(self) -> str:
|
||||||
|
return "https://api-inference.modelscope.cn/v1"
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
reference_images: list[str] | None = None,
|
||||||
|
aspect_ratio: str | None = None,
|
||||||
|
image_size: str | None = None,
|
||||||
|
) -> GeneratedImageResponse:
|
||||||
|
if not self.api_key:
|
||||||
|
raise ImageGenerationError(self.missing_key_message)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-ModelScope-Async-Mode": "true",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
size = _modelscope_size(aspect_ratio, image_size)
|
||||||
|
if size:
|
||||||
|
body["size"] = size
|
||||||
|
|
||||||
|
refs = list(reference_images or [])
|
||||||
|
if refs:
|
||||||
|
image_refs = [image_path_to_data_url(path) for path in refs]
|
||||||
|
body["image_url"] = image_refs[0] if len(image_refs) == 1 else image_refs
|
||||||
|
|
||||||
|
body.update(self.extra_body)
|
||||||
|
|
||||||
|
url = f"{self.api_base}/images/generations"
|
||||||
|
client = self._client or httpx.AsyncClient(timeout=self.timeout)
|
||||||
|
try:
|
||||||
|
return await self._generate_with_client(
|
||||||
|
client,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if self._client is None:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
async def _generate_with_client(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> GeneratedImageResponse:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ImageGenerationError("ModelScope image generation request timed out") from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise ImageGenerationError(f"ModelScope image generation request failed: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = _http_error_detail(response)
|
||||||
|
raise ImageGenerationError(f"ModelScope image generation failed: {detail}") from exc
|
||||||
|
|
||||||
|
task_data = response.json()
|
||||||
|
task_id = task_data.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"ModelScope did not return a task_id: {response.text[:500]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
images = await self._poll_task(client, task_id, headers)
|
||||||
|
|
||||||
|
self._require_images(images, task_data)
|
||||||
|
return GeneratedImageResponse(images=images, content="", raw=task_data)
|
||||||
|
|
||||||
|
async def _poll_task(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
task_id: str,
|
||||||
|
submit_headers: dict[str, str],
|
||||||
|
) -> list[str]:
|
||||||
|
poll_headers = {
|
||||||
|
"Authorization": submit_headers["Authorization"],
|
||||||
|
"X-ModelScope-Task-Type": "image_generation",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
poll_url = f"{self.api_base}/tasks/{task_id}"
|
||||||
|
|
||||||
|
for _ in range(_MODELSCOPE_POLL_MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
response = await client.get(poll_url, headers=poll_headers)
|
||||||
|
except httpx.RequestError:
|
||||||
|
await asyncio.sleep(_MODELSCOPE_POLL_INTERVAL_S)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = _http_error_detail(response)
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"ModelScope task polling failed: {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
status = data.get("task_status")
|
||||||
|
|
||||||
|
if status == "SUCCEED":
|
||||||
|
return await self._collect_images(client, data)
|
||||||
|
if status == "FAILED":
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"ModelScope image generation task failed: {data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(_MODELSCOPE_POLL_INTERVAL_S)
|
||||||
|
|
||||||
|
raise ImageGenerationError(
|
||||||
|
f"ModelScope image generation timed out after "
|
||||||
|
f"{_MODELSCOPE_POLL_MAX_ATTEMPTS} polls"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _collect_images(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
images: list[str] = []
|
||||||
|
for url in data.get("output_images") or []:
|
||||||
|
if isinstance(url, str) and url:
|
||||||
|
if url.startswith("data:image/"):
|
||||||
|
images.append(url)
|
||||||
|
else:
|
||||||
|
images.append(await _download_image_data_url(client, url))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Provider registration
|
# Provider registration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1766,3 +1963,4 @@ register_image_gen_provider(OpenAIImageGenerationClient)
|
|||||||
register_image_gen_provider(OpenRouterImageGenerationClient)
|
register_image_gen_provider(OpenRouterImageGenerationClient)
|
||||||
register_image_gen_provider(StepFunImageGenerationClient)
|
register_image_gen_provider(StepFunImageGenerationClient)
|
||||||
register_image_gen_provider(ZhipuImageGenerationClient)
|
register_image_gen_provider(ZhipuImageGenerationClient)
|
||||||
|
register_image_gen_provider(ModelScopeImageGenerationClient)
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
self,
|
self,
|
||||||
default_model: str = "openai-codex/gpt-5.6-sol",
|
default_model: str = "openai-codex/gpt-5.6-sol",
|
||||||
proxy: str | None = None,
|
proxy: str | None = None,
|
||||||
|
extra_body: dict[str, Any] | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(api_key=None, api_base=None)
|
super().__init__(api_key=None, api_base=None)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.proxy = proxy or None
|
self.proxy = proxy or None
|
||||||
|
self._extra_body = dict(extra_body or {})
|
||||||
|
|
||||||
async def _call_codex(
|
async def _call_codex(
|
||||||
self,
|
self,
|
||||||
@@ -74,6 +76,9 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
body["reasoning"] = reasoning_options
|
body["reasoning"] = reasoning_options
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
|
if self._extra_body:
|
||||||
|
# Apply explicit provider overrides last, matching other provider backends.
|
||||||
|
body.update(self._extra_body)
|
||||||
|
|
||||||
stage = "oauth_token"
|
stage = "oauth_token"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -99,9 +99,20 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
|
|||||||
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
|
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
|
||||||
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
|
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
|
||||||
}
|
}
|
||||||
|
_QWEN_THINKING_MODELS: frozenset[str] = frozenset({
|
||||||
|
"qwen3.7-max",
|
||||||
|
"qwen3.7-plus",
|
||||||
|
"qwen3.6-max-preview",
|
||||||
|
"qwen3.6-plus",
|
||||||
|
"qwen3.6-flash",
|
||||||
|
"qwen3.5-plus",
|
||||||
|
"qwen3.5-flash",
|
||||||
|
})
|
||||||
|
|
||||||
_MODEL_THINKING_STYLES: dict[str, str] = {
|
_MODEL_THINKING_STYLES: dict[str, str] = {
|
||||||
**dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"),
|
**dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"),
|
||||||
**dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"),
|
**dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"),
|
||||||
|
**dict.fromkeys(_QWEN_THINKING_MODELS, "enable_thinking"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
|||||||
|
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
call_id, _ = split_tool_call_id(msg.get("tool_call_id"))
|
call_id, _ = split_tool_call_id(msg.get("tool_call_id"))
|
||||||
output_text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
|
output = convert_tool_output(content)
|
||||||
input_items.append({"type": "function_call_output", "call_id": call_id, "output": output_text})
|
input_items.append({"type": "function_call_output", "call_id": call_id, "output": output})
|
||||||
|
|
||||||
return system_prompt, input_items
|
return system_prompt, input_items
|
||||||
|
|
||||||
@@ -84,6 +84,78 @@ def convert_user_message(content: Any) -> dict[str, Any]:
|
|||||||
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_tool_output(content: Any) -> str | list[dict[str, Any]]:
|
||||||
|
"""Convert a tool result to Responses API function-call output content.
|
||||||
|
|
||||||
|
The Responses API accepts text, image, and file blocks as function tool
|
||||||
|
output. Nanobot's file tools use Chat Completions-style ``text`` and
|
||||||
|
``image_url`` blocks for image reads; serializing those blocks as JSON
|
||||||
|
turns the image into inert text and can make the request unnecessarily
|
||||||
|
large. Preserve supported multimodal blocks and strip internal metadata.
|
||||||
|
"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
converted: list[dict[str, Any]] = []
|
||||||
|
for item in content:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
break
|
||||||
|
item_type = item.get("type")
|
||||||
|
if item_type in {"text", "input_text"}:
|
||||||
|
if set(item) - {"type", "text", "_meta"}:
|
||||||
|
break
|
||||||
|
text = item.get("text")
|
||||||
|
if not isinstance(text, str):
|
||||||
|
break
|
||||||
|
converted.append({"type": "input_text", "text": text})
|
||||||
|
elif item_type in {"image_url", "input_image"}:
|
||||||
|
image = item.get("image_url")
|
||||||
|
if isinstance(image, dict) and set(image) - {"url", "detail"}:
|
||||||
|
break
|
||||||
|
if set(item) - {"type", "image_url", "file_id", "detail", "_meta"}:
|
||||||
|
break
|
||||||
|
url = image.get("url") if isinstance(image, dict) else image
|
||||||
|
file_id = item.get("file_id")
|
||||||
|
detail = item.get(
|
||||||
|
"detail",
|
||||||
|
image.get("detail", "auto") if isinstance(image, dict) else "auto",
|
||||||
|
)
|
||||||
|
if detail not in {"low", "high", "auto", "original"}:
|
||||||
|
break
|
||||||
|
block = {"type": "input_image", "detail": detail}
|
||||||
|
if isinstance(url, str) and url:
|
||||||
|
block["image_url"] = url
|
||||||
|
elif isinstance(file_id, str) and file_id:
|
||||||
|
block["file_id"] = file_id
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
converted.append(block)
|
||||||
|
elif item_type in {"file", "input_file"}:
|
||||||
|
if set(item) - {
|
||||||
|
"type",
|
||||||
|
"file_data",
|
||||||
|
"file_id",
|
||||||
|
"file_url",
|
||||||
|
"filename",
|
||||||
|
"_meta",
|
||||||
|
}:
|
||||||
|
break
|
||||||
|
block = {"type": "input_file"}
|
||||||
|
for key in ("file_data", "file_id", "file_url", "filename"):
|
||||||
|
value = item.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
block[key] = value
|
||||||
|
if not any(key in block for key in ("file_data", "file_id", "file_url")):
|
||||||
|
break
|
||||||
|
converted.append(block)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if converted:
|
||||||
|
return converted
|
||||||
|
return json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Convert OpenAI function-calling tool schema to Responses API flat format."""
|
"""Convert OpenAI function-calling tool schema to Responses API flat format."""
|
||||||
converted: list[dict[str, Any]] = []
|
converted: list[dict[str, Any]] = []
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ async def consume_sse_with_reasoning(
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_response_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||||
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
|
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
|
||||||
content = ""
|
content = ""
|
||||||
@@ -129,6 +130,8 @@ async def consume_sse_with_reasoning(
|
|||||||
streamed_reasoning = False
|
streamed_reasoning = False
|
||||||
|
|
||||||
async for event in iter_sse(response):
|
async for event in iter_sse(response):
|
||||||
|
if on_response_event:
|
||||||
|
await on_response_event(event)
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
if event_type == "response.output_item.added":
|
if event_type == "response.output_item.added":
|
||||||
item = event.get("item") or {}
|
item = event.get("item") or {}
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ class ProviderSpec:
|
|||||||
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
|
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
|
||||||
|
|
||||||
# which provider implementation to use
|
# which provider implementation to use
|
||||||
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
|
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "xai_grok"
|
||||||
|
# | "github_copilot" | "bedrock"
|
||||||
backend: str = "openai_compat"
|
backend: str = "openai_compat"
|
||||||
|
|
||||||
# extra env vars / request headers supplied by the provider integration.
|
# extra env vars / request headers supplied by the provider integration.
|
||||||
@@ -420,6 +421,25 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
default_api_base="https://chatgpt.com/backend-api",
|
default_api_base="https://chatgpt.com/backend-api",
|
||||||
is_oauth=True,
|
is_oauth=True,
|
||||||
),
|
),
|
||||||
|
# xAI subscription: OAuth-based, with capability-gated server-hosted X Search.
|
||||||
|
ProviderSpec(
|
||||||
|
name="xai_grok",
|
||||||
|
keywords=("xai-grok", "xai_grok"),
|
||||||
|
env_key="",
|
||||||
|
display_name="xAI Grok",
|
||||||
|
model_catalog="builtin",
|
||||||
|
builtin_models=(
|
||||||
|
ProviderModelSpec(
|
||||||
|
id="xai-grok/grok-4.5",
|
||||||
|
label="Grok 4.5",
|
||||||
|
description="Grok via xAI subscription; X Search is enabled when supported.",
|
||||||
|
context_window=500000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backend="xai_grok",
|
||||||
|
default_api_base="https://cli-chat-proxy.grok.com/v1",
|
||||||
|
is_oauth=True,
|
||||||
|
),
|
||||||
# GitHub Copilot: OAuth-based
|
# GitHub Copilot: OAuth-based
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
name="github_copilot",
|
name="github_copilot",
|
||||||
@@ -471,6 +491,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
thinking_style="enable_thinking",
|
thinking_style="enable_thinking",
|
||||||
),
|
),
|
||||||
|
# ModelScope (魔搭社区): OpenAI-compatible API
|
||||||
|
ProviderSpec(
|
||||||
|
name="modelscope",
|
||||||
|
keywords=("modelscope",),
|
||||||
|
env_key="MODELSCOPE_API_KEY",
|
||||||
|
display_name="ModelScope",
|
||||||
|
backend="openai_compat",
|
||||||
|
is_gateway=True,
|
||||||
|
detect_by_base_keyword="modelscope",
|
||||||
|
default_api_base="https://api-inference.modelscope.cn/v1",
|
||||||
|
strip_model_prefixes=("modelscope",),
|
||||||
|
thinking_style="enable_thinking",
|
||||||
|
),
|
||||||
# Moonshot (月之暗面): Kimi K2.5/K2.6 choose temperature from thinking mode;
|
# Moonshot (月之暗面): Kimi K2.5/K2.6 choose temperature from thinking mode;
|
||||||
# the OpenAI-compatible provider omits it. K2.7 models require 1.0.
|
# the OpenAI-compatible provider omits it. K2.7 models require 1.0.
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
@@ -696,7 +729,12 @@ def find_by_name(name: str) -> ProviderSpec | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
|
def create_dynamic_spec(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
display_name: str = "",
|
||||||
|
thinking_style: str = "",
|
||||||
|
) -> ProviderSpec:
|
||||||
"""Create a dynamic ProviderSpec for custom user-defined providers."""
|
"""Create a dynamic ProviderSpec for custom user-defined providers."""
|
||||||
normalized = to_snake(name.replace("-", "_"))
|
normalized = to_snake(name.replace("-", "_"))
|
||||||
strip_prefixes = tuple(dict.fromkeys((name, normalized)))
|
strip_prefixes = tuple(dict.fromkeys((name, normalized)))
|
||||||
@@ -704,7 +742,7 @@ def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
|
|||||||
name=normalized,
|
name=normalized,
|
||||||
keywords=(),
|
keywords=(),
|
||||||
env_key="",
|
env_key="",
|
||||||
display_name=name.title(),
|
display_name=display_name or name.replace("-", " ").replace("_", " ").title(),
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
is_direct=True,
|
is_direct=True,
|
||||||
strip_model_prefixes=strip_prefixes,
|
strip_model_prefixes=strip_prefixes,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Provider used while the local WebUI is waiting for first-time setup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||||
|
|
||||||
|
|
||||||
|
class UnconfiguredProvider(LLMProvider):
|
||||||
|
"""Keep the gateway available for settings before a model is configured."""
|
||||||
|
|
||||||
|
def __init__(self, default_model: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._default_model = default_model
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
reasoning_effort: str | None = None,
|
||||||
|
tool_choice: str | dict | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
return LLMResponse(
|
||||||
|
content=(
|
||||||
|
"Nanobot needs a model before it can chat. Open Settings → Models "
|
||||||
|
"to configure a provider and model, then send your message again."
|
||||||
|
),
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="configuration",
|
||||||
|
error_should_retry=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return self._default_model
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user