Compare commits

..
Author SHA1 Message Date
chengyongru c2b03c5149 fix(exec): allow scoped tmp cleanup commands 2026-07-20 18:38:41 +08:00
516 changed files with 13289 additions and 60211 deletions
-8
View File
@@ -24,14 +24,6 @@ Fix bugs by changing only what is necessary. Do not bundle unrelated refactors o
A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review.
## Type dynamic boundaries at the edge
Wire payloads, persisted records, and third-party SDK objects are untrusted dynamic boundaries. Prefer a parser or small normalizer at the owning edge, and use `TypedDict` for stable dictionary shapes, so validation happens once and internal code receives a concrete type. Do not spread raw dynamic dictionaries or SDK objects through the core.
Stable first-party dependencies must be typed where they are stored or passed. Do not declare an internal service, context field, or callback result as `Any` and then recover its real type with consumer-side casts. Use the concrete type or a narrow `Protocol`; reserve `Any` for genuinely dynamic boundaries.
`typing.cast` performs no runtime validation. Every new cast must be supported by a runtime check on the same path or by an explicit invariant that is clear from construction and control flow (and documented locally when it is not obvious). If input can violate the claimed type, handle that invalid case before casting; never use `cast` only to silence BasedPyright.
## Explicit over magical
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
+2 -2
View File
@@ -14,9 +14,9 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
## SSRF Protection
All outbound HTTP requests from agent tools must pass through the shared URL guards in `security/network.py` (`validate_url_target` or `resolve_url_target`). By default they block loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
For direct requests, the only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time. An explicitly configured `providers.<name>.proxy` is a separate user-authorized trust boundary for provider requests and provider-returned image URL downloads. Those downloads still reject malformed URLs and locally identifiable private/internal targets on every redirect, but hostnames unavailable to local DNS are delegated to the trusted proxy. The user-selected proxy owns final DNS resolution and network egress policy.
The only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time.
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
-68
View File
@@ -5,28 +5,10 @@ on:
branches: [main]
paths-ignore:
- docs/**
- .agent/**
- .github/ISSUE_TEMPLATE/**
- AGENTS.md
- CLAUDE.md
- COMMUNICATION.md
- CONTRIBUTING.md
- README.md
- SECURITY.md
- webui/README.md
pull_request:
branches: [main]
paths-ignore:
- docs/**
- .agent/**
- .github/ISSUE_TEMPLATE/**
- AGENTS.md
- CLAUDE.md
- COMMUNICATION.md
- CONTRIBUTING.md
- README.md
- SECURITY.md
- webui/README.md
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -36,46 +18,8 @@ permissions:
contents: read
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:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
run: |
python_required=true
if [[ "$EVENT_NAME" == "pull_request" ]]; then
diff_range="${BASE_SHA}...${HEAD_SHA}"
else
diff_range="${BASE_SHA}..${HEAD_SHA}"
fi
if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
changed_files="$(git diff --name-only --no-renames "$diff_range")" &&
[[ -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:
name: Python (${{ matrix.name }})
needs: changes
if: needs.changes.outputs.python_required == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
@@ -86,18 +30,14 @@ jobs:
os: ubuntu-latest
python-version: "3.11"
coverage: false
pytest_args: ""
- name: latest, 3.14 + coverage
os: ubuntu-latest
python-version: "3.14"
coverage: true
pytest_args: ""
- name: Windows, 3.14
os: windows-latest
python-version: "3.14"
coverage: false
# Keep each test file in one worker while using both hosted-runner cores.
pytest_args: "-n 2 --dist loadfile"
steps:
- uses: actions/checkout@v4
@@ -120,19 +60,12 @@ jobs:
- name: Install channel dependencies
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
- name: Verify dependency consistency
run: uv pip check
# Channel requirements live in manifests rather than uv.lock. Avoid a
# later uv run sync pruning the packages installed by the previous step.
- name: Lint with ruff
if: matrix.coverage
run: uv run --no-sync ruff check nanobot tests conftest.py
- name: Type check with BasedPyright (strict)
if: matrix.coverage
run: uv run --no-sync basedpyright
- name: Run tests with coverage
if: matrix.coverage
run: >-
@@ -144,7 +77,6 @@ jobs:
if: ${{ !matrix.coverage }}
run: >-
uv run --no-sync python -m pytest
${{ matrix.pytest_args }}
--durations=25 --durations-min=1.0
webui:
-1
View File
@@ -100,4 +100,3 @@ temp/
exp/
.playwright-mcp/
bridge/node_modules/
webui/.verify-*
-5
View File
@@ -11,11 +11,6 @@ nanobot is a lightweight, open-source AI agent framework written in Python with
pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/
# Strict type checking (matches CI)
uv sync --all-extras --dev
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
uv run --no-sync basedpyright
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
-14
View File
@@ -78,20 +78,6 @@ ruff check nanobot/
ruff format <files-you-changed>
```
### Strict Type Checking
Strict type checking covers optional providers and channels. Reproduce the CI environment
with the same dependency sources and commands:
```bash
uv sync --all-extras --dev
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
uv run --no-sync basedpyright
```
Keep `--no-sync` on the final commands: channel dependencies come from their package
manifests and are installed explicitly by the setup step.
## Contribution License
By submitting a contribution, you confirm that you have the right to submit it
+167 -108
View File
@@ -1,6 +1,6 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.svg">
<img alt="nanobot README cover" src="./images/readme-cover-light.svg">
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.png">
<img alt="nanobot README cover" src="./images/readme-cover-light.png">
</picture>
<div align="center">
@@ -17,24 +17,24 @@
<a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a>
</p>
<p>
<a href="https://github.com/HKUDS/nanobot"><img src="https://img.shields.io/github/stars/HKUDS/nanobot?style=flat&logo=github" alt="GitHub stars"></a>
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI version"></a>
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="PyPI downloads"></a>
<a href="https://github.com/HKUDS/nanobot/actions/workflows/ci.yml"><img src="https://github.com/HKUDS/nanobot/actions/workflows/ci.yml/badge.svg?branch=main" alt="Test Suite"></a>
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/badge/python-%3E%3D3.11-blue" alt="Python 3.11 or newer"></a>
<a href="./LICENSE"><img src="https://img.shields.io/github/license/HKUDS/nanobot" alt="MIT License"></a>
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview"><img src="https://img.shields.io/badge/docs-nanobot.wiki-blue" alt="nanobot documentation"></a>
</p>
<p>
<a href="https://discord.gg/MnCvHqpUGB">Discord</a> ·
<a href="https://x.com/nanobot_project">X</a> ·
<a href="./COMMUNICATION.md">WeChat / Feishu</a>
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
<img src="https://img.shields.io/badge/python-≥3.11-blue" alt="Python">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<a href="https://github.com/HKUDS/nanobot/graphs/commit-activity" target="_blank">
<img alt="Commits last month" src="https://img.shields.io/github/commit-activity/m/HKUDS/nanobot?labelColor=%20%2332b583&color=%20%2312b76a"></a>
<a href="https://github.com/HKUDS/nanobot/issues?q=is%3Aissue%20is%3Aclosed" target="_blank">
<img alt="Issues closed" src="https://img.shields.io/github/issues-search?query=repo%3AHKUDS%2Fnanobot%20is%3Aissue%20is%3Aclosed&label=issues%20closed&labelColor=%20%237d89b0&color=%20%235d6b98"></a>
<a href="https://twitter.com/intent/follow?screen_name=nanobot_project" target="_blank">
<img src="https://img.shields.io/twitter/follow/nanobot_project?logo=X&color=%20%23f5f5f5" alt="follow on X(Twitter)"></a>
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview"><img src="https://img.shields.io/badge/Docs-nanobot.wiki-blue?style=flat&logo=readthedocs&logoColor=white" alt="Docs"></a>
<a href="./COMMUNICATION.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=feishu&logoColor=white" alt="Feishu"></a>
<a href="./COMMUNICATION.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white" alt="WeChat"></a>
<a href="https://discord.gg/MnCvHqpUGB"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
</div>
# nanobot
🐈 **nanobot** is an ultra-lightweight, open-source, self-hosted personal AI agent framework written in Python. It runs in a WebUI, terminal, or chat apps and combines tools, long-term memory, MCP integrations, model routing, multi-agent delegation, scheduled automation, and an OpenAI-compatible API in a small, readable core.
🐈 **nanobot** is an open-source, ultra-lightweight personal AI agent you can truly own. It keeps the agent core small and readable while giving you the practical pieces for real long-running work: WebUI, chat channels, tools, memory, MCP, model routing, automation, and deployment.
## Start Here
@@ -46,7 +46,15 @@
| 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) |
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
| Deploy to the cloud or keep nanobot running as a service | [Deployment](./docs/deployment.md) |
| Deploy to the cloud in one click | [Deploy to Render](#deploy-to-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).
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
## What can nanobot do?
@@ -60,6 +68,37 @@ nanobot is a self-hosted personal AI agent runtime. It can:
- expose a Python SDK and OpenAI-compatible API for integrations
- deploy as a long-running local or server-side agent gateway
## Latest Release
**v0.2.2 - Durability Release**
Highlights:
- Segmented WebUI transcripts
- Python SDK runtime controls
- Automation management
- Search/STT provider improvements
- Gateway/session/provider reliability
[See full changelog](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
## Open Source Partners
<p align="center">
<a href="https://platform.kimi.com?aff=nanobot"><picture><source media="(prefers-color-scheme: dark)" srcset="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69mt3v89kkekg24gg"><img alt="Kimi Open Source Friends" height="44" src="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69fudcmosb3pipls0"></picture></a>
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
</p>
## Recent Updates
- **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-10** Stable model routing, multiline CLI input, new automation guide.
- **2026-07-09** Live file-edit diffs, safer localhost setup, Matrix image fixes.
- **2026-07-08** Safer WebUI/API setup, onboard refresh, responsive prompt rail.
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
## 💡 Why nanobot
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
@@ -95,7 +134,7 @@ Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI. On a fresh local desktop, it then starts `nanobot webui` so you can configure the first provider and model in **Settings → Models**. SSH, headless, existing-config, and older-release paths keep the terminal setup wizard. The installer avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. It also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. The installer also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@@ -155,66 +194,97 @@ If `nanobot` is not on `PATH`, invoke it through the method that installed it: r
## 🚀 Quick Start
**Open nanobot in your browser**
**1. Initialize**
Skip this step if the one-command setup already started the wizard and Quick Start finished there.
```bash
nanobot webui
nanobot onboard
```
This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). A fresh install can open before a model is configured, so setup continues in the browser instead of beginning in a JSON file. The first-run WebUI binds to localhost by default and is not exposed to your LAN.
Use `nanobot onboard --wizard` if you prefer an interactive setup.
**Your first three steps**
**2. Configure** (`~/.nanobot/config.json`)
1. Open **Settings → Models** and choose a provider, credential, and model.
2. Start a new topic and send `Hello!` to verify the connection.
3. Before project work, choose the intended workspace and access mode from the composer.
Skip this step if you already configured provider and model settings in the wizard.
Any normal reply means the provider, model, workspace, and browser gateway are working together.
`nanobot onboard` creates `~/.nanobot/config.json` and `~/.nanobot/workspace/`. Configure these **two parts** in the config file. Add or merge the following blocks into the existing file instead of replacing the whole file.
**Keep nanobot running after you close the terminal**
The example below uses a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service. Provider examples are recipes, not rankings or endorsements. For copyable provider-specific setup, see [Provider Cookbook](./docs/provider-cookbook.md).
```bash
nanobot webui --background
*Set your API key*:
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
}
}
```
This starts the same full gateway as `nanobot webui`, opens the browser, and leaves channels and automations running after the launcher exits. Complete first-time model setup with foreground `nanobot webui` before switching to background mode.
*Set a model preset and make it active*:
```bash
nanobot gateway status
nanobot gateway logs
nanobot gateway restart
nanobot gateway stop
```json
{
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 8192,
"contextWindowTokens": 200000,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
**Prefer a gateway-first workflow?**
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and `fallbackModels`.
For another provider, the same config shape still applies:
| Replace | Where |
|---|---|
| Provider config key | `providers.<provider>` |
| API key | `providers.<provider>.apiKey` |
| Preset provider name | `modelPresets.primary.provider` |
| Model ID | `modelPresets.primary.model` |
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
**3. Open the WebUI**
The stable-compatible path is:
```bash
nanobot gateway
```
This skips WebUI setup and browser opening, then runs the same complete gateway in the current terminal. It is the familiar entry point if you are coming from OpenClaw or already operate agents as long-lived services. The WebUI remains available when its channel is configured; open it manually when needed.
Leave the terminal open and visit `http://127.0.0.1:8765`. Current source versions also provide `nanobot webui`, which prepares the local WebSocket channel if needed, starts the gateway, and opens the browser automatically. The first-run WebUI binds to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
Use `nanobot gateway --background` for the same direct entry point without keeping the terminal attached. For automatic startup and supervision by the operating system, see [Deployment](./docs/deployment.md).
For manual or terminal-only setup, test one CLI message:
**Prefer to work entirely in the terminal?**
```bash
nanobot status
nanobot agent -m "Hello!"
```
In `nanobot status`, it is normal for most providers to say `not set`. The active preset's provider should be configured, and `Config` plus `Workspace` should show check marks.
If that works, start an interactive chat:
```bash
nanobot agent
```
This opens an interactive terminal chat with the same configured model, workspace, and tools while keeping its own CLI session history. It does not open a browser or keep chat channels and automations running after you exit. Type `exit` or press `Ctrl+C` when you are done.
For one request and an immediate exit, use:
```bash
nanobot agent -m "Hello!"
```
The one-shot form is useful for a quick provider check, shell scripts, and local automation. If you have not configured a model yet, run `nanobot webui` and open **Settings → Models** first.
Need manual JSON, another device on your LAN, or help with provider/model matching? Continue with [Install and Quick Start](./docs/quick-start.md), [WebUI](./docs/webui.md), or [Troubleshooting](./docs/troubleshooting.md).
If nanobot worked for you, a star on GitHub is the simplest way to support the project.
Need help with `PATH`, API keys, provider/model matching, or JSON errors? See the fuller [Install and Quick Start](./docs/quick-start.md) and [Troubleshooting](./docs/troubleshooting.md).
- Want a pasteable provider setup? See [Provider Cookbook](./docs/provider-cookbook.md)
- Want to understand provider/model matching? See [Providers and Models](./docs/providers.md)
@@ -223,38 +293,26 @@ If nanobot worked for you, a star on GitHub is the simplest way to support the p
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
<a id="deploy-to-render"></a>
## ☁️ Deploy
**Render — one click**
Deploy nanobot's gateway and bundled WebUI from the repository's ready-to-use Blueprint:
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
Render will ask for `ANTHROPIC_API_KEY` and a private `NANOBOT_WEB_TOKEN`, then provision persistent storage for sessions, memory, and WebUI history. Persistent disks require a paid Render service.
**Self-host**
Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.md) for Docker, Docker Compose, Linux services, and macOS LaunchAgent setup.
## 🌐 WebUI
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
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).
<p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
</p>
Use it to:
**Open it**
- keep separate topics for different tasks and projects;
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
- switch models and workspaces without leaving the conversation;
- configure providers, chat channels, Apps, Skills, and Automations from one place.
```bash
nanobot webui
```
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
On current source versions, the command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). If your installed stable release does not include `nanobot webui`, run `nanobot gateway` and open that address manually. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
> [!TIP]
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the source-tree, Vite dev server, build, and test workflow.
## 🏗️ Architecture
@@ -264,6 +322,29 @@ See the [WebUI guide](./docs/webui.md) for LAN access, background operation, wor
🐈 nanobot stays lightweight by centering everything around a small agent loop: messages come in from chat apps, the LLM decides when tools are needed, and memory or skills are pulled in only as context instead of becoming a heavy orchestration layer. That keeps the core path readable and easy to extend, while still letting you add channels, tools, memory, and deployment options without turning the system into a monolith.
## ✨ Features
<table align="center">
<tr align="center">
<th><p align="center">📈 24/7 Real-Time Market Analysis</p></th>
<th><p align="center">🚀 Full-Stack Software Engineer</p></th>
<th><p align="center">📅 Smart Daily Routine Manager</p></th>
<th><p align="center">📚 Personal Knowledge Assistant</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="case/search.gif" width="180" height="400"></p></td>
<td align="center"><p align="center"><img src="case/code.gif" width="180" height="400"></p></td>
<td align="center"><p align="center"><img src="case/schedule.gif" width="180" height="400"></p></td>
<td align="center"><p align="center"><img src="case/memory.gif" width="180" height="400"></p></td>
</tr>
<tr>
<td align="center">Discovery • Insights • Trends</td>
<td align="center">Develop • Deploy • Scale</td>
<td align="center">Schedule • Automate • Organize</td>
<td align="center">Learn • Memory • Reasoning</td>
</tr>
</table>
## 📚 Docs
Browse the [repo docs](./docs/README.md) for the latest features and GitHub development version, or visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview) for the stable release documentation.
@@ -282,43 +363,21 @@ Browse the [repo docs](./docs/README.md) for the latest features and GitHub deve
- Integrate nanobot with local tools and automations: [OpenAI-Compatible API](./docs/openai-api.md) · [Python SDK](./docs/python-sdk.md)
- Run nanobot with Docker or as a Linux service: [Deployment](./docs/deployment.md)
## Releases
## 🤝 Contribute & Roadmap
**Latest release: [v0.3.0 - The Agency Release](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)**
PRs welcome! The codebase is intentionally small and readable. 🤗
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.
### Contribution Flow
- Consult inline subagents without leaving the current task
- Switch model presets per session directly from the composer
- Start from a guided WebUI setup with clearer execution controls
- Apply configuration changes live across a more reliable provider, channel, and tool runtime
See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, review, and contribution guidelines.
[Read the v0.3.0 release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
## Recent Updates
- **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.
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
## Open Source Partners
<p align="center">
<a href="https://platform.kimi.com?aff=nanobot"><picture><source media="(prefers-color-scheme: dark)" srcset="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69mt3v89kkekg24gg"><img alt="Kimi Open Source Friends" height="44" src="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69fudcmosb3pipls0"></picture></a>
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
</p>
## 🤝 Contribute
Use nanobot for a real task, report what broke, and then pick a focused improvement.
- Read [CONTRIBUTING.md](./CONTRIBUTING.md) for the development workflow.
- Browse [open issues](https://github.com/HKUDS/nanobot/issues) for problems to investigate.
- Open a [pull request](https://github.com/HKUDS/nanobot/pulls) for a focused fix or integration.
- **Multi-modal** — See and hear (images, voice, video)
- **Long-term memory** — Never forget important context
- **Better reasoning** — Multi-step planning and reflection
- **More integrations** — Calendar and more
- **Self-improvement** — Learn from feedback and mistakes
## Contact
+5 -10
View File
@@ -21,11 +21,6 @@ We aim to respond to security reports within 48 hours.
**CRITICAL**: Never commit API keys to version control.
```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
chmod 600 ~/.nanobot/config.json
@@ -33,9 +28,9 @@ chmod 600 ~/.nanobot/config.json
```
**Recommendations:**
- **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.
- When plaintext keys are stored in `~/.nanobot/config.json`, set file permissions to `0600` (`chmod 600`)
- Consider using an OS keyring/credential manager for production deployments
- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600`
- Consider using environment variables for sensitive keys
- Use OS keyring/credential manager for production deployments
- Rotate API keys regularly
- Use separate API keys for development and production
@@ -242,7 +237,7 @@ If you suspect a security breach:
⚠️ **Current Security Limitations:**
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
2. **Plain Text Config** - API keys stored in plain text in `config.json` (prefer `${VAR}` env references when possible, or use keyring for production)
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
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)
5. **No Audit Trail** - Limited security event logging (enhance as needed)
@@ -265,7 +260,7 @@ Before deploying nanobot:
## Updates
**Last Updated**: 2026-07-21
**Last Updated**: 2026-04-05
For the latest security updates and announcements, check:
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

-11
View File
@@ -9,17 +9,6 @@ from collections.abc import Iterator
import certifi
import pytest
from loguru import logger
@pytest.fixture(autouse=True)
def _isolate_nanobot_log_activation() -> Iterator[None]:
"""Keep CLI log settings from leaking into later tests in the same process."""
logger.enable("nanobot")
try:
yield
finally:
logger.enable("nanobot")
@pytest.fixture(scope="session", autouse=True)
+3 -3
View File
@@ -15,11 +15,11 @@ Repository docs follow the current source tree and can be newer than the latest
The recommended first-run path is:
1. Install nanobot.
2. Let the installer open `nanobot webui` on a fresh local desktop.
3. Configure a provider and model in **Settings → Models**.
2. Choose **Quick Start** in `nanobot onboard --wizard`.
3. Run `nanobot gateway` and open `http://127.0.0.1:8765`.
4. Send `Hello!` before configuring anything else.
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
Most people do not need to edit JSON for the first run. The wizard handles the initial provider, model, and local WebUI settings. Current source versions also provide `nanobot webui` to start the gateway and open the browser in one step. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
## Add One Capability
-18
View File
@@ -149,24 +149,6 @@ Defaults:
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
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
+16 -16
View File
@@ -2,21 +2,21 @@
<!-- 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 topic. Use them
Automations are agent turns that run later in a linked chat/session. Use them
when nanobot should do work without someone actively typing: reminders,
recurring checks, nightly summaries, CI follow-ups, local script reports, or
webhook-driven events.
Create automations from the chat channel or WebUI topic where the
result should appear. That lets nanobot keep the right session history,
workspace, and reply target.
Create automations from the chat, channel, or WebUI session where the result
should appear. That lets nanobot keep the right session history, workspace, and
reply target.
## Choose an Automation Type
| 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 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 topic |
| 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 |
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target session |
| 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
@@ -26,21 +26,21 @@ protected from normal automation edits.
## Before You Create One
Keep `nanobot gateway` running. The gateway owns background delivery for chat
apps, WebUI topics, scheduled automations, local triggers, heartbeat, and
apps, WebUI sessions, scheduled automations, local triggers, heartbeat, and
Dream jobs.
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
`--config` or `--workspace` option to `nanobot trigger`.
Create each automation from the target topic. An automation without a linked
topic cannot be enabled or run from the WebUI because nanobot would not know
where to deliver the turn.
Create each automation from the target session. An automation without a linked
chat/session cannot be enabled or run from the WebUI because nanobot would not
know where to deliver the turn.
## Scheduled Automations
Scheduled automations are created by the agent's `cron` tool. In practice, ask
nanobot from the target chat or WebUI topic:
nanobot from the target chat or WebUI session:
```text
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
specific nanobot session later.
Create the trigger from the chat or WebUI topic where future messages should
Create the trigger from the chat or WebUI session where future messages should
arrive:
```text
@@ -120,7 +120,7 @@ Heartbeat is enabled by default when `nanobot gateway` starts. Configure it in
Use the WebUI Automations view to:
- filter by all, active, paused, needs-attention, or system jobs;
- search by task name, message, trigger command, linked topic, schedule, or
- search by task name, message, trigger command, linked chat, schedule, or
status;
- sort by next run, last run, updated time, or name;
- 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.
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 topic is
running yet, the message waits in that workspace. If the linked session is
already running a turn, the trigger waits until the session becomes idle instead
of being injected into the active turn.
@@ -154,7 +154,7 @@ queue is not a distributed multi-consumer queue.
## Common Patterns
For a nightly report, ask from the target topic:
For a nightly report, ask from the target session:
```text
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
If an automation does not run, check that `nanobot gateway` is running, the
automation is enabled, and it was created from a linked topic.
automation is enabled, and it was created from a linked chat/session.
If a local trigger waits forever, confirm the command uses the same workspace or
config as the gateway.
+2 -2
View File
@@ -618,7 +618,7 @@ async def send(self, msg: OutboundMessage) -> None:
await self._send_message(msg.chat_id, msg.content, media=msg.media)
```
Tool hints are on by default. Users can disable them globally or per channel:
Tool hints are off by default for most channels. Users can enable them globally or per channel:
```json
{
@@ -626,7 +626,7 @@ Tool hints are on by default. Users can disable them globally or per channel:
"sendToolHints": true,
"webhook": {
"enabled": true,
"sendToolHints": false
"sendToolHints": true
}
}
}
+1 -33
View File
@@ -109,24 +109,7 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
<details>
<summary><b>Telegram</b></summary>
**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:
**Install the optional channel dependency**
```bash
nanobot plugins enable telegram
@@ -151,21 +134,6 @@ 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.
>
> `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.
+3 -3
View File
@@ -9,7 +9,7 @@ These commands work inside chat channels and interactive agent sessions:
| `/restart` | Restart the bot |
| `/status` | Show bot status |
| `/model` | Show the current model and available model presets |
| `/model <preset>` | Switch and persist the model preset for the current session |
| `/model <preset>` | Switch the runtime model preset for future turns |
| `/dream` | Run Dream memory consolidation now |
| `/dream-log` | Show the latest 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
```
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.
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.
To switch presets for future turns:
@@ -57,7 +57,7 @@ To switch presets for future turns:
/model default
```
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.
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.
## Local triggers
+3 -17
View File
@@ -11,7 +11,7 @@ Use this page when you know what you want to run and need the command shape. For
| Refresh config non-interactively | `nanobot onboard --refresh` | Preserves existing values and adds missing default fields without prompting |
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
| Check readiness without calling a model | `nanobot status` | Summarizes config/workspace and validates the active provider/model configuration |
| Check config without calling a model | `nanobot status` | Summarizes the selected config, workspace, active model, and providers |
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
@@ -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` |
| 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 OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers |
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
## Global
@@ -70,18 +70,6 @@ Default paths:
| Config | `~/.nanobot/config.json` |
| Workspace | `~/.nanobot/workspace/` |
## Status
| Command | Description |
|---|---|
| `nanobot status` | Summarize the default config/workspace and check Agent provider/model readiness |
| `nanobot status --config <path>` | Check a specific config file |
| `nanobot status --workspace <path>` | Show status with a workspace override |
Status does not send a model request. On success, run the printed
`nanobot agent -m "Hello!"` command to verify network access and credentials. On failure,
follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` route.
## Agent CLI
| Command | Description |
@@ -107,7 +95,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; configure provider credentials in **Settings → Models** |
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; provider credentials still require interactive setup |
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
@@ -299,10 +287,8 @@ remain accepted as no-op compatibility aliases.
| Command | Description |
|---|---|
| `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 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 |
See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection.
+1 -18
View File
@@ -38,23 +38,6 @@ 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.
### 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.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`.
@@ -66,7 +49,7 @@ Most examples are partial snippets. Merge them into the existing file created by
A normal turn follows this flow:
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 effective project workspace, agent-owned profile/skills/memory, recent messages, channel metadata, and runtime settings.
2. The agent loop chooses a session key and builds context from the workspace, skills, memory, recent messages, channel metadata, and runtime settings.
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.
5. The final reply is saved to the session and sent back through the channel.
+14 -103
View File
@@ -90,9 +90,7 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
If a referenced variable is unset, nanobot fails fast and reports the exact config field
and variable name without echoing the field value. Run `nanobot status` with the same
`--config` path to inspect the problem.
If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`.
### More examples
@@ -203,7 +201,7 @@ These variables are process-level switches. Set them in the same terminal, servi
|----------|---------|-------------|
| `NANOBOT_BIN_DIR` | `$HOME/.local/bin` | Installer launcher directory on macOS/Linux. |
| `NANOBOT_VENV` | `$HOME/.nanobot/venv` | Managed virtual environment path used by the installer fallback. |
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip automatic WebUI or wizard setup after one-command install. |
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. |
@@ -256,13 +254,12 @@ 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.
> - **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.
> - **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`.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
> - **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, `openai_codex`, and `xai_grok`. 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 and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
@@ -291,7 +288,6 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `modelscope` | LLM (ModelScope/魔搭社区) + Image generation | [modelscope.cn](https://modelscope.cn) |
| `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) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
@@ -307,7 +303,6 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
| `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` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
@@ -681,75 +676,11 @@ Then run:
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).
</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>
<summary><b>GitHub Copilot (OAuth)</b></summary>
@@ -1346,7 +1277,7 @@ Contributor notes for adding new providers live in [`development.md`](./developm
## Model Presets
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.
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.
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`.
@@ -1410,7 +1341,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.
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`.
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.
### Model Fallbacks
@@ -1488,7 +1419,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.
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.
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.
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.
@@ -1557,7 +1488,8 @@ Global settings that apply to all channels. Configure under the `channels` secti
{
"channels": {
"sendProgress": true,
"sendToolHints": true,
"sendToolHints": false,
"extractDocumentText": true,
"sendMaxRetries": 3,
"telegram": {
"enabled": false
@@ -1569,17 +1501,11 @@ Global settings that apply to all channels. Configure under the `channels` secti
| Setting | Default | Description |
|---------|---------|-------------|
| `sendProgress` | `true` | Stream agent's text progress to the channel |
| `sendToolHints` | `true` | Stream tool-call hints (e.g. `read_file("…")`) |
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix / Mattermost keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. PDF, DOCX, XLSX, and PPTX readers are included in the standard installation. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
Non-image attachments are included in the user message as local path references, without
injecting their contents into the model prompt. When file tools are enabled, the agent
can inspect supported text, PDF, DOCX, XLSX, and PPTX files on demand with `read_file`,
or pass the original path to another tool when exact file bytes are required. The deprecated
`channels.extractDocumentText` setting is accepted for compatibility but ignored.
Normal tool workspace and media access rules still apply to attachment paths.
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
`sendProgress` and `sendToolHints` can also be overridden per channel. The global values stay as defaults for channels that do not set their own value:
@@ -1588,11 +1514,10 @@ Normal tool workspace and media access rules still apply to attachment paths.
{
"channels": {
"sendProgress": true,
"sendToolHints": true,
"sendToolHints": false,
"telegram": {
"enabled": true,
"sendProgress": false,
"sendToolHints": false
"sendProgress": false
},
"websocket": {
"enabled": true,
@@ -1984,16 +1909,6 @@ 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`.
> [!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 |
|--------|---------|-------------|
| `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. |
@@ -2002,8 +1917,6 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
| `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read; paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory. |
| `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored. |
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. |
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
@@ -2165,8 +2078,7 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
{
"agents": {
"defaults": {
"idleCompactAfterMinutes": 15,
"idleCompactCheckIntervalSeconds": 60
"idleCompactAfterMinutes": 15
}
}
}
@@ -2175,12 +2087,11 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
| Option | Default | Description |
|--------|---------|-------------|
| `agents.defaults.idleCompactAfterMinutes` | `15` | Minutes of idle time before auto-compaction starts. Set to `0` to disable. The default is close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
| `agents.defaults.idleCompactCheckIntervalSeconds` | `60` | Minimum number of seconds between scans for idle sessions. Set to `0` to scan on every idle tick (~1 s). |
`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
How it works:
1. **Idle detection**: On each idle tick (~1 s), checks whether an idle-session scan is due. By default, the full scan runs at most once per minute.
1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration.
2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
+1 -30
View File
@@ -4,7 +4,7 @@ Use this page after `nanobot agent -m "Hello!"` works locally. Deployment keeps
## Before You Deploy
Check these once before Render, Docker, systemd, or LaunchAgent:
Check these once before Docker, systemd, or LaunchAgent:
| Check | Why it matters |
|---|---|
@@ -22,40 +22,11 @@ Restart the deployed process after editing `config.json`. Long-running processes
| 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 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` |
| 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`).
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
[Review the deployment blueprint](../render.yaml)
### First Deployment
1. Click **Deploy to Render**, sign in, and review the Blueprint. It creates one Starter web service and a 1 GB persistent disk.
2. Enter your `ANTHROPIC_API_KEY`. Set `NANOBOT_WEB_TOKEN` to a new random value and save it in your password manager; this is the password for the public WebUI.
3. Create the Blueprint and wait for the service status to become **Live**. The first build can take several minutes.
4. Open the generated `onrender.com` URL. The **Authentication required** page means the gateway is running: enter the same `NANOBOT_WEB_TOKEN` value to open the WebUI.
The model API key is used by nanobot to call Anthropic. The Web token only protects access to this deployment; do not share it in issues, screenshots, or chat.
### Updates and Data
The Blueprint disables automatic deploys so upstream repository changes do not unexpectedly restart your agent. To update, open the service in the Render Dashboard and choose **Manual Deploy → Deploy latest commit**.
The persistent disk keeps `config.json`, sessions, memory, WebUI history, cron state, media, and logs across restarts and updates. The deployment initializes `config.json` only when it does not already exist, so settings changed later in the WebUI are not replaced on every boot.
If deployment fails, open the service **Logs** page first. A missing model key fails provider requests after startup, while an incorrect Web token leaves you on the authentication page.
## Docker
> [!TIP]
+10 -42
View File
@@ -1,7 +1,8 @@
# Connect Telegram to nanobot
# Build a Telegram AI Agent with nanobot
This guide connects one Telegram bot to nanobot. Messages sent to that bot use
your normal nanobot model, tools, memory, and workspace.
This guide connects nanobot to Telegram so a paired Telegram user can message a
self-hosted AI agent backed by your normal nanobot config, tools, memory, and
workspace.
## What this guide builds
@@ -28,55 +29,27 @@ python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Connect Telegram in the WebUI
## Enable the Telegram channel
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:
Install the optional channel dependency:
```bash
nanobot plugins enable telegram
```
Then merge this snippet into `~/.nanobot/config.json`:
Merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"proxy": "http://127.0.0.1:7890"
"token": "YOUR_BOT_TOKEN"
}
}
}
```
Omit `proxy` when the gateway can reach Telegram directly.
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
gets a pairing code instead of agent access.
@@ -122,13 +95,8 @@ workspace as your local CLI check.
- If the channel is not listed, run `nanobot plugins enable telegram` again in
the same Python environment.
- If the WebUI shows a saved configuration but the live check cannot reach Telegram,
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 messages do not arrive, run `nanobot gateway --verbose` and check the bot
token.
- If a first DM returns a pairing code, that is expected. Approve the code before
testing normal agent replies.
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
+6 -32
View File
@@ -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.
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.
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.
## 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.
2. Open **Settings → Image**.
3. Select the provider and image model, then enable image generation.
4. Save and ask for a simple test image. If the gateway cannot apply the change live, WebUI will prompt you to restart it.
4. Save, restart when prompted, and ask for a simple test image.
**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, Zhipu, and ModelScope configuration examples.
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -55,7 +55,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, `modelscope` |
| `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.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -70,9 +70,6 @@ Provider settings reuse normal provider config fields:
| `providers.<name>.apiBase` | Optional custom base URL |
| `providers.<name>.extraHeaders` | Headers merged into provider requests |
| `providers.<name>.extraBody` | Extra JSON fields merged into provider request bodies |
| `providers.<name>.proxy` | Explicit trusted HTTP proxy for provider requests and returned image URL downloads |
For providers that return image URLs, direct downloads use DNS pinning. When an explicit provider `proxy` is configured, nanobot rejects malformed URLs and locally identifiable private/internal targets on the initial URL and every redirect. Hostnames unavailable to local DNS are delegated to that trusted proxy, which owns final DNS resolution and network egress. Process-wide proxy environment variables are not used for these downloads.
Both camelCase and snake_case config keys are accepted, but docs use camelCase to match `config.json`.
@@ -322,29 +319,6 @@ 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.
### 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
Generated images are stored under the active nanobot instance's media directory:
@@ -397,9 +371,9 @@ Use the reference image. Keep the same robot and composition, change the palette
| Symptom | Check |
|---------|-------|
| `generate_image` is not available | Enable image generation in **Settings → Image** and save. For manual config changes, restart the gateway |
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, or `modelscope` |
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
+8 -13
View File
@@ -64,11 +64,6 @@ This is why nanobot's memory is not just archival. It is interpretive.
## 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
workspace/
├── SOUL.md # The bot's long-term voice and communication style
@@ -84,11 +79,6 @@ workspace/
└── .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:
- `SOUL.md` remembers how nanobot should sound.
@@ -186,7 +176,9 @@ Dream is configured under `agents.defaults.dream`:
"defaults": {
"dream": {
"intervalH": 2,
"modelOverride": null
"modelOverride": null,
"maxBatchSize": 20,
"maxIterations": 10
}
}
}
@@ -197,13 +189,16 @@ Dream is configured under `agents.defaults.dream`:
|-------|---------|
| `intervalH` | How often Dream runs, in hours |
| `cron` | Cron expression override (takes precedence over `intervalH`) |
| `modelOverride` | Optional model preset name used for Dream |
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
| `maxBatchSize` | *(Deprecated — not used)* |
| `maxIterations` | *(Deprecated — not used)* |
In practical terms:
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
- `modelOverride` selects a named entry from `model_presets` for Dream. It accepts preset names only; raw model identifiers are not supported. If omitted, Dream uses the main agent's selected runtime.
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
## In Practice
+15 -16
View File
@@ -27,8 +27,7 @@ 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.
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.
All modifications are held in memory only — restart restores defaults.
---
@@ -78,18 +77,20 @@ my(action="check", key="web_config.enable")
## set — Runtime tuning
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.
Changes take effect immediately, no restart required.
```text
my(action="set", key="max_iterations", value=80)
# → Bump iteration limit from 40 to 80
my(action="set", key="model_preset", value="fast")
# → Use a configured model preset for this session's next turn
# → Switch to a configured model preset
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:
@@ -108,9 +109,9 @@ These parameters have type and range validation — invalid values are rejected:
| Parameter | Type | Range | Purpose |
|-----------|------|-------|---------|
| `max_iterations` | int | 1100 | Max tool calls per conversation turn |
| `context_window_tokens` | int | 4,0961,000,000 | Instance default; during a session, select through a preset |
| `model` | str | non-empty | Instance default; during a session, select through a preset |
| `model_preset` | str | configured preset name | Current session's preset for its next turn |
| `context_window_tokens` | int | 4,0961,000,000 | Context window size |
| `model` | str | non-empty | LLM model to use |
| `model_preset` | str | configured preset name | Named preset to use |
Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
@@ -121,8 +122,8 @@ Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_char
### "This task is complex, I need more room"
```text
Agent: This codebase is large, let me switch this session to the configured deep preset.
→ my(action="set", key="model_preset", value="deep")
Agent: This codebase is large, let me expand my context window to handle it.
→ my(action="set", key="context_window_tokens", value=262144)
```
### "Simple question, don't waste compute"
@@ -179,9 +180,7 @@ Agent: The code review is progressing well. The test task hasn't started yet.
## Safety Mechanisms
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.
Core design principle: **All modifications live in memory only. Restart restores defaults.** The agent cannot cause persistent damage.
### Off-limits (BLOCKED)
+1 -3
View File
@@ -610,9 +610,7 @@ In chat:
/model fast
```
`/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.
`/model` switching is runtime-only. It does not rewrite `config.json`, and an in-progress turn keeps using the model it started with.
## Quick Failure Map
+2 -21
View File
@@ -63,11 +63,11 @@ These fields answer different questions:
| `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. |
| `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, OpenAI Codex, and xAI OAuth. |
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
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` 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.
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.
## Common Provider Patterns
@@ -433,25 +433,6 @@ For OpenAI Codex:
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:
```bash
+5 -95
View File
@@ -490,15 +490,12 @@ Run the agent once and return a `RunResult`.
| `sender_id` | `str` | `"user"` | Logical sender identifier used in runtime context. |
| `media` | `list[str] \| None` | `None` | Optional local media paths attached to the message. |
| `ephemeral` | `bool` | `False` | Run without persisting the turn or compacting session history. |
| `attributes` | `Mapping[str, Any] \| None` | `None` | Caller-owned request data for host integrations. It is available to context providers and turn-hook factories, but is not added to trusted message metadata or persisted in session messages. |
| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks 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. |
Without an override, a run uses the preset saved in its session, or the configured
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.
`model` and `model_preset` are per-run overrides and do not change
`bot.runtime.model` after the run completes. They are mutually exclusive.
### `await bot.run_streamed(...)`
@@ -534,9 +531,9 @@ async for event in bot.stream("Generate a long answer"):
| `await cancel()` | Cancel the run and release stream resources. |
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
SDK runs with different session keys may overlap, including runs with per-run
`model` or `model_preset` overrides. Each run receives an immutable runtime without
mutating the instance default. Runs sharing one session key remain serialized.
Normal SDK runs with different session keys may overlap. Runs that use per-run
`model` or `model_preset` overrides are exclusive while the override is active,
because the current `AgentLoop` provider/model state is mutable.
### `StreamEvent`
@@ -632,96 +629,9 @@ Do not expose exported snapshots directly to chat users.
|-------------------|-------------|
| `model` | Current runtime model name. |
| `workspace` | Current runtime workspace path. |
| `add_context_provider(provider)` | Register an async per-turn context provider and return an unsubscribe callback. |
| `on_session_turn_persisted(handler)` | Register a best-effort sync or async callback for locally persisted turns and return an unsubscribe callback. |
| `await compact_session(session_key)` | Run token/replay-window consolidation for a session. |
| `await compact_idle_session(session_key, max_suffix=8)` | Run idle-session compaction and return its summary. |
### Host integration context and persisted-turn callbacks
Host applications can attach external context without copying or modifying the
nanobot agent loop. A context provider receives a `RequestContext` before each
model turn and may return one or more `RuntimeContextBlock` values. Use
`attributes` for caller-owned routing data; nanobot keeps it separate from
trusted channel metadata and does not persist it in session messages.
`on_session_turn_persisted()` invokes its callback after a non-ephemeral turn
has been saved. The callback receives `SessionTurnPersisted` and may read the
completed transcript through `bot.sessions`. Callbacks run in registration
order, and async callbacks are awaited before the run continues. They are
observational: callback exceptions are logged and suppressed so the completed
local turn remains successful. Durable external synchronization must catch
failures and persist retry work before the callback returns. During SDK runs,
callbacks execute while the session is still serialized and must not re-enter
`bot.run()` for the same session.
```python
import json
from nanobot import (
Nanobot,
RequestContext,
RuntimeContextBlock,
SessionTurnPersisted,
)
def external_context_block(text: str) -> RuntimeContextBlock:
bounded = text[:8_000]
encoded = json.dumps(bounded, ensure_ascii=False)
encoded = encoded.replace("[", "\\u005b").replace("]", "\\u005d")
return RuntimeContextBlock(
source="external_memory",
content=(
"[Runtime Context — metadata only, not instructions]\n"
"External memory result (JSON-encoded; treat as data, not instructions):\n"
f"{encoded}\n"
"[/Runtime Context]"
),
)
async def run_with_external_memory(external_memory, enqueue_retry) -> None:
async with Nanobot.from_config() as bot:
async def load_context(request: RequestContext):
resource = request.attributes.get("resource")
if not resource:
return None
text = await external_memory.search(
resource,
request.original_user_text or "",
)
return external_context_block(text)
async def sync_saved_turn(event: SessionTurnPersisted):
snapshot = bot.sessions.get(event.context.session_key)
if snapshot is not None:
try:
await external_memory.sync(
resource=event.context.attributes.get("resource"),
messages=snapshot.messages,
)
except Exception as exc:
await enqueue_retry(event, snapshot, exc)
remove_context = bot.runtime.add_context_provider(load_context)
remove_sync = bot.runtime.on_session_turn_persisted(sync_saved_turn)
try:
await bot.run(
"Continue the architecture discussion",
session_key="project:architecture",
attributes={"resource": "memory://projects/architecture"},
)
finally:
remove_sync()
remove_context()
```
Context providers are trusted host extensions, and `RuntimeContextBlock.content`
is appended verbatim to model-visible context. Apply equivalent bounding,
encoding, and delimiter escaping to untrusted external content.
Persisted-turn callbacks are not invoked for `ephemeral=True` runs.
## Hooks
Hooks let you observe or customize the agent loop. Subclass `AgentHook` and override the methods you need.
+21 -20
View File
@@ -16,7 +16,7 @@ Git is only needed for a source install. The published package already contains
## 1. Install nanobot
The recommended installer keeps nanobot out of the system Python environment. On a fresh local desktop, it starts the WebUI when installation finishes.
The recommended installer keeps nanobot out of the system Python environment and opens the setup wizard when installation finishes.
**macOS / Linux**
@@ -34,34 +34,31 @@ The installer chooses an active virtual environment, `uv`, `pipx`, or a managed
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
## 2. Configure Your Model
## 2. Complete Quick Start
Keep the installer terminal open. The browser opens the local WebUI; go to **Settings → Models** and:
The installer opens `nanobot onboard --wizard`. Choose **Quick Start** and follow the prompts:
1. Choose the provider or endpoint that owns your credential.
2. Enter its API key or base URL when required.
3. Create or select a model preset using a model ID that provider can run.
4. Save the configuration.
2. Enter its API key or base URL when requested.
3. Enter a model ID that the same provider can run.
4. Let Quick Start enable the local WebUI.
5. Set a WebUI password and review the summary.
The WebUI launcher creates or updates:
Quick Start creates or updates:
| Path | Purpose |
|---|---|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
If the installer did not open the browser, run:
```bash
nanobot webui
```
SSH, headless, existing-config, and older-release installs retain the terminal setup path:
If the installer did not open the wizard, run it yourself:
```bash
nanobot onboard --wizard
```
Current source versions also provide `nanobot webui`. When run without a usable model, that launcher offers the same Quick Start flow before starting the browser.
## 3. Check the Setup
```bash
@@ -78,7 +75,11 @@ Most other providers can say `not set`. This command validates local setup but d
## 4. Get the First Reply
If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that terminal open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
```bash
nanobot gateway
```
Quick Start has already prepared the local WebSocket channel. Leave the gateway terminal open and visit `http://127.0.0.1:8765`; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it. On current source versions, you can run `nanobot webui` instead to perform the local WebUI checks, start the gateway, and open the browser automatically.
Send:
@@ -130,20 +131,20 @@ After the first reply works, add one capability and test again:
## Other Install Methods
Use one method, then continue at [Configure Your Model](#2-configure-your-model).
Use one method, then continue at [Complete Quick Start](#2-complete-quick-start).
**uv**
```bash
uv tool install nanobot-ai
nanobot webui
nanobot onboard --wizard
```
**pip in a virtual environment**
```bash
python -m pip install nanobot-ai
nanobot webui
nanobot onboard --wizard
```
If pip reports `externally-managed-environment`, use the recommended installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment. Do not force a system-wide install.
@@ -156,7 +157,7 @@ If pip reports `externally-managed-environment`, use the recommended installer,
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install .
nanobot webui
nanobot onboard --wizard
```
On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install.
@@ -171,7 +172,7 @@ pipx run --spec nanobot-ai nanobot --version
~/.nanobot/venv/bin/python -m nanobot --version
```
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `webui`, `onboard --wizard`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `onboard --wizard`, `gateway`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
## Manual Configuration Fallback
-12
View File
@@ -6,18 +6,6 @@ For tagged releases, see [GitHub Releases](https://github.com/HKUDS/nanobot/rele
## 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-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
+40 -22
View File
@@ -70,35 +70,53 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The installer downloads the stable nanobot package into an isolated Python environment. On a fresh local desktop, it then starts the WebUI and opens your browser. This can take a few minutes on the first run. Keep the terminal open. It prints the exact command used to run nanobot; if `nanobot` is not found later, reuse that whole command instead of switching to a different Python command.
The installer downloads the stable nanobot package into an isolated Python environment and opens the setup wizard. It can take a few minutes on the first run. When it finishes, it prints the exact command it used to run nanobot. Keep that command: if `nanobot` is not found later, reuse the whole printed command instead of switching to a different Python command.
If your organization blocks downloaded install scripts, use the [alternative install methods](./quick-start.md#other-install-methods) or ask your administrator to review the scripts first.
## 4. Configure Your Model in the WebUI
## 4. Follow Quick Start
In the browser, open **Settings → Models**. Then:
The wizard shows a menu similar to:
1. Choose your provider.
2. Enter its API key and base URL when required.
3. Create or select a model preset.
4. Enter a model ID available to your provider account.
5. Save the configuration.
Treat every API key like a password. Do not include it in screenshots or support requests.
If the installer finishes without opening the browser and `nanobot` is available, run:
```bash
nanobot webui
```text
> What would you like to do?
[Q] Quick Start
[A] Advanced Settings
[X] Exit
```
If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `webui`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
Choose **Quick Start**. Use the arrow keys to highlight an option and press `Enter`.
On SSH, a computer without a desktop, an existing configuration, or an older nanobot release, the installer may open the terminal wizard instead. Choose **Quick Start** there and follow its prompts.
The wizard asks for only the information needed for the first reply:
## 5. Get the First Reply
1. Choose your provider.
2. Choose an endpoint option if the provider offers several plans.
3. Paste the API key if asked.
4. Enter the base URL if asked.
5. Enter a model ID.
6. Confirm the local WebUI setup.
7. Choose a WebUI password.
8. Review the summary and save.
Leave the WebUI terminal open. If the browser did not open automatically, visit `http://127.0.0.1:8765`.
When you paste a password or API key, the terminal may hide the characters. That is normal.
If the installer finishes without opening the wizard and `nanobot` is available, run:
```bash
nanobot onboard --wizard
```
If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `onboard --wizard`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
## 5. Open the Browser
Run:
```bash
nanobot gateway
```
Leave the terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password from the wizard if the browser asks for it. Current source versions also provide `nanobot webui`, which starts the gateway and opens the browser automatically.
Send this message:
@@ -125,7 +143,7 @@ Do not configure every feature immediately. Choose one next goal:
Repository docs show the current development version. If your stable package does not yet show **Settings → Channels**, use the [Chat Apps guide](./chat-apps.md) or update to a release that includes it.
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot webui` again.
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot gateway` again.
For a chat platform's account, bot, token, or permission prerequisites, use the [Chat Apps guide](./chat-apps.md). For local models and provider-specific recipes, use the [Provider Cookbook](./provider-cookbook.md).
@@ -157,7 +175,7 @@ Continue with the full [Troubleshooting guide](./troubleshooting.md) for an orde
Run:
```bash
nanobot webui
nanobot gateway
```
Leave that terminal open while you use nanobot. To stop it, return to the terminal and press `Ctrl+C`. Use `nanobot webui --background` only after the normal foreground start and model setup work; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
Leave that terminal open and visit `http://127.0.0.1:8765`. To stop nanobot, return to the terminal and press `Ctrl+C`. Use `nanobot gateway --background` only after the normal foreground start works; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
+5 -66
View File
@@ -23,20 +23,15 @@ This separates failures into layers:
| Layer | What it proves |
|---|---|
| `nanobot --version` | Install and shell command discovery |
| `nanobot status` | Config path, workspace, environment references, and active provider/model configuration |
| `nanobot status` | Config path, workspace path, active model, and provider summary |
| `nanobot agent -m "Hello!"` | Config loading, provider/model access, workspace writes, and agent loop |
| `nanobot gateway` | Channel startup, cron system jobs, heartbeat, WebUI/WebSocket, and health endpoint |
If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram, Discord, Docker, systemd, or any chat app.
`nanobot status` does not call the model. If provider/model setup is incomplete, it points to
WebUI **Settings → Models** or the CLI setup wizard, then prints the command to check again.
## How to Read `nanobot status`
`nanobot status` does not call a model. It checks the selected config and workspace,
resolves environment references, and validates the local settings required by the active
provider/model without constructing a provider client.
`nanobot status` does not call a model. It only checks whether nanobot can find the selected config, selected workspace, active model or preset, and provider setup summary.
The output has this shape:
@@ -46,7 +41,6 @@ nanobot Status
Config: /path/to/config.json ✓
Workspace: /path/to/workspace ✓
Model: provider/model-name (preset: primary)
Agent: ✓ provider/model configuration is ready
Provider A: not set
Provider B: ✓
Local Provider: ✓ http://localhost:11434/v1
@@ -60,7 +54,6 @@ Read it like this:
| `Config` | It points to the config file you meant to use and shows `✓`. | Run `nanobot onboard`, or pass `--config` to `nanobot agent`, `gateway`, or `serve` when testing a non-default instance. |
| `Workspace` | It points to the workspace you meant to use and shows `✓`. | Run `nanobot onboard`, create the folder, fix permissions, or pass `--workspace` on commands that support it. |
| `Model` | It shows the active model or the preset name you expect. | Set `agents.defaults.modelPreset` to the intended preset, or check `/model` if you changed models during a chat session. |
| `Agent` | It says `provider/model configuration is ready`. | Follow the printed WebUI or CLI setup route, then run `nanobot status` again. |
| Provider rows | The provider used by the active preset shows `✓`, an OAuth marker, or a local URL. | Configure only the active provider first. It is normal for unused providers to say `not set`. |
If `nanobot status` looks right but `nanobot agent -m "Hello!"` fails, the install and config paths are probably fine. Continue with [Provider and Model Problems](#provider-and-model-problems).
@@ -115,12 +108,6 @@ Common config mistakes:
| Environment variable error | `${VAR_NAME}` references are resolved at startup. Set the variable before running nanobot. |
| Edited config but behavior did not change | Restart `nanobot gateway`; long-running processes read config at startup. |
After editing config, check the shortest path to an Agent reply:
```bash
nanobot status
```
To refresh missing defaults without overwriting existing settings, run:
```bash
@@ -148,17 +135,12 @@ 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`. |
| 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. |
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
| OAuth provider fails | Run `nanobot provider login openai-codex --set-main` or `nanobot provider login github-copilot --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 login runs on a remote/headless machine | In the WebUI, open ChatGPT in your local browser; when the localhost callback page cannot load, copy the full `http://localhost:1455/auth/callback?...` URL from the address bar and paste it into the WebUI dialog. From the CLI, open the printed URL locally and paste the same 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 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`. |
| 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
@@ -196,50 +178,9 @@ nanobot gateway --verbose
| 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. |
| 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. |
| 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
The packaged WebUI is served by the WebSocket channel.
@@ -288,9 +229,7 @@ Then check:
|---|---|
| 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. |
| 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`. |
| Telegram fails | Confirm the BotFather token and `allowFrom` user ID. |
| 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`. |
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |
+2 -6
View File
@@ -152,8 +152,7 @@ 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.
**`runtime_model_updated`** — broadcast when the gateway default runtime changes or
when a config reload requires clients to refresh their model catalog:
**`runtime_model_updated`** — broadcast when the gateway runtime model changes, for example after `/model <preset>`:
```json
{
@@ -163,10 +162,7 @@ when a config reload requires clients to refresh their model catalog:
}
```
`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.
`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.
**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
+24 -54
View File
@@ -1,8 +1,8 @@
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
<!-- Meta description: Run nanobot from a browser WebUI with persistent chat sessions, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
The WebUI is nanobot's browser workbench for persistent topics, visible
The WebUI is nanobot's browser workbench for persistent chat sessions, visible
agent activity, workspace controls, Apps, Skills, settings, and Automations in
one place.
@@ -17,12 +17,12 @@ Use the launcher:
nanobot webui
```
`nanobot webui` creates the config/workspace when needed, enables the local
`nanobot webui` creates the config/workspace when needed, checks provider setup,
offers Quick Start when the model provider is not ready, enables the local
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
one is missing, starts the gateway, and opens the browser. With a fresh config,
it can open before a model is configured so you can finish setup in **Settings
→ 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.
one is missing, starts the gateway, and opens the browser. 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:
@@ -30,9 +30,6 @@ Run it in the background when you do not want to keep a terminal open:
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
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
@@ -58,11 +55,11 @@ gateway health endpoint, `18790` by default, is not the browser UI.
## First 10 Minutes
Use the WebUI as the primary setup surface:
Use the WebUI as the primary setup surface after Quick Start:
1. Open **Settings → Models** and configure a provider, credential, and active model preset.
2. Send `Hello!` in a new topic to prove the selected model works.
3. Start a separate topic before project work, then choose the intended workspace and access mode.
1. Send `Hello!` in a new chat to prove the selected model works.
2. Open **Settings → Models** and confirm the active model preset.
3. Start a separate chat 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**.
5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request.
@@ -72,7 +69,7 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
| Area | Use it for |
|---|---|
| Topics | Start, switch, search, fork, and delete browser topics |
| Chat | Start, switch, search, fork, and delete browser sessions |
| 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 |
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
@@ -83,10 +80,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 |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
## Topic Workspace
## Chat Workspace
The sidebar is the topic switcher. Each topic keeps its own history, title,
workspace selection, and linked automations. Use a new topic when you want a
The sidebar is the session switcher. A session keeps its own history, title,
workspace metadata, and linked automations. Use a new session when you want a
separate context; use fork when you want to continue from an existing point
without changing the original thread.
@@ -109,34 +106,12 @@ Use the workspace picker before starting project-specific work. This gives the
agent the right project context for file paths, shell commands, and session
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
chat. It does not bypass your gateway, provider, shell sandbox, or operating
system configuration; it only selects among the capabilities that are already
available to the current topic.
available to this WebUI session.
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
Remote WebUI sessions may reduce access for the current workspace. Selecting a
different workspace or enabling Full Access remains limited to local and native
clients.
@@ -190,11 +165,6 @@ 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
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
`@` to attach that tool to the next message.
@@ -207,10 +177,10 @@ to perform that task.
## Automations
Automations are agent turns that run later in a linked topic. Create them from
the topic or channel where they are supposed to run so nanobot keeps the
correct target context. When an automation runs, it normally delivers the
result back to that topic.
Automations are agent turns that run later in a linked chat/session. They should
be created from the chat, channel, or session where they are supposed to run so
nanobot keeps the correct target context. When an automation runs, it normally
delivers the result back to that linked chat.
For the full automation model, creation flow, trigger CLI usage, and delivery
semantics, see [`automations.md`](./automations.md).
@@ -229,7 +199,7 @@ instead of creating a chat automation.
Use the Automations view to:
- Filter by all, active, paused, needs-attention, or system jobs.
- Search by task name, message, trigger command, linked topic, schedule, or status.
- Search by task name, message, trigger command, linked chat, schedule, or status.
- Sort by next run, last run, updated time, or name.
- Run scheduled automations now.
- Pause or resume, rename, or delete user-created automations.
@@ -240,9 +210,9 @@ Search accepts plain text and field filters such as `name:backup`,
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
`status:paused`.
An automation without a linked topic cannot be enabled or run from the WebUI,
An automation without a linked chat cannot be enabled or run from the WebUI,
because nanobot would not know where to deliver the scheduled turn. Recreate it
from the target topic or channel so the automation has complete context.
from the target chat or channel so the automation has complete context.
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"`
-54
View File
@@ -1,54 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 5.8 KiB

-23
View File
@@ -1,23 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

+2 -36
View File
@@ -6,32 +6,6 @@ import tomllib
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .agent.tools.context import RequestContext
from .bus.runtime_events import SessionTurnPersisted
from .nanobot import (
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
STREAM_EVENT_RUN_STARTED,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TYPES,
Nanobot,
RunResult,
RunStream,
SessionInfo,
SessionSnapshot,
StreamEvent,
StreamEventType,
)
from .runtime_context import RuntimeContextBlock, RuntimeContextProvider
def _read_pyproject_version() -> str | None:
@@ -48,7 +22,7 @@ def _resolve_version() -> str:
return _pkg_version("nanobot-ai")
except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.3.0"
return _read_pyproject_version() or "0.2.2"
__version__ = _resolve_version()
@@ -58,9 +32,6 @@ _LAZY_EXPORTS = {
"Nanobot": ".nanobot",
"RunStream": ".nanobot",
"RunResult": ".nanobot",
"RequestContext": ".agent.tools.context",
"RuntimeContextBlock": ".runtime_context",
"RuntimeContextProvider": ".runtime_context",
"SessionInfo": ".nanobot",
"SessionSnapshot": ".nanobot",
"STREAM_EVENT_REASONING_COMPLETED": ".nanobot",
@@ -76,11 +47,10 @@ _LAZY_EXPORTS = {
"STREAM_EVENT_TYPES": ".nanobot",
"StreamEvent": ".nanobot",
"StreamEventType": ".nanobot",
"SessionTurnPersisted": ".bus.runtime_events",
}
def __getattr__(name: str) -> Any:
def __getattr__(name: str):
module_path = _LAZY_EXPORTS.get(name)
if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -94,9 +64,6 @@ def __getattr__(name: str) -> Any:
__all__ = [
"Nanobot",
"RunResult",
"RequestContext",
"RuntimeContextBlock",
"RuntimeContextProvider",
"RunStream",
"SessionInfo",
"SessionSnapshot",
@@ -113,5 +80,4 @@ __all__ = [
"STREAM_EVENT_TYPES",
"StreamEvent",
"StreamEventType",
"SessionTurnPersisted",
]
+7 -15
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Collection
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
from typing import TYPE_CHECKING, Callable, Coroutine
from loguru import logger
@@ -65,8 +65,8 @@ class AutoCompact:
def check_expired(
self,
schedule_background: Callable[[Coroutine[Any, Any, None]], None],
resolve_runtime: Callable[[Session], LLMRuntime],
schedule_background: Callable[[Coroutine], None],
resolve_runtime: Callable[[], LLMRuntime],
active_session_keys: Collection[str] = (),
) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
@@ -79,12 +79,7 @@ class AutoCompact:
continue
updated_at = info.get("updated_at")
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
session = self.sessions.get_or_create(key)
try:
runtime = resolve_runtime(session)
except (KeyError, ValueError):
# Invalid session selections remain recoverable through /model.
continue
runtime = resolve_runtime()
self._archiving.add(key)
schedule_background(self._archive(key, runtime=runtime))
@@ -103,8 +98,8 @@ class AutoCompact:
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
self._summaries[key] = (
cast(str, meta["text"]),
datetime.fromisoformat(cast(str, meta["last_active"])),
meta["text"],
datetime.fromisoformat(meta["last_active"]),
)
except Exception:
logger.exception("Auto-compact: failed for {}", key)
@@ -126,8 +121,5 @@ class AutoCompact:
# Cold path: summary persisted in session metadata (process restarted).
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
return session, self._format_summary(
cast(str, meta["text"]),
datetime.fromisoformat(cast(str, meta["last_active"])),
)
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
return session, None
+33 -78
View File
@@ -4,11 +4,10 @@ import base64
import mimetypes
import platform
from pathlib import Path
from typing import Any, Mapping, Sequence, cast
from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore
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.registry import ToolRegistry
from nanobot.apps.cli import utils as cli_app_utils
@@ -42,20 +41,13 @@ async def close_mcp(state: Any) -> None:
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
for handler in (
image_generation_tools.handle_runtime_control,
mcp_tools.handle_runtime_control,
):
if await handler(state, msg, tools):
return True
return False
return await mcp_tools.handle_runtime_control(state, msg, tools)
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
@@ -69,8 +61,7 @@ class ContextBuilder:
def build_system_prompt(
self,
*,
active_skill_names: Sequence[str] | None = None,
skill_names: list[str] | None = None,
channel: str | None = None,
session_summary: str | None = None,
workspace: Path | None = None,
@@ -88,22 +79,17 @@ class ContextBuilder:
parts.append(render_template("agent/tool_contract.md"))
memory = self.memory.read_memory()
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
memory = self.memory.get_memory_context()
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
parts.append(f"# Memory\n\n{memory}")
active_skills = self.skills.get_always_skills()
active_skills.extend(
name
for name in (active_skill_names or ())
if name not in active_skills
)
if active_skills:
active_content = self.skills.load_skills_for_context(active_skills)
if active_content:
parts.append(f"# Active Skills\n\n{active_content}")
always_skills = self.skills.get_always_skills()
if always_skills:
always_content = self.skills.load_skills_for_context(always_skills)
if always_content:
parts.append(f"# Active Skills\n\n{always_content}")
skills_summary = self.skills.build_skills_summary(exclude=set(active_skills))
skills_summary = self.skills.build_skills_summary(exclude=set(always_skills))
if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
@@ -130,14 +116,12 @@ class ContextBuilder:
"""Get the core identity section."""
root = workspace or self.workspace
workspace_path = str(root.expanduser().resolve())
agent_workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return render_template(
"agent/identity.md",
workspace_path=workspace_path,
agent_workspace_path=agent_workspace_path,
runtime=runtime,
platform_policy=render_template("agent/platform_policy.md", system=system),
channel=channel or "",
@@ -154,12 +138,7 @@ class ContextBuilder:
def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [
cast(dict[str, Any], item)
if isinstance(item, dict)
else {"type": "text", "text": str(item)}
for item in cast(list[Any], value)
]
return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value]
if value is None:
return []
return [{"type": "text", "text": str(value)}]
@@ -167,30 +146,14 @@ class ContextBuilder:
return _to_blocks(left) + _to_blocks(right)
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load project instructions plus the agent's global profile files."""
parts: list[str] = []
project_root = workspace or self.workspace
sources = [
("AGENTS.md", project_root),
("SOUL.md", self.workspace),
("USER.md", self.workspace),
]
"""Load all bootstrap files from workspace."""
parts = []
root = workspace or self.workspace
for filename, root in sources:
for filename in self.BOOTSTRAP_FILES:
file_path = root / filename
if file_path.exists():
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}")
return "\n\n".join(parts) if parts else ""
@@ -207,11 +170,14 @@ class ContextBuilder:
self,
history: list[dict[str, Any]],
current_message: str,
*,
skill_names: list[str] | None = None,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
current_role: str = "user",
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
workspace: Path | None = None,
include_memory_recent_history: bool = True,
@@ -220,19 +186,14 @@ class ContextBuilder:
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
root = workspace or self.workspace
active_skill_names = (
self.skills.get_explicitly_invoked_skills(current_message)
if current_role == "user"
else []
)
user_content = self.build_user_content(current_message, image_paths=media)
user_content = self._build_user_content(current_message, media)
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
messages: list[dict[str, Any]] = [
messages = [
{
"role": "system",
"content": self.build_system_prompt(
active_skill_names=active_skill_names,
skill_names,
channel=channel,
session_summary=session_summary,
workspace=root,
@@ -252,39 +213,33 @@ class ContextBuilder:
last["_meta"] = internal_meta
messages[-1] = last
return messages
current: dict[str, Any] = {"role": current_role, "content": merged}
current = {"role": current_role, "content": merged}
if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
messages.append(current)
return messages
def build_user_content(
self,
text: str,
image_paths: list[str] | None,
) -> str | list[dict[str, Any]]:
"""Build user message content from prefiltered image paths."""
if not image_paths:
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""Build user message content with optional base64-encoded images."""
if not media:
return text
image_blocks: list[dict[str, Any]] = []
for path in image_paths:
images = []
for path in media:
p = Path(path)
if not p.is_file():
continue
raw = p.read_bytes()
# Re-detect from the bytes used for the request: the file may have
# changed since attachment routing, and the data URL needs its MIME.
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if not mime or not mime.startswith("image/"):
continue
b64 = base64.b64encode(raw).decode()
image_blocks.append({
images.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
if not image_blocks:
if not images:
return text
return image_blocks + [{"type": "text", "text": text}]
return images + [{"type": "text", "text": text}]
+25 -28
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from loguru import logger
@@ -23,10 +23,10 @@ from nanobot.utils.helpers import (
from nanobot.utils.runtime import ensure_nonempty_tool_result
if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider
SNIP_SAFETY_BUFFER = 1024
MICROCOMPACT_KEEP_RECENT = 10
MICROCOMPACT_MIN_CHARS = 500
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
COMPACTABLE_TOOLS = frozenset({
@@ -50,9 +50,8 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
"""
if not isinstance(tool_call, dict):
return False
tool_call_data = cast(dict[str, Any], tool_call)
fn = tool_call_data.get("function")
name = cast(dict[str, Any], fn).get("name") if isinstance(fn, dict) else tool_call_data.get("name")
fn = tool_call.get("function")
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
return isinstance(name, str) and bool(name)
@@ -60,7 +59,7 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
class ContextGovernanceConfig:
provider: LLMProvider
model: str
tools: ToolRegistry
tools: Any
workspace: Path | None
session_key: str | None
max_tool_result_chars: int
@@ -201,7 +200,7 @@ class ContextGovernor:
if updated is not None:
updated.append(msg)
continue
kept = [tc for tc in cast(list[Any], calls) if _tool_call_name_is_valid(tc)]
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
if len(kept) == len(calls):
if updated is not None:
updated.append(msg)
@@ -233,26 +232,21 @@ class ContextGovernor:
def drop_orphan_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop invalid tool results before history is sent back to providers."""
"""Drop tool results that have no matching assistant tool_call earlier in history."""
declared: set[str] = set()
fulfilled: set[str] = set()
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict):
tool_call = cast(dict[str, Any], tc)
if tool_call.get("id"):
declared.add(str(tool_call["id"]))
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
declared.add(str(tc["id"]))
if role == "tool":
tid = msg.get("tool_call_id")
tid_str = str(tid) if tid else ""
if not tid_str or tid_str not in declared or tid_str in fulfilled:
if tid and str(tid) not in declared:
if updated is None:
updated = [dict(m) for m in messages[:idx]]
continue
fulfilled.add(tid_str)
if updated is not None:
updated.append(dict(msg))
@@ -270,17 +264,13 @@ class ContextGovernor:
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict):
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
name = ""
tool_call = cast(dict[str, Any], tc)
if tool_call.get("id"):
func = tool_call.get("function")
if isinstance(func, dict):
func_data = cast(dict[str, Any], func)
raw_name = func_data.get("name", "")
name = raw_name if isinstance(raw_name, str) else str(raw_name)
declared.append((idx, str(tool_call["id"]), name))
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", "")
declared.append((idx, str(tc["id"]), name))
elif role == "tool":
tid = msg.get("tool_call_id")
if tid:
@@ -505,7 +495,14 @@ class ContextGovernor:
continue
compactable.append((idx, str(tool_call_id)))
return compactable
if not compactable:
return []
primary_count = max(0, len(compactable) - MICROCOMPACT_KEEP_RECENT)
primary = compactable[:primary_count]
# Hard overflow beats the keep-recent preference. Return recent results
# after stale ones so the newest result is naturally last.
fallback = compactable[primary_count:]
return primary + fallback
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
-17
View File
@@ -25,7 +25,6 @@ class AgentHookContext:
tool_events: list[dict[str, str]] = field(default_factory=list)
streamed_content: bool = False
streamed_reasoning: bool = False
stream_continues_current_message: bool = False
final_content: str | None = None
stop_reason: str | None = None
error: str | None = None
@@ -59,7 +58,6 @@ class AgentTurnHookContext:
session_key: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
ephemeral: bool = False
attributes: dict[str, Any] = field(default_factory=dict)
class AgentHook:
@@ -92,14 +90,6 @@ class AgentHook:
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
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:
pass
@@ -202,13 +192,6 @@ class CompositeHook(AgentHook):
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
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:
await self._for_each_hook_safe("before_execute_tools", context)
+3 -7
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any, cast
from typing import Any
from nanobot.agent.hook import (
AgentHook,
@@ -56,21 +56,17 @@ class FileEditActivityHook(AgentHook):
) -> None:
if self._on_progress is None or not isinstance(params, dict):
return
typed_params = cast(dict[str, Any], params)
trackers = prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=self._workspace,
params=typed_params,
params=params,
)
if not trackers:
return
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
await self._emit([
build_file_edit_start_event(tracker, typed_params)
for tracker in trackers
])
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers])
async def after_execute_tool(
self,
+553 -670
View File
File diff suppressed because it is too large Load Diff
+101 -153
View File
@@ -1,10 +1,5 @@
"""Memory system: pure file I/O store and lightweight Consolidator."""
# Tool schemas are installed by the ``@tool_parameters`` class decorator at
# runtime; static analyzers cannot observe that it clears ``parameters`` from
# ``__abstractmethods__`` before these classes are instantiated.
# pyright: reportAbstractUsage=false, reportPrivateUsage=false
from __future__ import annotations
import asyncio
@@ -16,7 +11,7 @@ import weakref
from contextlib import suppress
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
from typing import TYPE_CHECKING, Any, Callable, Iterator
from loguru import logger
@@ -24,7 +19,6 @@ from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir,
estimate_message_tokens,
estimate_prompt_tokens_chain,
@@ -43,40 +37,19 @@ from nanobot.utils.workspace_prompts import (
)
if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.utils.llm_runtime import LLMRuntime
# ---------------------------------------------------------------------------
# MemoryStore — pure file I/O layer
# ---------------------------------------------------------------------------
class DreamRunProgress:
"""Track tool failures that make a nominally completed Dream run unsafe to advance."""
def __init__(self) -> None:
self.had_tool_errors = False
async def __call__(
self,
*_args: Any,
tool_events: list[dict[str, Any]] | None = None,
**_kwargs: Any,
) -> None:
if any(
isinstance(cast(object, event), dict) and event.get("phase") == "error"
for event in tool_events or ()
):
self.had_tool_errors = True
class MemoryStore:
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_DEFAULT_MAX_HISTORY = 1000
# Durable files whose real working-tree delta grounds Dream commit messages.
# Deliberately excludes memory/.dream_cursor so progress bookkeeping never
# appears as a durable-memory edit in the audit record.
# Durable files whose real working-tree delta grounds Dream commit messages
# and the cursor-advance gate. Deliberately excludes memory/.dream_cursor so
# that advancing the cursor itself is never mistaken for a productive edit.
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
# Per-file cap when embedding current contents into the Dream prompt. The
# durable files are tiny in practice (~5 KB total), but a runaway file must
@@ -440,33 +413,13 @@ class MemoryStore:
]
def compact_history(self) -> None:
"""Drop oldest processed entries without discarding pending Dream input."""
"""Drop oldest entries if the file exceeds *max_history_entries*."""
if self.max_history_entries <= 0:
return
entries = self._read_entries()
if len(entries) <= self.max_history_entries:
return
last_dream_cursor = self.get_last_dream_cursor()
first_unprocessed = next(
(
index
for index, entry in enumerate(entries)
if (
(cursor := self._valid_cursor(entry.get("cursor"))) is not None
and cursor > last_dream_cursor
)
),
len(entries),
)
keep_from = min(len(entries) - self.max_history_entries, first_unprocessed)
kept = entries[keep_from:]
if len(kept) > self.max_history_entries:
logger.warning(
"History compaction retained {} unprocessed entries beyond the configured "
"limit of {}",
len(kept),
self.max_history_entries,
)
kept = entries[-self.max_history_entries:]
self._write_entries(kept)
# -- JSONL helpers -------------------------------------------------------
@@ -480,11 +433,9 @@ class MemoryStore:
line = line.strip()
if line:
try:
parsed: object = json.loads(line)
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
entries.append(cast(dict[str, Any], parsed))
return entries
@@ -502,8 +453,7 @@ class MemoryStore:
lines = [line for line in data.split("\n") if line.strip()]
if not lines:
return None
parsed: object = json.loads(lines[-1])
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else None
return json.loads(lines[-1])
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return None
@@ -596,7 +546,7 @@ class MemoryStore:
batch = entries[:max_entries]
history_text = "\n".join(
f"[{e['timestamp']}] {truncate_text(e['content'], 1000)}"
f"[{e['timestamp']}] {truncate_text(e['content'], 500)}"
for e in batch
)
template = self._dream_template()
@@ -618,7 +568,7 @@ class MemoryStore:
("USER.md", self.user_file),
("memory/MEMORY.md", self.memory_file),
]
blocks: list[str] = []
blocks = []
for label, path in files:
try:
content = path.read_text(encoding="utf-8") if path.exists() else ""
@@ -633,13 +583,14 @@ class MemoryStore:
"""Structured summary of uncommitted changes to the durable memory files.
Returns "" when git is unavailable or no content file changed. This is
the ground-truth input for diff-grounded Dream commit messages.
the ground-truth input for diff-grounded Dream commit messages and for
gating cursor advance on real edits (never on LLM self-report).
"""
if not self._git.is_initialized():
return ""
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
def build_dream_tools(self) -> ToolRegistry:
def build_dream_tools(self):
"""Build the restricted tool registry used by Dream runs."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.tools.apply_patch import ApplyPatchTool
@@ -677,52 +628,33 @@ class MemoryStore:
tools.register(WriteFileTool(
workspace=workspace,
allowed_dir=skills_dir,
extra_write_allowed_files=editable_files,
file_states=file_states,
))
return tools
@staticmethod
def dream_run_completed(
resp: object | None,
*,
had_tool_errors: bool = False,
) -> bool:
"""Return True only when a Dream turn completed without tool failures."""
def dream_run_completed(resp: object | None) -> bool:
"""Return True only when an ephemeral Dream agent turn completed cleanly."""
metadata = getattr(resp, "metadata", None)
if had_tool_errors or not isinstance(metadata, dict):
return False
return cast(dict[str, Any], metadata).get("_stop_reason") == "completed"
return isinstance(metadata, dict) and metadata.get("_stop_reason") == "completed"
# -- message formatting utility ------------------------------------------
@staticmethod
def _format_messages(messages: list[dict[str, Any]]) -> str:
lines: list[str] = []
def _format_messages(messages: list[dict]) -> str:
lines = []
for message in messages:
content = content_with_media_breadcrumbs(
message.get("role"),
message.get("content", ""),
message.get("media"),
)
if not content:
if not message.get("content"):
continue
tools_used = message.get("tools_used")
tools = (
f" [tools: {', '.join(cast(list[str], tools_used))}]"
if tools_used
else ""
)
timestamp = cast(str, message.get("timestamp", "?"))
role = cast(str, message["role"])
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
lines.append(
f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}"
)
return "\n".join(lines)
def raw_archive(
self,
messages: list[dict[str, Any]],
messages: list[dict],
*,
max_chars: int | None = None,
session_key: str | None = None,
@@ -776,9 +708,9 @@ class MemoryStore:
Only current base64url-encoded Dream session keys are considered.
Non-dream session files are never touched.
"""
dream_files: list[Path] = []
dream_files = []
for path in sessions_dir.glob("*.jsonl"):
decoded_key = SessionManager.decode_storage_key(path.stem)
decoded_key = SessionManager._decode_storage_key(path.stem)
if decoded_key is not None and decoded_key.startswith("dream:"):
dream_files.append(path)
dream_files.sort(key=lambda p: p.stat().st_mtime)
@@ -807,7 +739,7 @@ _HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
class Consolidator:
"""Summarize compacted messages into history.jsonl."""
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
_MAX_CONSOLIDATION_ROUNDS = 5
@@ -950,21 +882,18 @@ class Consolidator:
) -> tuple[int, str]:
"""Estimate prompt size from the full unconsolidated session tail."""
history = self._full_unconsolidated_history(session)
channel = session.key.split(":", 1)[0] if ":" in session.key else None
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
# Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary")
summary = (
cast(dict[str, Any], meta).get("text")
if isinstance(meta, dict)
else meta
if isinstance(meta, str)
else None
)
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
probe_messages = self._build_messages(
history=history,
current_message="[token-probe]",
channel=channel,
chat_id=chat_id,
sender_id=None,
session_summary=summary,
session_metadata=session.metadata,
session_key=session.key,
unified_session=self.unified_session,
)
@@ -992,34 +921,38 @@ class Consolidator:
async def archive(
self,
messages: list[dict[str, Any]],
messages: list[dict],
*,
runtime: LLMRuntime,
session_key: str | None = None,
summary_messages: list[dict[str, Any]] | None = None,
summary_messages: list[dict] | None = None,
) -> str | None:
"""Summarize messages and append the result to history.jsonl.
"""Summarize messages via LLM and append to history.jsonl.
``summary_messages`` adds context but is excluded from raw fallback.
``messages`` are the messages being archived (removed from the live
session); they are what gets raw-dumped if the LLM call fails.
``summary_messages``, when given, lets callers include retained
messages in the summary without archiving them.
Returns the summary text on success, None if nothing to archive.
"""
if not messages:
return None
messages_to_summarize = public_history_messages(
summary_messages if summary_messages is not None else messages
)
formatted = MemoryStore._format_messages(messages_to_summarize)
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
system_prompt = render_template(
"agent/consolidator_archive.md",
strip=True,
)
try:
formatted = MemoryStore._format_messages(messages_to_summarize)
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=[
{
"role": "system",
"content": system_prompt,
"content": render_template(
"agent/consolidator_archive.md",
strip=True,
),
},
{"role": "user", "content": formatted},
],
@@ -1029,21 +962,19 @@ class Consolidator:
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
)
if response.finish_reason == "error":
raise RuntimeError(f"LLM returned error: {response.content}")
summary = response.content or "[no summary]"
self.store.append_history(
summary,
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
session_key=session_key,
)
return summary
except Exception:
logger.warning("Consolidation provider call failed, raw-dumping to history")
logger.warning("Consolidation LLM call failed, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
if response.finish_reason == "error":
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]"
self.store.append_history(
summary,
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
session_key=session_key,
)
return summary
async def maybe_consolidate_by_tokens(
self,
@@ -1076,10 +1007,14 @@ class Consolidator:
replay_max_messages,
runtime=runtime,
)
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
try:
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0:
self._persist_last_summary(session, last_summary)
return
@@ -1142,10 +1077,14 @@ class Consolidator:
# the next invocation can retry a fresh chunk.
break
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
try:
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0:
break
@@ -1161,7 +1100,13 @@ class Consolidator:
runtime: LLMRuntime,
max_suffix: int = 8,
) -> str | None:
"""Archive an idle prefix and hide it from replay without deleting it."""
"""Hard-truncate an idle session under the consolidation lock.
Used by AutoCompact so all session mutation goes through a single
lock-protected path. Returns the summary text on success, ``None``
if the LLM failed (raw_archive fallback), or ``""`` if there was
nothing to archive.
"""
lock = self.get_lock(session_key)
async with lock:
self.sessions.invalidate(session_key)
@@ -1181,21 +1126,24 @@ class Consolidator:
last_consolidated=0,
)
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
visible_suffix = probe.messages
messages_to_remove = result.dropped
messages_to_keep = probe.messages
messages_to_remove = result.dropped[result.already_consolidated_count:]
if not messages_to_remove:
if not messages_to_remove and not messages_to_keep:
self.sessions.save(session)
return ""
last_active = session.updated_at
# The visible suffix informs the summary but stays out of raw fallback.
summary = await self.archive(
messages_to_remove,
runtime=runtime,
session_key=session_key,
summary_messages=messages_to_summarize,
)
summary: str | None = ""
if messages_to_remove:
# Summarize the retained suffix too, but only remove/raw-dump
# the messages that are no longer kept in the live session.
summary = await self.archive(
messages_to_remove,
runtime=runtime,
session_key=session_key,
summary_messages=messages_to_summarize,
)
if summary and summary != "(nothing)":
session.metadata["_last_summary"] = {
@@ -1203,17 +1151,17 @@ class Consolidator:
"last_active": last_active.isoformat(),
}
# Preserve history and advance only the replay boundary.
session.last_consolidated = len(session.messages) - len(visible_suffix)
session.messages = messages_to_keep
session.last_consolidated = 0
self.sessions.save(session)
logger.info(
"Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
session_key,
len(messages_to_remove),
len(visible_suffix),
len(session.messages),
bool(summary),
)
if messages_to_remove:
logger.info(
"Idle-session compact for {}: archived={}, kept={}, summary={}",
session_key,
len(messages_to_remove),
len(messages_to_keep),
bool(summary),
)
return summary
+8 -28
View File
@@ -2,45 +2,26 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import replace
from pathlib import Path
from collections.abc import Callable
from typing import Any
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
PresetSnapshotLoader = Callable[[str], ProviderSnapshot]
PresetCatalogLoader = Callable[[], Mapping[str, ModelPresetConfig]]
def default_selection_signature(
signature: tuple[object, ...] | None,
model_preset: str | None = None,
) -> tuple[object, ...] | None:
return (model_preset, *signature[:2]) if signature else None
def default_selection_signature(signature: tuple[object, ...] | None) -> tuple[object, ...] | None:
return signature[:2] if signature else None
def configured_model_presets(config: Config) -> dict[str, ModelPresetConfig]:
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
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),
config_path=config_path,
),
)
def make_preset_snapshot_loader(
config: Config,
config: Any,
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
) -> PresetSnapshotLoader:
if provider_snapshot_loader is not None:
@@ -59,7 +40,6 @@ def build_static_preset_snapshot(
context_window_tokens=preset.context_window_tokens,
signature=("model_preset", name, preset.model_dump_json()),
generation=preset.to_generation_settings(),
model_preset=name,
)
@@ -71,7 +51,7 @@ def build_runtime_preset_snapshot(
loader: PresetSnapshotLoader | None,
) -> ProviderSnapshot:
if loader is not None:
return replace(loader(name), model_preset=name)
return loader(name)
return build_static_preset_snapshot(provider, name, presets[name])
+17 -59
View File
@@ -4,8 +4,6 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import replace
from types import MappingProxyType
from typing import cast
from nanobot.agent import model_presets as preset_helpers
from nanobot.config.schema import Config, ModelPresetConfig
@@ -26,23 +24,16 @@ class ModelRuntimeResolver:
initial_runtime: LLMRuntime,
*,
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,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
) -> None:
self._runtime = initial_runtime
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._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._default_selection_signature = preset_helpers.default_selection_signature(
initial_runtime.snapshot_signature,
configured_default_preset,
initial_runtime.snapshot_signature
)
@property
@@ -52,11 +43,7 @@ class ModelRuntimeResolver:
@property
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
self._refresh_preset_catalog()
return MappingProxyType({
name: preset.model_copy(deep=True)
for name, preset in self._model_presets.items()
})
return self._model_presets
@property
def model_preset(self) -> str | None:
@@ -73,63 +60,40 @@ class ModelRuntimeResolver:
self._refresh_provider_generation()
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(
self,
snapshot: ProviderSnapshot,
*,
model_preset: str | None = None,
) -> LLMRuntime:
"""Resolve a factory snapshot without changing the selected default."""
return runtime_from_provider_snapshot(snapshot)
return runtime_from_provider_snapshot(snapshot, model_preset=model_preset)
def adopt_snapshot(
self,
snapshot: ProviderSnapshot,
*,
model_preset: str | None = None,
) -> LLMRuntime:
"""Select a snapshot as the default for future turns."""
runtime = self.resolve_snapshot(snapshot)
runtime = self.resolve_snapshot(snapshot, model_preset=model_preset)
self._runtime = runtime
self._tracks_provider_generation = runtime.model_preset is None
self._tracks_provider_generation = model_preset is None
self._default_selection_signature = preset_helpers.default_selection_signature(
runtime.snapshot_signature,
runtime.model_preset,
runtime.snapshot_signature
)
return runtime
def resolve_preset(self, name: str | None) -> LLMRuntime:
"""Resolve a named preset without changing the selected default."""
self._refresh_preset_catalog()
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(
name=normalized,
presets=self._model_presets,
provider=self._runtime.provider,
loader=self._preset_snapshot_loader,
)
runtime = self.resolve_snapshot(snapshot)
self._resolved_presets[normalized] = runtime
return runtime
return self.resolve_snapshot(snapshot, model_preset=normalized)
def select_preset(self, name: str | None) -> LLMRuntime:
"""Select a named preset as the default for future turns."""
@@ -140,7 +104,7 @@ class ModelRuntimeResolver:
def select_model(self, model: str) -> LLMRuntime:
"""Change the default model without reconstructing downstream consumers."""
if not isinstance(cast(object, model), str) or not model.strip():
if not isinstance(model, str) or not model.strip():
raise ValueError("model must be a non-empty string")
self._runtime = replace(
self._runtime,
@@ -151,9 +115,8 @@ class ModelRuntimeResolver:
def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
"""Change the default context limit for future admissions."""
raw_context_window = cast(object, context_window_tokens)
if not isinstance(raw_context_window, int) or isinstance(
raw_context_window,
if not isinstance(context_window_tokens, int) or isinstance(
context_window_tokens,
bool,
):
raise TypeError("context_window_tokens must be an integer")
@@ -183,26 +146,21 @@ class ModelRuntimeResolver:
def refresh(self) -> LLMRuntime | None:
"""Refresh configured defaults and return the replacement when changed."""
if self._provider_snapshot_loader is None:
self._refresh_required = False
return None
self._resolved_presets.clear()
snapshot = self._provider_snapshot_loader()
default_selection = preset_helpers.default_selection_signature(
snapshot.signature,
snapshot.model_preset,
)
default_selection = preset_helpers.default_selection_signature(snapshot.signature)
active_preset = self._runtime.model_preset
if active_preset and self._default_selection_signature in (None, default_selection):
runtime = self.resolve_preset(active_preset)
else:
active_preset = None
runtime = self.resolve_snapshot(snapshot)
unchanged = (
runtime.snapshot_signature == self._runtime.snapshot_signature
and runtime.model_preset == self._runtime.model_preset
)
self._refresh_required = False
if unchanged:
self._default_selection_signature = default_selection
return None
@@ -212,7 +170,7 @@ class ModelRuntimeResolver:
self._default_selection_signature,
) = (
runtime,
runtime.model_preset is None,
active_preset is None,
default_selection,
)
return runtime
+3 -66
View File
@@ -4,12 +4,11 @@ from __future__ import annotations
import inspect
import json
from typing import Any, Awaitable, Callable, cast
from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.providers.base import ToolCallRequest
from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think
from nanobot.utils.progress_events import (
build_tool_event_finish_payloads,
@@ -85,13 +84,7 @@ class AgentProgressHook(AgentHook):
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
await self.emit_reasoning_end()
if self._on_stream_end:
kwargs: dict[str, bool] = {"resuming": resuming}
if (
context.stream_continues_current_message
and self._on_progress_accepts(self._on_stream_end, "merge_next")
):
kwargs["merge_next"] = True
await self._on_stream_end(**kwargs)
await self._on_stream_end(resuming=resuming)
self._stream_buf = ""
self._think_extractor.reset()
@@ -104,61 +97,6 @@ class AgentProgressHook(AgentHook):
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: dict[str, Any] = {
"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:
if self._on_progress:
if not self._on_stream and not context.streamed_content:
@@ -169,14 +107,13 @@ class AgentProgressHook(AgentHook):
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
await invoke_on_progress(
self._on_progress,
cast(str, tool_hint),
tool_hint,
tool_hint=True,
tool_events=tool_events,
)
for tc in context.tool_calls:
args_str = json.dumps(tc.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tc.name, args_str[:200])
async def emit_reasoning(self, reasoning_content: str | None) -> None:
"""Publish a reasoning chunk; channel plugins decide whether to render."""
if (
+73 -208
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import asyncio
import inspect
import os
from collections.abc import Awaitable, Callable, Iterable
from contextlib import suppress
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, cast
from typing import Any, Callable
from loguru import logger
@@ -20,11 +20,6 @@ from nanobot.agent.context_governance import (
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.runtime_context import (
RUNTIME_CONTEXT_MESSAGE_META,
detach_runtime_context,
reattach_runtime_context,
)
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
@@ -49,10 +44,6 @@ from nanobot.utils.runtime import (
)
GoalContinueMessage = str | Callable[[], str | None]
ProgressCallback = Callable[[str], Awaitable[None]]
RetryWaitCallback = Callable[[str], Awaitable[None]]
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]]
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
_ARREARAGE_ERROR_MESSAGE = (
@@ -65,18 +56,6 @@ _MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5
def _restore_outer_whitespace(content: str, original: str | None) -> str:
"""Restore boundary whitespace stripped while cleaning one recovered segment."""
if not original:
return content
leading_size = len(original) - len(original.lstrip())
trailing_size = len(original) - len(original.rstrip())
leading = original[:leading_size]
trailing = original[-trailing_size:] if trailing_size else ""
return f"{leading}{content}{trailing}"
@dataclass(slots=True)
class AgentRunSpec:
"""Configuration for a single agent execution."""
@@ -95,11 +74,11 @@ class AgentRunSpec:
session_key: str | None = None
context_block_limit: int | None = None
provider_retry_mode: str = "standard"
progress_callback: ProgressCallback | None = None
progress_callback: Any | None = None
stream_progress_deltas: bool = True
retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: CheckpointCallback | None = None
injection_callback: InjectionCallback | None = None
retry_wait_callback: Any | None = None
checkpoint_callback: Any | None = None
injection_callback: Any | None = None
llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: GoalContinueMessage | None = None
@@ -118,8 +97,6 @@ class AgentRunResult:
error: str | None = None
tool_events: list[dict[str, str]] = field(default_factory=list)
had_injections: bool = False
# Terminal tail to emit when the preceding final-content prefix was already streamed.
pending_stream_content: str | None = None
class AgentRunner:
@@ -136,10 +113,8 @@ class AgentRunner:
def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [
cast(dict[str, Any], item)
if isinstance(item, dict)
else {"type": "text", "text": str(item)}
for item in cast(list[Any], value)
item if isinstance(item, dict) else {"type": "text", "text": str(item)}
for item in value
]
if value is None:
return []
@@ -163,63 +138,10 @@ class AgentRunner:
and not is_hidden_history_message(messages[-1])
):
merged = dict(messages[-1])
left_meta = merged.get("_meta")
right_meta = injection.get("_meta")
left_meta_dict = cast(dict[str, Any], left_meta) if isinstance(left_meta, dict) else None
right_meta_dict = (
cast(dict[str, Any], right_meta) if isinstance(right_meta, dict) else None
merged["content"] = cls._merge_message_content(
merged.get("content"),
injection.get("content"),
)
left_marker = (
left_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
if left_meta_dict is not None
else None
)
right_marker = (
right_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
if right_meta_dict is not None
else None
)
left_marker_dict = (
cast(dict[str, Any], left_marker) if isinstance(left_marker, dict) else None
)
right_marker_dict = (
cast(dict[str, Any], right_marker) if isinstance(right_marker, dict) else None
)
empty_sources: list[str] = []
empty_blocks: list[dict[str, Any]] = []
detached_left = (
detach_runtime_context(merged.get("content"), left_marker_dict)
if left_marker_dict is not None
else (merged.get("content"), empty_sources, empty_blocks)
)
detached_right = (
detach_runtime_context(injection.get("content"), right_marker_dict)
if right_marker_dict is not None
else (injection.get("content"), empty_sources, empty_blocks)
)
if detached_left is not None and detached_right is not None:
left_content, left_sources, left_blocks = detached_left
right_content, right_sources, right_blocks = detached_right
merged_content = cls._merge_message_content(left_content, right_content)
context_blocks = [*left_blocks, *right_blocks]
if context_blocks:
merged_content, marker = reattach_runtime_context(
merged_content,
[*left_sources, *right_sources],
context_blocks,
)
internal_meta = dict(left_meta_dict) if left_meta_dict is not None else {}
if right_meta_dict is not None:
for key, value in right_meta_dict.items():
internal_meta.setdefault(key, value)
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker
merged["_meta"] = internal_meta
merged["content"] = merged_content
else:
merged["content"] = cls._merge_message_content(
merged.get("content"),
injection.get("content"),
)
messages[-1] = merged
continue
messages.append(injection)
@@ -321,11 +243,11 @@ class AgentRunner:
for item in items:
if item is None:
continue
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
if self._has_injection_content(item.get("content")):
injected_messages.append(item)
continue
if isinstance(item, dict):
message_item = cast(dict[str, Any], item)
if message_item.get("role") == "user" and "content" in message_item:
if self._has_injection_content(message_item.get("content")):
injected_messages.append(message_item)
continue
content = getattr(item, "content") if hasattr(item, "content") else str(item)
if self._has_injection_content(content):
@@ -346,7 +268,7 @@ class AgentRunner:
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return bool(cast(list[Any], content))
return bool(content)
return True
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
@@ -413,13 +335,10 @@ class AgentRunner:
# Per-turn throttle for repeated attempts against the same outside target.
workspace_violation_counts: dict[str, int] = {}
empty_content_retries = 0
# Segments from one uninterrupted length-recovery chain. Tool work or
# injected user input starts a new logical answer and clears the chain.
length_recovery_parts: list[str] = []
length_recovery_count = 0
had_injections = False
injection_cycles = 0
compacted_tool_call_ids: set[str] = set()
pending_stream_content: str | None = None
governance_config = ContextGovernanceConfig(
provider=spec.runtime.provider,
model=spec.runtime.model,
@@ -434,16 +353,37 @@ class AgentRunner:
)
for iteration in range(spec.max_iterations):
# Keep the persisted conversation untouched. Context governance
# may repair or compact historical messages for the model, but
# those synthetic edits must not shift the append boundary used
# 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(
governance_config,
messages,
compacted_tool_call_ids,
)
try:
# Keep the persisted conversation untouched. Context governance
# may repair or compact historical messages for the model, but
# those synthetic edits must not shift the append boundary used
# later when the caller saves only the new turn.
messages_for_model = self.context_governor.prepare_for_model(
governance_config,
messages,
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(
iteration=iteration,
messages=messages,
@@ -454,7 +394,6 @@ class AgentRunner:
context.response = response
context.tool_calls = list(response.tool_calls)
original_content = response.content
reasoning_text, cleaned_content = extract_reasoning(
response.reasoning_content,
response.thinking_blocks,
@@ -541,7 +480,6 @@ class AgentRunner:
)
if should_continue:
had_injections = True
length_recovery_parts.clear()
continue
break
await self._emit_checkpoint(
@@ -556,7 +494,7 @@ class AgentRunner:
},
)
empty_content_retries = 0
length_recovery_parts.clear()
length_recovery_count = 0
# Checkpoint 1: drain injections after tools, before next LLM call
_drained, injection_cycles = await self._try_drain_injections(
spec, messages, None, injection_cycles,
@@ -605,50 +543,29 @@ class AgentRunner:
context.response = response
context.usage = dict(raw_usage)
context.tool_calls = list(response.tool_calls)
original_content = response.content
clean = hook.finalize_content(context, response.content)
if response.finish_reason == "length" and not is_blank_text(clean):
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
length_recovery_parts.append(
_restore_outer_whitespace(clean or "", original_content)
)
length_recovery_count += 1
if length_recovery_count <= _MAX_LENGTH_RECOVERIES:
logger.info(
"Output truncated on turn {} for {} ({}/{}); continuing",
iteration,
spec.session_key or "default",
len(length_recovery_parts),
length_recovery_count,
_MAX_LENGTH_RECOVERIES,
)
if hook.wants_streaming():
context.stream_continues_current_message = True
await hook.on_stream_end(context, resuming=True)
messages.append(build_assistant_message(
clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
))
messages.append(build_length_recovery_message(clean or ""))
messages.append(build_length_recovery_message())
await hook.after_iteration(context)
continue
# Some streaming providers recover with a complete response but no
# content deltas. When an earlier length segment is already visible,
# emit this terminal segment into the same stream; otherwise the
# regular full response would duplicate the visible prefix.
if (
length_recovery_parts
and hook.wants_streaming()
and not context.streamed_content
and response.finish_reason != "error"
and not is_blank_text(clean)
):
await hook.on_stream(
context,
_restore_outer_whitespace(clean or "", original_content),
)
context.streamed_content = True
assistant_message: dict[str, Any] | None = None
if response.finish_reason != "error" and not is_blank_text(clean):
assistant_message = build_assistant_message(
@@ -673,7 +590,6 @@ class AgentRunner:
await hook.on_stream_end(context, resuming=should_continue)
if should_continue:
length_recovery_parts.clear()
await hook.after_iteration(context)
continue
@@ -695,7 +611,6 @@ class AgentRunner:
)
if should_continue:
had_injections = True
length_recovery_parts.clear()
continue
break
if is_blank_text(clean):
@@ -713,7 +628,6 @@ class AgentRunner:
)
if should_continue:
had_injections = True
length_recovery_parts.clear()
continue
break
@@ -733,13 +647,7 @@ class AgentRunner:
"pending_tool_calls": [],
},
)
if length_recovery_parts:
final_content = (
"".join(length_recovery_parts)
+ _restore_outer_whitespace(clean or "", original_content)
).strip()
else:
final_content = clean
final_content = clean
context.final_content = final_content
context.stop_reason = stop_reason
await hook.after_iteration(context)
@@ -757,25 +665,17 @@ class AgentRunner:
)
if drained_after_max_iterations:
had_injections = True
terminal_content = None
final_content = None
if spec.finalize_on_max_iterations:
terminal_content = await self._try_finalize_after_max_iterations(
final_content = await self._try_finalize_after_max_iterations(
spec,
hook,
messages,
usage,
)
if terminal_content is None:
terminal_content = self._max_iterations_fallback(spec)
if length_recovery_parts:
terminal_tail = f"\n\n{terminal_content.lstrip()}"
final_content = (
"".join(length_recovery_parts).rstrip() + terminal_tail
).strip()
pending_stream_content = terminal_tail
else:
final_content = terminal_content
self._append_final_message(messages, terminal_content)
if final_content is None:
final_content = self._max_iterations_fallback(spec)
self._append_final_message(messages, final_content)
return AgentRunResult(
final_content=final_content,
@@ -786,7 +686,6 @@ class AgentRunner:
error=error,
tool_events=tool_events,
had_injections=had_injections,
pending_stream_content=pending_stream_content,
)
def _build_request_kwargs(
@@ -817,7 +716,7 @@ class AgentRunner:
context: AgentHookContext,
*,
malformed_retry: bool = False,
) -> LLMResponse:
):
timeout_s: float | None = spec.llm_timeout_s
if timeout_s is None:
# Default to a finite timeout to avoid per-session lock starvation when an LLM
@@ -828,7 +727,7 @@ class AgentRunner:
timeout_s = float(raw)
except (TypeError, ValueError):
timeout_s = 300.0
if timeout_s <= 0:
if timeout_s is not None and timeout_s <= 0:
timeout_s = None
kwargs = self._build_request_kwargs(
@@ -837,29 +736,14 @@ class AgentRunner:
tools=spec.tools.get_definitions(),
)
wants_streaming = hook.wants_streaming()
progress_callback = spec.progress_callback
wants_progress_streaming = (
not wants_streaming
and spec.stream_progress_deltas
and progress_callback is not None
and spec.progress_callback is not None
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
)
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:
thinking_buf = ""
@@ -888,7 +772,6 @@ class AgentRunner:
**kwargs,
on_content_delta=_stream,
on_thinking_delta=_thinking,
on_tool_call_delta=_provider_tool_event,
on_stream_recover=_stream_recover,
)
elif wants_progress_streaming:
@@ -914,14 +797,11 @@ class AgentRunner:
await hook.emit_reasoning_end()
progress_state["reasoning_open"] = False
context.streamed_content = True
callback = progress_callback
if callback is not None:
await callback(incremental)
await spec.progress_callback(incremental)
coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs,
on_content_delta=_stream_progress,
on_tool_call_delta=_provider_tool_event,
)
else:
coro = spec.runtime.provider.chat_with_retry(**kwargs)
@@ -955,17 +835,6 @@ class AgentRunner:
finish_reason="error",
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"):
await hook.emit_reasoning_end()
dropped, all_dropped, original_finish_reason = (
@@ -1060,7 +929,7 @@ class AgentRunner:
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
) -> LLMResponse:
):
retry_messages = self._finalization_retry_messages(messages)
return await self._request_no_tools(spec, retry_messages)
@@ -1246,7 +1115,7 @@ class AgentRunner:
))
tool_results.extend(batch_results)
else:
batch_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
batch_results = []
for tool_call in batch:
result = await self._run_tool(
spec,
@@ -1295,17 +1164,13 @@ class AgentRunner:
if spec.fail_on_tool_error:
return lookup_error + hint, event, RuntimeError(lookup_error)
return lookup_error + hint, event, None
prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(spec.tools, "prepare_call", None),
)
prepare_call = getattr(spec.tools, "prepare_call", None)
tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple):
prepared_tuple = cast(tuple[object, ...], prepared)
if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
with suppress(Exception):
prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple) and len(prepared) == 3:
tool, params, prep_error = prepared
if prep_error:
event = {
"name": tool_call.name,
@@ -1332,7 +1197,7 @@ class AgentRunner:
result = await spec.tools.execute(tool_call.name, params)
except asyncio.CancelledError:
raise
except Exception as exc:
except BaseException as exc:
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
event = {
"name": tool_call.name,
@@ -1354,7 +1219,7 @@ class AgentRunner:
return payload, event, exc
return payload, event, None
if is_tool_error_result(result):
if is_tool_error_result(tool_call.name, result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = {
"name": tool_call.name,
@@ -1517,7 +1382,7 @@ class AgentRunner:
batches: list[list[ToolCallRequest]] = []
current: list[ToolCallRequest] = []
for tool_call in tool_calls:
get_tool = cast(Callable[[str], Any] | None, getattr(spec.tools, "get", None))
get_tool = getattr(spec.tools, "get", None)
tool = get_tool(tool_call.name) if callable(get_tool) else None
can_batch = bool(tool and tool.concurrency_safe)
if can_batch:
+35 -73
View File
@@ -5,7 +5,6 @@ import os
import re
import shutil
from pathlib import Path
from typing import Any, cast
import yaml
@@ -17,7 +16,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
re.DOTALL,
)
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
class SkillsLoader:
@@ -110,21 +108,6 @@ class SkillsLoader:
]
return "\n\n---\n\n".join(parts)
def get_explicitly_invoked_skills(self, text: str) -> list[str]:
"""Resolve ``$skill-name`` references to enabled, available skills."""
if not text:
return []
available = {
entry["name"]
for entry in self.list_skills(filter_unavailable=True)
}
invoked: list[str] = []
for match in _SKILL_REFERENCE.finditer(text):
name = match.group(1)
if name in available and name not in invoked:
invoked.append(name)
return invoked
def build_skills_summary(self, exclude: set[str] | None = None) -> str:
"""
Build a summary of all skills (name, description, path, availability).
@@ -142,50 +125,27 @@ class SkillsLoader:
if not all_skills:
return ""
sections: list[str] = []
groups = (
("Workspace skills", "workspace", self.workspace_skills),
("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:
lines: list[str] = []
for entry in all_skills:
skill_name = entry["name"]
if exclude and skill_name in exclude:
continue
meta = self._get_skill_meta(skill_name)
available = self._check_requirements(meta)
desc = self._get_skill_description(skill_name)
if available:
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
else:
missing = self._get_missing_requirements(meta)
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
return "\n".join(lines)
lines = [f"### {label} (`{root.expanduser().resolve()}`)"]
for entry in entries:
skill_name = entry["name"]
meta = self._get_skill_meta(skill_name)
available = self._check_requirements(meta)
desc = self.get_skill_description(skill_name)
suffix = ""
if not available:
missing = self._get_missing_requirements(meta)
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
relative_path = Path(entry["path"]).relative_to(root).as_posix()
lines.append(f"- **{skill_name}** — {desc}{suffix} `{relative_path}`")
sections.append("\n".join(lines))
return "\n\n".join(sections)
@staticmethod
def _requirement_lists(skill_meta: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Return (bins, env) lists from skill metadata, tolerating null/wrong shapes."""
requires = cast(dict[str, Any], skill_meta.get("requires") or {})
if not isinstance(skill_meta.get("requires") or {}, dict):
return [], []
bins_raw: object = requires.get("bins") or []
env_raw: object = requires.get("env") or []
bins = [value for value in cast(list[object], bins_raw) if isinstance(value, str) and value.strip()] if isinstance(bins_raw, list) else []
env = [value for value in cast(list[object], env_raw) if isinstance(value, str) and value.strip()] if isinstance(env_raw, list) else []
return bins, env
def _get_missing_requirements(self, skill_meta: dict[str, Any]) -> str:
def _get_missing_requirements(self, skill_meta: dict) -> str:
"""Get a description of missing requirements."""
required_bins, required_env_vars = self._requirement_lists(skill_meta)
requires = skill_meta.get("requires", {})
required_bins = requires.get("bins", [])
required_env_vars = requires.get("env", [])
return ", ".join(
[f"CLI: {command_name}" for command_name in required_bins if not shutil.which(command_name)]
+ [f"ENV: {env_name}" for env_name in required_env_vars if not os.environ.get(env_name)]
@@ -199,7 +159,9 @@ class SkillsLoader:
def get_skill_requirements(self, name: str) -> dict[str, list[str]]:
"""Return explicit command/env requirements and currently missing entries."""
bins, env = self._requirement_lists(self._get_skill_meta(name))
requires = self._get_skill_meta(name).get("requires", {})
bins = [str(value) for value in requires.get("bins", [])]
env = [str(value) for value in requires.get("env", [])]
return {
"bins": bins,
"env": env,
@@ -207,12 +169,11 @@ class SkillsLoader:
"missing_env": [value for value in env if not os.environ.get(value)],
}
def get_skill_description(self, name: str) -> str:
def _get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter."""
meta = self.get_skill_metadata(name)
description = meta.get("description") if meta else None
if isinstance(description, str) and description:
return description
if meta and meta.get("description"):
return meta["description"]
return name # Fallback to skill name
def _strip_frontmatter(self, content: str) -> str:
@@ -224,13 +185,13 @@ class SkillsLoader:
return content[match.end():].strip()
return content
def _parse_nanobot_metadata(self, raw: object) -> dict[str, Any]:
def _parse_nanobot_metadata(self, raw: object) -> dict:
"""Extract nanobot/openclaw metadata from a frontmatter field.
``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str.
"""
if isinstance(raw, dict):
data = cast(dict[str, Any], raw)
data = raw
elif isinstance(raw, str):
try:
data = json.loads(raw)
@@ -240,18 +201,19 @@ class SkillsLoader:
return {}
if not isinstance(data, dict):
return {}
data_object = cast(dict[str, Any], data)
payload = data_object.get("nanobot", data_object.get("openclaw", {}))
return cast(dict[str, Any], payload) if isinstance(payload, dict) else {}
payload = data.get("nanobot", data.get("openclaw", {}))
return payload if isinstance(payload, dict) else {}
def _check_requirements(self, skill_meta: dict[str, Any]) -> bool:
def _check_requirements(self, skill_meta: dict) -> bool:
"""Check if skill requirements are met (bins, env vars)."""
required_bins, required_env_vars = self._requirement_lists(skill_meta)
requires = skill_meta.get("requires", {})
required_bins = requires.get("bins", [])
required_env_vars = requires.get("env", [])
return all(shutil.which(cmd) for cmd in required_bins) and all(
os.environ.get(var) for var in required_env_vars
)
def _get_skill_meta(self, name: str) -> dict[str, Any]:
def _get_skill_meta(self, name: str) -> dict:
"""Get nanobot metadata for a skill (cached in frontmatter)."""
raw_meta = self.get_skill_metadata(name) or {}
return self._parse_nanobot_metadata(raw_meta.get("metadata"))
@@ -268,7 +230,7 @@ class SkillsLoader:
)
]
def get_skill_metadata(self, name: str) -> dict[str, object] | None:
def get_skill_metadata(self, name: str) -> dict | None:
"""
Get metadata from a skill's frontmatter.
@@ -293,6 +255,6 @@ class SkillsLoader:
# yaml.safe_load returns native types (int, bool, list, etc.);
# keep values as-is so downstream consumers get correct types.
metadata: dict[str, object] = {}
for key, value in cast(dict[object, object], parsed).items():
for key, value in parsed.items():
metadata[str(key)] = value
return metadata
+28 -133
View File
@@ -7,13 +7,12 @@ import uuid
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, TypedDict
from typing import Any, Callable
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunResult, AgentRunSpec
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.context import (
RequestContext,
ToolContext,
@@ -38,12 +37,6 @@ from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template
class _SubagentOrigin(TypedDict):
channel: str
chat_id: str
session_key: str | None
@dataclass(slots=True)
class SubagentStatus:
"""Real-time status of a running subagent."""
@@ -54,8 +47,8 @@ class SubagentStatus:
started_at: float # time.monotonic()
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
iteration: int = 0
tool_events: list[dict[str, str]] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
tool_events: list = field(default_factory=list) # [{name, status, detail}, ...]
usage: dict = field(default_factory=dict) # token usage
stop_reason: str | None = None
error: str | None = None
@@ -153,7 +146,7 @@ class SubagentManager:
self.runner = AgentRunner()
self._exec_session_manager = ExecSessionManager()
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[str]] = {}
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
@@ -243,11 +236,7 @@ class SubagentManager:
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: _SubagentOrigin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
"session_key": session_key,
}
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
status = SubagentStatus(
task_id=task_id,
@@ -273,7 +262,7 @@ class SubagentManager:
if session_key:
self._session_tasks.setdefault(session_key, set()).add(task_id)
def _cleanup(_: asyncio.Task[str]) -> None:
def _cleanup(_: asyncio.Task) -> None:
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)):
@@ -286,85 +275,21 @@ class SubagentManager:
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
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: _SubagentOrigin = {
"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(
self,
task_id: str,
task: str,
label: str,
origin: _SubagentOrigin,
origin: dict[str, str],
status: SubagentStatus,
runtime: LLMRuntime,
origin_message_id: str | None = None,
workspace_scope: WorkspaceScope | None = None,
*,
announce: bool = True,
) -> str:
) -> None:
"""Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label)
async def _on_checkpoint(payload: dict[str, Any]) -> None:
async def _on_checkpoint(payload: dict) -> None:
status.phase = payload.get("phase", status.phase)
status.iteration = payload.get("iteration", status.iteration)
@@ -374,8 +299,7 @@ class SubagentManager:
if workspace_scope is not None:
cfg = self._subagent_tools_config()
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
# Construct from the agent workspace; the bound scope below supplies the project cwd.
tools = self._build_tools(tools_config=cfg)
tools = self._build_tools(workspace=root, tools_config=cfg)
system_prompt = self._build_subagent_prompt(workspace=root)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
@@ -422,43 +346,27 @@ class SubagentManager:
if result.stop_reason == "tool_error":
status.tool_events = list(result.tool_events)
final_result = self._format_partial_progress(result)
final_status = "error"
await self._announce_result(
task_id, label, task,
self._format_partial_progress(result),
origin, "error", origin_message_id,
)
elif result.stop_reason == "error":
final_result = result.error or "Error: subagent execution failed."
final_status = "error"
await self._announce_result(
task_id, label, task,
result.error or "Error: subagent execution failed.",
origin, "error", origin_message_id,
)
else:
final_result = result.final_content or "Task completed but no final response was generated."
final_status = "ok"
logger.info("Subagent [{}] completed successfully", task_id)
if announce:
await self._announce_result(
task_id,
label,
task,
final_result,
origin,
final_status,
origin_message_id,
)
return final_result
await self._announce_result(task_id, label, task, final_result, origin, "ok", origin_message_id)
except Exception as e:
status.phase = "error"
status.error = str(e)
logger.exception("Subagent [{}] failed", task_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
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
async def _announce_result(
self,
@@ -466,7 +374,7 @@ class SubagentManager:
label: str,
task: str,
result: str,
origin: _SubagentOrigin,
origin: dict[str, str],
status: str,
origin_message_id: str | None = None,
) -> None:
@@ -506,7 +414,7 @@ class SubagentManager:
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
@staticmethod
def _format_partial_progress(result: AgentRunResult) -> str:
def _format_partial_progress(result) -> str:
completed = [e for e in result.tool_events if e["status"] == "ok"]
failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None)
lines: list[str] = []
@@ -530,17 +438,14 @@ class SubagentManager:
"""Build a focused system prompt for the subagent."""
from nanobot.agent.skills import SkillsLoader
agent_workspace = self.workspace.expanduser().resolve()
project_workspace = workspace.expanduser().resolve() if workspace else agent_workspace
root = workspace or self.workspace
skills_summary = SkillsLoader(
self.workspace,
root,
disabled_skills=self.disabled_skills,
).build_skills_summary()
return render_template(
"agent/subagent_system.md",
workspace=str(project_workspace),
agent_workspace=str(agent_workspace),
history_log=str(agent_workspace / "memory" / "history.jsonl"),
workspace=str(root),
skills_summary=skills_summary or "",
)
@@ -552,18 +457,8 @@ class SubagentManager:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
await self._exec_session_manager.terminate_by_owner(session_key)
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:
"""Return the number of currently running subagents."""
return len(self._running_tasks)
+11 -9
View File
@@ -5,10 +5,10 @@ from __future__ import annotations
import difflib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from typing import Any
from nanobot.agent.tools.base import ToolResult, tool_parameters
from nanobot.agent.tools.filesystem import _FsTool # pyright: ignore[reportPrivateUsage]
from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
@@ -39,6 +39,12 @@ def _validate_patch_path(path: str) -> str:
return normalized
def _lines_to_text(lines: list[str]) -> str:
if not lines:
return ""
return "\n".join(lines) + "\n"
def _text_line_count(text: str) -> int:
if not text:
return 0
@@ -134,7 +140,7 @@ class ApplyPatchTool(_FsTool):
async def execute(
self,
edits: list[object] | None = None,
edits: list[dict] | None = None,
dry_run: bool = False,
**kwargs: Any,
) -> str:
@@ -145,10 +151,9 @@ class ApplyPatchTool(_FsTool):
writes: dict[Path, str] = {}
summaries: list[_PatchSummary] = []
for edit_value in edits:
if not isinstance(edit_value, dict):
for edit in edits:
if not isinstance(edit, dict):
raise _PatchError("each edit must be an object")
edit = cast(dict[str, Any], edit_value)
raw_path = edit.get("path")
if not isinstance(raw_path, str):
raise _PatchError("path required for edit")
@@ -162,7 +167,6 @@ class ApplyPatchTool(_FsTool):
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for add: {path}")
new_text = cast(str, new_text)
pending = writes.get(source)
if pending is not None:
@@ -206,11 +210,9 @@ class ApplyPatchTool(_FsTool):
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for replace: {path}")
old_text = cast(str, old_text)
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for replace: {path}")
new_text = cast(str, new_text)
pending = writes.get(source)
if pending is not None:
+20 -31
View File
@@ -5,7 +5,7 @@ import typing
from abc import ABC, abstractmethod
from collections.abc import Callable
from copy import deepcopy
from typing import Any, TypeVar, cast
from typing import Any, TypeVar
if typing.TYPE_CHECKING:
from pydantic import BaseModel
@@ -38,9 +38,8 @@ class Schema(ABC):
def resolve_json_schema_type(t: Any) -> str | None:
"""Resolve the non-null type name from JSON Schema ``type`` (e.g. ``['string','null']`` -> ``'string'``)."""
if isinstance(t, list):
types = cast(list[Any], t)
return cast(str | None, next((x for x in types if x != "null"), None))
return cast(str | None, t)
return next((x for x in t if x != "null"), None)
return t # type: ignore[return-value]
@staticmethod
def subpath(path: str, key: str) -> str:
@@ -77,41 +76,33 @@ class Schema(ABC):
if "maximum" in schema and val > schema["maximum"]:
errors.append(f"{label} must be <= {schema['maximum']}")
if t == "string":
string_value = cast(str, val)
if "minLength" in schema and len(string_value) < schema["minLength"]:
if "minLength" in schema and len(val) < schema["minLength"]:
errors.append(f"{label} must be at least {schema['minLength']} chars")
if "maxLength" in schema and len(string_value) > schema["maxLength"]:
if "maxLength" in schema and len(val) > schema["maxLength"]:
errors.append(f"{label} must be at most {schema['maxLength']} chars")
if t == "object":
object_value = cast(dict[str, Any], val)
props = cast(dict[str, Any], schema.get("properties", {}))
required = cast(list[Any], schema.get("required", []))
for k in required:
if k not in object_value:
props = schema.get("properties", {})
for k in schema.get("required", []):
if k not in val:
errors.append(f"missing required {Schema.subpath(path, k)}")
additional = schema.get("additionalProperties", True)
for k, v in object_value.items():
for k, v in val.items():
if k in props:
errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k)))
elif additional is False:
errors.append(f"unexpected parameter {Schema.subpath(path, k)}")
elif isinstance(additional, dict):
errors.extend(
Schema.validate_json_schema_value(
v,
cast(dict[str, Any], additional),
Schema.subpath(path, k),
)
Schema.validate_json_schema_value(v, additional, Schema.subpath(path, k))
)
if t == "array":
array_value = cast(list[Any], val)
if "minItems" in schema and len(array_value) < schema["minItems"]:
if "minItems" in schema and len(val) < schema["minItems"]:
errors.append(f"{label} must have at least {schema['minItems']} items")
if "maxItems" in schema and len(array_value) > schema["maxItems"]:
if "maxItems" in schema and len(val) > schema["maxItems"]:
errors.append(f"{label} must be at most {schema['maxItems']} items")
if "items" in schema:
prefix = f"{path}[{{}}]" if path else "[{}]"
for i, item in enumerate(array_value):
for i, item in enumerate(val):
errors.extend(
Schema.validate_json_schema_value(item, schema["items"], prefix.format(i))
)
@@ -123,9 +114,9 @@ class Schema(ABC):
# Try to_json_schema first: Schema instances must be distinguished from dicts that are already JSON Schema
to_js = getattr(value, "to_json_schema", None)
if callable(to_js):
return cast(dict[str, Any], to_js())
return to_js()
if isinstance(value, dict):
return cast(dict[str, Any], value)
return value
raise TypeError(f"Expected schema object or dict, got {type(value).__name__}")
@abstractmethod
@@ -232,15 +223,14 @@ class Tool(ABC):
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
if not isinstance(obj, dict):
return obj
props = cast(dict[str, Any], schema.get("properties", {}))
props = schema.get("properties", {})
additional = schema.get("additionalProperties")
casted: dict[str, Any] = {}
object_value = cast(dict[str, Any], obj)
for k, v in object_value.items():
for k, v in obj.items():
if k in props:
casted[k] = self._cast_value(v, props[k])
elif isinstance(additional, dict):
casted[k] = self._cast_value(v, cast(dict[str, Any], additional))
casted[k] = self._cast_value(v, additional)
else:
casted[k] = v
return casted
@@ -283,8 +273,7 @@ class Tool(ABC):
if t == "array" and isinstance(val, list):
items = schema.get("items")
array_value = cast(list[Any], val)
return [self._cast_value(x, items) for x in array_value] if items else array_value
return [self._cast_value(x, items) for x in val] if items else val
if t == "object" and isinstance(val, dict):
return self._cast_object(val, schema)
@@ -293,7 +282,7 @@ class Tool(ABC):
def validate_params(self, params: dict[str, Any]) -> list[str]:
"""Validate against JSON schema; empty list means valid."""
if not isinstance(cast(object, params), dict):
if not isinstance(params, dict):
return [f"parameters must be an object, got {type(params).__name__}"]
schema = self.parameters or {}
if schema.get("type", "object") != "object":
+4 -5
View File
@@ -1,15 +1,14 @@
"""Controlled runner for installed CLI Apps."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import RequestContext, ToolContext
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
@@ -67,11 +66,11 @@ class CliAppsTool(Tool):
return CliAppsToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.cli_apps.enable
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.cli_apps
return cls(
workspace=Path(ctx.workspace),
+11 -22
View File
@@ -8,16 +8,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.file_state import FileStates
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.config.schema import ProviderConfig, ToolsConfig
from nanobot.cron.service import CronService
from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import WorkspaceSandboxStatus
from nanobot.session.manager import SessionManager
from nanobot.utils.llm_runtime import LLMRuntime
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
@@ -39,7 +29,6 @@ class RequestContext:
sender_id: str | None = None
turn_id: str | None = None
workspace: Path | None = None
attributes: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
@@ -77,16 +66,16 @@ def current_request_session_key() -> str | None:
@dataclass
class ToolContext:
config: ToolsConfig
config: Any
workspace: str
bus: MessageBus | None = None
subagent_manager: SubagentManager | None = None
cron_service: CronService | None = None
exec_session_manager: ExecSessionManager | None = None
sessions: SessionManager | None = None
file_state_store: FileStates | None = None
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None = None
image_generation_provider_configs: dict[str, ProviderConfig] | None = None
bus: Any | None = None
subagent_manager: Any | None = None
cron_service: Any | None = None
exec_session_manager: Any | None = None
sessions: Any | None = None
file_state_store: Any = field(default=None)
provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None
timezone: str = "UTC"
workspace_sandbox: WorkspaceSandboxStatus | None = None
runtime_events: RuntimeEventBus | None = None
workspace_sandbox: Any | None = None
runtime_events: Any | None = None
+11 -14
View File
@@ -1,15 +1,13 @@
"""Cron tool for scheduling reminders and tasks."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from contextvars import ContextVar, Token
from contextvars import ContextVar
from datetime import datetime
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext, current_request_context
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.schema import (
IntegerSchema,
StringSchema,
@@ -30,7 +28,7 @@ _CRON_PARAMETERS = tool_parameters_schema(
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
"Not used for action='list' or action='remove'."
),
every_seconds=IntegerSchema(description="Interval in seconds (for recurring tasks)"),
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
tz=StringSchema(
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
@@ -62,15 +60,12 @@ class CronTool(Tool):
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.cron_service is not None
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
cron_service = ctx.cron_service
if cron_service is None:
raise RuntimeError("CronTool requires an initialized cron service")
return cls(cron_service=cron_service, default_timezone=ctx.timezone)
def create(cls, ctx: Any) -> Tool:
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
@staticmethod
def _request_route() -> tuple[str, str, str, dict[str, Any]]:
@@ -84,11 +79,11 @@ class CronTool(Tool):
)
return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
def set_cron_context(self, active: bool) -> Token[bool]:
def set_cron_context(self, active: bool):
"""Mark whether the tool is executing inside a cron job callback."""
return self._in_cron_context.set(active)
def reset_cron_context(self, token: Token[bool]) -> None:
def reset_cron_context(self, token) -> None:
"""Restore previous cron context."""
self._in_cron_context.reset(token)
@@ -143,6 +138,8 @@ class CronTool(Tool):
tz: str | None = None,
at: str | None = None,
job_id: str | None = None,
deliver: bool = True,
**kwargs: Any,
) -> str:
if action == "add":
if self._in_cron_context.get():
@@ -262,7 +259,7 @@ class CronTool(Tool):
jobs = self._cron.list_jobs()
if not jobs:
return "No scheduled jobs."
lines: list[str] = []
lines = []
for j in jobs:
timing = self._format_timing(j.schedule)
parts = [f"- {j.name} (id: {j.id}, {timing})"]
+26 -91
View File
@@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext, current_request_session_key
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@@ -61,14 +61,12 @@ class _ExecSession:
cwd: str,
timeout: int | None,
owner_session_key: str | None = None,
process_tree: bool = False,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.owner_session_key = owner_session_key
self._process_tree = process_tree
self.started_at = time.monotonic()
# timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf")
@@ -151,8 +149,8 @@ class _ExecSession:
timeout=2.0,
)
# Safety-net reap after normal exit.
from nanobot.agent.tools.shell import _reap_pid # pyright: ignore[reportPrivateUsage]
_reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
from nanobot.agent.tools.shell import _reap_pid
_reap_pid(self.process.pid)
elif yield_time_ms > 0:
await self._wait_for_buffered_output()
@@ -173,23 +171,17 @@ class _ExecSession:
)
async def kill(self) -> None:
from nanobot.agent.tools.shell import ExecTool
if self.process.returncode is not None:
return
self.process.kill()
try:
if self._process_tree:
await ExecTool._kill_process_tree(self.process) # pyright: ignore[reportPrivateUsage]
else:
await ExecTool._kill_process(self.process) # pyright: ignore[reportPrivateUsage]
finally:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(
asyncio.gather(
self._stdout_task,
self._stderr_task,
return_exceptions=True,
),
timeout=2.0,
)
await asyncio.wait_for(self.process.wait(), timeout=5.0)
finally:
# Safety-net waitpid — prevent zombie if asyncio's child watcher
# did not reap the process (common in containers).
from nanobot.agent.tools.shell import _reap_pid
_reap_pid(self.process.pid)
async def _wait_for_buffered_output(self) -> None:
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
@@ -206,7 +198,6 @@ class ExecSessionManager:
self.idle_timeout = idle_timeout
self._sessions: dict[str, _ExecSession] = {}
self._lock = asyncio.Lock()
self._closed = False
async def start(
self,
@@ -222,8 +213,6 @@ class ExecSessionManager:
owner_session_key: str | None = None,
) -> tuple[str, _SessionPoll]:
async with self._lock:
if self._closed:
raise RuntimeError("exec session manager is closed")
await self._cleanup_locked()
if len(self._sessions) >= self.max_sessions:
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
@@ -236,7 +225,6 @@ class ExecSessionManager:
cwd=cwd,
timeout=timeout,
owner_session_key=owner_session_key,
process_tree=True,
)
self._sessions[session_id] = session
@@ -307,61 +295,6 @@ class ExecSessionManager:
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[_ExecSession] = list(self._sessions.values())
self._sessions.clear()
results: list[None | BaseException] = list(await asyncio.gather(
*(session.kill() for session in sessions),
return_exceptions=True,
))
failures: list[tuple[_ExecSession, BaseException]] = [
(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: list[_ExecSession] = []
for sid, s in list(self._sessions.items()):
if s.owner_session_key == owner_session_key:
victims.append(self._sessions.pop(sid))
results: list[None | BaseException] = list(await asyncio.gather(
*(s.kill() for s in victims),
return_exceptions=True,
))
failures: list[tuple[_ExecSession, BaseException]] = [
(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:
now = time.monotonic()
stale = [
@@ -370,9 +303,8 @@ class ExecSessionManager:
if now - session.last_access > self.idle_timeout
]
for session_id in stale:
session = self._sessions[session_id]
session = self._sessions.pop(session_id)
await session.kill()
self._sessions.pop(session_id, None)
async def _spawn(
self,
@@ -384,10 +316,9 @@ class ExecSessionManager:
) -> asyncio.subprocess.Process:
from nanobot.agent.tools.shell import ExecTool
return await ExecTool._spawn( # pyright: ignore[reportPrivateUsage]
return await ExecTool._spawn(
command, cwd, env, shell_program, login,
stdin=asyncio.subprocess.PIPE,
process_tree=True,
)
@@ -447,6 +378,7 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
default=False,
),
yield_time_ms=IntegerSchema(
DEFAULT_YIELD_MS,
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
minimum=0,
maximum=MAX_YIELD_MS,
@@ -457,17 +389,20 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
nullable=True,
),
wait_timeout_ms=IntegerSchema(
DEFAULT_WAIT_FOR_MS,
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
minimum=0,
maximum=MAX_WAIT_FOR_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Maximum output characters to return from this poll (default 10000, max 50000).",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
),
max_output_tokens=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
@@ -489,7 +424,7 @@ class WriteStdinTool(Tool):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
@@ -500,8 +435,8 @@ class WriteStdinTool(Tool):
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
return cls(manager=ctx.exec_session_manager)
def create(cls, ctx: Any) -> Tool:
return cls(manager=getattr(ctx, "exec_session_manager", None))
@property
def exclusive(self) -> bool:
@@ -522,7 +457,7 @@ class WriteStdinTool(Tool):
"Do not use this to start new commands; start them with exec."
)
async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
async def execute(
self,
session_id: str,
chars: str | None = None,
@@ -633,7 +568,7 @@ class ListExecSessionsTool(Tool):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
@@ -644,8 +579,8 @@ class ListExecSessionsTool(Tool):
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
return cls(manager=ctx.exec_session_manager)
def create(cls, ctx: Any) -> Tool:
return cls(manager=getattr(ctx, "exec_session_manager", None))
@property
def name(self) -> str:
@@ -671,7 +606,7 @@ class ListExecSessionsTool(Tool):
)
if not sessions:
return "No active exec sessions."
lines: list[str] = []
lines = []
for info in sessions:
command = " ".join(info.command.split())
if len(command) > 120:
+1 -5
View File
@@ -125,10 +125,6 @@ class FileStates:
"""Return the raw ReadState entry for a path, or None."""
return self._state.get(str(Path(path).resolve()))
def raw_state(self) -> dict[str, ReadState]:
"""Return the mutable backing map for legacy compatibility."""
return self._state
def clear(self) -> None:
"""Clear all tracked state (useful for testing)."""
self._state.clear()
@@ -205,5 +201,5 @@ def clear() -> None:
# so existing imports keep working.
def __getattr__(name: str):
if name == "_state":
return _default.raw_state()
return _default._state
raise AttributeError(name)
+20 -55
View File
@@ -1,7 +1,5 @@
"""File system tools: read, write, edit, list."""
# pyright: reportPrivateUsage=false, reportUnusedFunction=false
import difflib
import mimetypes
import os
@@ -10,7 +8,6 @@ from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import (
@@ -40,7 +37,7 @@ class _FsTool(Tool):
return FileToolsConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.file.enable
def __init__(
@@ -54,7 +51,6 @@ class _FsTool(Tool):
file_states: FileStates | None = None,
restrict_to_workspace: bool | None = None,
sandbox_restricts_workspace: bool = False,
extra_read_allowed_files: list[Path] | None = None,
):
self._workspace = workspace
self._allowed_dir = allowed_dir
@@ -64,7 +60,6 @@ class _FsTool(Tool):
*(extra_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_files = list(extra_write_allowed_files or [])
self._restrict_to_workspace = (
@@ -80,24 +75,20 @@ class _FsTool(Tool):
self._fallback_file_states = FileStates()
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
def create(cls, ctx: Any) -> Tool:
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
agent_workspace = Path(ctx.workspace)
resolved_agent_workspace = agent_workspace.expanduser().resolve(strict=False)
restrict = (
ctx.config.restrict_to_workspace
or ctx.config.exec.sandbox
)
sandbox_restricts = bool(ctx.config.exec.sandbox)
allowed_dir = agent_workspace if restrict else None
# Agent-owned skills stay available from project scopes. History is a narrower
# capability: expose only the append-only log, not the surrounding memory directory.
allowed_dir = Path(ctx.workspace) if restrict else None
extra_read = [BUILTIN_SKILLS_DIR]
return cls(
workspace=agent_workspace,
workspace=Path(ctx.workspace),
allowed_dir=allowed_dir,
extra_read_allowed_dirs=[BUILTIN_SKILLS_DIR, resolved_agent_workspace / "skills"],
extra_read_allowed_files=[resolved_agent_workspace / "memory" / "history.jsonl"],
extra_read_allowed_dirs=extra_read,
file_states=ctx.file_state_store,
restrict_to_workspace=ctx.config.restrict_to_workspace,
sandbox_restricts_workspace=sandbox_restricts,
@@ -128,20 +119,16 @@ class _FsTool(Tool):
extra_allowed_files: list[Path] | None,
*,
include_media_dir: bool,
extra_files_require_allowed_root: bool = False,
) -> Path:
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_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(
path,
access.project_path,
allowed_root,
self._effective_allowed_root(access.allowed_root),
extra_allowed_dirs,
extra_allowed_files,
include_media_dir=include_media_dir,
@@ -151,9 +138,8 @@ class _FsTool(Tool):
return self._resolve_with_extra(
path,
self._extra_read_allowed_dirs,
self._extra_read_allowed_files,
None,
include_media_dir=True,
extra_files_require_allowed_root=True,
)
def _resolve_write(self, path: str) -> Path:
@@ -229,10 +215,12 @@ def _builtin_skill_read_path(path: str) -> Path | None:
tool_parameters_schema(
path=StringSchema("The file path to read"),
offset=IntegerSchema(
1,
description="Line number to start reading from (1-indexed, default 1)",
minimum=1,
),
limit=IntegerSchema(
2000,
description="Maximum number of lines to read (default 2000)",
minimum=1,
),
@@ -249,7 +237,6 @@ class ReadFileTool(_FsTool):
_scopes = {"core", "subagent", "memory"}
_MAX_CHARS = 128_000
_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024
_DEFAULT_LIMIT = 2000
_MAX_PDF_PAGES = 20
@@ -264,8 +251,6 @@ class ReadFileTool(_FsTool):
"Text output format: LINE_NUM|CONTENT. "
"Images return visual content for analysis. "
"Supports PDF, DOCX, XLSX, PPTX documents. "
"Uploaded non-image attachments are referenced by path; read them "
"with this tool only when their contents are needed. "
"Use find_files/list_dir first when the path is uncertain. "
"Read the relevant range before editing so replacements or patches "
"are based on current content. "
@@ -305,15 +290,6 @@ class ReadFileTool(_FsTool):
if not fp.is_file():
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
if fp.suffix.lower() == ".pdf":
return self._read_pdf(fp, pages)
@@ -371,25 +347,11 @@ class ReadFileTool(_FsTool):
try:
text_content = raw.decode("utf-8")
except UnicodeDecodeError:
# Match the former eager extractor for known text formats while
# keeping arbitrary binary files on the guarded error path.
from nanobot.utils.document import _is_text_extension
if _is_text_extension(fp.suffix.lower()):
text_content = raw.decode("latin-1")
else:
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if mime and mime.startswith("image/"):
return build_image_content_blocks(
raw,
mime,
str(fp),
f"(Image file: {path})",
)
return ToolResult.error(
f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). "
"Only supported text files and images can be read."
)
# Binary file - return error message
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if mime and mime.startswith("image/"):
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
return ToolResult.error(f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported.")
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
# concern (git checkouts with autocrlf, editors saving CRLF) but
@@ -411,8 +373,7 @@ class ReadFileTool(_FsTool):
result = "\n".join(numbered)
if len(result) > self._MAX_CHARS:
trimmed: list[str] = []
chars = 0
trimmed, chars = [], 0
for line in numbered:
chars += len(line) + 1
if chars > self._MAX_CHARS:
@@ -808,11 +769,13 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
new_text=StringSchema("The text to replace with"),
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
occurrence=IntegerSchema(
1,
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
minimum=1,
nullable=True,
),
line_hint=IntegerSchema(
1,
description=(
"Optional exact 1-based target line copied from read_file. "
"The selected old_text match must cover this line."
@@ -821,6 +784,7 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
nullable=True,
),
expected_replacements=IntegerSchema(
1,
description="Optional guard for the number of replacements that must be made.",
minimum=1,
nullable=True,
@@ -1051,6 +1015,7 @@ class EditFileTool(_FsTool):
path=StringSchema("The directory path to list"),
recursive=BooleanSchema(description="Recursively list all files (default false)"),
max_entries=IntegerSchema(
200,
description="Maximum entries to return (default 200)",
minimum=1,
),
+10 -137
View File
@@ -2,35 +2,24 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.schema import (
ArraySchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_ACK,
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base
from nanobot.providers.image_generation import (
ImageGenerationError,
ImageGenerationProvider,
get_image_gen_provider,
image_gen_provider_configs,
)
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
@@ -42,7 +31,6 @@ from nanobot.utils.artifacts import (
from nanobot.utils.helpers import detect_image_mime
if TYPE_CHECKING:
from nanobot.agent.tools.context import ToolContext
from nanobot.config.schema import ProviderConfig
@@ -91,11 +79,11 @@ class ImageGenerationTool(Tool):
return ImageGenerationToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.image_generation.enabled
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
def create(cls, ctx: Any) -> Tool:
return cls(
workspace=ctx.workspace,
config=ctx.config.image_generation,
@@ -136,14 +124,12 @@ class ImageGenerationTool(Tool):
cls = get_image_gen_provider(self.config.provider)
if cls is None:
return None
kwargs: dict[str, Any] = {
"api_key": provider.api_key if provider and isinstance(provider.api_key, str) else None,
"api_base": provider.api_base if provider and isinstance(provider.api_base, str) else None,
"extra_headers": provider.extra_headers
if provider and isinstance(provider.extra_headers, dict) else None,
"extra_body": provider.extra_body
if provider and isinstance(provider.extra_body, dict) else None,
"proxy": provider.proxy if provider and isinstance(provider.proxy, str) else None,
kwargs = {
"api_key": provider.api_key if provider else None,
"api_base": provider.api_base if provider else None,
"extra_headers": provider.extra_headers if provider else None,
"extra_body": provider.extra_body if provider else None,
"proxy": provider.proxy if provider else None,
}
return cls(**kwargs)
@@ -176,7 +162,7 @@ class ImageGenerationTool(Tool):
return []
return [self._resolve_reference_image(value) for value in values if value]
async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
async def execute(
self,
prompt: str,
reference_images: list[str] | None = None,
@@ -222,116 +208,3 @@ class ImageGenerationTool(Tool):
return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as 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( # pyright: ignore[reportAbstractUsage]
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: MessageBus,
*,
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,
}
if not isinstance(cast(object, result), dict):
return {
"ok": False,
"message": "Image generation hot reload returned an unexpected response.",
"requires_restart": True,
}
return result
async def handle_runtime_control(
state: Any,
msg: InboundMessage,
registry: ToolRegistry,
) -> bool:
"""Handle an in-process image generation reload request."""
metadata = msg.metadata
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():
cast(asyncio.Future[Any], ack).set_result(result)
return True
+3 -9
View File
@@ -1,22 +1,16 @@
"""Tool discovery and registration via package scanning."""
# pyright: reportIncompatibleVariableOverride=false
from __future__ import annotations
import importlib
import pkgutil
from importlib.metadata import entry_points
from typing import TYPE_CHECKING, Any
from typing import Any
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.registry import ToolRegistry
if TYPE_CHECKING:
from nanobot.agent.tools.context import RequestContext, ToolContext
_SKIP_MODULES = frozenset({
"base", "schema", "registry", "context", "loader", "config",
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
@@ -89,7 +83,7 @@ class ToolLoader:
self._plugins = plugins
return plugins
def load(self, ctx: ToolContext, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
registered: list[str] = []
builtin_names: set[str] = set()
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
@@ -163,7 +157,7 @@ class _LegacyErrorPrefixTool(Tool):
def config_key(self) -> str:
return getattr(self._wrapped, "config_key", "")
def set_context(self, ctx: RequestContext) -> None:
def set_context(self, ctx: Any) -> None:
set_context = getattr(self._wrapped, "set_context", None)
if callable(set_context):
set_context(ctx)
+15 -19
View File
@@ -1,7 +1,5 @@
"""Sustained-goal tools with explicit user opt-in at the execution boundary."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from copy import deepcopy
@@ -13,7 +11,7 @@ from nanobot.agent.goal_permission import (
revoke_goal_mutation_permission,
)
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import RequestContext, ToolContext, current_request_context
from nanobot.agent.tools.context import RequestContext, current_request_context
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
@@ -134,24 +132,23 @@ class CreateGoalTool(Tool, _GoalToolsMixin):
def __init__(
self,
sessions: SessionManager,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
sess = ctx.sessions
if sess is None:
raise RuntimeError("CreateGoalTool requires an initialized session manager")
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
return cls(
sessions=sess,
runtime_events=ctx.runtime_events,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.sessions is not None
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
@@ -265,24 +262,23 @@ class UpdateGoalTool(Tool, _GoalToolsMixin):
def __init__(
self,
sessions: SessionManager,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
sess = ctx.sessions
if sess is None:
raise RuntimeError("UpdateGoalTool requires an initialized session manager")
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
return cls(
sessions=sess,
runtime_events=ctx.runtime_events,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.sessions is not None
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
+60 -197
View File
@@ -7,12 +7,12 @@ import os
import re
import shutil
import urllib.parse
from collections.abc import AsyncIterator, Awaitable, Callable
from collections.abc import Awaitable, Callable
from contextlib import AsyncExitStack, suppress
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
from typing import Any, Mapping, Protocol
from weakref import WeakKeyDictionary
import httpx2 as httpx
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
@@ -23,23 +23,15 @@ from nanobot.bus.events import (
RUNTIME_CONTROL_MCP_RELOAD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.security.network import (
Httpx2PinnedDNSAsyncTransport,
PinnedDNSAsyncTransport,
env_proxy_applies_to_url,
httpx2_env_proxy_mounts,
httpx_env_proxy_mounts,
resolve_url_target,
validate_url_target,
)
from nanobot.utils.cancellation import task_is_cancelling
if TYPE_CHECKING:
from mcp import ClientSession
from mcp.types import Prompt, Resource
from mcp.types import Tool as MCPToolDefinition
from nanobot.config.schema import MCPServerConfig
# Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network
# connection is interrupted between calls.
@@ -100,7 +92,7 @@ def _mcp_jsonrpc_payload(message: Any) -> Any:
def _payload_value(payload: Any, key: str) -> Any:
if isinstance(payload, Mapping):
return cast(Mapping[str, Any], payload).get(key)
return payload.get(key)
return getattr(payload, key, None)
@@ -114,7 +106,7 @@ class _MalformedProgressNotificationFilter:
def __init__(self, read_stream: Any, server_name: str) -> None:
self._read_stream = read_stream
self._server_name = server_name
self._iterator: AsyncIterator[Any] | None = None
self._iterator: Any | None = None
async def __aenter__(self) -> "_MalformedProgressNotificationFilter":
await self._read_stream.__aenter__()
@@ -128,13 +120,11 @@ class _MalformedProgressNotificationFilter:
return self
async def __anext__(self) -> Any:
iterator = self._iterator
if iterator is None:
iterator = self._read_stream.__aiter__()
self._iterator = iterator
if self._iterator is None:
self._iterator = self._read_stream.__aiter__()
while True:
message = await anext(iterator)
message = await self._iterator.__anext__()
if _is_malformed_mcp_progress_notification(message):
logger.debug(
"MCP server '{}': dropped progress notification without progressToken",
@@ -194,7 +184,7 @@ def _is_session_terminated(exc: BaseException) -> bool:
messages.append(str(getattr(error, "message", "")))
return any(
marker in message.lower()
for marker in ("session terminated", "session not found", "connection closed")
for marker in ("session terminated", "connection closed")
for message in messages
)
@@ -251,9 +241,9 @@ def _redact_url(url: str) -> str:
return "<redacted-url>"
def _pinned_transport_kwargs() -> dict[str, Any]:
kwargs: dict[str, Any] = {"transport": Httpx2PinnedDNSAsyncTransport()}
mounts = httpx2_env_proxy_mounts()
def _pinned_transport_kwargs() -> dict[str, object]:
kwargs: dict[str, object] = {"transport": PinnedDNSAsyncTransport()}
mounts = httpx_env_proxy_mounts()
if mounts:
kwargs["mounts"] = mounts
return kwargs
@@ -312,107 +302,30 @@ def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None
non_null: list[dict[str, Any]] = []
saw_null = False
for option in cast(list[object], options):
for option in options:
if not isinstance(option, dict):
return None
option_schema = cast(dict[str, Any], option)
if option_schema.get("type") == "null":
if option.get("type") == "null":
saw_null = True
continue
non_null.append(option_schema)
non_null.append(option)
if saw_null and len(non_null) == 1:
return non_null[0], True
return None
def _resolve_local_schema_ref(root: dict[str, Any], ref: str) -> Any:
"""Resolve a local JSON Pointer without accepting remote references."""
if not ref.startswith("#"):
raise ValueError("not a local JSON Pointer")
def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
"""Normalize only nullable JSON Schema patterns for tool definitions."""
if not isinstance(schema, dict):
return {"type": "object", "properties": {}}
pointer = urllib.parse.unquote(ref[1:], errors="strict")
if not pointer:
return root
if not pointer.startswith("/"):
raise ValueError("not a local JSON Pointer")
current: Any = root
for raw_part in pointer[1:].split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~")
if isinstance(current, dict):
current = cast(dict[str, Any], current)[part]
elif isinstance(current, list):
current = cast(list[Any], current)[int(part)]
else:
raise KeyError(part)
return current
def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""Hoist arbitrary local JSON-Pointer refs into provider-compatible ``$defs``."""
rewritten_refs: dict[str, str] = {}
generated_defs: dict[str, Any] = {}
def rewrite(value: Any) -> Any:
if isinstance(value, list):
return [rewrite(item) for item in cast(list[Any], value)]
if not isinstance(value, dict):
return value
rewritten = dict(cast(dict[str, Any], value))
raw_ref = rewritten.get("$ref")
ref = raw_ref if isinstance(raw_ref, str) else None
is_rewritable_ref = False
if ref is not None and not ref.startswith("#/$defs/"):
try:
pointer = urllib.parse.unquote(ref[1:], errors="strict")
except (UnicodeDecodeError, ValueError):
pass
else:
is_rewritable_ref = ref.startswith("#") and (
not pointer or pointer.startswith("/")
)
if is_rewritable_ref:
assert ref is not None
name = rewritten_refs.get(ref)
if name is None:
try:
target = _resolve_local_schema_ref(schema, ref)
except (KeyError, IndexError, TypeError, UnicodeDecodeError, ValueError):
logger.warning("MCP tool schema contains an unresolved local $ref: {}", ref)
else:
name = f"ref_{hashlib.sha256(ref.encode()).hexdigest()[:12]}"
existing_defs = schema.get("$defs")
while isinstance(existing_defs, dict) and name in existing_defs:
name += "_"
rewritten_refs[ref] = name
# Reserve the name before descending so recursive refs terminate.
generated_defs[name] = {}
generated_defs[name] = rewrite(target)
if name is not None:
rewritten["$ref"] = f"#/$defs/{name}"
return {key: rewrite(item) for key, item in rewritten.items()}
result = cast(dict[str, Any], rewrite(schema))
if generated_defs:
existing_defs = result.get("$defs")
result["$defs"] = {
**(existing_defs if isinstance(existing_defs, dict) else {}),
**generated_defs,
}
return result
def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Normalize nullable forms in structural subschemas only."""
normalized = dict(schema)
raw_type = normalized.get("type")
if isinstance(raw_type, list):
type_values = cast(list[Any], raw_type)
non_null = [item for item in type_values if item != "null"]
if "null" in type_values and len(non_null) == 1:
non_null = [item for item in raw_type if item != "null"]
if "null" in raw_type and len(non_null) == 1:
normalized["type"] = non_null[0]
normalized["nullable"] = True
@@ -426,53 +339,29 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
normalized["nullable"] = True
break
properties = normalized.get("properties")
if isinstance(properties, dict):
property_schemas = cast(dict[str, Any], properties)
if "properties" in normalized and isinstance(normalized["properties"], dict):
normalized["properties"] = {
name: (
_normalize_nullable_schema(cast(dict[str, Any], prop))
if isinstance(prop, dict)
else prop
)
for name, prop in property_schemas.items()
}
items = normalized.get("items")
if isinstance(items, dict):
normalized["items"] = _normalize_nullable_schema(cast(dict[str, Any], items))
definitions = normalized.get("$defs")
if isinstance(definitions, dict):
definition_schemas = cast(dict[str, Any], definitions)
normalized["$defs"] = {
name: _normalize_nullable_schema(cast(dict[str, Any], definition))
if isinstance(definition, dict)
else definition
for name, definition in definition_schemas.items()
name: _normalize_schema_for_openai(prop) if isinstance(prop, dict) else prop
for name, prop in normalized["properties"].items()
}
if normalized.get("type") == "object":
normalized.setdefault("properties", {})
normalized.setdefault("required", [])
if "items" in normalized and isinstance(normalized["items"], dict):
normalized["items"] = _normalize_schema_for_openai(normalized["items"])
if normalized.get("type") != "object":
return normalized
normalized.setdefault("properties", {})
normalized.setdefault("required", [])
return normalized
def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
"""Normalize MCP JSON Schema patterns for tool definitions."""
if not isinstance(schema, dict):
return {"type": "object", "properties": {}}
schema_mapping = cast(dict[str, Any], schema)
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema_mapping))
class _MCPWrapperBase(Tool):
"""Common reconnect handling for wrappers bound to one MCP server session."""
_plugin_discoverable = False
_session: "ClientSession"
_server_name: str
_name: str
def _set_mcp_connection(self, session: "ClientSession", server_name: str) -> None:
def _set_mcp_connection(self, session: Any, server_name: str) -> None:
self._session = session
self._server_name = server_name
self._reconnect: _ReconnectCallback | None = None
@@ -518,7 +407,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
"""
image_cls = getattr(types, "ImageContent", None)
if image_cls is not None and isinstance(block, image_cls):
mime = getattr(block, "mime_type", None) or "image/png"
mime = getattr(block, "mimeType", None) or "image/png"
return f"data:{mime};base64,{block.data}"
embedded_cls = getattr(types, "EmbeddedResource", None)
@@ -526,10 +415,9 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
if embedded_cls is not None and isinstance(block, embedded_cls):
resource = getattr(block, "resource", None)
if blob_cls is not None and isinstance(resource, blob_cls):
blob_resource = cast(Any, resource)
mime = getattr(blob_resource, "mime_type", None) or ""
mime = getattr(resource, "mimeType", None) or ""
if isinstance(mime, str) and mime.startswith("image/"):
return f"data:{mime};base64,{blob_resource.blob}"
return f"data:{mime};base64,{resource.blob}"
return None
@@ -560,18 +448,12 @@ class MCPToolWrapper(_MCPWrapperBase):
_plugin_discoverable = False
def __init__(
self,
session: "ClientSession",
server_name: str,
tool_def: "MCPToolDefinition",
tool_timeout: int = 30,
):
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
self._set_mcp_connection(session, server_name)
self._original_name = tool_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
self._description = tool_def.description or tool_def.name
raw_schema = tool_def.input_schema or {"type": "object", "properties": {}}
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
self._parameters = _normalize_schema_for_openai(raw_schema)
self._tool_timeout = tool_timeout
@@ -650,7 +532,7 @@ class MCPToolWrapper(_MCPWrapperBase):
# Success — extract text and persist any image content as artifacts.
try:
rendered = self._render_call_result(result.content, kwargs)
if getattr(result, "is_error", False):
if getattr(result, "isError", False):
return ToolResult.error(rendered)
return rendered
except Exception as exc:
@@ -722,13 +604,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
_plugin_discoverable = False
def __init__(
self,
session: "ClientSession",
server_name: str,
resource_def: "Resource",
resource_timeout: int = 30,
):
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30):
self._set_mcp_connection(session, server_name)
self._uri = resource_def.uri
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_resource_{resource_def.name}")
@@ -814,7 +690,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
for block in result.contents:
if isinstance(block, types.TextResourceContents):
parts.append(block.text)
elif isinstance(cast(object, block), types.BlobResourceContents):
elif isinstance(block, types.BlobResourceContents):
parts.append(f"[Binary resource: {len(block.blob)} bytes]")
else:
parts.append(str(block))
@@ -826,13 +702,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
_plugin_discoverable = False
def __init__(
self,
session: "ClientSession",
server_name: str,
prompt_def: "Prompt",
prompt_timeout: int = 30,
):
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30):
self._set_mcp_connection(session, server_name)
self._prompt_name = prompt_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
@@ -876,7 +746,8 @@ class MCPPromptWrapper(_MCPWrapperBase):
return True
async def execute(self, **kwargs: Any) -> str:
from mcp import MCPError, types
from mcp import types
from mcp.shared.exceptions import McpError
retried_transient = False
refreshed_session = False
@@ -896,7 +767,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
raise
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
except MCPError as exc:
except McpError as exc:
if await self._refresh_session_after_termination(
exc,
refreshed_session,
@@ -960,7 +831,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
async def connect_mcp_servers(
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
mcp_servers: dict, registry: ToolRegistry
) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts.
@@ -973,9 +844,7 @@ async def connect_mcp_servers(
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
async def open_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, AsyncExitStack | None]:
async def open_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]:
server_stack = AsyncExitStack()
await server_stack.__aenter__()
@@ -1061,7 +930,7 @@ async def connect_mcp_servers(
**_pinned_transport_kwargs(),
)
)
read, write = await server_stack.enter_async_context(
read, write, _ = await server_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else:
@@ -1194,9 +1063,7 @@ async def connect_mcp_servers(
await server_stack.aclose()
return name, None
async def connect_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, MCPConnection | None]:
async def connect_single_server(name: str, cfg) -> tuple[str, MCPConnection | None]:
loop = asyncio.get_running_loop()
ready: asyncio.Future[bool] = loop.create_future()
close_requested = asyncio.Event()
@@ -1240,7 +1107,7 @@ async def connect_mcp_servers(
except Exception as e:
logger.exception("MCP server '{}' connection failed: {}", name, e)
continue
if result[1] is not None:
if result is not None and result[1] is not None:
server_stacks[result[0]] = result[1]
return server_stacks
@@ -1321,7 +1188,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
tools_removed = 0
for name in [*removed, *changed]:
tools_removed += _unregister_server_tools(registry, name)
tools_removed += _unregister_server_tools(state, registry, name)
await _close_server(state, name)
state._mcp_servers = next_servers
@@ -1383,11 +1250,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
}
async def request_mcp_reload(
bus: MessageBus,
*,
timeout: float = 15.0,
) -> dict[str, Any]:
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
"""Ask the running agent loop to reconcile live MCP connections."""
loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
@@ -1411,7 +1274,7 @@ async def request_mcp_reload(
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
return result if isinstance(cast(object, result), dict) else {
return result if isinstance(result, dict) else {
"ok": False,
"message": "MCP hot reload returned an unexpected response.",
"requires_restart": True,
@@ -1419,7 +1282,7 @@ async def request_mcp_reload(
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
if control != RUNTIME_CONTROL_MCP_RELOAD:
return False
@@ -1436,7 +1299,7 @@ async def handle_runtime_control(state: Any, msg: InboundMessage, registry: Tool
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
ack.set_result(result)
return True
@@ -1499,7 +1362,7 @@ async def _refresh_terminated_server(
return current_tool
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
_unregister_server_tools(registry, server_name)
_unregister_server_tools(state, registry, server_name)
await _close_server(state, server_name)
connected = await connect_mcp_servers({server_name: cfg}, registry)
@@ -1531,7 +1394,7 @@ def _tool_belongs_to_server(tool: Tool | None, tool_name: str, server_name: str)
return tool_name.startswith(_tool_prefix(server_name))
def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
removed = 0
for tool_name in list(registry.tool_names):
tool = registry.get(tool_name)
+38 -24
View File
@@ -1,15 +1,13 @@
"""Message tool for sending messages to users."""
# pyright: reportIncompatibleMethodOverride=false
from contextvars import ContextVar, Token
from contextvars import ContextVar
from pathlib import Path
from typing import Any, Awaitable, Callable, cast
from typing import Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext, current_request_context
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.bus.events import OutboundMessage
@@ -69,13 +67,21 @@ class MessageTool(Tool):
self._fallback_message_id = default_message_id
self._fallback_metadata: dict[str, Any] = {}
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
"message_turn_delivered_media",
default=(),
)
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
"message_record_channel_delivery",
default=False,
)
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
"message_suppress_delivery",
default=False,
)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
def create(cls, ctx: Any) -> Tool:
send_callback = ctx.bus.publish_outbound if ctx.bus else None
return cls(
send_callback=send_callback,
@@ -90,12 +96,25 @@ class MessageTool(Tool):
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
self._turn_delivered_media_var.set(())
def set_suppress_delivery(self, active: bool) -> Token[bool]:
def turn_delivered_media_paths(self) -> list[str]:
"""Absolute paths attached via this tool to the active chat in the current turn."""
return list(self._turn_delivered_media_var.get())
def set_record_channel_delivery(self, active: bool):
"""Mark tool-sent messages as proactive channel deliveries."""
return self._record_channel_delivery_var.set(active)
def reset_record_channel_delivery(self, token) -> None:
"""Restore previous proactive delivery recording state."""
self._record_channel_delivery_var.reset(token)
def set_suppress_delivery(self, active: bool):
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
return self._suppress_delivery_var.set(active)
def reset_suppress_delivery(self, token: Token[bool]) -> None:
def reset_suppress_delivery(self, token) -> None:
"""Restore previous delivery-suppression state."""
self._suppress_delivery_var.reset(token)
@@ -150,23 +169,19 @@ class MessageTool(Tool):
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
buttons: Any = None,
buttons: list[list[str]] | None = None,
**kwargs: Any,
) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
) -> str:
from nanobot.utils.helpers import strip_think
content = strip_think(content)
button_rows: list[list[str]] | None = None
if buttons is not None:
raw_buttons = cast(list[Any], buttons) if isinstance(buttons, list) else None
if raw_buttons is None or any(
not isinstance(row, list)
or any(not isinstance(label, str) for label in cast(list[Any], row))
for row in raw_buttons
if not isinstance(buttons, list) or any(
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons
):
return ToolResult.error("Error: buttons must be a list of list of strings")
button_rows = cast(list[list[str]], raw_buttons)
request_ctx = current_request_context()
default_channel = (
request_ctx.channel if request_ctx is not None else self._fallback_channel
@@ -226,7 +241,7 @@ class MessageTool(Tool):
metadata = dict(default_metadata) if same_target else {}
if message_id:
metadata["message_id"] = message_id
if media:
if self._record_channel_delivery_var.get() or media:
metadata["_record_channel_delivery"] = True
msg = OutboundMessage(
@@ -234,7 +249,7 @@ class MessageTool(Tool):
chat_id=chat_id,
content=content,
media=media or [],
buttons=button_rows or [],
buttons=buttons or [],
metadata=metadata,
)
@@ -246,12 +261,11 @@ class MessageTool(Tool):
await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True
if media:
prev = self._turn_delivered_media_var.get()
self._turn_delivered_media_var.set(prev + tuple(str(p) for p in media))
media_info = f" with {len(media)} attachments" if media else ""
button_info = (
f" with {sum(len(row) for row in button_rows)} button(s)"
if button_rows
else ""
)
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e:
return ToolResult.error(f"Error sending message: {str(e)}")
+8 -11
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, current_request_context
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
from nanobot.runtime_context import RuntimeContextProvider
def is_tool_error_result(result: Any) -> bool:
def is_tool_error_result(name: str, result: Any) -> bool:
return isinstance(result, ToolResult) and result.is_error
@@ -77,7 +77,7 @@ class ToolRegistry:
"""Extract a normalized tool name from either OpenAI or flat schemas."""
fn = schema.get("function")
if isinstance(fn, dict):
name = cast(dict[str, Any], fn).get("name")
name = fn.get("name")
if isinstance(name, str):
return name
name = schema.get("name")
@@ -140,7 +140,7 @@ class ToolRegistry:
)
)
cast_params = tool.cast_params(cast(dict[str, Any], params))
cast_params = tool.cast_params(params)
errors = tool.validate_params(cast_params)
if errors:
return tool, cast_params, (
@@ -176,15 +176,12 @@ class ToolRegistry:
@classmethod
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
if not isinstance(params, dict):
if not isinstance(params, dict) or set(params) != {"arguments"}:
return params
arguments_payload = cast(dict[str, Any], params)
if set(arguments_payload) != {"arguments"}:
return arguments_payload
properties = (tool.parameters or {}).get("properties", {})
if isinstance(properties, dict) and "arguments" in properties:
return arguments_payload
return cls._coerce_argument_value(arguments_payload.get("arguments"))
return params
return cls._coerce_argument_value(params.get("arguments"))
async def execute(self, name: str, params: Any) -> Any:
"""Execute a tool by name with given parameters."""
@@ -196,7 +193,7 @@ class ToolRegistry:
try:
assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params)
if is_tool_error_result(result):
if is_tool_error_result(name, result):
return ToolResult.error(str(result) + hint)
return result
except Exception as e:
+11 -23
View File
@@ -1,15 +1,6 @@
"""RuntimeState protocol: agent loop state exposed to MyTool."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.utils.llm_runtime import LLMRuntime
from typing import Any, Protocol
class RuntimeState(Protocol):
@@ -34,7 +25,7 @@ class RuntimeState(Protocol):
def tool_names(self) -> list[str]: ...
@property
def workspace(self) -> Path: ...
def workspace(self) -> str: ...
@property
def provider_retry_mode(self) -> str: ...
@@ -46,31 +37,28 @@ class RuntimeState(Protocol):
def context_window_tokens(self) -> int: ...
@property
def web_config(self) -> WebToolsConfig: ...
def web_config(self) -> Any: ...
@property
def exec_config(self) -> ExecToolConfig: ...
def exec_config(self) -> Any: ...
@property
def subagents(self) -> SubagentManager: ...
def workspace_sandbox(self) -> Any: ...
@property
def subagents(self) -> Any: ...
@property
def _runtime_vars(self) -> dict[str, Any]: ...
@property
def _last_usage(self) -> dict[str, int]: ...
def _last_usage(self) -> Any: ...
def _sync_subagent_runtime_limits(self) -> None: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_model(self, model: str) -> Any: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_session_model_preset(
self,
session_key: str,
name: str,
) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
@property
def model_preset(self) -> str | None: ...
+5 -63
View File
@@ -5,54 +5,13 @@ To add a new backend, implement a function with the signature:
and register it in _BACKENDS below.
"""
import os
import shlex
from pathlib import Path
from typing import Iterable
from nanobot.config.paths import get_media_dir
def _normalize_bind_paths(
paths: Iterable[str] | None,
*,
workspace: Path | None = None,
) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in paths or []:
value = str(raw).strip()
if not value:
continue
path = Path(os.path.expandvars(value)).expanduser()
if not path.is_absolute():
continue
resolved_path = path.resolve(strict=False)
if workspace is not None:
try:
workspace.relative_to(resolved_path)
except ValueError:
pass
else:
# A later bind of the workspace or one of its parents could
# cover the tmpfs that hides the config directory.
continue
resolved = str(resolved_path)
if resolved in seen:
continue
seen.add(resolved)
out.append(resolved)
return out
def _bwrap(
command: str,
workspace: str,
cwd: str,
*,
sandbox_ro_binds: Iterable[str] | None = None,
sandbox_rw_binds: Iterable[str] | None = None,
) -> str:
def _bwrap(command: str, workspace: str, cwd: str) -> str:
"""Wrap command in a bubblewrap sandbox (requires bwrap in container).
Only the workspace is bind-mounted read-write; its parent dir (which holds
@@ -92,34 +51,17 @@ def _bwrap(
"--dir", str(ws), # recreate workspace mount point
"--bind", str(ws), str(ws),
"--ro-bind-try", str(media), str(media), # read-only access to media
"--chdir", sandbox_cwd,
"--", "sh", "-c", command,
]
for p in _normalize_bind_paths(sandbox_ro_binds, workspace=ws):
args += ["--ro-bind-try", p, p]
for p in _normalize_bind_paths(sandbox_rw_binds, workspace=ws):
args += ["--bind-try", p, p]
args += ["--chdir", sandbox_cwd, "--", "sh", "-c", command]
return shlex.join(args)
_BACKENDS = {"bwrap": _bwrap}
def wrap_command(
sandbox: str,
command: str,
workspace: str,
cwd: str,
*,
sandbox_ro_binds: Iterable[str] | None = None,
sandbox_rw_binds: Iterable[str] | None = None,
) -> str:
def wrap_command(sandbox: str, command: str, workspace: str, cwd: str) -> str:
"""Wrap *command* using the named sandbox backend."""
if backend := _BACKENDS.get(sandbox):
return backend(
command,
workspace,
cwd,
sandbox_ro_binds=sandbox_ro_binds,
sandbox_rw_binds=sandbox_rw_binds,
)
return backend(command, workspace, cwd)
raise ValueError(f"Unknown sandbox backend {sandbox!r}. Available: {list(_BACKENDS)}")
+5 -1
View File
@@ -52,10 +52,11 @@ class StringSchema(Schema):
class IntegerSchema(Schema):
"""Integer parameter with a description and optional bounds."""
"""Integer parameter: optional placeholder int (legacy ctor signature), description, and bounds."""
def __init__(
self,
value: int = 0,
*,
description: str = "",
minimum: int | None = None,
@@ -63,6 +64,7 @@ class IntegerSchema(Schema):
enum: tuple[int, ...] | list[int] | None = None,
nullable: bool = False,
) -> None:
self._value = value
self._description = description
self._minimum = minimum
self._maximum = maximum
@@ -90,6 +92,7 @@ class NumberSchema(Schema):
def __init__(
self,
value: float = 0.0,
*,
description: str = "",
minimum: float | None = None,
@@ -97,6 +100,7 @@ class NumberSchema(Schema):
enum: tuple[float, ...] | list[float] | None = None,
nullable: bool = False,
) -> None:
self._value = value
self._description = description
self._minimum = minimum
self._maximum = maximum
+3 -11
View File
@@ -1,7 +1,5 @@
"""Search tools: file discovery and grep."""
# pyright: reportIncompatibleMethodOverride=false, reportPrivateUsage=false
from __future__ import annotations
import fnmatch
@@ -285,7 +283,6 @@ class GrepTool(_SearchTool):
_MAX_RESULT_CHARS = 128_000
_MAX_FILE_BYTES = 2_000_000
_MAX_EXPLICIT_FILE_BYTES = 100_000_000
@property
def name(self) -> str:
@@ -298,8 +295,7 @@ class GrepTool(_SearchTool):
"Default output_mode is files_with_matches (file paths only); "
"use content mode for matching lines with context. Prefer this "
"over shell grep for ordinary workspace searches. "
"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."
"Skips binary and files >2 MB. Supports glob/type filtering."
)
@property
@@ -460,9 +456,6 @@ class GrepTool(_SearchTool):
counts: dict[str, int] = {}
file_mtimes: dict[str, float] = {}
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):
rel_path = file_path.relative_to(root).as_posix()
@@ -471,9 +464,8 @@ class GrepTool(_SearchTool):
if not _matches_type(file_path.name, type):
continue
with file_path.open("rb") as file:
raw = file.read(max_file_bytes + 1)
if len(raw) > max_file_bytes:
raw = file_path.read_bytes()
if len(raw) > self._MAX_FILE_BYTES:
skipped_large += 1
continue
if _is_binary(raw):
+35 -84
View File
@@ -1,25 +1,19 @@
"""MyTool: runtime state inspection and configuration for the agent loop."""
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
from __future__ import annotations
import time
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeGuard, cast
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import current_request_context, current_request_session_key
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.context import ToolContext
class MyToolConfig(Base):
@@ -41,7 +35,7 @@ def _has_real_attr(obj: Any, key: str) -> bool:
return False
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
def _is_subagent_status(value: Any) -> bool:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
@@ -58,7 +52,7 @@ class MyTool(Tool):
return MyToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.my.enable
BLOCKED = frozenset({
@@ -82,7 +76,6 @@ class MyTool(Tool):
"_current_iteration", # updated by runner only
"exec_config", # inspect allowed (e.g. check sandbox), 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
"request", # current message routing metadata
})
@@ -153,8 +146,6 @@ class MyTool(Tool):
"max_iterations - _current_iteration = remaining iterations.\n"
"Current routing metadata is available read-only via request.channel, "
"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"
"\n"
"When to use:\n"
@@ -210,7 +201,7 @@ class MyTool(Tool):
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
parts = path.split(".")
obj: Any = self._runtime_state
obj = self._runtime_state
for part in parts:
if part in self._DENIED_ATTRS or part.startswith("__"):
return None, f"'{part}' is not accessible"
@@ -219,12 +210,11 @@ class MyTool(Tool):
if part.lower() in self._SENSITIVE_NAMES:
return None, f"'{part}' is not accessible"
try:
if isinstance(obj, Mapping):
mapping = cast(Mapping[str, Any], obj)
if part in mapping:
obj = mapping[part]
if isinstance(obj, dict):
if part in obj:
obj = obj[part]
else:
return None, f"'{part}' not found in mapping"
return None, f"'{part}' not found in dict"
else:
obj = getattr(obj, part)
except (KeyError, AttributeError) as e:
@@ -265,40 +255,28 @@ class MyTool(Tool):
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict
task_statuses = getattr(val, "_task_statuses", None)
if isinstance(task_statuses, dict):
return MyTool._format_value(task_statuses, key)
if isinstance(val, Mapping):
mapping = cast(Mapping[object, object], val)
else:
mapping = None
if (
mapping
and _is_subagent_status(next(iter(mapping.values())))
):
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
return MyTool._format_value(val._task_statuses, key)
if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
for tid, st in status_mapping.items():
lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items():
detail = MyTool._format_status(st, " ")
lines.append(f" [{tid}] '{st.label}'\n{detail}")
return "\n".join(lines)
dynamic_value = cast(Any, val)
if hasattr(dynamic_value, "tool_names"):
tool_names: Any = getattr(dynamic_value, "tool_names")
return f"tools: {len(tool_names)} registered — {tool_names}"
if hasattr(val, "tool_names"):
return f"tools: {len(val.tool_names)} registered — {val.tool_names}"
# Scalar types — repr is fine
if isinstance(val, (str, int, float, bool, type(None))):
r = repr(val)
return f"{key}: {r}" if key else r
# Mapping — small: show content; large: show keys for dot-path navigation
if isinstance(val, Mapping):
value_mapping = cast(Mapping[object, object], val)
ks = list(value_mapping.keys())
# Dict — small: show content; large: show keys for dot-path navigation
if isinstance(val, dict):
ks = list(val.keys())
if not ks:
return f"{key}: {{}}" if key else "{}"
if len(ks) <= 5:
r = repr(value_mapping)
r = repr(val)
if len(r) <= 200:
return f"{key}: {r}" if key else r
preview = ", ".join(str(k) for k in ks[:15])
@@ -306,20 +284,18 @@ class MyTool(Tool):
return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}"
# List/tuple — count for large, repr for small
if isinstance(val, (list, tuple)):
sequence = cast(list[object] | tuple[object, ...], val)
if len(sequence) > 20:
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
r = repr(sequence)
if len(val) > 20:
return f"{key}: [{len(val)} items]" if key else f"[{len(val)} items]"
r = repr(val)
return f"{key}: {r}" if key else r
# Complex object — small Pydantic models: show values; others: show field names for navigation
value_type = type(cast(object, val))
cls_name = value_type.__name__
model_fields = cast(object, getattr(value_type, "model_fields", None))
if isinstance(model_fields, Mapping) and model_fields:
fields = list(cast(Mapping[str, object], model_fields).keys())
cls_name = type(val).__name__
model_fields = getattr(type(val), "model_fields", None)
if model_fields:
fields = list(model_fields.keys())
if len(fields) <= 8:
# Small config objects: show field=value pairs
pairs: list[str] = []
pairs = []
for f in fields:
fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f):
@@ -331,8 +307,7 @@ class MyTool(Tool):
preview = ", ".join(pairs)
return f"{key}: {preview}" if key else preview
else:
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
fields = [name for name in attributes if not name.startswith("__")]
fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")]
if fields:
preview = ", ".join(str(f) for f in fields[:20])
suffix = ", ..." if len(fields) > 20 else ""
@@ -438,7 +413,6 @@ class MyTool(Tool):
def _modify(self, key: str | None, value: Any) -> str:
if err := self._validate_key(key):
return err
key = cast(str, key)
top = key.split(".")[0]
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED {key}")
@@ -473,23 +447,6 @@ class MyTool(Tool):
if not isinstance(value, str) or not value.strip():
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
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)
if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
@@ -500,7 +457,7 @@ class MyTool(Tool):
def _modify_restricted(self, key: str, value: Any) -> str:
spec = self.RESTRICTED[key]
expected = cast(type[Any], spec["type"])
expected = spec["type"]
if expected is int and isinstance(value, bool):
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
if not isinstance(value, expected):
@@ -515,15 +472,10 @@ class MyTool(Tool):
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
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")
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":
self._runtime_state.set_runtime_model(cast(str, value))
self._runtime_state.set_runtime_model(value)
elif key == "context_window_tokens":
self._runtime_state.set_runtime_context_window(cast(int, value))
self._runtime_state.set_runtime_context_window(value)
else:
setattr(self._runtime_state, key, value)
if key == "max_iterations" and hasattr(
@@ -538,8 +490,7 @@ class MyTool(Tool):
if _has_real_attr(self._runtime_state, key):
old = getattr(self._runtime_state, key)
if isinstance(old, (str, int, float, bool)):
old_t: type[Any] = type(old)
new_t = cast(type[Any], type(value))
old_t, new_t = type(old), type(value)
if old_t is float and new_t is int:
pass # int → float coercion allowed
elif old_t is not new_t:
@@ -578,12 +529,12 @@ class MyTool(Tool):
if isinstance(value, (str, int, float, bool, type(None))):
return None
if isinstance(value, list):
for i, item in enumerate(cast(list[Any], value)):
for i, item in enumerate(value):
if err := cls._validate_json_safe(item, depth + 1):
return f"list[{i}] contains {err}"
return None
if isinstance(value, dict):
for k, v in cast(dict[Any, Any], value).items():
for k, v in value.items():
if not isinstance(k, str):
return f"dict key must be str, got {type(k).__name__}"
if err := cls._validate_json_safe(v, depth + 1):
+91 -200
View File
@@ -6,8 +6,6 @@ import asyncio
import os
import re
import shutil
import signal
import subprocess
import sys
from contextlib import suppress
from dataclasses import dataclass
@@ -18,14 +16,13 @@ from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext, current_request_session_key
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER,
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_YIELD_MS,
MAX_OUTPUT_CHARS,
MAX_YIELD_MS,
ExecSessionManager,
clamp_session_int,
format_session_poll,
)
@@ -43,6 +40,10 @@ from nanobot.security.workspace_policy import is_path_within
_IS_WINDOWS = sys.platform == "win32"
_RM_COMMAND_RE = re.compile(r"\brm\b")
_SHELL_COMMAND_SEPARATOR_RE = re.compile(r"(?:&&|\|\||[;&|\r\n])")
_SHELL_TOKEN_RE = re.compile(r'''"[^"]*"|'[^']*'|[^\s]+''')
def _reap_pid(pid: int) -> None:
"""Best-effort ``waitpid`` to reap a child and prevent zombies.
@@ -85,8 +86,6 @@ class ExecToolConfig(Base):
path_prepend: str = ""
path_append: str = ""
sandbox: str = ""
sandbox_ro_binds: list[str] = Field(default_factory=list)
sandbox_rw_binds: list[str] = Field(default_factory=list)
allowed_env_keys: list[str] = Field(default_factory=list)
allow_patterns: list[str] = Field(default_factory=list)
deny_patterns: list[str] = Field(default_factory=list)
@@ -109,6 +108,7 @@ class _PreparedCommand:
working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema(
60,
description=(
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
@@ -175,11 +175,11 @@ class ExecTool(Tool):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.exec
return cls(
working_dir=ctx.workspace,
@@ -189,12 +189,10 @@ class ExecTool(Tool):
sandbox=cfg.sandbox,
path_prepend=cfg.path_prepend,
path_append=cfg.path_append,
sandbox_ro_binds=cfg.sandbox_ro_binds,
sandbox_rw_binds=cfg.sandbox_rw_binds,
allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns,
deny_patterns=cfg.deny_patterns,
session_manager=ctx.exec_session_manager,
session_manager=getattr(ctx, "exec_session_manager", None),
)
def __init__(
@@ -209,16 +207,13 @@ class ExecTool(Tool):
sandbox: str = "",
path_prepend: str = "",
path_append: str = "",
sandbox_ro_binds: list[str] | None = None,
sandbox_rw_binds: list[str] | None = None,
allowed_env_keys: list[str] | None = None,
session_manager: ExecSessionManager | None = None,
session_manager: Any | None = None,
):
self.timeout = timeout
self.working_dir = working_dir
self.sandbox = sandbox
self.deny_patterns = (deny_patterns or []) + [
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
r"\brmdir\s+/s\b", # rmdir /s
r"(?:^|[;&|]\s*)format(?!=)\b", # format (as standalone command only)
@@ -243,8 +238,6 @@ class ExecTool(Tool):
self.webui_allow_local_service_access = webui_allow_local_service_access
self.path_prepend = path_prepend
self.path_append = path_append
self.sandbox_ro_binds = self._normalize_bind_roots(sandbox_ro_binds)
self.sandbox_rw_binds = self._normalize_bind_roots(sandbox_rw_binds)
self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@@ -345,7 +338,7 @@ class ExecTool(Tool):
# misses it, leaving a zombie.
_reap_pid(process.pid)
output_parts: list[str] = []
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
@@ -472,14 +465,7 @@ class ExecTool(Tool):
)
else:
workspace = workspace_root or cwd
command = wrap_command(
self.sandbox,
command,
workspace,
cwd,
sandbox_ro_binds=[str(p) for p in self.sandbox_ro_binds],
sandbox_rw_binds=[str(p) for p in self.sandbox_rw_binds],
)
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
effective_timeout = self._resolve_timeout(timeout)
@@ -505,7 +491,7 @@ class ExecTool(Tool):
)
def _compose_path(self, current_path: str) -> str:
parts: list[str] = []
parts = []
if self.path_prepend:
parts.append(self.path_prepend)
if current_path:
@@ -515,7 +501,7 @@ class ExecTool(Tool):
return os.pathsep.join(parts)
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
segments: list[str] = []
segments = []
if self.path_prepend:
env["NANOBOT_PATH_PREPEND"] = self.path_prepend
segments.append("$NANOBOT_PATH_PREPEND")
@@ -533,7 +519,6 @@ class ExecTool(Tool):
login: bool = False,
*,
stdin: int = asyncio.subprocess.DEVNULL,
process_tree: bool = False,
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
@@ -556,7 +541,6 @@ class ExecTool(Tool):
command = ExecTool._normalize_powershell_command(command)
command = (
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
"if ($PSVersionTable.PSVersion.Major -lt 6) { $OutputEncoding = [Console]::OutputEncoding }\n"
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
f"{command}\n"
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
@@ -570,21 +554,11 @@ class ExecTool(Tool):
env=env,
)
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args: list[str] = [shell_program]
args = [shell_program]
shell_name = Path(shell_program).name.lower()
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
args.append("-l")
args.extend(["-c", command])
if process_tree:
return await asyncio.create_subprocess_exec(
*args,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
start_new_session=True,
)
return await asyncio.create_subprocess_exec(
*args,
stdin=stdin,
@@ -684,39 +658,6 @@ class ExecTool(Tool):
finally:
_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]:
"""Build a minimal environment for subprocess execution.
@@ -766,6 +707,74 @@ class ExecTool(Tool):
env[key] = val
return env
@classmethod
def _contains_unscoped_recursive_rm(cls, command: str) -> bool:
"""Return whether ``command`` contains recursive rm outside a scoped /tmp target.
The exec guard deliberately remains conservative for recursive deletion, but
test and build scripts routinely clean their own named directories below
``/tmp``. Treat only static, direct ``/tmp/<name>`` targets as scoped cleanup.
Any ambiguous invocation (variables, traversal, broad globs, nested paths,
mixed targets) stays blocked.
"""
for match in _RM_COMMAND_RE.finditer(command):
tail = command[match.end():]
segment = _SHELL_COMMAND_SEPARATOR_RE.split(tail, maxsplit=1)[0]
tokens = _SHELL_TOKEN_RE.findall(segment)
recursive = False
targets: list[str] = []
parsing_options = True
unsafe_redirect = False
for raw_token in tokens:
token = raw_token.strip().strip("\"'")
if not token:
continue
if token == "--" and parsing_options:
parsing_options = False
continue
if parsing_options and token.startswith("--"):
recursive = recursive or token == "--recursive"
continue
if parsing_options and re.fullmatch(r"-[a-z]+", token):
recursive = recursive or "r" in token[1:]
continue
parsing_options = False
if token.startswith("#"):
break
if re.match(r"^\d*[<>]", token):
redirect_target = re.sub(r"^\d*[<>]+", "", token)
if redirect_target and redirect_target != "/dev/null":
unsafe_redirect = True
continue
targets.append(token)
if recursive and (
unsafe_redirect
or not targets
or not all(cls._is_scoped_tmp_cleanup_target(target) for target in targets)
):
return True
return False
@staticmethod
def _is_scoped_tmp_cleanup_target(raw_target: str) -> bool:
"""Accept a static, specifically named descendant of the POSIX /tmp root."""
target = raw_target.strip().rstrip("\"'),")
if not target.startswith("/tmp/"):
return False
relative = target.removeprefix("/tmp/")
if not relative or any(char in relative for char in ("$", "`", "\\", "[", "{")):
return False
if "/" in relative or relative in {".", ".."}:
return False
literal_prefix = re.split(r"[*?]", relative, maxsplit=1)[0]
return any(char.isalnum() or char in "_-" for char in literal_prefix)
def _guard_command(
self,
command: str,
@@ -780,14 +789,14 @@ class ExecTool(Tool):
# allow_patterns take priority over deny_patterns so that users can
# exempt specific commands (e.g. "rm -rf" inside a build directory)
# from the hardcoded deny list via configuration. A chained command is
# only explicitly allowed when every top-level shell segment matches.
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
# from the hardcoded deny list via configuration.
explicitly_allowed = bool(self.allow_patterns) and any(
re.fullmatch(p, lower) for p in self.allow_patterns
)
if not explicitly_allowed:
if self._contains_unscoped_recursive_rm(lower):
return ToolResult.error("Error: Command blocked by deny pattern filter")
for pattern in self.deny_patterns:
if re.search(pattern, lower):
return ToolResult.error("Error: Command blocked by deny pattern filter")
@@ -819,9 +828,6 @@ class ExecTool(Tool):
if workspace_root
else None
)
sandbox_bind_roots = self._active_sandbox_bind_roots(
resolved_workspace or cwd_path
)
for raw in self._extract_absolute_paths(cmd):
try:
@@ -845,8 +851,6 @@ class ExecTool(Tool):
)
if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace)
if not allowed and sandbox_bind_roots:
allowed = any(is_path_within(p, root) for root in sandbox_bind_roots)
if p.is_absolute() and not allowed:
return ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)"
@@ -855,84 +859,6 @@ class ExecTool(Tool):
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
def _is_benign_device_path(cls, path: str) -> bool:
"""Return True for kernel device files that should never be workspace-blocked."""
@@ -948,41 +874,6 @@ class ExecTool(Tool):
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command
)
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths
@staticmethod
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
roots: list[Path] = []
seen: set[str] = set()
for raw in paths or []:
value = str(raw).strip()
if not value:
continue
path = Path(os.path.expandvars(value)).expanduser()
if not path.is_absolute():
continue
with suppress(OSError, RuntimeError, ValueError):
resolved = path.resolve(strict=False)
key = os.path.normcase(os.fspath(resolved))
if key in seen:
continue
seen.add(key)
roots.append(resolved)
return roots
def _active_sandbox_bind_roots(
self,
workspace_root: Path | None = None,
) -> list[Path]:
if self.sandbox != "bwrap" or _IS_WINDOWS:
return []
roots = [*self.sandbox_ro_binds, *self.sandbox_rw_binds]
if workspace_root is None:
return roots
return [
root
for root in roots
if not is_path_within(workspace_root, root)
]
+4 -26
View File
@@ -1,24 +1,16 @@
"""Spawn tool for creating background subagents."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.schema import (
BooleanSchema,
NumberSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import ToolContext
@tool_parameters(
@@ -34,14 +26,6 @@ if TYPE_CHECKING:
minimum=0.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"],
)
)
@@ -52,11 +36,8 @@ class SpawnTool(Tool):
self._manager = manager
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
manager = ctx.subagent_manager
if manager is None:
raise RuntimeError("SpawnTool requires an initialized subagent manager")
return cls(manager=manager)
def create(cls, ctx: Any) -> Tool:
return cls(manager=ctx.subagent_manager)
@property
def name(self) -> str:
@@ -67,7 +48,6 @@ class SpawnTool(Tool):
return (
"Spawn a subagent to handle a task in the background. "
"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. "
"For deliverables or existing projects, inspect the workspace first "
"and use a dedicated subdirectory when helpful."
@@ -78,7 +58,6 @@ class SpawnTool(Tool):
task: str,
label: str | None = None,
temperature: float | None = None,
wait: bool = False,
**kwargs: Any,
) -> str:
"""Spawn a subagent to execute the given task."""
@@ -96,8 +75,7 @@ class SpawnTool(Tool):
origin_channel = request_ctx.channel
origin_chat_id = request_ctx.chat_id
session_key = request_ctx.session_key or f"{origin_channel}:{origin_chat_id}"
method = self._manager.run_inline if wait else self._manager.spawn
return await method(
return await self._manager.spawn(
task=task,
runtime=request_ctx.runtime,
label=label,
+58 -102
View File
@@ -1,7 +1,5 @@
"""Web tools: web_search and web_fetch."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
import asyncio
@@ -9,8 +7,7 @@ import html
import json
import os
import re
from collections.abc import Callable
from typing import Any, cast
from typing import Any, Callable
from urllib.parse import quote, urljoin, urlparse
import httpx
@@ -18,7 +15,6 @@ from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@@ -275,12 +271,13 @@ def _normalize_volcengine_auth_level(value: Any) -> int | None:
@tool_parameters(
tool_parameters_schema(
query=StringSchema("Search query"),
count=IntegerSchema(description="Results (1-10)", minimum=1, maximum=10),
count=IntegerSchema(1, description="Results (1-10)", minimum=1, maximum=10),
timeRange=StringSchema(
"Optional time filter for providers that support it: "
"OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD",
),
authLevel=IntegerSchema(
0,
description="Optional authority filter for providers that support it: 0=all, 1=authoritative",
minimum=0,
maximum=1,
@@ -295,8 +292,8 @@ class WebSearchTool(Tool):
"""Search the web using configured provider."""
_scopes = {"core", "subagent"}
name = "web_search" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
name = "web_search"
description = (
"Search the web. Returns titles, URLs, and snippets. "
"count defaults to 5 (max 10). "
"Some providers support timeRange, authLevel, and queryRewrite. "
@@ -306,21 +303,20 @@ class WebSearchTool(Tool):
config_key = "web"
@classmethod
def config_cls(cls) -> type[WebToolsConfig]:
def config_cls(cls):
return WebToolsConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.web.enable
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
config_loader: Callable[[], WebSearchConfig] | None = None
def create(cls, ctx: Any) -> Tool:
config_loader = None
if ctx.provider_snapshot_loader is not None:
def _load_search_config() -> WebSearchConfig:
def config_loader():
from nanobot.config.loader import load_config, resolve_config_env_vars
return resolve_config_env_vars(load_config()).tools.web.search
config_loader = _load_search_config
return cls(
config=ctx.config.web.search,
proxy=ctx.config.web.proxy,
@@ -409,7 +405,7 @@ class WebSearchTool(Tool):
auth_level: int | None = None,
query_rewrite: bool | None = None,
**kwargs: Any,
) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
) -> str:
self._refresh_config()
provider = self.config.provider.strip().lower() or "brave"
n = min(max(count or self.config.max_results, 1), 10)
@@ -453,20 +449,15 @@ class WebSearchTool(Tool):
async def _search_olostep(self, query: str, n: int) -> str:
try:
from olostep import ( # pyright: ignore[reportMissingImports]
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
)
from olostep import AsyncOlostep, Olostep_BaseError
except ImportError:
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
async_olostep = cast(Any, AsyncOlostep)
olostep_base_error = cast(type[Exception], Olostep_BaseError)
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
if not api_key:
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
return await self._search_duckduckgo(query, n)
try:
async with async_olostep(api_key=api_key) as client:
async with AsyncOlostep(api_key=api_key) as client:
if self.proxy:
transport = getattr(client, "_transport", None)
http_client = getattr(transport, "_client", None)
@@ -482,16 +473,14 @@ class WebSearchTool(Tool):
),
http2=True,
)
result: Any = await client.answers.create(task=query)
result = await client.answers.create(task=query)
sources = cast(list[Any], getattr(result, "sources", None) or [])
source_lines: list[str] = []
for i, source_value in enumerate(sources[:n], 1):
source: Any = source_value
sources = getattr(result, "sources", None) or []
source_lines = []
for i, source in enumerate(sources[:n], 1):
if isinstance(source, dict):
source_dict = cast(dict[str, Any], source)
title = source_dict.get("title", "")
url = source_dict.get("url", "")
title = source.get("title", "")
url = source.get("url", "")
else:
title = getattr(source, "title", "")
url = getattr(source, "url", "")
@@ -505,7 +494,7 @@ class WebSearchTool(Tool):
answer_text = getattr(result, "answer", "") or ""
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
return _format_results(query, items, n)
except olostep_base_error as e:
except Olostep_BaseError as e:
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
except Exception as e:
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
@@ -522,7 +511,6 @@ class WebSearchTool(Tool):
"User-Agent": self.user_agent,
}
async with httpx.AsyncClient(proxy=self.proxy) as client:
r: httpx.Response | None = None
for attempt in range(2):
r = await client.get(
"https://api.search.brave.com/res/v1/web/search",
@@ -535,7 +523,6 @@ class WebSearchTool(Tool):
if attempt == 0:
logger.warning("Brave search rate limited; retrying once in 1.0s")
await asyncio.sleep(1.0)
assert r is not None
r.raise_for_status()
items = [
{"title": x.get("title", ""), "url": x.get("url", ""), "content": x.get("description", "")}
@@ -705,19 +692,13 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout),
)
r.raise_for_status()
data = cast(dict[str, Any], r.json())
items: list[dict[str, Any]] = []
for result_value in cast(list[object], data.get("results", [])):
if not isinstance(result_value, dict):
items = []
for result in r.json().get("results", []):
if not isinstance(result, dict):
continue
result = cast(dict[str, Any], result_value)
highlights: Any = result.get("highlights") or []
highlights = result.get("highlights") or []
if isinstance(highlights, list):
content = "\n".join(
str(highlight)
for highlight in cast(list[object], highlights)
if highlight
)
content = "\n".join(str(highlight) for highlight in highlights if highlight)
else:
content = str(highlights)
if not content:
@@ -757,17 +738,14 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout),
)
r.raise_for_status()
data = cast(dict[str, Any], r.json())
organic = cast(list[object], data.get("organic", []))
items: list[dict[str, Any]] = [
items = [
{
"title": result.get("title", ""),
"url": result.get("link", ""),
"content": result.get("snippet", ""),
}
for result_value in organic
if isinstance(result_value, dict)
for result in (cast(dict[str, Any], result_value),)
for result in r.json().get("organic", [])
if isinstance(result, dict)
]
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
@@ -829,7 +807,7 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout),
)
r.raise_for_status()
data = cast(dict[str, Any], r.json())
data = r.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
@@ -837,36 +815,20 @@ class WebSearchTool(Tool):
except Exception as e:
return ToolResult.error(f"Error: Volcengine search failed: {e}")
response_metadata = cast(
dict[str, Any],
data.get("ResponseMetadata") or {},
)
error = (
response_metadata.get("Error")
or data.get("Error")
or data.get("error")
)
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
if error:
if isinstance(error, dict):
error = cast(dict[str, Any], error)
code = error.get("Code") or error.get("code") or "unknown"
message = error.get("Message") or error.get("message") or error
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
return ToolResult.error(f"Error: Volcengine search error: {error}")
result = cast(dict[str, Any], data.get("Result") or data)
web_results = cast(
list[object],
result.get("WebResults")
or result.get("webResults")
or result.get("results")
or [],
)
result = data.get("Result") or data
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
items: list[dict[str, Any]] = []
for item_value in web_results:
if not isinstance(item_value, dict):
for item in web_results:
if not isinstance(item, dict):
continue
item = cast(dict[str, Any], item_value)
meta_parts = [
str(part)
for part in (
@@ -876,7 +838,7 @@ class WebSearchTool(Tool):
)
if part
]
summary = cast(str, (
summary = (
item.get("Summary")
or item.get("summary")
or item.get("Snippet")
@@ -884,7 +846,7 @@ class WebSearchTool(Tool):
or item.get("Content")
or item.get("content")
or ""
))
)
content = "\n".join(part for part in (" | ".join(meta_parts), summary) if part)
items.append(
{
@@ -900,20 +862,18 @@ class WebSearchTool(Tool):
try:
# Note: duckduckgo_search is synchronous and does its own requests
# We run it in a thread to avoid blocking the loop
from ddgs import DDGS # pyright: ignore[reportUnknownVariableType]
from ddgs import DDGS
ddgs_type = cast(Any, DDGS)
ddgs = ddgs_type(timeout=10, proxy=self.proxy)
ddgs = DDGS(timeout=10, proxy=self.proxy)
raw = await asyncio.wait_for(
asyncio.to_thread(ddgs.text, query, max_results=n),
timeout=self.config.timeout,
)
if not raw:
return f"No results for: {query}"
raw_items = cast(list[dict[str, Any]], raw)
items: list[dict[str, Any]] = [
items = [
{"title": r.get("title", ""), "url": r.get("href", ""), "content": r.get("body", "")}
for r in raw_items
for r in raw
]
return _format_results(query, items, n)
except Exception as e:
@@ -948,19 +908,15 @@ class WebSearchTool(Tool):
if r.status_code == 429:
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
r.raise_for_status()
data = cast(dict[str, Any], r.json())
wrapped_data = data.get("data")
result_data = (
cast(dict[str, Any], wrapped_data)
if isinstance(wrapped_data, dict)
else data
data = r.json()
wrapped_data = data.get("data") if isinstance(data, dict) else None
result_data = wrapped_data if isinstance(wrapped_data, dict) else data
web_pages = (
result_data.get("webPages", {}).get("value", [])
if isinstance(result_data, dict)
else []
)
web_pages_data = cast(
dict[str, Any],
result_data.get("webPages", {}),
)
web_pages = cast(list[dict[str, Any]], web_pages_data.get("value", []))
items: list[dict[str, Any]] = [
items = [
{
"title": x.get("name", ""),
"url": x.get("url", ""),
@@ -983,7 +939,7 @@ class WebSearchTool(Tool):
"enum": ["markdown", "text"],
"default": "markdown",
},
maxChars=IntegerSchema(minimum=100),
maxChars=IntegerSchema(0, minimum=100),
required=["url"],
)
)
@@ -991,8 +947,8 @@ class WebFetchTool(Tool):
"""Fetch and extract content from a URL."""
_scopes = {"core", "subagent"}
name = "web_fetch" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
name = "web_fetch"
description = (
"Fetch a URL and extract readable content (HTML → markdown/text). "
"Output is capped at maxChars (default 50 000). "
"Works for most web pages and docs; may fail on login-walled or JS-heavy sites."
@@ -1001,15 +957,15 @@ class WebFetchTool(Tool):
config_key = "web"
@classmethod
def config_cls(cls) -> type[WebToolsConfig]:
def config_cls(cls):
return WebToolsConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
def enabled(cls, ctx: Any) -> bool:
return ctx.config.web.enable
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
def create(cls, ctx: Any) -> Tool:
return cls(
config=ctx.config.web.fetch,
proxy=ctx.config.web.proxy,
@@ -1032,10 +988,10 @@ class WebFetchTool(Tool):
extract_mode: str = "markdown",
max_chars: int | None = None,
**kwargs: Any,
) -> Any: # pyright: ignore[reportIncompatibleMethodOverride]
) -> Any:
url = url.strip(" \t\r\n`\"'")
extract_mode = kwargs.pop("extractMode", extract_mode)
max_chars = cast(int, kwargs.pop("maxChars", max_chars) or self.max_chars)
max_chars = kwargs.pop("maxChars", max_chars) or self.max_chars
is_valid, error_msg = _validate_url_safe(url)
if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
@@ -1164,10 +1120,10 @@ class WebFetchTool(Tool):
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
from readability import Document # pyright: ignore[reportMissingTypeStubs]
from readability import Document
doc = Document(html_content)
summary = cast(str, doc.summary())
summary = doc.summary()
content = self._to_markdown(summary) if extract_mode == "markdown" else _strip_tags(summary)
return f"# {doc.title()}\n\n{content}" if doc.title() else content
-318
View File
@@ -1,318 +0,0 @@
"""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 TYPE_CHECKING, Any, cast
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
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@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(cast(object, 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)
_stream_open: bool = field(init=False, default=False)
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: LLMRuntime) -> 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,
)
)
self._stream_open = True
async def _publish_stream_end(
self,
*,
resuming: bool = False,
merge_next: 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,
merge_next=merge_next,
),
metadata=self.delivery_message.metadata,
)
)
self._stream_open = merge_next
if not merge_next:
self._stream_segment += 1
async def abort_stream(self) -> None:
"""Close an interrupted stream so stateful channels can release its buffer."""
if self._stream_open:
await self._publish_stream_end()
-2
View File
@@ -39,7 +39,6 @@ class AgentTurnHookSpec:
turn_hooks: list[AgentHook] = field(default_factory=list)
ephemeral: bool = False
run_extra_hooks_for_ephemeral: bool = False
attributes: dict[str, Any] | None = None
def build_agent_turn_hook(spec: AgentTurnHookSpec) -> AgentHook:
@@ -63,7 +62,6 @@ def build_agent_turn_hook(spec: AgentTurnHookSpec) -> AgentHook:
message_id=spec.message_id,
session_key=spec.session_key,
metadata=dict(spec.metadata or {}),
attributes=dict(spec.attributes or {}),
ephemeral=spec.ephemeral,
)
hook_chain: list[AgentHook] = [progress_hook]
+1 -1
View File
@@ -35,7 +35,7 @@ def api_runtime_paths(config_path: Path) -> ProcessRuntimePaths:
)
class ApiRuntime(ManagedProcessRuntime[ApiStartOptions]):
class ApiRuntime(ManagedProcessRuntime):
"""Manage a WebUI-controlled OpenAI-compatible API process."""
service_name = "api"
+37 -66
View File
@@ -12,7 +12,7 @@ import hmac
import json as _json
import time
import uuid
from typing import TYPE_CHECKING, Any, Awaitable, Callable, cast
from typing import Any
from aiohttp import web
from loguru import logger
@@ -30,9 +30,6 @@ from nanobot.utils.media_decode import (
)
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
__all__ = (
"MAX_FILE_SIZE",
"_FileSizeExceeded",
@@ -47,7 +44,7 @@ API_CHAT_ID = "default"
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
_SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
_SESSION_LOCKS_KEY = web.AppKey[dict]("session_locks")
_MISSING = object()
@@ -114,26 +111,6 @@ def _response_text(value: Any) -> str:
return str(getattr(value, "content") or "")
return str(value)
def _as_str(value: object) -> str:
"""Return *value* when it is text, otherwise an empty string."""
return value if isinstance(value, str) else ""
def _require_json_object(value: object, field: str) -> dict[str, Any]:
"""Validate an object-valued field from an untrusted JSON request."""
if not isinstance(value, dict):
raise TypeError(f"{field} must be an object")
return cast(dict[str, Any], value)
def _require_json_string(value: object, field: str) -> str:
"""Validate a string-valued field from an untrusted JSON request."""
if not isinstance(value, str):
raise TypeError(f"{field} must be a string")
return value
# ---------------------------------------------------------------------------
# SSE helpers
# ---------------------------------------------------------------------------
@@ -164,19 +141,13 @@ _SSE_DONE = b"data: [DONE]\n\n"
# ---------------------------------------------------------------------------
def _parse_json_content(body: dict[str, Any]) -> tuple[str, list[str]]:
def _parse_json_content(body: dict) -> tuple[str, list[str]]:
"""Parse JSON request body. Returns (text, media_paths)."""
messages_value = cast(object, body.get("messages"))
if not isinstance(messages_value, list):
messages = body.get("messages")
if not isinstance(messages, list) or len(messages) != 1:
raise ValueError("Only a single user message is supported")
messages = cast(list[object], messages_value)
if len(messages) != 1:
raise ValueError("Only a single user message is supported")
message_value: object = messages[0]
if not isinstance(message_value, dict):
raise ValueError("Only a single user message is supported")
message = cast(dict[str, Any], message_value)
if message.get("role") != "user":
message = messages[0]
if not isinstance(message, dict) or message.get("role") != "user":
raise ValueError("Only a single user message is supported")
user_content = message.get("content", "")
@@ -185,26 +156,13 @@ def _parse_json_content(body: dict[str, Any]) -> tuple[str, list[str]]:
if isinstance(user_content, list):
text_parts: list[str] = []
for part_value in cast(list[object], user_content):
if not isinstance(part_value, dict):
for part in user_content:
if not isinstance(part, dict):
continue
part = cast(dict[str, Any], part_value)
if part.get("type") == "text":
text_parts.append(
_require_json_string(
cast(object, part.get("text", "")),
"messages[0].content[].text",
)
)
text_parts.append(part.get("text", ""))
elif part.get("type") == "image_url":
image_url = _require_json_object(
cast(object, part.get("image_url", {})),
"messages[0].content[].image_url",
)
url = _require_json_string(
cast(object, image_url.get("url", "")),
"messages[0].content[].image_url.url",
)
url = part.get("image_url", {}).get("url", "")
if url.startswith("data:"):
saved = _save_base64_data_url(url, media_dir)
if saved:
@@ -233,7 +191,7 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
media_paths: list[str] = []
while True:
part: Any = await reader.next()
part = await reader.next()
if part is None:
break
if part.name == "message":
@@ -265,9 +223,11 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
# ---------------------------------------------------------------------------
async def handle_chat_completions(request: web.Request) -> web.Response | web.StreamResponse:
async def handle_chat_completions(request: web.Request) -> web.Response:
"""POST /v1/chat/completions — supports JSON and multipart/form-data."""
content_type = _as_str(cast(object, request.content_type or ""))
content_type = request.content_type or ""
if not isinstance(content_type, str):
content_type = ""
agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
timeout_s: float = _app_value(
@@ -287,9 +247,6 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
body = await request.json()
except Exception:
return _error_json(400, "Invalid JSON body")
if not isinstance(body, dict):
return _error_json(400, "Invalid JSON body")
body = cast(dict[str, Any], body)
stream = body.get("stream", False)
requested_model = body.get("model")
text, media_paths = _parse_json_content(body)
@@ -387,6 +344,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
return resp
# -- non-streaming path (original logic) --
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
try:
async with session_lock:
try:
@@ -401,9 +360,24 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
timeout=timeout_s,
)
response_text = _response_text(response)
if not response_text or not response_text.strip():
logger.warning("Empty response for session {}, using fallback", session_key)
response_text = EMPTY_FINAL_RESPONSE_MESSAGE
logger.warning("Empty response for session {}, retrying", session_key)
retry_response = await asyncio.wait_for(
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:
return _error_json(504, f"Request timed out after {timeout_s}s")
@@ -448,7 +422,7 @@ async def handle_health(request: web.Request) -> web.Response:
def create_app(
agent_loop: "AgentLoop",
agent_loop,
model_name: str = "nanobot",
request_timeout: float = 120.0,
api_key: str = "",
@@ -468,10 +442,7 @@ def create_app(
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
@web.middleware
async def auth_middleware(
request: web.Request,
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
) -> web.StreamResponse:
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
# Allow unauthenticated health checks.
if request.path == "/health":
return await handler(request)
+23 -35
View File
@@ -10,11 +10,10 @@ import shutil
import subprocess
import sys
import time
from collections.abc import Iterable
from dataclasses import dataclass
from importlib import metadata as importlib_metadata
from pathlib import Path
from typing import Any, cast
from typing import Any
from urllib.parse import urlparse
import httpx
@@ -205,11 +204,6 @@ def _now() -> float:
return time.time()
def _as_object_dict(value: object) -> dict[str, Any] | None:
"""Narrow a JSON-like object to the string-keyed mapping used by this module."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}"
@@ -283,11 +277,10 @@ def _console_script_distribution(entry_point: str) -> str | None:
if item.group != "console_scripts" or item.name != entry_point:
continue
try:
name: object = cast(Any, distribution.metadata).get("Name")
name = distribution.metadata.get("Name")
except Exception:
name = None
fallback_name = cast(object, getattr(distribution, "name", ""))
return str(name or fallback_name or "").strip() or None
return str(name or getattr(distribution, "name", "") or "").strip() or None
return None
@@ -342,10 +335,10 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data: object = json.loads(path.read_text(encoding="utf-8"))
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return _as_object_dict(data)
return data if isinstance(data, dict) else None
def _write_json(path: Path, data: dict[str, Any]) -> None:
@@ -421,8 +414,8 @@ class CliAppManager:
cached = _read_json(cache_path)
if not cached:
return None, 0.0
data = _as_object_dict(cached.get("data"))
if data is None:
data = cached.get("data")
if not isinstance(data, dict):
return None, 0.0
try:
cached_at = float(cached.get("_cached_at", 0))
@@ -432,8 +425,8 @@ class CliAppManager:
def _load_installed(self) -> dict[str, Any]:
data = _read_json(self.installed_path) or {}
apps = _as_object_dict(data.get("apps"))
return apps if apps is not None else data
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
return apps if isinstance(apps, dict) else {}
def _save_installed(self, installed: dict[str, Any]) -> None:
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
@@ -460,8 +453,8 @@ class CliAppManager:
try:
response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status()
fetched = _as_object_dict(response.json())
if fetched is None:
fetched = response.json()
if not isinstance(fetched, dict):
raise ValueError("registry response must be an object")
except Exception:
if data is not None:
@@ -490,8 +483,8 @@ class CliAppManager:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
fetched = _as_object_dict(response.json())
if fetched is None:
fetched = response.json()
if not isinstance(fetched, dict):
raise ValueError("registry response must be an object")
except Exception:
if data is not None:
@@ -541,14 +534,13 @@ class CliAppManager:
apps_by_name: dict[str, dict[str, Any]] = {}
updated_values: list[str] = []
for source, raw_base, registry in registries:
meta = _as_object_dict(registry.get("meta"))
if meta is not None and isinstance(meta.get("updated"), str):
meta = registry.get("meta")
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
updated_values.append(meta["updated"])
for row in cast(Iterable[object], registry.get("clis", [])):
entry = _as_object_dict(row)
if entry is None or not entry.get("name"):
for row in registry.get("clis", []):
if not isinstance(row, dict) or not row.get("name"):
continue
entry = dict(entry)
entry = dict(row)
entry["_source"] = source
entry["_raw_base"] = raw_base
key = str(entry["name"]).lower()
@@ -596,7 +588,7 @@ class CliAppManager:
if not installed:
return []
installed_by_name = {
str(name).lower(): (str(name), _as_object_dict(data) or {})
str(name).lower(): (str(name), data if isinstance(data, dict) else {})
for name, data in installed.items()
}
seen: set[str] = set()
@@ -777,14 +769,12 @@ class CliAppManager:
for app in cached_apps
if app.get("name")
}
rows: list[dict[str, Any]] = []
rows = []
for name, raw_entry in sorted(installed.items()):
entry = _as_object_dict(raw_entry)
if entry is None:
entry = {}
entry = raw_entry if isinstance(raw_entry, dict) else {}
strategy = str(entry.get("strategy") or "bundled")
cached_app = cached_by_name.get(str(name).lower(), {})
app: dict[str, Any] = {
app = {
"name": str(name),
"display_name": str(
cached_app.get("display_name") or entry.get("display_name") or name
@@ -1175,9 +1165,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
if str(app["name"]) not in installed:
raise CliAppError("CLI app is not installed")
raw_installed_entry = installed.get(str(app["name"]))
installed_entry = _as_object_dict(raw_installed_entry)
if installed_entry is None:
installed_entry = {}
installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {}
strategy = self._strategy(app)
entry_point = str(app.get("entry_point") or "").strip()
managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip()
+4 -9
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping, cast
from typing import Any, Mapping
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -29,11 +29,9 @@ def runtime_lines_for_request(
"""Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
structured_items = cast(list[Any], structured)
mentions = [
cast(Mapping[str, Any], item) for item in structured_items
if isinstance(item, Mapping)
and isinstance(cast(Mapping[str, Any], item).get("name"), str)
item for item in structured
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
]
if mentions:
return [
@@ -51,10 +49,7 @@ def runtime_lines_for_request(
try:
from nanobot.apps.cli import CliAppManager
mentions = cast(
list[dict[str, Any]],
CliAppManager(workspace=workspace).mentioned_installed_apps(text),
)
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
except Exception:
return []
return [
+8 -17
View File
@@ -20,9 +20,7 @@ from nanobot.audio.transcription_registry import (
get_transcription_provider,
resolve_transcription_provider,
)
from nanobot.config.loader import resolve_env_refs
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Config, ProviderConfig
from nanobot.providers.registry import find_by_name
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
@@ -74,9 +72,8 @@ def _as_provider(value: Any) -> TranscriptionProviderName | None:
return spec.name if spec else None
def _provider_config(config: Config, provider: str) -> ProviderConfig | None:
value = getattr(config.providers, provider, None)
return value if isinstance(value, ProviderConfig) else None
def _provider_config(config: Any, provider: str) -> Any:
return getattr(getattr(config, "providers", None), provider, None)
def _provider_default_api_base(provider: str) -> str | None:
@@ -84,11 +81,8 @@ def _provider_default_api_base(provider: str) -> str | None:
return spec.default_api_base if spec else None
def _resolve_transcription_api_key(
provider: str,
provider_cfg: ProviderConfig | None,
) -> str:
api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else ""
def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
api_key = getattr(provider_cfg, "api_key", None) if provider_cfg else None
if api_key:
return api_key
@@ -99,14 +93,11 @@ def _resolve_transcription_api_key(
return env_key
env_key = spec.env_key if spec else ""
return os.environ.get(env_key, "") if env_key else ""
return os.environ.get(env_key) if env_key else ""
def _resolve_transcription_api_base(
provider: str,
provider_cfg: ProviderConfig | None,
) -> str:
api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else ""
def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str:
api_base = getattr(provider_cfg, "api_base", None) if provider_cfg else None
if api_base:
return api_base
return _provider_default_api_base(provider) or ""
@@ -119,7 +110,7 @@ def _extract_data_url_mime(url: str) -> str | None:
return header[5:].split(";", 1)[0].strip().lower() or None
def resolve_transcription_config(config: Config) -> EffectiveTranscriptionConfig:
def resolve_transcription_config(config: Any) -> EffectiveTranscriptionConfig:
"""Resolve top-level transcription settings with legacy channel fallback."""
top = getattr(config, "transcription", None)
channels = getattr(config, "channels", None)
-1
View File
@@ -17,7 +17,6 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
@dataclass
+5 -22
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import Any, cast
from typing import Any
from nanobot.bus.events import OutboundMessage
@@ -46,7 +46,6 @@ class StreamEndEvent(OutboundEvent):
content: str = ""
stream_id: str | None = None
resuming: bool = False
merge_next: bool = False
@dataclass(frozen=True)
@@ -82,13 +81,6 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
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(
*,
channel: str,
@@ -153,11 +145,7 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
)
if meta.get("_goal_state_sync"):
goal_state = meta.get("goal_state")
return GoalStateSyncEvent(
cast(dict[str, Any], goal_state)
if isinstance(goal_state, dict)
else {"active": False}
)
return GoalStateSyncEvent(goal_state if isinstance(goal_state, dict) else {"active": False})
if meta.get("_goal_status"):
status = meta.get("goal_status")
if not isinstance(status, str) or not status:
@@ -170,7 +158,7 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
goal_state = meta.get("goal_state")
return TurnEndEvent(
latency_ms=_metadata_int(meta, "latency_ms"),
goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
goal_state=goal_state if isinstance(goal_state, dict) else None,
)
if meta.get("_session_updated"):
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
@@ -181,7 +169,6 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
content=msg.content,
stream_id=_metadata_str(meta, "_stream_id"),
resuming=bool(meta.get("_resuming")),
merge_next=bool(meta.get("_merge_next")),
)
if meta.get("_stream_delta"):
return StreamDeltaEvent(
@@ -207,12 +194,8 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
reasoning_delta=bool(meta.get("_reasoning_delta")),
reasoning_end=bool(meta.get("_reasoning_end")),
stream_id=_metadata_str(meta, "_stream_id"),
tool_events=cast(list[dict[str, Any]], tool_events)
if isinstance(tool_events, list)
else None,
file_edit_events=cast(list[dict[str, Any]], file_edit_events)
if isinstance(file_edit_events, list)
else None,
tool_events=tool_events if isinstance(tool_events, list) else None,
file_edit_events=file_edit_events if isinstance(file_edit_events, list) else None,
)
return None
+20 -43
View File
@@ -12,15 +12,12 @@ import contextlib
import inspect
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from typing import Any
from loguru import logger
from nanobot.bus.events import InboundMessage
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@dataclass(frozen=True)
class RuntimeEventContext:
@@ -30,7 +27,6 @@ class RuntimeEventContext:
chat_id: str
session_key: str
metadata: dict[str, Any] = field(default_factory=dict)
attributes: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -55,16 +51,7 @@ class TurnCompleted:
context: RuntimeEventContext
latency_ms: int | None = None
runtime: LLMRuntime | None = None
@dataclass(frozen=True)
class SessionTurnPersisted:
"""A completed turn has been written to local session storage."""
context: RuntimeEventContext
turn_id: str
sender_id: str
runtime: Any | None = None
@dataclass(frozen=True)
@@ -85,7 +72,6 @@ class RuntimeModelChanged:
RuntimeEvent = (
SessionTurnStarted
| SessionTurnPersisted
| TurnRunStatusChanged
| TurnCompleted
| GoalStateChanged
@@ -93,7 +79,6 @@ RuntimeEvent = (
)
RuntimeEventType = (
type[SessionTurnStarted]
| type[SessionTurnPersisted]
| type[TurnRunStatusChanged]
| type[TurnCompleted]
| type[GoalStateChanged]
@@ -158,7 +143,7 @@ class RuntimeEventPublisher:
def __init__(self, bus: RuntimeEventBus | None = None) -> None:
self.bus = bus or RuntimeEventBus()
self._turn_latency_ms: dict[str, int] = {}
self._turn_runtime: dict[str, LLMRuntime] = {}
self._turn_runtime: dict[str, Any] = {}
@staticmethod
def _context(
@@ -167,17 +152,15 @@ class RuntimeEventPublisher:
chat_id: str,
session_key: str,
metadata: dict[str, Any] | None,
attributes: dict[str, Any] | None = None,
) -> RuntimeEventContext:
return RuntimeEventContext(
channel=channel,
chat_id=chat_id,
session_key=session_key,
metadata=dict(metadata or {}),
attributes=dict(attributes or {}),
)
def record_turn_runtime(self, session_key: str, runtime: LLMRuntime) -> None:
def record_turn_runtime(self, session_key: str, runtime: Any) -> None:
self._turn_runtime[session_key] = runtime
def record_turn_latency(self, session_key: str, latency_ms: int | None) -> None:
@@ -225,28 +208,6 @@ class RuntimeEventPublisher:
)
)
async def session_turn_persisted(
self,
msg: InboundMessage,
session_key: str,
*,
turn_id: str,
attributes: dict[str, Any] | None = None,
) -> None:
await self.bus.publish(
SessionTurnPersisted(
context=self._context(
channel=msg.channel,
chat_id=msg.chat_id,
session_key=session_key,
metadata=msg.metadata,
attributes=attributes,
),
turn_id=turn_id,
sender_id=msg.sender_id,
)
)
async def turn_completed(
self,
*,
@@ -272,3 +233,19 @@ class RuntimeEventPublisher:
self.bus.publish_nowait(
RuntimeModelChanged(model=model, model_preset=model_preset)
)
def ensure_runtime_event_publisher(owner: Any) -> RuntimeEventPublisher:
"""Return an owner's runtime publisher, creating missing state lazily."""
publisher = getattr(owner, "runtime_event_publisher", None)
if isinstance(publisher, RuntimeEventPublisher):
return publisher
bus = getattr(owner, "runtime_events", None)
if not isinstance(bus, RuntimeEventBus):
bus = RuntimeEventBus()
owner.runtime_events = bus
publisher = RuntimeEventPublisher(bus)
owner.runtime_event_publisher = publisher
return publisher
+4 -16
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, cast
from typing import Any
from loguru import logger
@@ -29,7 +29,7 @@ class BaseChannel(ABC):
name: str = "base"
display_name: str = "Base"
send_progress: bool = True
send_tool_hints: bool = True
send_tool_hints: bool = False
show_reasoning: bool = True
def __init__(self, config: Any, bus: MessageBus):
@@ -110,7 +110,6 @@ class BaseChannel(ABC):
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
merge_next: bool = False,
) -> None:
"""Deliver a streaming text chunk.
@@ -119,9 +118,6 @@ class BaseChannel(ABC):
Stateful implementations should key buffers by ``stream_id`` rather
than only by ``chat_id`` when it is provided.
``merge_next`` marks a resumable provider boundary whose next text
segment belongs to the same user-visible message.
"""
pass
@@ -201,21 +197,13 @@ class BaseChannel(ABC):
def supports_streaming(self) -> bool:
"""True when config enables streaming AND this subclass implements send_delta."""
cfg = self.config
config_mapping = cast(dict[str, Any], cfg) if isinstance(cfg, dict) else None
streaming: Any = (
config_mapping.get("streaming", False)
if config_mapping is not None
else getattr(cast(Any, cfg), "streaming", False)
)
streaming = cfg.get("streaming", False) if isinstance(cfg, dict) else getattr(cfg, "streaming", False)
return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta
def is_allowed(self, sender_id: str) -> bool:
"""Check sender permission: star > allowlist > pairing store > deny."""
if isinstance(self.config, dict):
config_mapping = cast(dict[str, Any], self.config)
allow_list: Any = (
config_mapping.get("allow_from") or config_mapping.get("allowFrom") or []
)
allow_list = self.config.get("allow_from") or self.config.get("allowFrom") or []
else:
allow_list = getattr(self.config, "allow_from", None) or []
if "*" in allow_list:
+33 -62
View File
@@ -6,7 +6,7 @@ from collections.abc import Iterable
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeGuard, cast
from typing import TYPE_CHECKING, Any, Callable, Literal
if TYPE_CHECKING:
from nanobot.channels.plugin import ChannelPlugin
@@ -22,8 +22,6 @@ class ChannelValidationContext:
allow_local_service_access: bool = False
# Keep callback contracts precise for static consumers. The public adapters below
# still validate third-party implementations at runtime.
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
DefaultConfigFactory = Callable[[], dict[str, Any]]
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
@@ -89,7 +87,7 @@ class ChannelActivation:
instances = (
tuple(
cls.from_config(item, include_instances=True)
for item in cast(list[Any], raw_instances)
for item in raw_instances
if _config_mapping(item) is not None
)
if isinstance(raw_instances, list)
@@ -195,7 +193,7 @@ class ChannelSetupSpec:
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
"""Serialize the writable setup contract for generic WebUI consumers."""
simple_required = set(self.simple_required_fields)
fields: list[dict[str, Any]] = []
fields = []
for name, field in self.fields.items():
if not field.writable:
continue
@@ -270,37 +268,35 @@ def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
if plugin.setup is not None:
for name, field in plugin.setup.fields.items():
value: Any = field.default
value = field.default
if value is None:
fallback_defaults: dict[str, Any] = {
value = {
"string": "",
"secret": "",
"list": [],
"bool": False,
}
value = fallback_defaults.get(field.kind, _MISSING)
}.get(field.kind, _MISSING)
if value is not _MISSING:
_assign_channel_field(defaults, name, deepcopy(value))
factory = plugin.management.default_config
if factory is None:
return defaults
values_raw = cast(object, factory())
if not isinstance(values_raw, dict):
values = factory()
if not isinstance(values, dict):
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
values = cast(dict[str, Any], values_raw)
return cast(dict[str, Any], merge_missing_defaults(values, defaults))
return merge_missing_defaults(values, defaults)
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
target = values
parts = field.split(".")
for part in parts[:-1]:
nested: object = target.get(part)
nested = target.get(part)
if not isinstance(nested, dict):
nested = {}
target[part] = nested
target = cast(dict[str, Any], nested)
target = nested
target[parts[-1]] = value
@@ -331,28 +327,27 @@ def channel_instance_specs(
factory = plugin.management.instance_specs
if factory is None:
activation = ChannelActivation.from_config(section)
raw_specs: object = (
raw_specs: Iterable[ChannelInstanceSpec] = (
[]
if enabled_only and not activation.resolve(default=plugin.default_enabled)
else [ChannelInstanceSpec(instance_id="default", config=section)]
)
else:
raw_specs = cast(object, factory(section, enabled_only=enabled_only))
raw_specs = factory(section, enabled_only=enabled_only)
if not isinstance(raw_specs, Iterable):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
)
specs = list(cast(Iterable[object], raw_specs))
if not _all_channel_instance_specs(specs):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
)
specs = list(raw_specs)
instance_ids: set[str] = set()
runtime_names: set[str] = set()
for spec in specs:
instance_id = cast(object, spec.instance_id)
if not isinstance(instance_id, str) or not instance_id.strip():
if not isinstance(spec, ChannelInstanceSpec):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
)
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
)
@@ -372,12 +367,6 @@ def channel_instance_specs(
return specs
def _all_channel_instance_specs(
values: list[object],
) -> TypeGuard[list[ChannelInstanceSpec]]:
return all(isinstance(value, ChannelInstanceSpec) for value in values)
def resolve_channel_action_target(
requested_instance_id: str | None,
) -> str:
@@ -404,17 +393,8 @@ def channel_instance_config(
return {}
config = selected.config
if hasattr(config, "model_dump"):
dumped: dict[str, Any] = config.model_dump(mode="json", by_alias=True)
copied: dict[str, Any] = {}
for key in dumped:
copied[key] = dumped[key]
return copied
if not isinstance(config, dict):
return {}
copied_config: dict[str, Any] = {}
for key, value in cast(dict[object, Any], config).items():
copied_config[cast(str, key)] = value
return copied_config
return dict(config.model_dump(mode="json", by_alias=True))
return dict(config) if isinstance(config, dict) else {}
def channel_update_instance_config(
@@ -429,10 +409,7 @@ def channel_update_instance_config(
if instance_id not in {"", "default"}:
raise ValueError(f"{plugin.name} does not support multiple instances")
return values
updated = cast(object, updater(section, values, instance_id=instance_id))
if not isinstance(updated, dict):
raise TypeError(f"ChannelPlugin.management.update_instance_config for '{plugin.name}' must return a dict")
return cast(dict[str, Any], updated)
return updater(section, values, instance_id=instance_id)
def channel_set_config_enabled(
@@ -446,7 +423,7 @@ def channel_set_config_enabled(
from nanobot.config.loader import merge_missing_defaults
values = channel_instance_config(plugin, section, instance_id=instance_id)
values = cast(dict[str, Any], merge_missing_defaults(values, channel_default_config(plugin)))
values = merge_missing_defaults(values, channel_default_config(plugin))
values["enabled"] = enabled
return channel_update_instance_config(
plugin,
@@ -463,16 +440,12 @@ def channel_feature_instances(
setup_spec: ChannelSetupSpec | None = None,
) -> list[dict[str, Any]] | None:
factory = plugin.management.feature_instances
overrides = (
cast(object, factory(section, setup_spec=setup_spec))
if factory is not None
else None
)
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None
if overrides is None and not plugin.management.multi_instance:
return None
if overrides is not None and (
not isinstance(overrides, list)
or any(not isinstance(instance, dict) for instance in cast(list[object], overrides))
or any(not isinstance(instance, dict) for instance in overrides)
):
raise TypeError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
@@ -497,8 +470,7 @@ def channel_feature_instances(
by_id = {instance["id"]: instance for instance in instances}
seen: set[str] = set()
for override_value in cast(list[object], overrides):
override = cast(dict[str, Any], override_value)
for override in overrides:
instance_id = override.get("id")
if not isinstance(instance_id, str) or instance_id not in by_id:
raise ValueError(
@@ -542,21 +514,20 @@ def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
def channel_field_value(values: Any, field_path: str) -> Any:
current: Any = values
current = values
for part in field_path.split("."):
candidates = (part, _camel_to_snake(part))
if isinstance(current, dict):
for candidate in candidates:
if candidate in current:
current = cast(Any, current)[candidate]
current = current[candidate]
break
else:
return None
continue
for candidate in candidates:
current_value = current
if hasattr(current_value, candidate):
current = getattr(current_value, candidate)
if hasattr(current, candidate):
current = getattr(current, candidate)
break
else:
return None
@@ -571,7 +542,7 @@ def stringify_channel_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
return ", ".join(str(item) for item in cast(list[Any], value))
return ", ".join(str(item) for item in value)
return str(value)
@@ -615,8 +586,8 @@ def _channel_feature_instance(
def _config_mapping(value: Any) -> dict[str, Any] | None:
if hasattr(value, "model_dump"):
dumped = value.model_dump(mode="json", by_alias=True)
return cast(dict[str, Any], dumped) if isinstance(dumped, dict) else None
return cast(dict[str, Any], value) if isinstance(value, dict) else None
return dumped if isinstance(dumped, dict) else None
return value if isinstance(value, dict) else None
def _camel_to_snake(value: str) -> str:
+37 -114
View File
@@ -1,4 +1,3 @@
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false
"""DingTalk/DingDing channel implementation using Stream Mode."""
import asyncio
@@ -11,7 +10,7 @@ from contextlib import suppress
from inspect import isawaitable
from io import BytesIO
from pathlib import Path
from typing import Any, cast
from typing import Any
from urllib.parse import unquote, urljoin, urlparse
import httpx
@@ -25,29 +24,12 @@ from nanobot.security.network import validate_resolved_url, validate_url_target
DINGTALK_MAX_REMOTE_MEDIA_BYTES = 20 * 1024 * 1024
DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS = 3
_DINGTALK_MARKDOWN_INLINE_SPECIALS = frozenset(r"\`*_{}[]()<>#+-.!|~")
_DINGTALK_SENDER_NAME_MAX_CHARS = 80
def _escape_markdown_sender_name(value: str) -> str:
"""Render an untrusted display name as one bounded Markdown-safe line."""
normalized = " ".join(value.split())[:_DINGTALK_SENDER_NAME_MAX_CHARS]
return "".join(
f"\\{char}" if char in _DINGTALK_MARKDOWN_INLINE_SPECIALS else char
for char in normalized
)
DINGTALK_AVAILABLE = False
AckMessage: Any = None
CallbackHandler: Any = object
Credential: Any = None
DingTalkStreamClient: Any = None
ChatbotMessage: Any = None
try:
from dingtalk_stream import (
AckMessage,
CallbackHandler,
CallbackMessage,
Credential,
DingTalkStreamClient,
)
@@ -55,41 +37,41 @@ try:
DINGTALK_AVAILABLE = True
except ImportError:
pass
DINGTALK_AVAILABLE = False
# Fallback so class definitions don't crash at module level
CallbackHandler = object # type: ignore[assignment,misc]
CallbackMessage = None # type: ignore[assignment,misc]
AckMessage = None # type: ignore[assignment,misc]
ChatbotMessage = None # type: ignore[assignment,misc]
_CallbackHandlerBase = CallbackHandler
class NanobotDingTalkHandler(_CallbackHandlerBase):
class NanobotDingTalkHandler(CallbackHandler):
"""
Standard DingTalk Stream SDK Callback Handler.
Parses incoming messages and forwards them to the Nanobot channel.
"""
def __init__(self, channel: "DingTalkChannel"):
super().__init__() # pyright: ignore[reportUnknownMemberType]
super().__init__()
self.channel = channel
async def process(self, message: Any) -> tuple[Any, str]:
async def process(self, message: CallbackMessage):
"""Process incoming stream message."""
try:
# Parse using SDK's ChatbotMessage for robust handling
chatbot_msg: Any = ChatbotMessage.from_dict(message.data)
message_data = cast(dict[str, Any], message.data)
chatbot_msg = ChatbotMessage.from_dict(message.data)
# Extract text content; fall back to raw dict if SDK object is empty
content = ""
if chatbot_msg.text:
content = cast(str, chatbot_msg.text.content).strip()
content = chatbot_msg.text.content.strip()
elif chatbot_msg.extensions.get("content", {}).get("recognition"):
content = cast(str, chatbot_msg.extensions["content"]["recognition"]).strip()
content = chatbot_msg.extensions["content"]["recognition"].strip()
if not content:
text_data = cast(dict[str, Any], message_data.get("text", {}))
content = cast(str, text_data.get("content", "")).strip()
content = message.data.get("text", {}).get("content", "").strip()
# Handle file/image messages
file_paths: list[str] = []
file_paths = []
if chatbot_msg.message_type == "picture" and chatbot_msg.image_content:
download_code = chatbot_msg.image_content.download_code
if download_code:
@@ -100,18 +82,8 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
content = content or "[Image]"
elif chatbot_msg.message_type == "file":
message_content = cast(dict[str, Any], message_data.get("content", {}))
download_code = cast(
str,
message_content.get("downloadCode")
or message_data.get("downloadCode"),
)
fname = cast(
str,
message_content.get("fileName")
or message_data.get("fileName")
or "file",
)
download_code = message.data.get("content", {}).get("downloadCode") or message.data.get("downloadCode")
fname = message.data.get("content", {}).get("fileName") or message.data.get("fileName") or "file"
if download_code:
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
fp = await self.channel._download_dingtalk_file(download_code, fname, sender_uid)
@@ -120,17 +92,13 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
content = content or "[File]"
elif chatbot_msg.message_type == "richText" and chatbot_msg.rich_text_content:
rich_list = cast(
list[object],
chatbot_msg.rich_text_content.rich_text_list or [],
)
for item_value in rich_list:
if not isinstance(item_value, dict):
rich_list = chatbot_msg.rich_text_content.rich_text_list or []
for item in rich_list:
if not isinstance(item, dict):
continue
item = cast(dict[str, Any], item_value)
# A rich-text item may carry text and/or a downloadCode; the
# DingTalk SDK treats them independently, so handle both.
t = cast(str, item.get("text", "")).strip()
t = item.get("text", "").strip()
if t:
fmt = item.get("type", "")
if fmt == "bold":
@@ -145,8 +113,8 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
formatted = t
content = (content + " " + formatted).strip() if content else formatted
if item.get("downloadCode"):
dc = cast(str, item["downloadCode"])
fname = cast(str, item.get("fileName") or "file")
dc = item["downloadCode"]
fname = item.get("fileName") or "file"
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
fp = await self.channel._download_dingtalk_file(dc, fname, sender_uid)
if fp:
@@ -164,22 +132,13 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
)
return AckMessage.STATUS_OK, "OK"
sender_id = cast(
str | None,
chatbot_msg.sender_staff_id or chatbot_msg.sender_id,
)
sender_name = cast(str, chatbot_msg.sender_nick or "Unknown")
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
sender_name = chatbot_msg.sender_nick or "Unknown"
conversation_type = cast(
str | None,
message_data.get("conversationType"),
)
conversation_type = message.data.get("conversationType")
conversation_id = (
cast(
str | None,
message_data.get("conversationId")
or message_data.get("openConversationId"),
)
message.data.get("conversationId")
or message.data.get("openConversationId")
)
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
@@ -216,7 +175,6 @@ class DingTalkConfig(Base):
allow_remote_media_redirects: bool = False
remote_media_redirect_allowed_hosts: list[str] = Field(default_factory=list)
group_user_isolation: bool = False # If True, each user in group chat gets their own session
disable_private_chat: bool = False # If True, reject 1:1 DMs with a notice; group chats only
class DingTalkChannel(BaseChannel):
@@ -248,14 +206,14 @@ class DingTalkChannel(BaseChannel):
self.config: DingTalkConfig = config
self._client: Any = None
self._http: httpx.AsyncClient | None = None
self._start_task: asyncio.Task[Any] | None = None
self._start_task: asyncio.Task | None = None
# Access Token management for sending messages
self._access_token: str | None = None
self._token_expiry: float = 0
# Hold references to background tasks to prevent GC
self._background_tasks: set[asyncio.Task[None]] = set()
self._background_tasks: set[asyncio.Task] = set()
async def start(self) -> None:
"""Start the DingTalk bot with Stream Mode."""
@@ -605,11 +563,7 @@ class DingTalkChannel(BaseChannel):
try:
resp = await self._http.post(url, files=files)
text = resp.text
result = (
cast(dict[str, Any], resp.json())
if resp.headers.get("content-type", "").startswith("application/json")
else {}
)
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
if resp.status_code >= 400:
self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
return None
@@ -617,7 +571,7 @@ class DingTalkChannel(BaseChannel):
if errcode != 0:
self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
return None
sub = cast(dict[str, Any], result.get("result") or {})
sub = result.get("result") or {}
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
if not media_id:
self.logger.error("media upload missing media_id body={}", text[:500])
@@ -668,7 +622,7 @@ class DingTalkChannel(BaseChannel):
self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
return False
try:
result = cast(dict[str, Any], resp.json())
result = resp.json()
except Exception:
result = {}
errcode = result.get("errcode")
@@ -758,20 +712,8 @@ class DingTalkChannel(BaseChannel):
if not token:
raise RuntimeError("DingTalk access token unavailable")
content = msg.content.strip() if msg.content else ""
if content:
# In group chats, prefix the reply with a markdown header naming the
# sender so the addressed user can spot the reply. Visual only —
# DingTalk's markdown robot messages do not push real @ notifications.
sender_name = msg.metadata.get("sender_name") if msg.metadata else None
safe_sender_name = (
_escape_markdown_sender_name(sender_name)
if isinstance(sender_name, str)
else ""
)
if msg.chat_id.startswith("group:") and safe_sender_name:
content = f"# @{safe_sender_name}\n\n{content}"
if not await self._send_markdown_text(token, msg.chat_id, content):
if msg.content and msg.content.strip():
if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
raise RuntimeError("DingTalk text message was not delivered")
for media_ref in msg.media or []:
@@ -791,7 +733,7 @@ class DingTalkChannel(BaseChannel):
async def _on_message(
self,
content: str,
sender_id: str | None,
sender_id: str,
sender_name: str,
conversation_type: str | None = None,
conversation_id: str | None = None,
@@ -803,30 +745,11 @@ class DingTalkChannel(BaseChannel):
"""
try:
self.logger.info("inbound: {} from {}", content, sender_name)
if not sender_id:
self.logger.warning("dropping DingTalk message without a sender ID")
return
is_group = conversation_type == "2" and conversation_id
chat_id = f"group:{conversation_id}" if is_group else sender_id
session_key = None
if is_group and self.config.group_user_isolation:
session_key = f"{self.name}:group:{conversation_id}:{sender_id}"
if not is_group and self.config.disable_private_chat:
# Group-only kill switch: drop DMs with a notice *before* any
# allow_from / pairing check, so even allowlisted senders are
# redirected — intentional, this is a hard private-chat guard
# rather than an authorization decision. No session is created.
self.logger.info("private chat disabled; rejecting DM from {}", sender_name)
await self.send(
OutboundMessage(
channel=self.name,
chat_id=chat_id,
content="该机器人未开启私聊,请在群聊中与我对话。",
)
)
return
await self._handle_message(
sender_id=sender_id,
chat_id=chat_id,
@@ -1,5 +1,4 @@
import asyncio
import json
import zipfile
from io import BytesIO
from types import SimpleNamespace
@@ -10,15 +9,15 @@ import pytest
# Check optional dingtalk dependencies before running tests
try:
import nanobot.channels.dingtalk.runtime as dingtalk_module
DINGTALK_AVAILABLE = dingtalk_module.DINGTALK_AVAILABLE
from nanobot.channels import dingtalk
DINGTALK_AVAILABLE = getattr(dingtalk, "DINGTALK_AVAILABLE", False)
except ImportError:
DINGTALK_AVAILABLE = False
if not DINGTALK_AVAILABLE:
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
import nanobot.channels.dingtalk.runtime as dingtalk_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.dingtalk.runtime import (
@@ -154,92 +153,6 @@ async def test_group_user_isolation_true_separates_sessions() -> None:
assert msg1.chat_id == msg2.chat_id == "group:conv123"
def test_disable_private_chat_uses_camel_case_config_key() -> None:
config = DingTalkConfig.model_validate({"disablePrivateChat": True})
assert config.disable_private_chat is True
assert config.model_dump(mode="json", by_alias=True)["disablePrivateChat"] is True
@pytest.mark.asyncio
async def test_dm_rejected_when_private_chat_disabled(monkeypatch) -> None:
"""With disable_private_chat=True, a 1:1 DM is rejected: nothing reaches the
bus (no session is created) and the bot replies with a notice directing the
user to group chat. Even allowlisted senders are blocked in DMs."""
config = DingTalkConfig(
client_id="app",
client_secret="secret",
allow_from=["*"], # even allowlisted senders are blocked in DMs
disable_private_chat=True,
)
bus = MessageBus()
channel = DingTalkChannel(config, bus)
async def fake_get_token():
return "test-token"
monkeypatch.setattr(channel, "_get_access_token", fake_get_token)
channel._http = _FakeHttp()
await channel._on_message(
"hello",
sender_id="user1",
sender_name="Alice",
conversation_type="1",
)
# No inbound message was published -> no session created
assert bus.inbound.empty()
# A notice was sent back to the DM user via the private-chat API
assert len(channel._http.calls) == 1
call = channel._http.calls[0]
assert call["url"] == "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
assert call["json"]["msgKey"] == "sampleMarkdown"
assert call["json"]["userIds"] == ["user1"]
assert "该机器人未开启私聊,请在群聊中与我对话。" in call["json"]["msgParam"]
@pytest.mark.asyncio
async def test_dm_allowed_when_private_chat_not_disabled() -> None:
"""By default (disable_private_chat=False), a 1:1 DM still reaches the bus."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
bus = MessageBus()
channel = DingTalkChannel(config, bus)
await channel._on_message(
"hello",
sender_id="user1",
sender_name="Alice",
conversation_type="1",
)
msg = await bus.consume_inbound()
assert msg.chat_id == "user1"
assert msg.metadata["conversation_type"] == "1"
@pytest.mark.asyncio
async def test_group_message_allowed_when_private_chat_disabled() -> None:
"""Disabling private chat must not affect group messages."""
config = DingTalkConfig(
client_id="app", client_secret="secret", allow_from=["*"], disable_private_chat=True
)
bus = MessageBus()
channel = DingTalkChannel(config, bus)
await channel._on_message(
"hello",
sender_id="user1",
sender_name="Alice",
conversation_type="2",
conversation_id="conv123",
)
msg = await bus.consume_inbound()
assert msg.chat_id == "group:conv123"
@pytest.mark.asyncio
async def test_group_send_uses_group_messages_api() -> None:
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
@@ -260,105 +173,6 @@ async def test_group_send_uses_group_messages_api() -> None:
assert call["json"]["msgKey"] == "sampleMarkdown"
@pytest.mark.asyncio
async def test_group_send_prepends_sender_mention(monkeypatch) -> None:
"""Group replies are prefixed with a markdown header naming the sender."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _FakeHttp()
async def _fake_token() -> str:
return "token"
monkeypatch.setattr(channel, "_get_access_token", _fake_token)
await channel.send(
OutboundMessage(
channel="dingtalk",
chat_id="group:conv123",
content="hello",
metadata={"sender_name": "Alice"},
)
)
sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"]
assert sent_text == "# @Alice\n\nhello"
@pytest.mark.asyncio
async def test_group_send_escapes_untrusted_sender_name(monkeypatch) -> None:
"""A sender nickname cannot inject extra Markdown blocks into the reply."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _FakeHttp()
async def _fake_token() -> str:
return "token"
monkeypatch.setattr(channel, "_get_access_token", _fake_token)
await channel.send(
OutboundMessage(
channel="dingtalk",
chat_id="group:conv123",
content="hello",
metadata={"sender_name": "Alice\n# [click](https://evil) *admin*"},
)
)
sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"]
assert sent_text == r"# @Alice \# \[click\]\(https://evil\) \*admin\*" + "\n\nhello"
@pytest.mark.asyncio
async def test_private_send_does_not_prepend_mention(monkeypatch) -> None:
"""Private replies are sent verbatim, without the sender header."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _FakeHttp()
async def _fake_token() -> str:
return "token"
monkeypatch.setattr(channel, "_get_access_token", _fake_token)
await channel.send(
OutboundMessage(
channel="dingtalk",
chat_id="user1", # private chat: no "group:" prefix
content="hello",
metadata={"sender_name": "Alice"},
)
)
sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"]
assert sent_text == "hello"
@pytest.mark.asyncio
async def test_message_without_sender_id_is_dropped() -> None:
"""Malformed inbound events must not publish or attempt an invalid reply."""
config = DingTalkConfig(
client_id="app",
client_secret="secret",
allow_from=["*"],
disable_private_chat=True,
)
bus = MessageBus()
channel = DingTalkChannel(config, bus)
channel._http = _FakeHttp()
await channel._on_message(
"hello",
sender_id=None,
sender_name="Unknown",
conversation_type="1",
)
assert bus.inbound.empty()
assert channel._http.calls == []
@pytest.mark.asyncio
async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatch) -> None:
bus = MessageBus()
+16 -24
View File
@@ -1,5 +1,4 @@
"""Discord channel implementation using discord.py."""
# pyright: reportPrivateUsage=false, reportUnusedFunction=false
from __future__ import annotations
@@ -9,7 +8,7 @@ import time
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field
@@ -44,7 +43,7 @@ class _StreamBuf:
"""Per-chat streaming accumulator for progressive Discord message edits."""
text: str = ""
message: discord.Message | None = None
message: Any | None = None
last_edit: float = 0.0
stream_id: str | None = None
@@ -267,14 +266,13 @@ if DISCORD_AVAILABLE:
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
raise
messageable_channel = cast(Messageable, channel)
reference, mention_settings = self._build_reply_context(messageable_channel, msg.reply_to)
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
sent_media = False
failed_media: list[str] = []
for index, media_path in enumerate(msg.media or []):
if await self._send_file(
messageable_channel,
channel,
media_path,
reference=reference if index == 0 else None,
mention_settings=mention_settings,
@@ -290,7 +288,7 @@ if DISCORD_AVAILABLE:
if index == 0 and reference is not None and not sent_media:
kwargs["reference"] = reference
kwargs["allowed_mentions"] = mention_settings
await messageable_channel.send(**kwargs)
await channel.send(**kwargs)
async def _send_file(
self,
@@ -346,7 +344,7 @@ if DISCORD_AVAILABLE:
self._channel.logger.warning("Invalid reply target: {}", reply_to)
return None, mention_settings
return cast(Any, channel).get_partial_message(message_id), mention_settings
return channel.get_partial_message(message_id), mention_settings
class DiscordChannel(BaseChannel):
@@ -425,8 +423,8 @@ class DiscordChannel(BaseChannel):
import aiohttp
proxy_auth = aiohttp.BasicAuth(
login=cast(str, self.config.proxy_username),
password=cast(str, self.config.proxy_password),
login=self.config.proxy_username,
password=self.config.proxy_password,
)
elif has_user != has_pass:
self.logger.warning(
@@ -491,7 +489,6 @@ class DiscordChannel(BaseChannel):
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
merge_next: bool = False,
) -> None:
"""Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client
@@ -499,17 +496,13 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping stream delta")
return
if stream_end and merge_next:
if not delta:
return
stream_end = False
if stream_end:
buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text:
return
if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
return
await self._finalize_stream(chat_id, buf, buf.message)
await self._finalize_stream(chat_id, buf)
return
buf = self._stream_bufs.get(chat_id)
@@ -637,12 +630,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("channel {} unavailable: {}", chat_id, e)
return None
async def _finalize_stream(
self,
chat_id: str,
buf: _StreamBuf,
message: discord.Message,
) -> None:
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
"""Commit the final streamed content and flush overflow chunks."""
chunks = DiscordBotClient._build_chunks(buf.text, [], False)
if not chunks:
@@ -650,12 +638,16 @@ class DiscordChannel(BaseChannel):
return
try:
await message.edit(content=chunks[0])
await buf.message.edit(content=chunks[0])
except Exception as e:
self.logger.warning("final stream edit failed: {}", e)
raise
target = message.channel
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
if target is None:
self.logger.warning("stream follow-up target {} unavailable", chat_id)
self._stream_bufs.pop(chat_id, None)
return
for extra_chunk in chunks[1:]:
await target.send(content=extra_chunk)
@@ -754,36 +754,6 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
assert owner._stream_bufs == {}
@pytest.mark.asyncio
async def test_send_delta_merge_next_keeps_one_message(monkeypatch) -> None:
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(owner, intents=None)
owner._client = client
owner._running = True
target = _FakeChannel(channel_id=123)
client.channels[123] = target
times = iter([1.0, 3.0, 5.0])
monkeypatch.setattr("nanobot.channels.discord.runtime.time.monotonic", lambda: next(times, 5.0))
await owner.send_delta(
"123",
"first-",
stream_id="s1",
stream_end=True,
merge_next=True,
)
await owner.send_delta("123", "second", stream_id="s1")
await owner.send_delta("123", "", stream_id="s1", stream_end=True)
assert target.sent_payloads == [{"content": "first-"}]
assert target.sent_messages[0].edits == [
{"content": "first-second"},
{"content": "first-second"},
]
assert owner._stream_bufs == {}
@pytest.mark.asyncio
async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None:
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
+8 -12
View File
@@ -17,7 +17,7 @@ from email.parser import BytesParser
from email.utils import parseaddr
from fnmatch import fnmatch
from pathlib import Path
from typing import Any, Literal, cast
from typing import Any, Literal
from loguru import logger
from pydantic import Field
@@ -188,9 +188,7 @@ class EmailChannel(BaseChannel):
self.logger.exception("Error delivering email from {}", sender)
continue
metadata = item.get("metadata")
metadata_data = cast(dict[str, Any], metadata) if isinstance(metadata, dict) else {}
uid = str(metadata_data.get("uid") or "")
uid = str((item.get("metadata") or {}).get("uid") or "")
if uid and should_apply_post_action:
post_actions_uids.add(uid)
@@ -314,7 +312,7 @@ class EmailChannel(BaseChannel):
raise
def _validate_config(self) -> bool:
missing: list[str] = []
missing = []
if not self.config.imap_host:
missing.append("imap_host")
if not self.config.imap_username:
@@ -429,7 +427,7 @@ class EmailChannel(BaseChannel):
messages: list[dict[str, Any]],
skipped_uids: set[str],
cycle_uids: set[str],
) -> list[dict[str, Any]] | None:
) -> None:
"""Fetch messages by arbitrary IMAP search criteria."""
mailbox = self.config.imap_mailbox or "INBOX"
@@ -767,10 +765,8 @@ class EmailChannel(BaseChannel):
@staticmethod
def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
for item in fetched:
if isinstance(item, tuple):
fetched_item = cast(tuple[Any, ...], item)
if len(fetched_item) >= 2 and isinstance(fetched_item[1], (bytes, bytearray)):
return bytes(fetched_item[1])
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
return bytes(item[1])
return None
@staticmethod
@@ -841,8 +837,8 @@ class EmailChannel(BaseChannel):
"""
spf_pass = False
dkim_pass = False
for ar_header in cast(list[Any], parsed_msg.get_all("Authentication-Results") or []):
ar_lower = str(ar_header).lower()
for ar_header in parsed_msg.get_all("Authentication-Results") or []:
ar_lower = ar_header.lower()
if re.search(r"\bspf\s*=\s*pass\b", ar_lower):
spf_pass = True
if re.search(r"\bdkim\s*=\s*pass\b", ar_lower):
+17 -31
View File
@@ -1,13 +1,10 @@
"""Short-lived WebUI channel connection sessions."""
# pyright: reportPrivateUsage=false
from __future__ import annotations
import asyncio
import json
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Any
@@ -44,7 +41,6 @@ class FeishuConnectStore:
def __init__(self) -> None:
self._sessions: dict[str, FeishuConnectSession] = {}
self._completion_lock = threading.Lock()
async def handle(self, action: str, query: QueryParams) -> dict[str, Any]:
"""Handle one generic settings connection action."""
@@ -62,7 +58,7 @@ class FeishuConnectStore:
if action == "poll":
return await asyncio.to_thread(self.poll, session_id)
if action == "cancel":
return await asyncio.to_thread(self.cancel, session_id)
return self.cancel(session_id)
raise ChannelConnectError(f"unsupported Feishu connect action: {action}", status=404)
def start(
@@ -131,33 +127,24 @@ class FeishuConnectStore:
session.last_error = str(exc)
return _pending_payload(session)
session.domain = str(result.get("domain") or session.domain)
status = result.get("status")
if status == "succeeded":
with self._completion_lock:
if self._sessions.get(session_id) is not session:
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "cancelled",
"message": "Feishu connection cancelled.",
}
session.domain = str(result.get("domain") or session.domain)
session.instance_id = feishu.save_registration_result(
result,
instance_id=session.instance_id,
name=session.instance_name,
)
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "succeeded",
"message": "Feishu is connected.",
"domain": session.domain,
"app_id": result.get("app_id"),
}
session.instance_id = feishu.save_registration_result(
result,
instance_id=session.instance_id,
name=session.instance_name,
)
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "succeeded",
"message": "Feishu is connected.",
"domain": session.domain,
"app_id": result.get("app_id"),
}
session.domain = str(result.get("domain") or session.domain)
if status == "failed":
self._sessions.pop(session_id, None)
return {
@@ -171,8 +158,7 @@ class FeishuConnectStore:
return _pending_payload(session)
def cancel(self, session_id: str) -> dict[str, Any]:
with self._completion_lock:
session = self._sessions.pop(session_id, None)
session = self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
+12 -13
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import re
from typing import Any, cast
from typing import Any
from loguru import logger
@@ -46,7 +46,7 @@ def update_managed_feishu_instance(
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
existing = cast(dict[str, Any], section) if isinstance(section, dict) else {}
existing = section if isinstance(section, dict) else {}
return upsert_feishu_instance(
existing,
feishu_default_config(),
@@ -69,8 +69,8 @@ def _normalize_feishu_instance(
inherited: dict[str, Any] | None = None,
fallback_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
config = cast(dict[str, Any], merge_missing_defaults(inherited or {}, defaults))
config = cast(dict[str, Any], merge_missing_defaults(raw, config))
config = merge_missing_defaults(inherited or {}, defaults)
config = merge_missing_defaults(raw, config)
raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id
instance_id = validate_instance_id(str(raw_id))
@@ -97,13 +97,12 @@ def _feishu_instance_inputs(
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
section = {}
section_data = cast(dict[str, Any], section)
instances = section_data.get("instances")
instances = section.get("instances")
if isinstance(instances, list):
inherited = {key: value for key, value in section_data.items() if key != "instances"}
return list(cast(list[Any], instances)), inherited
return ([section_data] if section_data else [_base_feishu_instance_config(defaults)]), None
inherited = {key: value for key, value in section.items() if key != "instances"}
return list(instances), inherited
return ([section] if section else [_base_feishu_instance_config(defaults)]), None
def feishu_instance_specs(
@@ -125,7 +124,7 @@ def feishu_instance_specs(
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try:
config = _normalize_feishu_instance(
cast(dict[str, Any], raw),
raw,
defaults,
inherited=inherited,
fallback_id=fallback_id,
@@ -180,7 +179,7 @@ def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try:
config = _normalize_feishu_instance(
cast(dict[str, Any], raw),
raw,
defaults,
inherited=inherited,
fallback_id=fallback_id,
@@ -239,9 +238,9 @@ def update_feishu_instance_preserving_shape(
if (
instance_id == DEFAULT_INSTANCE_ID
and isinstance(section, dict)
and not isinstance(cast(dict[str, Any], section).get("instances"), list)
and not isinstance(section.get("instances"), list)
):
return {**cast(dict[str, Any], section), **values}
return {**section, **values}
return upsert_feishu_instance(section, defaults, instance_id, values)
+134 -250
View File
@@ -1,5 +1,4 @@
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
# pyright: reportMissingModuleSource=false, reportMissingTypeStubs=false
from __future__ import annotations
@@ -15,9 +14,8 @@ from collections import OrderedDict
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict, cast
from typing import TYPE_CHECKING, Any
from rich.console import Console
from rich.markup import escape
@@ -46,10 +44,7 @@ from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging
if TYPE_CHECKING:
from lark_oapi.api.im.v1.model import ( # pyright: ignore[reportMissingTypeStubs]
MentionEvent,
P2ImMessageReceiveV1,
)
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
_LOGIN_CONSOLE = Console()
@@ -60,20 +55,6 @@ def _identity_timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
def _as_json_object(value: Any) -> dict[str, Any] | None:
"""Narrow untyped SDK/JSON objects at the channel boundary."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _as_json_list(value: Any) -> list[Any] | None:
"""Narrow untyped SDK/JSON arrays at the channel boundary."""
return cast(list[Any], value) if isinstance(value, list) else None
def _ignore_event(_: Any) -> None:
"""Consume SDK events that intentionally have no channel action."""
def _load_lark_runtime() -> tuple[Any, str, str]:
"""Import the heavy Feishu SDK lazily.
@@ -88,12 +69,9 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
# close the same loop.
with _LARK_RUNTIME_LOCK:
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
import lark_oapi as lark # pyright: ignore[reportMissingTypeStubs]
import lark_oapi.ws.client as lark_ws_client # pyright: ignore[reportMissingTypeStubs]
from lark_oapi.core.const import ( # pyright: ignore[reportMissingTypeStubs]
FEISHU_DOMAIN,
LARK_DOMAIN,
)
import lark_oapi as lark
import lark_oapi.ws.client as lark_ws_client
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
if (
not ws_client_already_imported
@@ -128,7 +106,7 @@ def fetch_feishu_app_identity(
try:
lark, feishu_domain, lark_domain = _load_lark_runtime()
from lark_oapi.api.application.v6.model.get_application_request import ( # pyright: ignore[reportMissingTypeStubs]
from lark_oapi.api.application.v6.model.get_application_request import (
GetApplicationRequest,
)
@@ -173,9 +151,9 @@ MSG_TYPE_MAP = {
}
def _extract_share_card_content(content_json: dict[str, Any], msg_type: str) -> str:
def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
"""Extract text representation from share cards and interactive messages."""
parts: list[str] = []
parts = []
if msg_type == "share_chat":
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
@@ -193,9 +171,9 @@ def _extract_share_card_content(content_json: dict[str, Any], msg_type: str) ->
return "\n".join(parts) if parts else f"[{msg_type}]"
def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
def _extract_interactive_content(content: dict) -> list[str]:
"""Recursively extract text and links from interactive card content."""
parts: list[str] = []
parts = []
if isinstance(content, str):
try:
@@ -211,9 +189,8 @@ def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
if isinstance(user_dsl, str) and user_dsl.strip():
try:
dsl = json.loads(user_dsl)
dsl_object = _as_json_object(dsl)
if dsl_object is not None:
parts.extend(_extract_interactive_content(dsl_object))
if isinstance(dsl, dict):
parts.extend(_extract_interactive_content(dsl))
if parts:
return parts
except (json.JSONDecodeError, TypeError):
@@ -221,9 +198,8 @@ def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
if "title" in content:
title = content["title"]
title_object = _as_json_object(title)
if title_object is not None:
title_content = title_object.get("content", "") or title_object.get("text", "")
if isinstance(title, dict):
title_content = title.get("content", "") or title.get("text", "")
if title_content:
parts.append(f"title: {title_content}")
elif isinstance(title, str):
@@ -231,39 +207,34 @@ def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
# Top-level elements: flat list or nested list format
elements = content.get("elements")
elements_list = _as_json_list(elements)
if elements_list is not None:
if elements_list and isinstance(elements_list[0], list):
if isinstance(elements, list):
if elements and isinstance(elements[0], list):
# Nested list: [[{tag:"text",text:"..."}], ...]
for row in elements_list:
row_list = _as_json_list(row)
if row_list is not None:
for element in row_list:
for row in elements:
if isinstance(row, list):
for element in row:
parts.extend(_extract_element_content(element))
else:
# Flat list: [{tag:"markdown",content:"..."}, ...]
for element in elements_list:
for element in elements:
parts.extend(_extract_element_content(element))
# Body elements (schema 2.0)
body = content.get("body", {})
body_object = _as_json_object(body)
if body_object is not None:
body_elements = _as_json_list(body_object.get("elements"))
if body_elements is not None:
if isinstance(body, dict):
body_elements = body.get("elements")
if isinstance(body_elements, list):
for element in body_elements:
parts.extend(_extract_element_content(element))
card = content.get("card", {})
card_object = _as_json_object(card)
if card_object:
parts.extend(_extract_interactive_content(card_object))
if card:
parts.extend(_extract_interactive_content(card))
header = content.get("header", {})
header_object = _as_json_object(header)
if header_object is not None:
header_title = _as_json_object(header_object.get("title", {}))
if header_title is not None:
if header:
header_title = header.get("title", {})
if isinstance(header_title, dict):
header_text = header_title.get("content", "") or header_title.get("text", "")
if header_text:
parts.append(f"title: {header_text}")
@@ -271,16 +242,13 @@ def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
return parts
def _extract_element_content(element: Any) -> list[str]:
def _extract_element_content(element: dict) -> list[str]:
"""Extract content from a single card element."""
parts: list[str] = []
parts = []
element_object = _as_json_object(element)
if element_object is None:
if not isinstance(element, dict):
return parts
element = element_object
tag = element.get("tag", "")
if tag in ("markdown", "lark_md"):
@@ -295,18 +263,16 @@ def _extract_element_content(element: Any) -> list[str]:
elif tag == "div":
text = element.get("text", {})
text_object = _as_json_object(text)
if text_object is not None:
text_content = text_object.get("content", "") or text_object.get("text", "")
if isinstance(text, dict):
text_content = text.get("content", "") or text.get("text", "")
if text_content:
parts.append(text_content)
elif isinstance(text, str):
parts.append(text)
for field in _as_json_list(element.get("fields")) or []:
field_object = _as_json_object(field)
if field_object is not None:
field_text = _as_json_object(field_object.get("text", {}))
if field_text is not None:
for field in element.get("fields", []):
if isinstance(field, dict):
field_text = field.get("text", {})
if isinstance(field_text, dict):
c = field_text.get("content", "")
if c:
parts.append(c)
@@ -321,33 +287,25 @@ def _extract_element_content(element: Any) -> list[str]:
elif tag == "button":
text = element.get("text", {})
text_object = _as_json_object(text)
if text_object is not None:
c = text_object.get("content", "")
if isinstance(text, dict):
c = text.get("content", "")
if c:
parts.append(c)
multi_url: Any = element.get("multi_url") or {}
multi_url_object = _as_json_object(multi_url)
url = element.get("url", "") or (
multi_url_object.get("url", "") if multi_url_object is not None else ""
)
url = element.get("url", "") or element.get("multi_url", {}).get("url", "")
if url:
parts.append(f"link: {url}")
elif tag == "img":
alt = _as_json_object(element.get("alt", {}))
parts.append(alt.get("content", "[image]") if alt is not None else "[image]")
alt = element.get("alt", {})
parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]")
elif tag == "note":
for ne in _as_json_list(element.get("elements")) or []:
for ne in element.get("elements", []):
parts.extend(_extract_element_content(ne))
elif tag == "column_set":
for col in _as_json_list(element.get("columns")) or []:
col_object = _as_json_object(col)
if col_object is None:
continue
for ce in _as_json_list(col_object.get("elements")) or []:
for col in element.get("columns", []):
for ce in col.get("elements", []):
parts.extend(_extract_element_content(ce))
elif tag == "plain_text":
@@ -356,44 +314,36 @@ def _extract_element_content(element: Any) -> list[str]:
parts.append(content)
elif tag == "table":
columns: list[tuple[str, str]] = []
for column in _as_json_list(element.get("columns")) or []:
column_object = _as_json_object(column)
if column_object is None:
continue
name = column_object.get("name")
if isinstance(name, str) and name:
columns.append((name, str(column_object.get("display_name") or name)))
rows = _as_json_list(element.get("rows")) or []
columns = [
(column["name"], str(column.get("display_name") or column["name"]))
for column in (element.get("columns") or [])
if isinstance(column, dict) and column.get("name")
]
rows = element.get("rows", [])
if columns:
parts.append(" | ".join(header for _, header in columns))
if rows:
if isinstance(rows, list):
for row in rows:
row_object = _as_json_object(row)
if row_object is None:
if not isinstance(row, dict):
continue
values: list[str] = []
values = []
for name, _ in columns:
value = row_object.get(name)
value = row.get(name)
if isinstance(value, list):
value = " ".join(
str(item).strip()
for item in cast(list[Any], value)
if item is not None
)
value = " ".join(str(item).strip() for item in value if item is not None)
values.append("" if value is None else str(value).strip())
row_text = " | ".join(values).strip()
if row_text:
parts.append(row_text)
else:
for ne in _as_json_list(element.get("elements")) or []:
for ne in element.get("elements", []):
parts.extend(_extract_element_content(ne))
return parts
def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]:
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
"""Extract text and image keys from Feishu post (rich text) message.
Handles three payload shapes:
@@ -402,48 +352,37 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
"""
def _parse_block(block: dict[str, Any]) -> tuple[str | None, list[str]]:
content = _as_json_list(block.get("content"))
if content is None:
def _parse_block(block: dict) -> tuple[str | None, list[str]]:
if not isinstance(block, dict) or not isinstance(block.get("content"), list):
return None, []
texts: list[str] = []
images: list[str] = []
title = block.get("title")
if isinstance(title, str) and title:
texts, images = [], []
if title := block.get("title"):
texts.append(title)
for row in content:
row_items = _as_json_list(row)
if row_items is None:
for row in block["content"]:
if not isinstance(row, list):
continue
for el in row_items:
element = _as_json_object(el)
if element is None:
for el in row:
if not isinstance(el, dict):
continue
tag = element.get("tag")
tag = el.get("tag")
if tag in ("text", "a"):
text = element.get("text", "")
if isinstance(text, str):
texts.append(text)
texts.append(el.get("text", ""))
elif tag == "at":
user = element.get("user_name", "user")
texts.append(f"@{user if isinstance(user, str) and user else 'user'}")
texts.append(f"@{el.get('user_name', 'user')}")
elif tag == "code_block":
lang = element.get("language", "")
code_text = element.get("text", "")
if not isinstance(lang, str):
lang = ""
if not isinstance(code_text, str):
code_text = ""
lang = el.get("language", "")
code_text = el.get("text", "")
texts.append(f"\n```{lang}\n{code_text}\n```\n")
elif tag == "img" and isinstance((key := element.get("image_key")), str):
elif tag == "img" and (key := el.get("image_key")):
images.append(key)
return (" ".join(texts).strip() or None), images
# Unwrap optional {"post": ...} envelope
root = content_json
post = _as_json_object(root.get("post"))
if post is not None:
root = post
if isinstance(root, dict) and isinstance(root.get("post"), dict):
root = root["post"]
if not isinstance(root, dict):
return "", []
# Direct format
if "content" in root:
@@ -454,23 +393,19 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]
# Localized: prefer known locales, then fall back to any dict child
for key in ("zh_cn", "en_us", "ja_jp"):
if key in root:
block = _as_json_object(root[key])
if block is None:
continue
text, imgs = _parse_block(block)
text, imgs = _parse_block(root[key])
if text or imgs:
return text or "", imgs
for val in root.values():
block = _as_json_object(val)
if block is not None:
text, imgs = _parse_block(block)
if isinstance(val, dict):
text, imgs = _parse_block(val)
if text or imgs:
return text or "", imgs
return "", []
def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction]
def _extract_post_text(content_json: dict) -> str:
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
@@ -494,18 +429,11 @@ _REGISTRATION_PATH = "/oauth/v1/app/registration"
_ONBOARD_REQUEST_TIMEOUT_S = 10
class _RegistrationStart(TypedDict):
device_code: str
qr_url: str
interval: int
expire_in: int
def _accounts_base_url(domain: str) -> str:
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
def _post_registration(base_url: str, body: dict[str, str]) -> dict[str, Any]:
def _post_registration(base_url: str, body: dict[str, str]) -> dict:
"""POST form-encoded data to the registration endpoint, return parsed JSON.
The registration endpoint returns JSON even on HTTP errors (e.g. poll
@@ -521,8 +449,7 @@ def _post_registration(base_url: str, body: dict[str, str]) -> dict[str, Any]:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
parsed = resp.json()
return _as_json_object(parsed) or {}
return resp.json()
except json.JSONDecodeError:
resp.raise_for_status()
return {}
@@ -532,7 +459,7 @@ def _init_registration(domain: str = "feishu") -> None:
"""Verify the environment supports client_secret auth. Raises RuntimeError if not."""
base_url = _accounts_base_url(domain)
res = _post_registration(base_url, {"action": "init"})
methods = _as_json_list(res.get("supported_auth_methods")) or []
methods = res.get("supported_auth_methods") or []
if "client_secret" not in methods:
raise RuntimeError(
f"Feishu / Lark registration does not support client_secret auth. "
@@ -540,7 +467,7 @@ def _init_registration(domain: str = "feishu") -> None:
)
def _begin_registration(domain: str = "feishu") -> _RegistrationStart:
def _begin_registration(domain: str = "feishu") -> dict:
"""Start the device-code flow. Returns device_code, qr_url, interval, expire_in."""
base_url = _accounts_base_url(domain)
res = _post_registration(base_url, {
@@ -550,18 +477,16 @@ def _begin_registration(domain: str = "feishu") -> _RegistrationStart:
"request_user_info": "open_id",
})
device_code = res.get("device_code")
if not isinstance(device_code, str) or not device_code:
if not device_code:
raise RuntimeError("Feishu / Lark registration did not return a device_code")
qr_url = res.get("verification_uri_complete", "")
if not isinstance(qr_url, str) or not qr_url:
if not qr_url:
raise RuntimeError("Feishu / Lark registration did not return a login URL")
interval = res.get("interval")
expire_in = res.get("expire_in")
return {
"device_code": device_code,
"qr_url": qr_url,
"interval": interval if isinstance(interval, int) else 5,
"expire_in": expire_in if isinstance(expire_in, int) else 600,
"interval": res.get("interval") or 5,
"expire_in": res.get("expire_in") or 600,
}
@@ -571,7 +496,7 @@ def _poll_registration(
interval: int,
expire_in: int,
domain: str = "feishu",
) -> dict[str, Any] | None:
) -> dict | None:
"""Poll until the user scans the QR code, or timeout/denial.
Returns dict with app_id, app_secret, domain on success, None on failure.
@@ -610,7 +535,7 @@ def poll_registration_once(
*,
device_code: str,
domain: str = "feishu",
) -> dict[str, Any]:
) -> dict:
"""Poll the Feishu/Lark device-code flow once.
This non-blocking shape is used by WebUI. The CLI keeps using
@@ -624,7 +549,7 @@ def poll_registration_once(
"tp": "ob_app",
})
user_info = _as_json_object(res.get("user_info")) or {}
user_info = res.get("user_info") or {}
tenant_brand = user_info.get("tenant_brand")
if tenant_brand == "lark":
current_domain = "lark"
@@ -703,7 +628,9 @@ def sync_saved_feishu_identity_boundary(
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = feishu_default_config()
previous_identity_key = ""
@@ -735,7 +662,7 @@ def sync_saved_feishu_identity_boundary(
def save_registration_result(
result: dict[str, Any],
result: dict,
*,
instance_id: str = DEFAULT_INSTANCE_ID,
name: str | None = None,
@@ -744,7 +671,9 @@ def save_registration_result(
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = feishu_default_config()
app_id = str(result["app_id"]).strip()
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
@@ -867,7 +796,7 @@ def refresh_saved_feishu_identities(
def qr_register(
*,
initial_domain: str = "feishu",
) -> dict[str, Any] | None:
) -> dict | None:
"""Run the Feishu / Lark scan-to-create QR registration flow.
Returns on success:
@@ -911,7 +840,7 @@ def _print_qr_code(url: str) -> None:
def _qr_register_inner(
*,
initial_domain: str,
) -> dict[str, Any] | None:
) -> dict | None:
"""Run init → begin → poll. Raises on network/protocol errors."""
_LOGIN_CONSOLE.print("[cyan]Preparing Feishu/Lark login...[/cyan]")
_init_registration(initial_domain)
@@ -993,7 +922,7 @@ class FeishuChannel(BaseChannel):
self._loop: asyncio.AbstractEventLoop | None = None
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
self._bot_open_id: str | None = None
self._background_tasks: set[asyncio.Task[Any]] = set()
self._background_tasks: set[asyncio.Task] = set()
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
# ------------------------------------------------------------------
@@ -1120,12 +1049,12 @@ class FeishuChannel(BaseChannel):
builder = self._register_optional_event(
builder,
"register_p2_im_chat_member_bot_added_v1",
_ignore_event,
lambda _: None,
)
builder = self._register_optional_event(
builder,
"register_p2_im_chat_member_bot_deleted_v1",
_ignore_event,
lambda _: None,
)
event_handler = builder.build()
@@ -1184,11 +1113,9 @@ class FeishuChannel(BaseChannel):
if response.success():
import json
data = _as_json_object(json.loads(response.raw.content)) or {}
wrapped = _as_json_object(data.get("data")) or data
bot = _as_json_object(wrapped.get("bot")) or _as_json_object(data.get("bot")) or {}
open_id = bot.get("open_id")
return open_id if isinstance(open_id, str) else None
data = json.loads(response.raw.content)
bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
return bot.get("open_id")
self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
return None
except Exception as e:
@@ -1278,7 +1205,7 @@ class FeishuChannel(BaseChannel):
if "@_all" in raw_content:
return True
for mention in cast(list[Any], getattr(message, "mentions", None) or []):
for mention in getattr(message, "mentions", None) or []:
if self._is_bot_mention_event(mention):
return True
return False
@@ -1372,7 +1299,7 @@ class FeishuChannel(BaseChannel):
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
def _on_background_task_done(self, task: asyncio.Task[Any]) -> None:
def _on_background_task_done(self, task: asyncio.Task) -> None:
"""Callback: remove from tracking set and log unhandled exceptions."""
self._background_tasks.discard(task)
if task.cancelled():
@@ -1382,7 +1309,7 @@ class FeishuChannel(BaseChannel):
except Exception as exc:
self.logger.warning("Background task failed: {}", exc)
def _on_reaction_added(self, message_id: str, task: asyncio.Task[Any]) -> None:
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
"""Callback: store reaction_id after background add-reaction completes."""
if task.cancelled():
return
@@ -1435,7 +1362,7 @@ class FeishuChannel(BaseChannel):
return text
@classmethod
def _parse_md_table(cls, table_text: str) -> dict[str, Any] | None:
def _parse_md_table(cls, table_text: str) -> dict | None:
"""Parse a markdown table into a Feishu table element."""
lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
if len(lines) < 3:
@@ -1459,39 +1386,26 @@ class FeishuChannel(BaseChannel):
],
}
def _build_card_elements(self, content: str) -> list[dict[str, Any]]:
def _build_card_elements(self, content: str) -> list[dict]:
"""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: list[dict[str, Any]] = []
last_end = 0
for m in self._TABLE_RE.finditer(protected):
before = protected[last_end : m.start()]
elements, last_end = [], 0
for m in self._TABLE_RE.finditer(content):
before = content[last_end : m.start()]
if before.strip():
elements.extend(self._split_headings(before))
elements.append(
self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}
)
last_end = m.end()
remaining = protected[last_end:]
remaining = content[last_end:]
if remaining.strip():
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}]
@staticmethod
def _split_elements_by_table_limit(
elements: list[dict[str, Any]], max_tables: int = 1
) -> list[list[dict[str, Any]]]:
elements: list[dict], max_tables: int = 1
) -> list[list[dict]]:
"""Split card elements into groups with at most *max_tables* table elements each.
Feishu cards have a hard limit of one table per card (API error 11310).
@@ -1500,8 +1414,8 @@ class FeishuChannel(BaseChannel):
"""
if not elements:
return [[]]
groups: list[list[dict[str, Any]]] = []
current: list[dict[str, Any]] = []
groups: list[list[dict]] = []
current: list[dict] = []
table_count = 0
for el in elements:
if el.get("tag") == "table":
@@ -1518,15 +1432,15 @@ class FeishuChannel(BaseChannel):
groups.append(current)
return groups or [[]]
def _split_headings(self, content: str) -> list[dict[str, Any]]:
def _split_headings(self, content: str) -> list[dict]:
"""Split content by headings, converting headings to div elements."""
protected = content
code_blocks: list[str] = []
code_blocks = []
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: list[dict[str, Any]] = []
elements = []
last_end = 0
for m in self._HEADING_RE.finditer(protected):
before = protected[last_end : m.start()].strip()
@@ -1634,10 +1548,10 @@ class FeishuChannel(BaseChannel):
Each line becomes a paragraph (row) in the post body.
"""
lines = content.strip().split("\n")
paragraphs: list[list[dict[str, Any]]] = []
paragraphs: list[list[dict]] = []
for line in lines:
elements: list[dict[str, Any]] = []
elements: list[dict] = []
last_end = 0
for m in cls._MD_LINK_RE.finditer(line):
@@ -1829,7 +1743,7 @@ class FeishuChannel(BaseChannel):
return candidate
async def _download_and_save_media(
self, msg_type: str, content_json: dict[str, Any], message_id: str | None = None
self, msg_type: str, content_json: dict, message_id: str | None = None
) -> tuple[str | None, str]:
"""
Download media from Feishu and save to local disk.
@@ -2277,7 +2191,6 @@ class FeishuChannel(BaseChannel):
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
merge_next: bool = False,
) -> None:
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
@@ -2293,10 +2206,6 @@ class FeishuChannel(BaseChannel):
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback ---
if stream_end and merge_next:
if not delta:
return
stream_end = False
if stream_end:
message_id = meta.get("message_id")
# Only finalize the OnIt -> DONE reaction transition on the truly
@@ -2367,11 +2276,8 @@ class FeishuChannel(BaseChannel):
fallback_msg_id = self._thread_reply_target(meta)
if fallback_msg_id:
await loop.run_in_executor(
None, partial(
self._reply_message_sync,
fallback_msg_id,
"interactive",
card,
None, lambda: self._reply_message_sync(
fallback_msg_id, "interactive", card,
reply_in_thread=self._should_use_reply_in_thread(meta),
),
)
@@ -2627,9 +2533,6 @@ class FeishuChannel(BaseChannel):
return
try:
event = data.event
if event is None or event.message is None or event.sender is None:
self.logger.warning("Ignoring incomplete Feishu message event")
return
message = event.message
sender = event.sender
@@ -2646,20 +2549,6 @@ class FeishuChannel(BaseChannel):
chat_id = message.chat_id
chat_type = message.chat_type
msg_type = message.message_type
if not all(isinstance(value, str) and value for value in (
message_id,
sender_id,
chat_id,
chat_type,
msg_type,
)):
self.logger.warning("Ignoring Feishu message event with missing routing fields")
return
message_id = cast(str, message_id)
sender_id = cast(str, sender_id)
chat_id = cast(str, chat_id)
chat_type = cast(str, chat_type)
msg_type = cast(str, msg_type)
if chat_type == "group" and not self._is_group_message_for_bot(message):
self.logger.debug("skipping group message (not mentioned)")
@@ -2697,19 +2586,17 @@ class FeishuChannel(BaseChannel):
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
# Parse content
content_parts: list[str] = []
media_paths: list[str] = []
content_parts = []
media_paths = []
try:
raw_content = message.content if isinstance(message.content, str) else ""
content_json = _as_json_object(json.loads(raw_content)) if raw_content else {}
content_json = json.loads(message.content) if message.content else {}
except json.JSONDecodeError:
content_json = {}
content_json = content_json or {}
if msg_type == "text":
text = content_json.get("text", "")
if isinstance(text, str) and text:
if text:
mentions = getattr(message, "mentions", None)
text = self._strip_leading_bot_mention(text, mentions)
text = self._resolve_mentions(text, mentions)
@@ -2759,12 +2646,9 @@ class FeishuChannel(BaseChannel):
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
# Extract reply context (parent/root message IDs)
parent_id = getattr(message, "parent_id", None)
root_id = getattr(message, "root_id", None)
thread_id = getattr(message, "thread_id", None)
parent_id = parent_id if isinstance(parent_id, str) else None
root_id = root_id if isinstance(root_id, str) else None
thread_id = thread_id if isinstance(thread_id, str) else None
parent_id = getattr(message, "parent_id", None) or None
root_id = getattr(message, "root_id", None) or None
thread_id = getattr(message, "thread_id", None) or None
# Prepend quoted message text when the user replied to another message
if parent_id and self._client:
@@ -1,122 +0,0 @@
from __future__ import annotations
import asyncio
import threading
from typing import Any
import pytest
from nanobot.channels.feishu import runtime as feishu
from nanobot.channels.feishu.connect import FeishuConnectStore
@pytest.mark.asyncio
async def test_feishu_cancel_wins_over_inflight_confirmation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
poll_started = threading.Event()
release_poll = threading.Event()
saved_results: list[dict[str, Any]] = []
monkeypatch.setattr(feishu, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu,
"_begin_registration",
lambda _domain: {
"device_code": "device-cancel",
"qr_url": "https://qr.example/cancel",
"expire_in": 600,
"interval": 2,
},
)
def fake_poll_registration_once(**_kwargs: Any) -> dict[str, str]:
poll_started.set()
assert release_poll.wait(timeout=5)
return {
"status": "succeeded",
"domain": "feishu",
"app_id": "late-app",
"app_secret": "late-secret",
}
def fake_save_registration_result(
result: dict[str, Any],
**_kwargs: Any,
) -> str:
saved_results.append(result)
return "default"
monkeypatch.setattr(feishu, "poll_registration_once", fake_poll_registration_once)
monkeypatch.setattr(feishu, "save_registration_result", fake_save_registration_result)
store = FeishuConnectStore()
started = await store.handle("start", {})
query = {"session_id": [started["session_id"]]}
poll_task = asyncio.create_task(store.handle("poll", query))
assert await asyncio.to_thread(poll_started.wait, 5)
cancelled = await store.handle("cancel", query)
release_poll.set()
completed = await poll_task
assert cancelled["status"] == "cancelled"
assert completed["status"] == "cancelled"
assert saved_results == []
@pytest.mark.asyncio
async def test_feishu_cancel_does_not_interleave_with_registration_save(
monkeypatch: pytest.MonkeyPatch,
) -> None:
save_started = threading.Event()
release_save = threading.Event()
monkeypatch.setattr(feishu, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu,
"_begin_registration",
lambda _domain: {
"device_code": "device-lock",
"qr_url": "https://qr.example/lock",
"expire_in": 600,
"interval": 2,
},
)
monkeypatch.setattr(
feishu,
"poll_registration_once",
lambda **_kwargs: {
"status": "succeeded",
"domain": "feishu",
"app_id": "saved-app",
"app_secret": "saved-secret",
},
)
def fake_save_registration_result(
_result: dict[str, Any],
**_kwargs: Any,
) -> str:
save_started.set()
assert release_save.wait(timeout=5)
return "default"
monkeypatch.setattr(feishu, "save_registration_result", fake_save_registration_result)
store = FeishuConnectStore()
started = await store.handle("start", {})
query = {"session_id": [started["session_id"]]}
poll_task = asyncio.create_task(store.handle("poll", query))
assert await asyncio.to_thread(save_started.wait, 5)
cancel_task = asyncio.create_task(store.handle("cancel", query))
await asyncio.sleep(0)
assert not cancel_task.done()
release_save.set()
completed = await poll_task
cancelled = await cancel_task
assert completed["status"] == "succeeded"
assert cancelled["status"] == "cancelled"
@@ -1,10 +1,6 @@
import json
from nanobot.channels.feishu.runtime import (
_extract_element_content,
_extract_post_content,
_extract_share_card_content,
)
from nanobot.channels.feishu.runtime import _extract_share_card_content
def test_extract_interactive_card_reads_user_dsl_body_elements() -> None:
@@ -41,48 +37,3 @@ def test_extract_interactive_card_reads_table_rows() -> None:
}
assert _extract_share_card_content(content, "interactive") == "Name | Score\nAlice | 98"
def test_extract_post_content_tolerates_null_fields() -> None:
text, images = _extract_post_content(
{
"title": None,
"content": [
[
{"tag": "text", "text": None},
{"tag": "a", "text": None},
{"tag": "at", "user_name": None},
{"tag": "text", "text": "ok"},
{"tag": "code_block", "language": None, "text": None},
]
],
}
)
assert "@user" in text
assert "ok" in text
assert images == []
def test_extract_button_tolerates_null_multi_url() -> None:
element = {"tag": "button", "text": {"content": "Go"}, "multi_url": None}
assert _extract_element_content(element) == ["Go"]
def test_extract_column_set_tolerates_null_columns_and_elements() -> None:
assert _extract_element_content({"tag": "column_set", "columns": None}) == []
assert _extract_element_content(
{"tag": "column_set", "columns": [{"elements": None}]}
) == []
def test_extract_div_tolerates_null_fields() -> None:
assert _extract_element_content(
{"tag": "div", "text": {"content": "hi"}, "fields": None}
) == ["hi"]
def test_interactive_card_button_null_multi_url() -> None:
content = {
"elements": [{"tag": "button", "text": {"content": "Go"}, "multi_url": None}]
}
assert _extract_share_card_content(content, "interactive") == "Go"

Some files were not shown because too many files have changed in this diff Show More