Compare commits

..
Author SHA1 Message Date
Xubin Ren ab8783758a feat(webui): add browser companion launch 2026-07-20 04:51:26 +08:00
388 changed files with 6285 additions and 34708 deletions
+2 -2
View File
@@ -14,9 +14,9 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
## SSRF Protection ## 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. 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.
+3 -58
View File
@@ -18,39 +18,8 @@ permissions:
contents: read contents: read
jobs: jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
python_required: ${{ steps.paths.outputs.python_required }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect Python-relevant changes
id: paths
shell: bash
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
python_required=true
if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
changed_files="$(git diff --name-only --no-renames "$BASE_SHA" "$HEAD_SHA")" &&
[[ -n "$changed_files" ]] &&
! grep -qvE '^(webui/|nanobot/channels/[^/]+/webui/|docs/)' <<< "$changed_files"; then
python_required=false
fi
echo "python_required=$python_required" >> "$GITHUB_OUTPUT"
test: test:
name: Python (${{ matrix.name }}) name: Python (${{ matrix.name }})
needs: changes
if: needs.changes.outputs.python_required == 'true'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
@@ -88,26 +57,21 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: uv sync --all-extras --dev run: uv sync --all-extras --dev
- name: Install channel dependencies
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
# 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 - name: Lint with ruff
if: matrix.coverage if: matrix.coverage
run: uv run --no-sync ruff check nanobot tests conftest.py run: uv run ruff check nanobot tests conftest.py
- name: Run tests with coverage - name: Run tests with coverage
if: matrix.coverage if: matrix.coverage
run: >- run: >-
uv run --no-sync python -m pytest uv run python -m pytest
--cov=nanobot --cov-report=term-missing:skip-covered --cov=nanobot --cov-report=term-missing:skip-covered
--durations=25 --durations-min=1.0 --durations=25 --durations-min=1.0
- name: Run compatibility tests - name: Run compatibility tests
if: ${{ !matrix.coverage }} if: ${{ !matrix.coverage }}
run: >- run: >-
uv run --no-sync python -m pytest uv run python -m pytest
--durations=25 --durations-min=1.0 --durations=25 --durations-min=1.0
webui: webui:
@@ -141,22 +105,3 @@ jobs:
- name: Build WebUI - name: Build WebUI
working-directory: webui working-directory: webui
run: bun run build run: bun run build
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Build image with default channel dependencies
run: docker build -t nanobot:test .
- name: Verify default WhatsApp dependencies
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
- name: Verify runtime dependency permissions
run: >-
docker run --rm --user 1000:1000 --entrypoint sh nanobot:test -c
'test -w /app/.venv && test ! -w /app && test ! -w /app/nanobot &&
python -m scripts.install_channel_dependencies discord && python -c "import discord"'
-1
View File
@@ -100,4 +100,3 @@ temp/
exp/ exp/
.playwright-mcp/ .playwright-mcp/
bridge/node_modules/ bridge/node_modules/
webui/.verify-*
+5 -25
View File
@@ -15,38 +15,18 @@ RUN apt-get update && \
WORKDIR /app WORKDIR /app
# Keep the runtime environment writable by the non-root nanobot user. Enabled
# channels may install their manifest-declared dependencies at startup.
ENV VIRTUAL_ENV=/app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN uv venv --seed "$VIRTUAL_ENV"
# Install Python dependencies first (cached layer). Hatch reads the custom build # Install Python dependencies first (cached layer). Hatch reads the custom build
# hook from hatch_build.py even for this metadata-only install. # hook from hatch_build.py even for this metadata-only install.
ARG NANOBOT_EXTRAS= ARG NANOBOT_EXTRAS=whatsapp
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./ COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
RUN mkdir -p nanobot && touch nanobot/__init__.py && \ RUN mkdir -p nanobot && touch nanobot/__init__.py && \
if [ -n "$NANOBOT_EXTRAS" ]; then \ NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]" && \
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
--python "$VIRTUAL_ENV/bin/python" --no-cache ".[${NANOBOT_EXTRAS}]"; \
else \
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
--python "$VIRTUAL_ENV/bin/python" --no-cache .; \
fi && \
rm -rf nanobot rm -rf nanobot
# Copy the full source and install # Copy the full source and install
COPY nanobot/ nanobot/ COPY nanobot/ nanobot/
COPY scripts/install_channel_dependencies.py scripts/
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/ COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache . RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
# Preinstall selected channel dependencies from their manifests. A comma-separated
# list keeps the image configurable while preserving WhatsApp in the default image.
ARG NANOBOT_CHANNELS=whatsapp
RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \
python -m scripts.install_channel_dependencies "$channel"; \
done
# Render deploy template (see render.yaml): committed gateway config that wires # Render deploy template (see render.yaml): committed gateway config that wires
# secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved # secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved
@@ -54,10 +34,10 @@ RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \
# won't shadow it. Only used when RENDER=true; ignored by local runs. # won't shadow it. Only used when RENDER=true; ignored by local runs.
COPY render-config.json ./ COPY render-config.json ./
# Create the non-root user and hand ownership of the writable virtualenv to it. # Create non-root user and config directory
RUN useradd -m -u 1000 -s /bin/bash nanobot && \ RUN useradd -m -u 1000 -s /bin/bash nanobot && \
mkdir -p /home/nanobot/.nanobot && \ mkdir -p /home/nanobot/.nanobot && \
chown -R nanobot:nanobot /home/nanobot /app/.venv chown -R nanobot:nanobot /home/nanobot /app
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
+102 -56
View File
@@ -1,6 +1,6 @@
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.svg"> <source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.png">
<img alt="nanobot README cover" src="./images/readme-cover-light.svg"> <img alt="nanobot README cover" src="./images/readme-cover-light.png">
</picture> </picture>
<div align="center"> <div align="center">
@@ -46,7 +46,15 @@
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) | | Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) | | Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) | | Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
| Deploy to the cloud or keep nanobot running as a service | [Deployment](./docs/deployment.md), including [one-click Render setup](./docs/deployment.md#render) | | 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? ## What can nanobot do?
@@ -60,18 +68,19 @@ nanobot is a self-hosted personal AI agent runtime. It can:
- expose a Python SDK and OpenAI-compatible API for integrations - expose a Python SDK and OpenAI-compatible API for integrations
- deploy as a long-running local or server-side agent gateway - deploy as a long-running local or server-side agent gateway
## Releases ## Latest Release
**Latest release: [v0.3.0 - The Agency Release](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)** **v0.2.2 - Durability Release**
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. Highlights:
- Consult inline subagents without leaving the current task - Segmented WebUI transcripts
- Switch model presets per session directly from the composer - Python SDK runtime controls
- Start from a guided WebUI setup with clearer execution controls - Automation management
- Apply configuration changes live across a more reliable provider, channel, and tool runtime - Search/STT provider improvements
- Gateway/session/provider reliability
[Read the v0.3.0 release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0) [See full changelog](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
## Open Source Partners ## Open Source Partners
@@ -82,11 +91,11 @@ The Agency Release turns nanobot from a durable workbench into an agent runtime
## Recent Updates ## Recent Updates
- **2026-07-24** Guided first-run setup, inline subagents, and model switching from the composer. - **2026-07-12** Explicit `/goal` activation, safer runtime and workspace access.
- **2026-07-23** Grok OAuth with hosted X Search, live image settings, and clearer fallback models. - **2026-07-11** Syntax-highlighted previews and diffs, queued prompts, safer edits.
- **2026-07-22** Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI. - **2026-07-10** Stable model routing, multiline CLI input, new automation guide.
- **2026-07-21** Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup. - **2026-07-09** Live file-edit diffs, safer localhost setup, Matrix image fixes.
- **2026-07-20** Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects. - **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). For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
@@ -125,7 +134,7 @@ Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex 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. To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@@ -185,64 +194,97 @@ If `nanobot` is not on `PATH`, invoke it through the method that installed it: r
## 🚀 Quick Start ## 🚀 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 ```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. Skip this step if you already configured provider and model settings in the wizard.
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.
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 *Set your API key*:
nanobot webui --background
```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 ```json
nanobot gateway status {
nanobot gateway logs "modelPresets": {
nanobot gateway restart "primary": {
nanobot gateway stop "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 ```bash
nanobot gateway 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 ```bash
nanobot agent 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. 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).
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).
- Want a pasteable provider setup? See [Provider Cookbook](./docs/provider-cookbook.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) - Want to understand provider/model matching? See [Providers and Models](./docs/providers.md)
@@ -253,20 +295,24 @@ Need manual JSON, another device on your LAN, or help with provider/model matchi
## 🌐 WebUI ## 🌐 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"> <p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900"> <img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
</p> </p>
Use it to: **Open it**
- keep separate topics for different tasks and projects; ```bash
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts; nanobot webui
- switch models and workspaces without leaving the conversation; ```
- configure providers, chat channels, Apps, Skills, and Automations from one place.
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 ## 🏗️ Architecture
+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. **CRITICAL**: Never commit API keys to version control.
```bash ```bash
# ✅ Best: Use environment variable references in config (never writes the key to disk)
# In ~/.nanobot/config.json:
# "apiKey": "${ANTHROPIC_API_KEY}"
# Then supply the key at runtime via env var or Docker secret.
# ✅ Good: Store in config file with restricted permissions # ✅ Good: Store in config file with restricted permissions
chmod 600 ~/.nanobot/config.json chmod 600 ~/.nanobot/config.json
@@ -33,9 +28,9 @@ chmod 600 ~/.nanobot/config.json
``` ```
**Recommendations:** **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. - Store API keys in `~/.nanobot/config.json` with file permissions set to `0600`
- When plaintext keys are stored in `~/.nanobot/config.json`, set file permissions to `0600` (`chmod 600`) - Consider using environment variables for sensitive keys
- Consider using an OS keyring/credential manager for production deployments - Use OS keyring/credential manager for production deployments
- Rotate API keys regularly - Rotate API keys regularly
- Use separate API keys for development and production - Use separate API keys for development and production
@@ -242,7 +237,7 @@ If you suspect a security breach:
⚠️ **Current Security Limitations:** ⚠️ **Current Security Limitations:**
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed) 1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
2. **Plain Text Config** - API keys stored in plain text 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 3. **No Session Management** - No automatic session expiry
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux) 4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux)
5. **No Audit Trail** - Limited security event logging (enhance as needed) 5. **No Audit Trail** - Limited security event logging (enhance as needed)
@@ -265,7 +260,7 @@ Before deploying nanobot:
## Updates ## Updates
**Last Updated**: 2026-07-21 **Last Updated**: 2026-04-05
For the latest security updates and announcements, check: For the latest security updates and announcements, check:
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories - GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
-2
View File
@@ -2,8 +2,6 @@ x-common-config: &common-config
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NANOBOT_CHANNELS: ${NANOBOT_CHANNELS:-whatsapp}
volumes: volumes:
- ~/.nanobot:/home/nanobot/.nanobot - ~/.nanobot:/home/nanobot/.nanobot
cap_drop: cap_drop:
+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: The recommended first-run path is:
1. Install nanobot. 1. Install nanobot.
2. Let the installer open `nanobot webui` on a fresh local desktop. 2. Choose **Quick Start** in `nanobot onboard --wizard`.
3. Configure a provider and model in **Settings → Models**. 3. Run `nanobot gateway` and open `http://127.0.0.1:8765`.
4. Send `Hello!` before configuring anything else. 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 ## 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. The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
### Agent-Owned State vs Effective Project Context
Runtime code distinguishes the configured agent workspace from the effective
project workspace carried by a session scope. They are often the same path, but
a WebUI chat may select a separate project:
| Concern | Path owner |
|---|---|
| Sessions, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace |
| Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace |
| Workspace access mode and project metadata | Session workspace scope |
`ContextBuilder` combines project instructions with agent-owned profile and
memory. Filesystem and search tools use the project as their ordinary boundary
and receive only capability-specific read access to built-in/agent skills and
the exact agent history file. Keep those cross-root capabilities read-only and
explicit; do not treat the entire agent workspace as an allowed root.
## Memory and Sessions ## Memory and Sessions
Session history is the near-term conversation replay. Memory is the longer-term workspace state. Session history is the near-term conversation replay. Memory is the longer-term workspace state.
+16 -23
View File
@@ -2,21 +2,21 @@
<!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. --> <!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. -->
Automations are agent turns that run later in a linked 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, when nanobot should do work without someone actively typing: reminders,
recurring checks, nightly summaries, CI follow-ups, local script reports, or recurring checks, nightly summaries, CI follow-ups, local script reports, or
webhook-driven events. webhook-driven events.
Create automations from the chat channel or WebUI topic where the Create automations from the chat, channel, or WebUI session where the result
result should appear. That lets nanobot keep the right session history, should appear. That lets nanobot keep the right session history, workspace, and
workspace, and reply target. reply target.
## Choose an Automation Type ## Choose an Automation Type
| Type | Starts from | Best for | Created with | | Type | Starts from | Best for | Created with |
|---|---|---|---| |---|---|---|---|
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target topic to schedule it with the `cron` tool | | Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target 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 topic | | 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` | | Heartbeat | Protected system schedule | Quiet recurring checks that should only report useful results | Edit `<workspace>/HEARTBEAT.md` |
The two user-created automation types are scheduled automations and local The two user-created automation types are scheduled automations and local
@@ -26,21 +26,21 @@ protected from normal automation edits.
## Before You Create One ## Before You Create One
Keep `nanobot gateway` running. The gateway owns background delivery for chat Keep `nanobot gateway` running. The gateway owns background delivery for chat
apps, WebUI topics, scheduled automations, local triggers, heartbeat, and apps, WebUI sessions, scheduled automations, local triggers, heartbeat, and
Dream jobs. Dream jobs.
Use the same workspace and config for the gateway and any process that sends Use the same workspace and config for the gateway and any process that sends
local trigger messages. If you run multiple nanobot instances, pass the matching local trigger messages. If you run multiple nanobot instances, pass the matching
`--config` or `--workspace` option to `nanobot trigger`. `--config` or `--workspace` option to `nanobot trigger`.
Create each automation from the target topic. An automation without a linked Create each automation from the target session. An automation without a linked
topic cannot be enabled or run from the WebUI because nanobot would not know chat/session cannot be enabled or run from the WebUI because nanobot would not
where to deliver the turn. know where to deliver the turn.
## Scheduled Automations ## Scheduled Automations
Scheduled automations are created by the agent's `cron` tool. In practice, ask Scheduled automations are created by the agent's `cron` tool. In practice, ask
nanobot from the target chat or WebUI topic: nanobot from the target chat or WebUI session:
```text ```text
Every weekday at 9am, check open pull requests and summarize blockers here. Every weekday at 9am, check open pull requests and summarize blockers here.
@@ -68,7 +68,7 @@ report, use heartbeat instead of a user-created scheduled automation.
Local triggers let a local script or external service send a message into a Local triggers let a local script or external service send a message into a
specific nanobot session later. specific nanobot session later.
Create the trigger from the chat or WebUI topic where future messages should Create the trigger from the chat or WebUI session where future messages should
arrive: arrive:
```text ```text
@@ -120,7 +120,7 @@ Heartbeat is enabled by default when `nanobot gateway` starts. Configure it in
Use the WebUI Automations view to: Use the WebUI Automations view to:
- filter by all, active, paused, needs-attention, or system jobs; - filter by all, active, paused, needs-attention, or system jobs;
- search by task name, message, trigger command, linked topic, schedule, or - search by task name, message, trigger command, linked chat, schedule, or
status; status;
- sort by next run, last run, updated time, or name; - sort by next run, last run, updated time, or name;
- run scheduled automations now; - run scheduled automations now;
@@ -137,15 +137,8 @@ message. Copy the `nanobot trigger ...` command from the WebUI and replace
Automation delivery is workspace-local. Scheduled jobs and local trigger Automation delivery is workspace-local. Scheduled jobs and local trigger
deliveries use the same workspace as the gateway. deliveries use the same workspace as the gateway.
WebUI automation replies are written to the linked topic even when no browser
is connected. When the WebUI is opened again, it replays the stored reply and
compares the topic's durable activity time with its persisted read position to
show **New activity**. A successful automation `lastStatus` means the agent turn
completed; it does not mean a browser had a live WebSocket connection or that
the user already read the reply.
Local trigger messages are written to a durable queue. If the gateway is not Local trigger messages are written to a durable queue. If the gateway is not
running yet, the message waits in that workspace. If the linked 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 already running a turn, the trigger waits until the session becomes idle instead
of being injected into the active turn. of being injected into the active turn.
@@ -161,7 +154,7 @@ queue is not a distributed multi-consumer queue.
## Common Patterns ## Common Patterns
For a nightly report, ask from the target topic: For a nightly report, ask from the target session:
```text ```text
Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow. Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow.
@@ -188,7 +181,7 @@ generate-report | nanobot trigger <trigger-id>
## Troubleshooting ## Troubleshooting
If an automation does not run, check that `nanobot gateway` is running, the If an automation does not run, check that `nanobot gateway` is running, the
automation is enabled, and it was created from a linked 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 If a local trigger waits forever, confirm the command uses the same workspace or
config as the gateway. config as the gateway.
+3 -4
View File
@@ -235,7 +235,7 @@ Do not add a runtime module directly under `nanobot/channels/`, create a paralle
`manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker. `manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker.
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, dependency requirements, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance. The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, optional dependency extra, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection. Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection.
@@ -618,7 +618,7 @@ async def send(self, msg: OutboundMessage) -> None:
await self._send_message(msg.chat_id, msg.content, media=msg.media) 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 ```json
{ {
@@ -626,7 +626,7 @@ Tool hints are on by default. Users can disable them globally or per channel:
"sendToolHints": true, "sendToolHints": true,
"webhook": { "webhook": {
"enabled": true, "enabled": true,
"sendToolHints": false "sendToolHints": true
} }
} }
} }
@@ -777,7 +777,6 @@ git clone https://github.com/HKUDS/nanobot.git
cd nanobot cd nanobot
python -m pip install -e . python -m pip install -e .
nanobot plugins list # should show the package as "webhook" nanobot plugins list # should show the package as "webhook"
nanobot plugins enable webhook
nanobot gateway # test end-to-end nanobot gateway # test end-to-end
``` ```
+4 -36
View File
@@ -46,8 +46,8 @@ The sections below explain what each chat platform requires and provide manual c
> [!NOTE] > [!NOTE]
> If you are upgrading from a version where chat app SDKs were installed by default, > If you are upgrading from a version where chat app SDKs were installed by default,
> enable the channel in the same Python environment so nanobot installs its > install the channel extra in the same Python environment before enabling or
> manifest-declared dependencies: > restarting that channel:
> >
> ```bash > ```bash
> nanobot plugins enable <channel> > nanobot plugins enable <channel>
@@ -109,24 +109,7 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
<details> <details>
<summary><b>Telegram</b></summary> <summary><b>Telegram</b></summary>
**Recommended WebUI setup** **Install the optional channel dependency**
1. Create a bot with `@BotFather` and copy its token.
2. Run `nanobot webui`, then open **Settings → Channels → Telegram**.
3. Paste the token. If the gateway cannot reach Telegram directly, expand
**Advanced** and add an HTTP or SOCKS proxy.
4. Save and enable Telegram, then send the bot a direct message.
The configuration badge means nanobot found a saved token. The live connection
check is separate, so a temporary Telegram or proxy outage does not make an
existing configuration disappear. Saved tokens and proxy URLs remain masked.
See the [step-by-step Telegram guide](./guides/telegram-ai-agent.md) for pairing
and troubleshooting.
**Manual setup**
Install the optional channel dependency:
```bash ```bash
nanobot plugins enable telegram nanobot plugins enable telegram
@@ -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. > You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
> >
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages. > `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
@@ -217,7 +185,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
nanobot plugins enable mochat nanobot plugins enable mochat
``` ```
Without these dependencies, Mochat still works through HTTP polling. Without this extra, Mochat still works through HTTP polling.
**1. Ask nanobot to set up Mochat for you** **1. Ask nanobot to set up Mochat for you**
+3 -3
View File
@@ -9,7 +9,7 @@ These commands work inside chat channels and interactive agent sessions:
| `/restart` | Restart the bot | | `/restart` | Restart the bot |
| `/status` | Show bot status | | `/status` | Show bot status |
| `/model` | Show the current model and available model presets | | `/model` | Show the current model and available model presets |
| `/model <preset>` | Switch 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` | Run Dream memory consolidation now |
| `/dream-log` | Show the latest Dream memory change | | `/dream-log` | Show the latest Dream memory change |
| `/dream-log <sha>` | Show a specific Dream memory change | | `/dream-log <sha>` | Show a specific Dream memory change |
@@ -47,7 +47,7 @@ Use `/model` to inspect the current runtime model:
/model /model
``` ```
The response shows the current 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: To switch presets for future turns:
@@ -57,7 +57,7 @@ To switch presets for future turns:
/model default /model default
``` ```
Preset names come from the top-level `modelPresets` config. Switching 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 ## Local triggers
+2 -4
View File
@@ -20,7 +20,7 @@ Use this page when you know what you want to run and need the command shape. For
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` | | Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on | | Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat | | Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by 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 ## Global
@@ -95,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 --no-open` | Prepare and start the WebUI without opening a browser |
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port | | `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
| `nanobot webui --gateway-port <port>` | Override the gateway health 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. 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.
@@ -287,10 +287,8 @@ remain accepted as no-op compatibility aliases.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model | | `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support |
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model | | `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state | | `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state | | `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection. See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection.
+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. The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance.
### Agent Workspace and Project Workspace
The configured workspace is the **agent workspace**. A WebUI chat can also select
a different **project workspace** for repository-specific work without moving the
agent's identity or durable state.
| Resource | Owner when a project is selected |
|---|---|
| Project instructions | `AGENTS.md` from the selected project; there is no fallback to the agent workspace's `AGENTS.md` |
| Agent profile | `SOUL.md` and `USER.md` from the agent workspace; project-local files with those names are ignored |
| Memory and custom skills | `memory/` and `skills/` from the agent workspace |
| Relative file paths and shell working directory | The selected project workspace |
When no separate project is selected, one directory normally serves both roles.
Selecting a project changes the working context for that chat; it does not create
a second agent or relocate the configured agent workspace.
## Config Format ## Config Format
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`. `config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
@@ -66,7 +49,7 @@ Most examples are partial snippets. Merge them into the existing file created by
A normal turn follows this flow: A normal turn follows this flow:
1. A channel receives a user message and publishes it to the message bus. 1. A channel receives a user message and publishes it to the message bus.
2. The agent loop chooses a session key and builds context from the 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. 3. The provider receives the model request.
4. If the model asks for tools, the runner executes them and feeds results back to the model. 4. If the model asks for tools, the runner executes them and feeds results back to the model.
5. The final reply is saved to the session and sent back through the channel. 5. The final reply is saved to the session and sent back through the channel.
+11 -95
View File
@@ -201,11 +201,9 @@ 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_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_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_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_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`. |
| `NANOBOT_CHANNELS` | `whatsapp` | Docker build argument containing comma-separated channels whose manifest dependencies are preinstalled. |
| `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. | | `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. |
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface. Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
@@ -254,13 +252,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. > - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config. > - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **ModelScope**: If you're using ModelScope's OpenAI-compatible endpoint, set `"apiBase": "https://api-inference.modelscope.cn/v1"` in your modelscope provider config.
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`. > - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config. > - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default. > - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config. > - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`. > - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers, `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 | | Provider | Purpose | Get API Key |
|----------|---------|-------------| |----------|---------|-------------|
@@ -289,7 +286,6 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) | | `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `modelscope` | LLM (ModelScope/魔搭社区) + Image generation | [modelscope.cn](https://modelscope.cn) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) | | `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
@@ -305,7 +301,6 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `vllm` | LLM (local, any OpenAI-compatible server) | — | | `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) | | `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` |
| `xai_grok` | LLM (Grok, OAuth) | `nanobot provider login xai-grok --set-main` |
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` | | `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) | | `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
@@ -679,75 +674,11 @@ Then run:
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
``` ```
To opt in to Codex Fast mode, merge this provider setting into `config.json`:
```json
{
"providers": {
"openaiCodex": {
"extraBody": {
"service_tier": "priority"
}
}
}
}
```
`priority` is the Responses API request value used by Codex Fast mode. The setting only works
for models and accounts that support Fast mode; remove `service_tier` to return to standard
processing. Fast mode consumes Codex credits at a higher rate. See the
[OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details.
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems). For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
</details> </details>
<details>
<summary><b>xAI Grok (OAuth)</b></summary>
Use an eligible X Premium / Grok subscription without putting an API key in
`config.json`:
```bash
nanobot provider login xai-grok --set-main
nanobot agent -m "Hello from Grok."
```
The default model is `xai-grok/grok-4.5` with a 500,000-token context window.
The provider reads xAI's model catalog and includes the server-hosted `x_search`
tool only when the selected model advertises `supportsBackendSearch`. Models
without that capability continue normally without hosted X Search. When enabled,
searches run inside xAI's Responses API and citations arrive as inline links.
This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the
public OAuth client and proxy contract used by
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md).
The browser flow uses a random loopback callback and PKCE. The resulting token
is stored in the active instance's `auth/xai.json` (normally
`~/.nanobot/auth/xai.json`), separately from Grok Build so rotating refresh
tokens cannot invalidate one another.
To use a provider-specific proxy, merge this into `config.json` before login:
```json
{
"providers": {
"xaiGrok": {
"proxy": "http://127.0.0.1:7890"
}
}
}
```
The proxy applies to OAuth discovery, token exchange/refresh, model-catalog
lookups, and subscription model requests. Because this integration depends on
xAI's public Grok Build client contract, an upstream contract change may require
a nanobot update.
</details>
<details> <details>
<summary><b>GitHub Copilot (OAuth)</b></summary> <summary><b>GitHub Copilot (OAuth)</b></summary>
@@ -1344,7 +1275,7 @@ Contributor notes for adding new providers live in [`development.md`](./developm
## Model Presets ## Model Presets
Model presets let you name a complete model configuration and 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`. 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`.
@@ -1408,7 +1339,7 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
`default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config. `default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config.
Set `agents.defaults.modelPreset` to choose the 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 ### Model Fallbacks
@@ -1486,7 +1417,7 @@ Inline fallback object:
Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries. Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries.
Failover normally runs when the primary provider returns a 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. 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.
@@ -1555,7 +1486,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
{ {
"channels": { "channels": {
"sendProgress": true, "sendProgress": true,
"sendToolHints": true, "sendToolHints": false,
"extractDocumentText": true, "extractDocumentText": true,
"sendMaxRetries": 3, "sendMaxRetries": 3,
"telegram": { "telegram": {
@@ -1568,7 +1499,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
| Setting | Default | Description | | Setting | Default | Description |
|---------|---------|-------------| |---------|---------|-------------|
| `sendProgress` | `true` | Stream agent's text progress to the channel | | `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`. | | `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. | | `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) | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
@@ -1581,11 +1512,10 @@ Global settings that apply to all channels. Configure under the `channels` secti
{ {
"channels": { "channels": {
"sendProgress": true, "sendProgress": true,
"sendToolHints": true, "sendToolHints": false,
"telegram": { "telegram": {
"enabled": true, "enabled": true,
"sendProgress": false, "sendProgress": false
"sendToolHints": false
}, },
"websocket": { "websocket": {
"enabled": true, "enabled": true,
@@ -1977,16 +1907,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`. For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
> [!NOTE]
> When a restricted WebUI chat selects a project outside the configured agent
> workspace, that project becomes the normal file and shell boundary. Nanobot
> adds capability-specific, read-only access for built-in skills, the agent
> workspace's `skills/` directory, and the exact agent
> `memory/history.jsonl` file. Neighboring memory/profile files and all
> cross-workspace writes remain denied. Agent-owned `SOUL.md` and `USER.md` are
> assembled into model context directly; this does not grant file tools broader
> access to the agent workspace.
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. | | `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. |
@@ -1995,8 +1915,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.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.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.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.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. | | `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. | | `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. |
@@ -2158,8 +2076,7 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"idleCompactAfterMinutes": 15, "idleCompactAfterMinutes": 15
"idleCompactCheckIntervalSeconds": 60
} }
} }
} }
@@ -2168,12 +2085,11 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
| Option | Default | Description | | 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.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. `sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
How it works: 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). 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. 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. 4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
+1 -35
View File
@@ -4,7 +4,7 @@ Use this page after `nanobot agent -m "Hello!"` works locally. Deployment keeps
## Before You Deploy ## Before You Deploy
Check these once before Render, Docker, systemd, or LaunchAgent: Check these once before Docker, systemd, or LaunchAgent:
| Check | Why it matters | | Check | Why it matters |
|---|---| |---|---|
@@ -22,23 +22,11 @@ Restart the deployed process after editing `config.json`. Long-running processes
| Runtime | Use it for | State location | Useful first command | | Runtime | Use it for | State location | Useful first command |
|---|---|---|---| |---|---|---|---|
| Render | One-click hosted gateway and WebUI | Persistent disk at `/home/nanobot/.nanobot` | [Deploy to Render](#render) |
| Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` | | Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` |
| Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` | | Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` |
| systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` | | systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` |
| macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` | | macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` |
## Render
Run nanobot online without managing a server. The blueprint deploys the gateway and bundled WebUI together, with a persistent disk so sessions, memory, and chat history survive restarts.
> [!IMPORTANT]
> This setup requires a paid Render service because persistent disks are not available on the free tier. During setup, provide `ANTHROPIC_API_KEY` and set `NANOBOT_WEB_TOKEN` to a strong private password (for example, generate one with `openssl rand -hex 32`).
[![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)
## Docker ## Docker
> [!TIP] > [!TIP]
@@ -74,22 +62,6 @@ Run nanobot online without managing a server. The blueprint deploys the gateway
### Docker Compose ### Docker Compose
The default image preinstalls WhatsApp dependencies. To bake other enabled
channels into an image (recommended for deployments without PyPI access), pass
a comma-separated `NANOBOT_CHANNELS` build argument:
```bash
NANOBOT_CHANNELS=telegram,slack docker compose build
```
The image keeps nanobot in a virtual environment owned by its built-in non-root
runtime user (UID 1000). If an enabled channel was not preinstalled, gateway
startup can therefore install its manifest-declared dependencies. Rebuilding
with `NANOBOT_CHANNELS` keeps that installation reproducible instead of relying
on the container's writable layer. If you override the container with a
different `--user`, bake every enabled channel into the image because that UID
is not guaranteed write access to the virtual environment.
```bash ```bash
docker compose run --rm nanobot-cli onboard # first-time setup docker compose run --rm nanobot-cli onboard # first-time setup
vim ~/.nanobot/config.json # add API keys vim ~/.nanobot/config.json # add API keys
@@ -122,12 +94,6 @@ bwrap sandbox is enabled.
# Build the image # Build the image
docker build -t nanobot . docker build -t nanobot .
# Or preinstall a regular Python extra such as Bedrock support
docker build --build-arg NANOBOT_EXTRAS=bedrock -t nanobot .
# Or preinstall dependencies for a specific set of channels
docker build --build-arg NANOBOT_CHANNELS=telegram,slack -t nanobot .
# Initialize config (first time only) # Initialize config (first time only)
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
-1
View File
@@ -44,7 +44,6 @@ Use **Settings → Channels** in the WebUI for guided setup. These guides explai
| Enable web search | [Configure web search](./configure-web-search.md) | | Enable web search | [Configure web search](./configure-web-search.md) |
| Add model fallback | [Configure model fallback](./configure-model-fallback.md) | | Add model fallback | [Configure model fallback](./configure-model-fallback.md) |
| Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) | | Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) |
| Improve Ollama tool prompt-cache reuse | [Configure Ollama prompt caching](./configure-ollama-prompt-cache.md) |
| Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) | | Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) |
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) | | Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) | | Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |
@@ -1,239 +0,0 @@
# How to Improve Ollama Tool-Calling Prompt Cache Reuse in nanobot
Some Ollama model templates move or remove tool definitions as a conversation
switches between user, assistant, and tool messages. nanobot can send a correct
append-only chat request while the model template still renders a different token
prefix. On slower local hardware, re-evaluating that prefix can add tens of seconds
to an otherwise simple tool-using turn.
This guide shows how to diagnose that specific pattern and create a derived
`llama3.1:8b` tag with a prefix-stable tool template. It does not modify nanobot or
overwrite the original Ollama model.
## What you will build
- a repeatable two-turn cache check
- an optional derived `llama3.1:8b-prefix-stable-v1` Ollama tag
- a nanobot model preset that uses the derived tag
## When to use this
Use this guide when all of the following are true:
- direct Ollama responses are reasonably fast;
- nanobot becomes slow after the model calls a tool;
- Ollama logs show a long main prompt, a much shorter tool follow-up, and low
initial cache reuse on the next main prompt;
- the model is `llama3.1:8b` with a template that renders concrete tools only for
the final user message.
Do not apply this template to another model family without checking that model's
tool-call format first.
## Diagnose the rendered prompt
Stop any existing Ollama process, then start a single-slot debug server. A single
slot makes the cache sequence easier to read.
**macOS or Linux**
```bash
OLLAMA_CONTEXT_LENGTH=16384 \
OLLAMA_NUM_PARALLEL=1 \
OLLAMA_DEBUG=1 \
ollama serve
```
**Windows PowerShell**
```powershell
$env:OLLAMA_CONTEXT_LENGTH = "16384"
$env:OLLAMA_NUM_PARALLEL = "1"
$env:OLLAMA_DEBUG = "1"
ollama serve
```
In another terminal, use a fresh session and explicitly request a tool so both
turns exercise the agent loop:
```bash
nanobot agent --session cli:ollama-cache-check \
--message "Use the exec tool to calculate 2+2, then answer"
nanobot agent --session cli:ollama-cache-check \
--message "Use the exec tool to calculate 4+7, then answer"
```
In the Ollama output, find each `new prompt` line and the first
`cached n_tokens` line that follows it. Later increasing `cached n_tokens` lines
are prompt-evaluation progress, not additional initial cache hits.
A cache-unfriendly tool template may produce a pattern like this:
```text
turn 1 main: 2 / 8460 initially cached
turn 1 tool follow-up: 3713 / 3758 initially cached
turn 2 main: 3767 / 8519 initially cached
```
The cache is working, but the next main request can reuse only the shorter prompt.
Hardware throughput determines how expensive the remaining evaluation is.
To inspect the API request bodies as well, add
`OLLAMA_DEBUG_LOG_REQUESTS=1` before starting Ollama. These logs can contain system
prompts, workspace context, and user messages. Keep them local and disable request
logging after diagnosis.
## Why this happens with the stock template
The tested `llama3.1:8b` template conditionally expands the tool definitions inside
a user message:
```gotemplate
{{- if and $.Tools $last }}
... render tool definitions ...
{{- end }}
```
The first request ends with a user message, so the tools are rendered there. After
nanobot appends an assistant tool call and its result, that user message is no
longer last, so the same API request history renders without the concrete tool
block. On the next user turn, the tools reappear at a new position.
This is a model-template behavior. At the API boundary, nanobot continues to append
the assistant tool call and tool result and sends the same tool definitions.
## Create a prefix-stable derived model
Create `PrefixStable.Modelfile` with the content below. The template keeps concrete
tool definitions in the system block, where they remain in the same position across
user and tool messages.
```dockerfile
FROM llama3.1:8b
TEMPLATE """{{- if or .System .Tools }}<|start_header_id|>system<|end_header_id|>
{{- if .System }}
{{ .System }}
{{- end }}
{{- if .Tools }}
Cutting Knowledge Date: December 2023
When you receive a tool call response, use the output to format an answer to the original user question.
You are a helpful assistant with tool calling capabilities.
Given the following functions, respond with a JSON function call with the proper arguments when a tool is needed.
Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. Do not use variables.
{{ range .Tools }}
{{- . }}
{{ end }}
{{- end }}<|eot_id|>
{{- end }}
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 }}
{{- if eq .Role "user" }}<|start_header_id|>user<|end_header_id|>
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
{{ end }}
{{- else if eq .Role "assistant" }}<|start_header_id|>assistant<|end_header_id|>
{{- if .ToolCalls }}
{{ range .ToolCalls }}
{"name": "{{ .Function.Name }}", "parameters": {{ .Function.Arguments }}}{{ end }}
{{- else }}
{{ .Content }}
{{- end }}{{ if not $last }}<|eot_id|>{{ end }}
{{- else if eq .Role "tool" }}<|start_header_id|>ipython<|end_header_id|>
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
{{ end }}
{{- end }}
{{- end }}"""
```
Create the new tag:
```bash
ollama create llama3.1:8b-prefix-stable-v1 -f PrefixStable.Modelfile
ollama list
```
Ollama reuses the existing model layers. The new tag adds a small template and
manifest instead of copying the base weights.
## Select the derived model in nanobot
Merge this preset into `~/.nanobot/config.json` and select it:
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1"
}
},
"modelPresets": {
"ollamaPrefixStable": {
"label": "Ollama Llama 3.1 prefix-stable",
"provider": "ollama",
"model": "llama3.1:8b-prefix-stable-v1",
"maxTokens": 2048,
"contextWindowTokens": 16384,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "ollamaPrefixStable"
}
}
}
```
Verify the selected model and repeat the two-turn check:
```bash
nanobot status
nanobot agent --session cli:ollama-stable-check \
--message "Use the exec tool to calculate 2+2, then answer"
nanobot agent --session cli:ollama-stable-check \
--message "Use the exec tool to calculate 4+7, then answer"
```
In one controlled test with Ollama 0.32.1, `llama3.1:8b`, and one slot, the second
main request improved from `3767 / 8519` initially cached (44.22%) to
`8505 / 8520` (99.82%). The number of re-evaluated tokens fell from 4752 to 15.
Treat these numbers as a diagnostic example, not a performance guarantee.
## Roll back
Switch `agents.defaults.modelPreset` back to the original preset. When no config
uses the derived tag, remove it with:
```bash
ollama rm llama3.1:8b-prefix-stable-v1
```
Removing the derived tag does not remove `llama3.1:8b`.
## Limitations
- The template above is specific to the tested `llama3.1:8b` tool-call format.
- Ollama or the model publisher may update the stock template in a later release.
- Validate multiple tool calls, tool errors, parallel calls, and long conversations
before using a custom template for unattended workloads.
- A higher cache ratio reduces prompt evaluation, but model generation, tool
execution, process startup, and storage can still dominate end-to-end latency.
- Multiple Ollama slots change cache scheduling and may produce different results.
## Related nanobot docs
- [Provider Cookbook: Ollama Local Model](../provider-cookbook.md#recipe-ollama-local-model)
- [Providers and Models: Ollama](../providers.md#ollama)
- [Troubleshooting](../troubleshooting.md)
+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 This guide connects nanobot to Telegram so a paired Telegram user can message a
your normal nanobot model, tools, memory, and workspace. self-hosted AI agent backed by your normal nanobot config, tools, memory, and
workspace.
## What this guide builds ## What this guide builds
@@ -28,55 +29,27 @@ python -m pip install nanobot-ai
nanobot onboard --wizard nanobot onboard --wizard
``` ```
## Connect Telegram in the WebUI ## Enable the Telegram channel
Start the WebUI: Install the optional channel dependency:
```bash
nanobot webui
```
Open **Settings → Channels → Telegram**:
1. If Telegram support is not installed, turn on its switch and confirm the
installation.
2. Paste the token from BotFather.
3. If the gateway cannot reach Telegram directly, expand **Advanced** and enter
an HTTP or SOCKS proxy such as `http://127.0.0.1:7890`.
4. Save and enable Telegram.
The configuration badge appears as soon as a bot token is saved. A connection
check is separate: if Telegram is temporarily unreachable, the saved
configuration remains valid and the bot can continue working in environments
where the gateway has network access.
Saved tokens and proxy URLs are masked. A proxy entered here is used both for
the connection check and for normal Telegram traffic.
## Manual setup
For a headless installation, install Telegram support:
```bash ```bash
nanobot plugins enable telegram nanobot plugins enable telegram
``` ```
Then merge this snippet into `~/.nanobot/config.json`: Merge this snippet into `~/.nanobot/config.json`:
```json ```json
{ {
"channels": { "channels": {
"telegram": { "telegram": {
"enabled": true, "enabled": true,
"token": "YOUR_BOT_TOKEN", "token": "YOUR_BOT_TOKEN"
"proxy": "http://127.0.0.1:7890"
} }
} }
} }
``` ```
Omit `proxy` when the gateway can reach Telegram directly.
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
gets a pairing code instead of agent access. gets a pairing code instead of agent access.
@@ -122,13 +95,8 @@ workspace as your local CLI check.
- If the channel is not listed, run `nanobot plugins enable telegram` again in - If the channel is not listed, run `nanobot plugins enable telegram` again in
the same Python environment. the same Python environment.
- If the WebUI shows a saved configuration but the live check cannot reach Telegram, - If messages do not arrive, run `nanobot gateway --verbose` and check the bot
the token is still saved. Confirm the gateway can reach `api.telegram.org`, token.
or open **Advanced → Network proxy** and enter a proxy.
- If Telegram rejects the token, copy the current token from BotFather or
regenerate it.
- If messages do not arrive, run `nanobot gateway --verbose` and confirm the
Telegram channel is enabled.
- If a first DM returns a pairing code, that is expected. Approve the code before - If a first DM returns a pairing code, that is expected. Approve the code before
testing normal agent replies. testing normal agent replies.
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled. - If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
+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. 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 ## Quick Setup
@@ -11,7 +11,7 @@ The feature is disabled by default. Open **Settings → Image**, choose a config
1. Add the image provider credential under **Settings → Models** if it is not already configured. 1. Add the image provider credential under **Settings → Models** if it is not already configured.
2. Open **Settings → Image**. 2. Open **Settings → Image**.
3. Select the provider and image model, then enable image generation. 3. Select the provider and image model, then enable image generation.
4. Save 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** **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] > [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup. > Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -55,7 +55,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool | | `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, `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.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one | | `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` | | `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -70,9 +70,6 @@ Provider settings reuse normal provider config fields:
| `providers.<name>.apiBase` | Optional custom base URL | | `providers.<name>.apiBase` | Optional custom base URL |
| `providers.<name>.extraHeaders` | Headers merged into provider requests | | `providers.<name>.extraHeaders` | Headers merged into provider requests |
| `providers.<name>.extraBody` | Extra JSON fields merged into provider request bodies | | `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`. 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. Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
### ModelScope
ModelScope (魔搭社区) API-Inference supports text-to-image generation and image editing via an async task pattern.
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1664x928`) or using aspect ratio presets.
```json
{
"providers": {
"modelscope": {
"apiKey": "${MODELSCOPE_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "modelscope",
"model": "Qwen/Qwen-Image-2512"
}
}
}
```
## Artifacts ## Artifacts
Generated images are stored under the active nanobot instance's media directory: Generated images are stored under the active nanobot instance's media directory:
@@ -397,9 +371,9 @@ Use the reference image. Keep the same robot and composition, change the palette
| Symptom | Check | | 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 | | 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 | | AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later | | Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files | | Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
-10
View File
@@ -64,11 +64,6 @@ This is why nanobot's memory is not just archival. It is interpretive.
## The Files ## The Files
In this page, `workspace` means the configured **agent workspace** (the default
is `~/.nanobot/workspace/`, or the path passed with `--workspace`). Selecting a
different project in the WebUI changes that chat's project context and tool
working directory; it does not relocate the files below.
```text ```text
workspace/ workspace/
├── SOUL.md # The bot's long-term voice and communication style ├── SOUL.md # The bot's long-term voice and communication style
@@ -84,11 +79,6 @@ workspace/
└── .git/ # Version history for long-term memory files └── .git/ # Version history for long-term memory files
``` ```
A selected project may provide its own `AGENTS.md`, but project-local `SOUL.md`,
`USER.md`, and `memory/` do not replace the agent-owned files above. This keeps
one agent's profile and memory continuous while it works across projects. Use a
separate configured agent workspace when identity or memory must be isolated.
These files play different roles: These files play different roles:
- `SOUL.md` remembers how nanobot should sound. - `SOUL.md` remembers how nanobot should sound.
+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. 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 All modifications are held in memory only — restart restores defaults.
stored in the current session so the selection survives a restart.
--- ---
@@ -78,18 +77,20 @@ my(action="check", key="web_config.enable")
## set — Runtime tuning ## set — Runtime tuning
Changes do not require a restart. `model_preset` is saved for the current session and Changes take effect immediately, no restart required.
applies to its next turn; other writable runtime tuning takes effect immediately.
Direct `model` and `context_window_tokens` writes are rejected during an active session
because those setters change the shared instance default. Configure a named preset for
model or context-window changes instead.
```text ```text
my(action="set", key="max_iterations", value=80) my(action="set", key="max_iterations", value=80)
# → Bump iteration limit from 40 to 80 # → Bump iteration limit from 40 to 80
my(action="set", key="model_preset", value="fast") my(action="set", key="model_preset", value="fast")
# → 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: 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 | | Parameter | Type | Range | Purpose |
|-----------|------|-------|---------| |-----------|------|-------|---------|
| `max_iterations` | int | 1100 | Max tool calls per conversation turn | | `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 | | `context_window_tokens` | int | 4,0961,000,000 | Context window size |
| `model` | str | non-empty | Instance default; during a session, select through a preset | | `model` | str | non-empty | LLM model to use |
| `model_preset` | str | configured preset name | Current session's preset for its next turn | | `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. 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" ### "This task is complex, I need more room"
```text ```text
Agent: This codebase is large, let me switch this session to the configured deep preset. Agent: This codebase is large, let me expand my context window to handle it.
→ my(action="set", key="model_preset", value="deep") → my(action="set", key="context_window_tokens", value=262144)
``` ```
### "Simple question, don't waste compute" ### "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 ## Safety Mechanisms
Core design principle: **The tool does not rewrite `config.json`.** Instance-wide Core design principle: **All modifications live in memory only. Restart restores defaults.** The agent cannot cause persistent damage.
changes live in memory only, while `model_preset` persists only as the current
session's selector.
### Off-limits (BLOCKED) ### Off-limits (BLOCKED)
+2 -10
View File
@@ -431,13 +431,7 @@ curl -sS http://localhost:11434/v1/models
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
``` ```
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If every response is slow, try a smaller local model or lower `contextWindowTokens`. If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If the response is very slow, try a smaller local model or lower `contextWindowTokens`.
If direct Ollama responses are fast but tool-using nanobot turns repeatedly evaluate
thousands of prompt tokens, the model's chat template may be moving its tool
definitions between requests. See
[Improve Ollama Tool-Calling Prompt Cache Reuse](./guides/configure-ollama-prompt-cache.md)
for a diagnostic procedure and an optional model-specific workaround.
## Recipe: vLLM or LM Studio ## Recipe: vLLM or LM Studio
@@ -610,9 +604,7 @@ In chat:
/model fast /model fast
``` ```
`/model` stores the selection in the current session without rewriting `config.json`. `/model` switching is runtime-only. It does not rewrite `config.json`, and an in-progress turn keeps using the model it started with.
The selection survives restarts, does not affect other sessions, and an in-progress
turn keeps using the model it started with.
## Quick Failure Map ## Quick Failure Map
+2 -28
View File
@@ -63,11 +63,11 @@ These fields answer different questions:
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. | | `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. | | `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. | | `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers, 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`. 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 ## Common Provider Patterns
@@ -331,13 +331,6 @@ Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
Most Ollama setups do not require an API key. Most Ollama setups do not require an API key.
Ollama renders the OpenAI-compatible messages and tools through each model's chat
template. If ordinary model responses are fast but tool-using turns show low prompt
cache reuse, diagnose the rendered template before changing nanobot's context or
memory settings. The
[Ollama prompt-cache guide](./guides/configure-ollama-prompt-cache.md) explains the
log pattern and a tested `llama3.1:8b` workaround.
### vLLM or Other Local OpenAI-Compatible Server ### vLLM or Other Local OpenAI-Compatible Server
```json ```json
@@ -433,25 +426,6 @@ For OpenAI Codex:
nanobot provider login openai-codex --set-main nanobot provider login openai-codex --set-main
``` ```
For an eligible X Premium / Grok subscription:
```bash
nanobot provider login xai-grok --set-main
```
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and
exposes the hosted `x_search` tool only when the selected model advertises
`supportsBackendSearch`; otherwise the model runs without hosted X Search.
When enabled, Grok can search current X posts and return inline source links
without invoking a local nanobot tool. Credentials are stored under the
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
`config.json` and not in Grok Build's credential file.
The login is xAI subscription OAuth, not X Developer OAuth. It follows the
public client contract documented and implemented by
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md);
xAI may change that upstream contract independently of nanobot.
For GitHub Copilot: For GitHub Copilot:
```bash ```bash
+5 -7
View File
@@ -494,10 +494,8 @@ Run the agent once and return a `RunResult`.
| `model` | `str \| None` | `None` | Override the model for this run only. | | `model` | `str \| None` | `None` | Override the model for this run only. |
| `model_preset` | `str \| None` | `None` | Override the model preset for this run only. | | `model_preset` | `str \| None` | `None` | Override the model preset for this run only. |
Without an override, a run uses the preset saved in its session, or the configured `model` and `model_preset` are per-run overrides and do not change
default when that session has no saved selection. `model` and `model_preset` are `bot.runtime.model` after the run completes. They are mutually exclusive.
mutually exclusive per-run overrides; they do not change the saved session selection
or `bot.runtime.model` after the run completes.
### `await bot.run_streamed(...)` ### `await bot.run_streamed(...)`
@@ -533,9 +531,9 @@ async for event in bot.stream("Generate a long answer"):
| `await cancel()` | Cancel the run and release stream resources. | | `await cancel()` | Cancel the run and release stream resources. |
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. | | `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
SDK runs with different session keys may overlap, including runs with per-run Normal SDK runs with different session keys may overlap. Runs that use per-run
`model` or `model_preset` overrides. Each run receives an immutable runtime without `model` or `model_preset` overrides are exclusive while the override is active,
mutating the instance default. Runs sharing one session key remain serialized. because the current `AgentLoop` provider/model state is mutable.
### `StreamEvent` ### `StreamEvent`
+21 -20
View File
@@ -16,7 +16,7 @@ Git is only needed for a source install. The published package already contains
## 1. Install nanobot ## 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** **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). 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. 1. Choose the provider or endpoint that owns your credential.
2. Enter its API key or base URL when required. 2. Enter its API key or base URL when requested.
3. Create or select a model preset using a model ID that provider can run. 3. Enter a model ID that the same provider can run.
4. Save the configuration. 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 | | Path | Purpose |
|---|---| |---|---|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings | | `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files | | `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
If the installer did not open the browser, run: If the installer did not open the wizard, run it yourself:
```bash
nanobot webui
```
SSH, headless, existing-config, and older-release installs retain the terminal setup path:
```bash ```bash
nanobot onboard --wizard 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 ## 3. Check the Setup
```bash ```bash
@@ -78,7 +75,11 @@ Most other providers can say `not set`. This command validates local setup but d
## 4. Get the First Reply ## 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: Send:
@@ -130,20 +131,20 @@ After the first reply works, add one capability and test again:
## Other Install Methods ## 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** **uv**
```bash ```bash
uv tool install nanobot-ai uv tool install nanobot-ai
nanobot webui nanobot onboard --wizard
``` ```
**pip in a virtual environment** **pip in a virtual environment**
```bash ```bash
python -m pip install nanobot-ai 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. 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 git clone https://github.com/HKUDS/nanobot.git
cd nanobot cd nanobot
python -m pip install . 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. 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 ~/.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 ## Manual Configuration Fallback
-12
View File
@@ -6,18 +6,6 @@ For tagged releases, see [GitHub Releases](https://github.com/HKUDS/nanobot/rele
## Highlights ## Highlights
- **2026-07-24** 🧭 Guided first-run setup, inline subagents, and model switching from the composer.
- **2026-07-23** 🔎 Grok OAuth with hosted X Search, live image settings, and clearer fallback models.
- **2026-07-22** 🔌 Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI.
- **2026-07-21** ⚡ Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup.
- **2026-07-20** 💬 Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects.
- **2026-07-19** 🔀 Cross-provider failover, safer local triggers, WhatsApp group allowlists, and sturdier workspace staging.
- **2026-07-18** 🧰 More resilient automation recovery and UTF-8 CLI App installs.
- **2026-07-17** 🌙 Kimi K3 support, more reliable scheduled jobs, and cleaner provider behavior.
- **2026-07-16** 📁 Native folder picker bridges, tighter Docker defaults, and bounded session caching.
- **2026-07-15** 🔐 Short-lived Render access, safer gateway shutdown, validated file previews, and highlighted app mentions.
- **2026-07-14** 📎 Document attachments, one-click Render deployment, clearer workflow docs, and stronger Windows support.
- **2026-07-13** 🌍 Guided WebUI setup, Brazilian Portuguese, and steadier Dream, gateway, and Discord behavior.
- **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access. - **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access.
- **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits. - **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide. - **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
+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 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. 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. ```text
2. Enter its API key and base URL when required. > What would you like to do?
3. Create or select a model preset. [Q] Quick Start
4. Enter a model ID available to your provider account. [A] Advanced Settings
5. Save the configuration. [X] Exit
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
``` ```
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: 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. 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). 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: Run:
```bash ```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`.
+2 -50
View File
@@ -135,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`. | | Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. | | Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. | | Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
| OAuth provider fails | Run 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 OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. | | Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. | | Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. | | Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. | | Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
| xAI OAuth needs a proxy | Set `providers.xaiGrok.proxy` before login. It applies to OAuth discovery, token exchange/refresh, and Grok subscription requests. |
| xAI login runs on a remote/headless machine | In the WebUI, finish sign-in in your local browser; if the loopback redirect cannot reach the server, copy the final URL from the address bar into the WebUI dialog. From the CLI, run `nanobot provider login xai-grok` interactively, open the printed URL elsewhere, and paste the final callback URL or authorization code when prompted. |
| xAI returns 403 or subscription access denied | Confirm the signed-in account has an eligible X Premium / Grok subscription, then run `nanobot provider login xai-grok` again. This provider does not use an xAI API key or X Developer OAuth. |
| xAI returns 400 `invalid-argument` | Read the bounded `Response body` appended to the provider error. Hosted `x_search` is sent only when xAI's model catalog advertises `supportsBackendSearch`; the model ID `grok-4.5` itself is valid. |
| xAI model or X Search stops working after an upstream release | The integration follows Grok Build's public OAuth/proxy client contract. Update nanobot if xAI changes that contract. |
## Langfuse Problems ## Langfuse Problems
@@ -183,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. | | Port already in use | Change `gateway.port`, `channels.websocket.port`, or the `--port` CLI flag for the relevant command. |
| WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. | | WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. |
| Config changes ignored | Restart the gateway. | | Config changes ignored | Restart the gateway. |
| Startup pauses at `Installing optional feature` | An enabled channel is missing its Python dependencies. See [Slow Optional Channel Dependency Installation](#slow-optional-channel-dependency-installation). |
| Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. | | Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. |
| Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. | | Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. |
### Slow Optional Channel Dependency Installation
Before loading enabled channels, the gateway checks the dependencies declared by their
channel manifests. The CLI and WebUI normally install these dependencies when a channel is
enabled. Installation during startup is a recovery path for an enabled config whose Python
environment no longer has the required packages, for example after manually editing the
config, upgrading nanobot, or recreating an isolated `uv tool`/`pipx` environment. The
gateway waits for the install so an enabled channel is not silently skipped; later starts
skip the installation once the dependencies are present.
If access to PyPI is slow in your region, configure pip to use a trusted package index. The
installer honors the standard `PIP_INDEX_URL` environment variable, including when nanobot
itself was installed with `uv tool`:
```bash
PIP_INDEX_URL=https://your-trusted-mirror.example/simple nanobot gateway
```
For the systemd user service created by `nanobot gateway install-service`, add a drop-in:
```bash
systemctl --user edit nanobot-gateway.service
```
```ini
[Service]
Environment="PIP_INDEX_URL=https://your-trusted-mirror.example/simple"
```
Then reload and restart the service:
```bash
systemctl --user daemon-reload
systemctl --user restart nanobot-gateway.service
```
For a system-level or custom service, use `sudo systemctl edit <unit>` instead. Prefer an
HTTPS index operated by an organization you trust, and do not put index credentials in
commands or logs.
## WebUI Problems ## WebUI Problems
The packaged WebUI is served by the WebSocket channel. The packaged WebUI is served by the WebSocket channel.
@@ -275,9 +229,7 @@ Then check:
|---|---| |---|---|
| Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. | | Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. |
| Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. | | Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. |
| Telegram 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 fails | Confirm the BotFather token and `allowFrom` user ID. |
| Telegram rejects the token | Copy the current token from BotFather or regenerate it. |
| Telegram receives no messages | Confirm the channel is enabled, the gateway is running, and the sender is paired or listed in `allowFrom`. |
| Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. | | Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. |
| WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. | | WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. |
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. | | Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |
+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. 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 **`runtime_model_updated`** — broadcast when the gateway runtime model changes, for example after `/model <preset>`:
when a config reload requires clients to refresh their model catalog:
```json ```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 `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.
to refresh model settings after default-runtime and config changes. `/model <preset>`
is session-scoped; its selection is reflected through `session_updated` and the
session row's `model_preset` field instead of this global event.
**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)): **`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
+24 -54
View File
@@ -1,8 +1,8 @@
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents # Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
<!-- Meta description: Run nanobot from a browser WebUI with persistent 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 agent activity, workspace controls, Apps, Skills, settings, and Automations in
one place. one place.
@@ -17,12 +17,12 @@ Use the launcher:
nanobot webui nanobot webui
``` ```
`nanobot webui` creates the config/workspace when needed, 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 WebSocket channel after confirmation, generates a WebUI bootstrap secret when
one is missing, starts the gateway, and opens the browser. With a fresh config, one is missing, starts the gateway, and opens the browser. The first-run path
it can open before a model is configured so you can finish setup in **Settings binds the WebUI to `127.0.0.1` by default, so it is not available from other
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so devices on your LAN.
it is not available from other devices on your LAN.
Run it in the background when you do not want to keep a terminal open: Run it in the background when you do not want to keep a terminal open:
@@ -30,9 +30,6 @@ Run it in the background when you do not want to keep a terminal open:
nanobot webui --background nanobot webui --background
``` ```
Complete first-time model setup in a foreground `nanobot webui` session before using
`--background`.
Manage the background gateway with `nanobot gateway status`, `nanobot gateway Manage the background gateway with `nanobot gateway status`, `nanobot gateway
logs`, `nanobot gateway restart`, and `nanobot gateway stop`. logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
@@ -58,11 +55,11 @@ gateway health endpoint, `18790` by default, is not the browser UI.
## First 10 Minutes ## First 10 Minutes
Use the WebUI as the primary setup surface: Use the WebUI as the primary setup surface after Quick Start:
1. Open **Settings → Models** and configure a provider, credential, and active model preset. 1. Send `Hello!` in a new chat to prove the selected model works.
2. Send `Hello!` in a new topic to prove the selected model works. 2. Open **Settings → Models** and confirm the active model preset.
3. Start a separate topic before project work, then choose the intended workspace and access mode. 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**. 4. Add only one capability next: a chat channel in **Settings → Channels**, a web/voice/image provider in **Settings**, or an App/MCP integration in **Apps**.
5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request. 5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request.
@@ -72,7 +69,7 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
| Area | Use it for | | 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 | | Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
| Workspace | Pick the project workspace before asking for file or shell work | | Workspace | Pick the project workspace before asking for file or shell work |
| Access | Choose the access mode for local capabilities allowed by your gateway configuration | | Access | Choose the access mode for local capabilities allowed by your gateway configuration |
@@ -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 | | Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options | | Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
## Topic Workspace ## Chat Workspace
The sidebar is the topic switcher. Each topic keeps its own history, title, The sidebar is the session switcher. A session keeps its own history, title,
workspace selection, and linked automations. Use a new topic when you want a 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 separate context; use fork when you want to continue from an existing point
without changing the original thread. 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 agent the right project context for file paths, shell commands, and session
metadata. metadata.
Selecting a project does not replace the configured agent workspace. The two
paths have different responsibilities:
| Selected project provides | Agent workspace continues to provide |
|---|---|
| Project `AGENTS.md` | `SOUL.md` and `USER.md` |
| Relative file paths and shell working directory | Long-term memory and history |
| The normal read/write boundary in Restricted mode | Custom skills and instance state |
Project-local `SOUL.md` and `USER.md` files are ignored, and the agent workspace's
`AGENTS.md` is not inherited by a separately selected project. When the selected
project is the configured agent workspace, both roles naturally use the same
directory.
The access control in the composer controls the local capability level for the The access control in the composer controls the local capability level for the
chat. It does not bypass your gateway, provider, shell sandbox, or operating chat. It does not bypass your gateway, provider, shell sandbox, or operating
system configuration; it only selects among the capabilities that are already system configuration; it only selects among the capabilities that are already
available to the current topic. available to this WebUI session.
In Restricted mode, ordinary file and shell work stays inside the selected Remote WebUI sessions may reduce access for the current workspace. Selecting a
project. To preserve agent continuity, filesystem/search tools receive narrow,
read-only access to built-in skills, custom skills in the agent workspace, and
the exact agent `memory/history.jsonl` file. This does not grant access to
neighboring memory or profile files, and it does not allow writes outside the
selected project. These tool exceptions do not broaden the browser's file
preview boundary.
Remote WebUI connections may reduce access for the current workspace. Selecting a
different workspace or enabling Full Access remains limited to local and native different workspace or enabling Full Access remains limited to local and native
clients. clients.
@@ -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 built-in web search provider; mention the Firecrawl MCP preset with `@` when a
turn needs Firecrawl's richer web data tools. turn needs Firecrawl's richer web data tools.
The Parallel Search preset connects to the free, anonymous Parallel Search MCP
endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
It is an optional integration and does not replace nanobot's built-in web search
provider; mention `@parallel-search` when a turn should use it.
After an App or integration is available, mention it from the composer with After an App or integration is available, mention it from the composer with
`@` to attach that tool to the next message. `@` to attach that tool to the next message.
@@ -207,10 +177,10 @@ to perform that task.
## Automations ## Automations
Automations are agent turns that run later in a linked topic. Create them from Automations are agent turns that run later in a linked chat/session. They should
the topic or channel where they are supposed to run so nanobot keeps the be created from the chat, channel, or session where they are supposed to run so
correct target context. When an automation runs, it normally delivers the nanobot keeps the correct target context. When an automation runs, it normally
result back to that topic. delivers the result back to that linked chat.
For the full automation model, creation flow, trigger CLI usage, and delivery For the full automation model, creation flow, trigger CLI usage, and delivery
semantics, see [`automations.md`](./automations.md). semantics, see [`automations.md`](./automations.md).
@@ -229,7 +199,7 @@ instead of creating a chat automation.
Use the Automations view to: Use the Automations view to:
- Filter by all, active, paused, needs-attention, or system jobs. - Filter by all, active, paused, needs-attention, or system jobs.
- Search by task name, message, trigger command, linked 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. - Sort by next run, last run, updated time, or name.
- Run scheduled automations now. - Run scheduled automations now.
- Pause or resume, rename, or delete user-created automations. - Pause or resume, rename, or delete user-created automations.
@@ -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 `chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
`status:paused`. `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 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 Local triggers do not have a WebUI "Run now" action because each run needs a
message. Use the copied `nanobot trigger ...` command and replace `"message"` message. Use the copied `nanobot trigger ...` command and replace `"message"`
-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

+1 -1
View File
@@ -22,7 +22,7 @@ def _resolve_version() -> str:
return _pkg_version("nanobot-ai") return _pkg_version("nanobot-ai")
except PackageNotFoundError: except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info. # Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.3.0" return _read_pyproject_version() or "0.2.2"
__version__ = _resolve_version() __version__ = _resolve_version()
+2 -7
View File
@@ -66,7 +66,7 @@ class AutoCompact:
def check_expired( def check_expired(
self, self,
schedule_background: Callable[[Coroutine], None], schedule_background: Callable[[Coroutine], None],
resolve_runtime: Callable[[Session], LLMRuntime], resolve_runtime: Callable[[], LLMRuntime],
active_session_keys: Collection[str] = (), active_session_keys: Collection[str] = (),
) -> None: ) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks.""" """Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
@@ -79,12 +79,7 @@ class AutoCompact:
continue continue
updated_at = info.get("updated_at") updated_at = info.get("updated_at")
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key): if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
session = self.sessions.get_or_create(key) runtime = resolve_runtime()
try:
runtime = resolve_runtime(session)
except (KeyError, ValueError):
# Invalid session selections remain recoverable through /model.
continue
self._archiving.add(key) self._archiving.add(key)
schedule_background(self._archive(key, runtime=runtime)) schedule_background(self._archive(key, runtime=runtime))
+4 -30
View File
@@ -8,7 +8,6 @@ from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
from nanobot.agent.tools import image_generation as image_generation_tools
from nanobot.agent.tools import mcp as mcp_tools from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.cli import utils as cli_app_utils from nanobot.apps.cli import utils as cli_app_utils
@@ -42,20 +41,13 @@ async def close_mcp(state: Any) -> None:
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool: async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
for handler in ( return await mcp_tools.handle_runtime_control(state, msg, tools)
image_generation_tools.handle_runtime_control,
mcp_tools.handle_runtime_control,
):
if await handler(state, msg, tools):
return True
return False
class ContextBuilder: class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent.""" """Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG _RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
_MAX_RECENT_HISTORY = 50 _MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens) _MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
@@ -124,14 +116,12 @@ class ContextBuilder:
"""Get the core identity section.""" """Get the core identity section."""
root = workspace or self.workspace root = workspace or self.workspace
workspace_path = str(root.expanduser().resolve()) workspace_path = str(root.expanduser().resolve())
agent_workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system() system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return render_template( return render_template(
"agent/identity.md", "agent/identity.md",
workspace_path=workspace_path, workspace_path=workspace_path,
agent_workspace_path=agent_workspace_path,
runtime=runtime, runtime=runtime,
platform_policy=render_template("agent/platform_policy.md", system=system), platform_policy=render_template("agent/platform_policy.md", system=system),
channel=channel or "", channel=channel or "",
@@ -156,30 +146,14 @@ class ContextBuilder:
return _to_blocks(left) + _to_blocks(right) return _to_blocks(left) + _to_blocks(right)
def _load_bootstrap_files(self, workspace: Path | None = None) -> str: def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load project instructions plus the agent's global profile files.""" """Load all bootstrap files from workspace."""
parts = [] parts = []
project_root = workspace or self.workspace root = workspace or self.workspace
sources = [
("AGENTS.md", project_root),
("SOUL.md", self.workspace),
("USER.md", self.workspace),
]
for filename, root in sources: for filename in self.BOOTSTRAP_FILES:
file_path = root / filename file_path = root / filename
if file_path.exists(): if file_path.exists():
content = file_path.read_text(encoding="utf-8") content = file_path.read_text(encoding="utf-8")
if filename == "SOUL.md" and self._is_template_content(
content,
"legacy/SOUL.md",
):
content = load_bundled_template("SOUL.md") or content
if not content.strip():
continue
if filename in self._SKIPPABLE_DEFAULTS and self._is_template_content(
content, filename
):
continue
parts.append(f"## {filename}\n\n{content}") parts.append(f"## {filename}\n\n{content}")
return "\n\n".join(parts) if parts else "" return "\n\n".join(parts) if parts else ""
+2 -5
View File
@@ -232,9 +232,8 @@ class ContextGovernor:
def drop_orphan_tool_results( def drop_orphan_tool_results(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Drop 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() declared: set[str] = set()
fulfilled: set[str] = set()
updated: list[dict[str, Any]] | None = None updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages): for idx, msg in enumerate(messages):
role = msg.get("role") role = msg.get("role")
@@ -244,12 +243,10 @@ class ContextGovernor:
declared.add(str(tc["id"])) declared.add(str(tc["id"]))
if role == "tool": if role == "tool":
tid = msg.get("tool_call_id") tid = msg.get("tool_call_id")
tid_str = str(tid) if tid else "" if tid and str(tid) not in declared:
if not tid_str or tid_str not in declared or tid_str in fulfilled:
if updated is None: if updated is None:
updated = [dict(m) for m in messages[:idx]] updated = [dict(m) for m in messages[:idx]]
continue continue
fulfilled.add(tid_str)
if updated is not None: if updated is not None:
updated.append(dict(msg)) updated.append(dict(msg))
-16
View File
@@ -25,7 +25,6 @@ class AgentHookContext:
tool_events: list[dict[str, str]] = field(default_factory=list) tool_events: list[dict[str, str]] = field(default_factory=list)
streamed_content: bool = False streamed_content: bool = False
streamed_reasoning: bool = False streamed_reasoning: bool = False
stream_continues_current_message: bool = False
final_content: str | None = None final_content: str | None = None
stop_reason: str | None = None stop_reason: str | None = None
error: str | None = None error: str | None = None
@@ -91,14 +90,6 @@ class AgentHook:
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
pass pass
async def on_provider_tool_event(
self,
context: AgentHookContext,
event: dict[str, Any],
) -> None:
"""Observe a provider-hosted tool lifecycle event."""
pass
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
pass pass
@@ -201,13 +192,6 @@ class CompositeHook(AgentHook):
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
await self._for_each_hook_safe("on_stream_end", context, resuming=resuming) await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
async def on_provider_tool_event(
self,
context: AgentHookContext,
event: dict[str, Any],
) -> None:
await self._for_each_hook_safe("on_provider_tool_event", context, event)
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
await self._for_each_hook_safe("before_execute_tools", context) await self._for_each_hook_safe("before_execute_tools", context)
+334 -403
View File
File diff suppressed because it is too large Load Diff
+42 -67
View File
@@ -43,33 +43,13 @@ if TYPE_CHECKING:
# MemoryStore — pure file I/O layer # 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(event, dict) and event.get("phase") == "error"
for event in tool_events or ()
):
self.had_tool_errors = True
class MemoryStore: class MemoryStore:
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md.""" """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_DEFAULT_MAX_HISTORY = 1000 _DEFAULT_MAX_HISTORY = 1000
# Durable files whose real working-tree delta grounds Dream commit messages. # Durable files whose real working-tree delta grounds Dream commit messages
# Deliberately excludes memory/.dream_cursor so progress bookkeeping never # and the cursor-advance gate. Deliberately excludes memory/.dream_cursor so
# appears as a durable-memory edit in the audit record. # that advancing the cursor itself is never mistaken for a productive edit.
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md") _DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
# Per-file cap when embedding current contents into the Dream prompt. The # 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 # durable files are tiny in practice (~5 KB total), but a runaway file must
@@ -453,11 +433,9 @@ class MemoryStore:
line = line.strip() line = line.strip()
if line: if line:
try: try:
parsed = json.loads(line) entries.append(json.loads(line))
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if isinstance(parsed, dict):
entries.append(parsed)
return entries return entries
@@ -475,8 +453,7 @@ class MemoryStore:
lines = [line for line in data.split("\n") if line.strip()] lines = [line for line in data.split("\n") if line.strip()]
if not lines: if not lines:
return None return None
parsed = json.loads(lines[-1]) return json.loads(lines[-1])
return parsed if isinstance(parsed, dict) else None
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError): except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return None return None
@@ -606,7 +583,8 @@ class MemoryStore:
"""Structured summary of uncommitted changes to the durable memory files. """Structured summary of uncommitted changes to the durable memory files.
Returns "" when git is unavailable or no content file changed. This is 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(): if not self._git.is_initialized():
return "" return ""
@@ -655,18 +633,10 @@ class MemoryStore:
return tools return tools
@staticmethod @staticmethod
def dream_run_completed( def dream_run_completed(resp: object | None) -> bool:
resp: object | None, """Return True only when an ephemeral Dream agent turn completed cleanly."""
*,
had_tool_errors: bool = False,
) -> bool:
"""Return True only when a Dream turn completed without tool failures."""
metadata = getattr(resp, "metadata", None) metadata = getattr(resp, "metadata", None)
return ( return isinstance(metadata, dict) and metadata.get("_stop_reason") == "completed"
not had_tool_errors
and isinstance(metadata, dict)
and metadata.get("_stop_reason") == "completed"
)
# -- message formatting utility ------------------------------------------ # -- message formatting utility ------------------------------------------
@@ -971,19 +941,18 @@ class Consolidator:
messages_to_summarize = public_history_messages( messages_to_summarize = public_history_messages(
summary_messages if summary_messages is not None else messages summary_messages if summary_messages is not None else messages
) )
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: try:
formatted = MemoryStore._format_messages(messages_to_summarize)
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
response = await runtime.provider.chat_with_retry( response = await runtime.provider.chat_with_retry(
model=runtime.model, model=runtime.model,
messages=[ messages=[
{ {
"role": "system", "role": "system",
"content": system_prompt, "content": render_template(
"agent/consolidator_archive.md",
strip=True,
),
}, },
{"role": "user", "content": formatted}, {"role": "user", "content": formatted},
], ],
@@ -993,21 +962,19 @@ class Consolidator:
max_tokens=runtime.generation.max_tokens, max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort, reasoning_effort=runtime.generation.reasoning_effort,
) )
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: 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) self.store.raw_archive(messages, session_key=session_key)
return None 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( async def maybe_consolidate_by_tokens(
self, self,
@@ -1040,10 +1007,14 @@ class Consolidator:
replay_max_messages, replay_max_messages,
runtime=runtime, runtime=runtime,
) )
estimated, source = self.estimate_session_prompt_tokens( try:
session, estimated, source = self.estimate_session_prompt_tokens(
runtime=runtime, session,
) runtime=runtime,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0: if estimated <= 0:
self._persist_last_summary(session, last_summary) self._persist_last_summary(session, last_summary)
return return
@@ -1106,10 +1077,14 @@ class Consolidator:
# the next invocation can retry a fresh chunk. # the next invocation can retry a fresh chunk.
break break
estimated, source = self.estimate_session_prompt_tokens( try:
session, estimated, source = self.estimate_session_prompt_tokens(
runtime=runtime, session,
) runtime=runtime,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0: if estimated <= 0:
break break
+4 -22
View File
@@ -2,9 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
from typing import Any from typing import Any
from nanobot.config.schema import ModelPresetConfig from nanobot.config.schema import ModelPresetConfig
@@ -12,31 +10,16 @@ from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
PresetSnapshotLoader = Callable[[str], ProviderSnapshot] PresetSnapshotLoader = Callable[[str], ProviderSnapshot]
PresetCatalogLoader = Callable[[], Mapping[str, ModelPresetConfig]]
def default_selection_signature( def default_selection_signature(signature: tuple[object, ...] | None) -> tuple[object, ...] | None:
signature: tuple[object, ...] | None, return signature[:2] if signature else None
model_preset: str | None = None,
) -> tuple[object, ...] | None:
return (model_preset, *signature[:2]) if signature else None
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]: def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
return {**config.model_presets, "default": config.resolve_default_preset()} return {**config.model_presets, "default": config.resolve_default_preset()}
def load_model_preset_catalog(
config_path: Path | None = None,
) -> dict[str, ModelPresetConfig]:
"""Load the current preset catalog from the configured file."""
from nanobot.config.loader import load_config, resolve_config_env_vars
return configured_model_presets(
resolve_config_env_vars(load_config(config_path)),
)
def make_preset_snapshot_loader( def make_preset_snapshot_loader(
config: Any, config: Any,
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None, provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
@@ -57,7 +40,6 @@ def build_static_preset_snapshot(
context_window_tokens=preset.context_window_tokens, context_window_tokens=preset.context_window_tokens,
signature=("model_preset", name, preset.model_dump_json()), signature=("model_preset", name, preset.model_dump_json()),
generation=preset.to_generation_settings(), generation=preset.to_generation_settings(),
model_preset=name,
) )
@@ -69,7 +51,7 @@ def build_runtime_preset_snapshot(
loader: PresetSnapshotLoader | None, loader: PresetSnapshotLoader | None,
) -> ProviderSnapshot: ) -> ProviderSnapshot:
if loader is not None: if loader is not None:
return replace(loader(name), model_preset=name) return loader(name)
return build_static_preset_snapshot(provider, name, presets[name]) return build_static_preset_snapshot(provider, name, presets[name])
+14 -54
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import replace from dataclasses import replace
from types import MappingProxyType
from nanobot.agent import model_presets as preset_helpers from nanobot.agent import model_presets as preset_helpers
from nanobot.config.schema import Config, ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
@@ -25,23 +24,16 @@ class ModelRuntimeResolver:
initial_runtime: LLMRuntime, initial_runtime: LLMRuntime,
*, *,
model_presets: Mapping[str, ModelPresetConfig] | None = None, model_presets: Mapping[str, ModelPresetConfig] | None = None,
preset_catalog_loader: preset_helpers.PresetCatalogLoader | None = None,
configured_default_preset: str | None = None,
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None, provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None, preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
) -> None: ) -> None:
self._runtime = initial_runtime self._runtime = initial_runtime
self._model_presets = dict(model_presets or {}) self._model_presets = dict(model_presets or {})
self._preset_catalog_loader = preset_catalog_loader
self._preset_catalog_refresh_required = False
self._provider_snapshot_loader = provider_snapshot_loader self._provider_snapshot_loader = provider_snapshot_loader
self._preset_snapshot_loader = preset_snapshot_loader self._preset_snapshot_loader = preset_snapshot_loader
self._refresh_required = False
self._resolved_presets: dict[str, LLMRuntime] = {}
self._tracks_provider_generation = initial_runtime.model_preset is None self._tracks_provider_generation = initial_runtime.model_preset is None
self._default_selection_signature = preset_helpers.default_selection_signature( self._default_selection_signature = preset_helpers.default_selection_signature(
initial_runtime.snapshot_signature, initial_runtime.snapshot_signature
configured_default_preset,
) )
@property @property
@@ -51,11 +43,7 @@ class ModelRuntimeResolver:
@property @property
def model_presets(self) -> Mapping[str, ModelPresetConfig]: def model_presets(self) -> Mapping[str, ModelPresetConfig]:
self._refresh_preset_catalog() return self._model_presets
return MappingProxyType({
name: preset.model_copy(deep=True)
for name, preset in self._model_presets.items()
})
@property @property
def model_preset(self) -> str | None: def model_preset(self) -> str | None:
@@ -72,63 +60,40 @@ class ModelRuntimeResolver:
self._refresh_provider_generation() self._refresh_provider_generation()
return self._runtime return self._runtime
def admit(self) -> LLMRuntime:
"""Resolve the immutable runtime for the next turn admission."""
if self._refresh_required:
self.refresh()
self._refresh_provider_generation()
return self._runtime
def invalidate(self) -> None:
"""Refresh configured runtime state on the next admission."""
self._refresh_required = True
self._preset_catalog_refresh_required = True
self._resolved_presets.clear()
def _refresh_preset_catalog(self) -> None:
if not self._preset_catalog_refresh_required:
return
if self._preset_catalog_loader is not None:
self._model_presets = dict(self._preset_catalog_loader())
self._preset_catalog_refresh_required = False
def resolve_snapshot( def resolve_snapshot(
self, self,
snapshot: ProviderSnapshot, snapshot: ProviderSnapshot,
*,
model_preset: str | None = None,
) -> LLMRuntime: ) -> LLMRuntime:
"""Resolve a factory snapshot without changing the selected default.""" """Resolve a factory snapshot without changing the selected default."""
return runtime_from_provider_snapshot(snapshot) return runtime_from_provider_snapshot(snapshot, model_preset=model_preset)
def adopt_snapshot( def adopt_snapshot(
self, self,
snapshot: ProviderSnapshot, snapshot: ProviderSnapshot,
*,
model_preset: str | None = None,
) -> LLMRuntime: ) -> LLMRuntime:
"""Select a snapshot as the default for future turns.""" """Select a snapshot as the default for future turns."""
runtime = self.resolve_snapshot(snapshot) runtime = self.resolve_snapshot(snapshot, model_preset=model_preset)
self._runtime = runtime 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( self._default_selection_signature = preset_helpers.default_selection_signature(
runtime.snapshot_signature, runtime.snapshot_signature
runtime.model_preset,
) )
return runtime return runtime
def resolve_preset(self, name: str | None) -> LLMRuntime: def resolve_preset(self, name: str | None) -> LLMRuntime:
"""Resolve a named preset without changing the selected default.""" """Resolve a named preset without changing the selected default."""
self._refresh_preset_catalog()
normalized = preset_helpers.normalize_preset_name(name, self._model_presets) normalized = preset_helpers.normalize_preset_name(name, self._model_presets)
cached = self._resolved_presets.get(normalized)
if cached is not None:
return cached
snapshot = preset_helpers.build_runtime_preset_snapshot( snapshot = preset_helpers.build_runtime_preset_snapshot(
name=normalized, name=normalized,
presets=self._model_presets, presets=self._model_presets,
provider=self._runtime.provider, provider=self._runtime.provider,
loader=self._preset_snapshot_loader, loader=self._preset_snapshot_loader,
) )
runtime = self.resolve_snapshot(snapshot) return self.resolve_snapshot(snapshot, model_preset=normalized)
self._resolved_presets[normalized] = runtime
return runtime
def select_preset(self, name: str | None) -> LLMRuntime: def select_preset(self, name: str | None) -> LLMRuntime:
"""Select a named preset as the default for future turns.""" """Select a named preset as the default for future turns."""
@@ -181,26 +146,21 @@ class ModelRuntimeResolver:
def refresh(self) -> LLMRuntime | None: def refresh(self) -> LLMRuntime | None:
"""Refresh configured defaults and return the replacement when changed.""" """Refresh configured defaults and return the replacement when changed."""
if self._provider_snapshot_loader is None: if self._provider_snapshot_loader is None:
self._refresh_required = False
return None return None
self._resolved_presets.clear()
snapshot = self._provider_snapshot_loader() snapshot = self._provider_snapshot_loader()
default_selection = preset_helpers.default_selection_signature( default_selection = preset_helpers.default_selection_signature(snapshot.signature)
snapshot.signature,
snapshot.model_preset,
)
active_preset = self._runtime.model_preset active_preset = self._runtime.model_preset
if active_preset and self._default_selection_signature in (None, default_selection): if active_preset and self._default_selection_signature in (None, default_selection):
runtime = self.resolve_preset(active_preset) runtime = self.resolve_preset(active_preset)
else: else:
active_preset = None
runtime = self.resolve_snapshot(snapshot) runtime = self.resolve_snapshot(snapshot)
unchanged = ( unchanged = (
runtime.snapshot_signature == self._runtime.snapshot_signature runtime.snapshot_signature == self._runtime.snapshot_signature
and runtime.model_preset == self._runtime.model_preset and runtime.model_preset == self._runtime.model_preset
) )
self._refresh_required = False
if unchanged: if unchanged:
self._default_selection_signature = default_selection self._default_selection_signature = default_selection
return None return None
@@ -210,7 +170,7 @@ class ModelRuntimeResolver:
self._default_selection_signature, self._default_selection_signature,
) = ( ) = (
runtime, runtime,
runtime.model_preset is None, active_preset is None,
default_selection, default_selection,
) )
return runtime return runtime
+1 -64
View File
@@ -9,7 +9,6 @@ from typing import Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.providers.base import ToolCallRequest
from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think
from nanobot.utils.progress_events import ( from nanobot.utils.progress_events import (
build_tool_event_finish_payloads, build_tool_event_finish_payloads,
@@ -85,13 +84,7 @@ class AgentProgressHook(AgentHook):
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
await self.emit_reasoning_end() await self.emit_reasoning_end()
if self._on_stream_end: if self._on_stream_end:
kwargs: dict[str, bool] = {"resuming": resuming} await self._on_stream_end(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)
self._stream_buf = "" self._stream_buf = ""
self._think_extractor.reset() self._think_extractor.reset()
@@ -104,61 +97,6 @@ class AgentProgressHook(AgentHook):
self._session_key, self._session_key,
) )
async def on_provider_tool_event(
self,
context: AgentHookContext,
event: dict[str, Any],
) -> None:
if not self._on_progress:
return
phase = event.get("phase")
name = event.get("name")
call_id = event.get("call_id")
if (
phase not in {"start", "end", "error"}
or not isinstance(name, str)
or not name
or not call_id
):
return
arguments = event.get("arguments")
if not isinstance(arguments, dict):
arguments = {}
payload = {
"version": 1,
"phase": phase,
"call_id": str(call_id),
"name": name,
"arguments": arguments,
"result": event.get("result") if phase == "end" else None,
"error": event.get("error") if phase == "error" else None,
"files": [],
"embeds": [],
}
if phase == "start":
await self.emit_reasoning_end()
tool_call = ToolCallRequest(id=str(call_id), name=name, arguments=arguments)
tool_hint = self._strip_think(self._tool_hint([tool_call])) or name
await invoke_on_progress(
self._on_progress,
tool_hint,
tool_hint=True,
tool_events=[payload],
)
logger.info(
"Provider-hosted tool call: {}({})",
name,
json.dumps(arguments, ensure_ascii=False)[:200],
)
return
if on_progress_accepts_tool_events(self._on_progress):
await invoke_on_progress(
self._on_progress,
"",
tool_hint=False,
tool_events=[payload],
)
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
if self._on_progress: if self._on_progress:
if not self._on_stream and not context.streamed_content: if not self._on_stream and not context.streamed_content:
@@ -176,7 +114,6 @@ class AgentProgressHook(AgentHook):
for tc in context.tool_calls: for tc in context.tool_calls:
args_str = json.dumps(tc.arguments, ensure_ascii=False) args_str = json.dumps(tc.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tc.name, args_str[:200]) logger.info("Tool call: {}({})", tc.name, args_str[:200])
async def emit_reasoning(self, reasoning_content: str | None) -> None: async def emit_reasoning(self, reasoning_content: str | None) -> None:
"""Publish a reasoning chunk; channel plugins decide whether to render.""" """Publish a reasoning chunk; channel plugins decide whether to render."""
if ( if (
+52 -160
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import os import os
from contextlib import suppress
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -19,11 +20,6 @@ from nanobot.agent.context_governance import (
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest 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.session.history_visibility import is_hidden_history_message
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
IncrementalThinkExtractor, IncrementalThinkExtractor,
@@ -60,18 +56,6 @@ _MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3 _MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5 _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) @dataclass(slots=True)
class AgentRunSpec: class AgentRunSpec:
"""Configuration for a single agent execution.""" """Configuration for a single agent execution."""
@@ -113,8 +97,6 @@ class AgentRunResult:
error: str | None = None error: str | None = None
tool_events: list[dict[str, str]] = field(default_factory=list) tool_events: list[dict[str, str]] = field(default_factory=list)
had_injections: bool = False 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: class AgentRunner:
@@ -156,51 +138,10 @@ class AgentRunner:
and not is_hidden_history_message(messages[-1]) and not is_hidden_history_message(messages[-1])
): ):
merged = dict(messages[-1]) merged = dict(messages[-1])
left_meta = merged.get("_meta") merged["content"] = cls._merge_message_content(
right_meta = injection.get("_meta") merged.get("content"),
left_marker = ( injection.get("content"),
left_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(left_meta, dict)
else None
) )
right_marker = (
right_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(right_meta, dict)
else None
)
detached_left = (
detach_runtime_context(merged.get("content"), left_marker)
if isinstance(left_marker, dict)
else (merged.get("content"), [], [])
)
detached_right = (
detach_runtime_context(injection.get("content"), right_marker)
if isinstance(right_marker, dict)
else (injection.get("content"), [], [])
)
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) if isinstance(left_meta, dict) else {}
if isinstance(right_meta, dict):
for key, value in right_meta.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 messages[-1] = merged
continue continue
messages.append(injection) messages.append(injection)
@@ -394,13 +335,10 @@ class AgentRunner:
# Per-turn throttle for repeated attempts against the same outside target. # Per-turn throttle for repeated attempts against the same outside target.
workspace_violation_counts: dict[str, int] = {} workspace_violation_counts: dict[str, int] = {}
empty_content_retries = 0 empty_content_retries = 0
# Segments from one uninterrupted length-recovery chain. Tool work or length_recovery_count = 0
# injected user input starts a new logical answer and clears the chain.
length_recovery_parts: list[str] = []
had_injections = False had_injections = False
injection_cycles = 0 injection_cycles = 0
compacted_tool_call_ids: set[str] = set() compacted_tool_call_ids: set[str] = set()
pending_stream_content: str | None = None
governance_config = ContextGovernanceConfig( governance_config = ContextGovernanceConfig(
provider=spec.runtime.provider, provider=spec.runtime.provider,
model=spec.runtime.model, model=spec.runtime.model,
@@ -415,16 +353,37 @@ class AgentRunner:
) )
for iteration in range(spec.max_iterations): for iteration in range(spec.max_iterations):
# Keep the persisted conversation untouched. Context governance try:
# may repair or compact historical messages for the model, but # Keep the persisted conversation untouched. Context governance
# those synthetic edits must not shift the append boundary used # may repair or compact historical messages for the model, but
# later when the caller saves only the new turn. A governance # those synthetic edits must not shift the append boundary used
# failure must stop the run instead of sending an ungoverned copy. # later when the caller saves only the new turn.
messages_for_model = self.context_governor.prepare_for_model( messages_for_model = self.context_governor.prepare_for_model(
governance_config, governance_config,
messages, messages,
compacted_tool_call_ids, compacted_tool_call_ids,
) )
except Exception:
logger.exception(
"Context governance failed on turn {} for {}; applying minimal repair",
iteration,
spec.session_key or "default",
)
try:
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
messages
)
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
messages_for_model
)
messages_for_model = ContextGovernor.drop_orphan_tool_results(
messages_for_model
)
messages_for_model = ContextGovernor.backfill_missing_tool_results(
messages_for_model
)
except Exception:
messages_for_model = messages
context = AgentHookContext( context = AgentHookContext(
iteration=iteration, iteration=iteration,
messages=messages, messages=messages,
@@ -435,7 +394,6 @@ class AgentRunner:
context.response = response context.response = response
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
original_content = response.content
reasoning_text, cleaned_content = extract_reasoning( reasoning_text, cleaned_content = extract_reasoning(
response.reasoning_content, response.reasoning_content,
response.thinking_blocks, response.thinking_blocks,
@@ -522,7 +480,6 @@ class AgentRunner:
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
length_recovery_parts.clear()
continue continue
break break
await self._emit_checkpoint( await self._emit_checkpoint(
@@ -537,7 +494,7 @@ class AgentRunner:
}, },
) )
empty_content_retries = 0 empty_content_retries = 0
length_recovery_parts.clear() length_recovery_count = 0
# Checkpoint 1: drain injections after tools, before next LLM call # Checkpoint 1: drain injections after tools, before next LLM call
_drained, injection_cycles = await self._try_drain_injections( _drained, injection_cycles = await self._try_drain_injections(
spec, messages, None, injection_cycles, spec, messages, None, injection_cycles,
@@ -586,50 +543,29 @@ class AgentRunner:
context.response = response context.response = response
context.usage = dict(raw_usage) context.usage = dict(raw_usage)
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
original_content = response.content
clean = hook.finalize_content(context, response.content) clean = hook.finalize_content(context, response.content)
if response.finish_reason == "length" and not is_blank_text(clean): if response.finish_reason == "length" and not is_blank_text(clean):
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES: length_recovery_count += 1
length_recovery_parts.append( if length_recovery_count <= _MAX_LENGTH_RECOVERIES:
_restore_outer_whitespace(clean, original_content)
)
logger.info( logger.info(
"Output truncated on turn {} for {} ({}/{}); continuing", "Output truncated on turn {} for {} ({}/{}); continuing",
iteration, iteration,
spec.session_key or "default", spec.session_key or "default",
len(length_recovery_parts), length_recovery_count,
_MAX_LENGTH_RECOVERIES, _MAX_LENGTH_RECOVERIES,
) )
if hook.wants_streaming(): if hook.wants_streaming():
context.stream_continues_current_message = True
await hook.on_stream_end(context, resuming=True) await hook.on_stream_end(context, resuming=True)
messages.append(build_assistant_message( messages.append(build_assistant_message(
clean, clean,
reasoning_content=response.reasoning_content, reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks, thinking_blocks=response.thinking_blocks,
)) ))
messages.append(build_length_recovery_message(clean)) messages.append(build_length_recovery_message())
await hook.after_iteration(context) await hook.after_iteration(context)
continue 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, original_content),
)
context.streamed_content = True
assistant_message: dict[str, Any] | None = None assistant_message: dict[str, Any] | None = None
if response.finish_reason != "error" and not is_blank_text(clean): if response.finish_reason != "error" and not is_blank_text(clean):
assistant_message = build_assistant_message( assistant_message = build_assistant_message(
@@ -654,7 +590,6 @@ class AgentRunner:
await hook.on_stream_end(context, resuming=should_continue) await hook.on_stream_end(context, resuming=should_continue)
if should_continue: if should_continue:
length_recovery_parts.clear()
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
@@ -676,7 +611,6 @@ class AgentRunner:
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
length_recovery_parts.clear()
continue continue
break break
if is_blank_text(clean): if is_blank_text(clean):
@@ -694,7 +628,6 @@ class AgentRunner:
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
length_recovery_parts.clear()
continue continue
break break
@@ -714,13 +647,7 @@ class AgentRunner:
"pending_tool_calls": [], "pending_tool_calls": [],
}, },
) )
if length_recovery_parts: final_content = clean
final_content = (
"".join(length_recovery_parts)
+ _restore_outer_whitespace(clean, original_content)
).strip()
else:
final_content = clean
context.final_content = final_content context.final_content = final_content
context.stop_reason = stop_reason context.stop_reason = stop_reason
await hook.after_iteration(context) await hook.after_iteration(context)
@@ -738,25 +665,17 @@ class AgentRunner:
) )
if drained_after_max_iterations: if drained_after_max_iterations:
had_injections = True had_injections = True
terminal_content = None final_content = None
if spec.finalize_on_max_iterations: 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, spec,
hook, hook,
messages, messages,
usage, usage,
) )
if terminal_content is None: if final_content is None:
terminal_content = self._max_iterations_fallback(spec) final_content = self._max_iterations_fallback(spec)
if length_recovery_parts: self._append_final_message(messages, final_content)
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)
return AgentRunResult( return AgentRunResult(
final_content=final_content, final_content=final_content,
@@ -767,7 +686,6 @@ class AgentRunner:
error=error, error=error,
tool_events=tool_events, tool_events=tool_events,
had_injections=had_injections, had_injections=had_injections,
pending_stream_content=pending_stream_content,
) )
def _build_request_kwargs( def _build_request_kwargs(
@@ -826,20 +744,6 @@ class AgentRunner:
) )
progress_state: dict[str, bool] | None = None progress_state: dict[str, bool] | None = None
active_hosted_tools: dict[str, dict[str, Any]] = {}
async def _provider_tool_event(event: dict[str, Any]) -> None:
if event.get("kind") != "hosted_tool":
return
await hook.on_provider_tool_event(context, event)
call_id = event.get("call_id")
if not call_id:
return
call_id = str(call_id)
if event.get("phase") == "start":
active_hosted_tools[call_id] = dict(event)
elif event.get("phase") in {"end", "error"}:
active_hosted_tools.pop(call_id, None)
if wants_streaming: if wants_streaming:
thinking_buf = "" thinking_buf = ""
@@ -868,7 +772,6 @@ class AgentRunner:
**kwargs, **kwargs,
on_content_delta=_stream, on_content_delta=_stream,
on_thinking_delta=_thinking, on_thinking_delta=_thinking,
on_tool_call_delta=_provider_tool_event,
on_stream_recover=_stream_recover, on_stream_recover=_stream_recover,
) )
elif wants_progress_streaming: elif wants_progress_streaming:
@@ -899,7 +802,6 @@ class AgentRunner:
coro = spec.runtime.provider.chat_stream_with_retry( coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs, **kwargs,
on_content_delta=_stream_progress, on_content_delta=_stream_progress,
on_tool_call_delta=_provider_tool_event,
) )
else: else:
coro = spec.runtime.provider.chat_with_retry(**kwargs) coro = spec.runtime.provider.chat_with_retry(**kwargs)
@@ -933,17 +835,6 @@ class AgentRunner:
finish_reason="error", finish_reason="error",
error_kind="timeout", error_kind="timeout",
) )
# chat_stream_with_retry may recover internally, so only fail unfinished
# hosted calls after the provider returns its final error response.
if response.finish_reason == "error":
for event in list(active_hosted_tools.values()):
await _provider_tool_event({
**event,
"phase": "error",
"result": None,
"error": response.content
or "Model request failed before the provider-hosted tool completed.",
})
if progress_state and progress_state.get("reasoning_open"): if progress_state and progress_state.get("reasoning_open"):
await hook.emit_reasoning_end() await hook.emit_reasoning_end()
dropped, all_dropped, original_finish_reason = ( dropped, all_dropped, original_finish_reason = (
@@ -1276,9 +1167,10 @@ class AgentRunner:
prepare_call = getattr(spec.tools, "prepare_call", None) prepare_call = getattr(spec.tools, "prepare_call", None)
tool, params, prep_error = None, tool_call.arguments, None tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call): if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments) with suppress(Exception):
if isinstance(prepared, tuple) and len(prepared) == 3: prepared = prepare_call(tool_call.name, tool_call.arguments)
tool, params, prep_error = prepared if isinstance(prepared, tuple) and len(prepared) == 3:
tool, params, prep_error = prepared
if prep_error: if prep_error:
event = { event = {
"name": tool_call.name, "name": tool_call.name,
@@ -1305,7 +1197,7 @@ class AgentRunner:
result = await spec.tools.execute(tool_call.name, params) result = await spec.tools.execute(tool_call.name, params)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except BaseException as exc:
await hook.on_execute_tool_error(context, tool_call, tool, params, exc) await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
+23 -42
View File
@@ -125,50 +125,27 @@ class SkillsLoader:
if not all_skills: if not all_skills:
return "" return ""
sections: list[str] = [] lines: list[str] = []
groups = ( for entry in all_skills:
("Workspace skills", "workspace", self.workspace_skills), skill_name = entry["name"]
("Built-in skills", "builtin", self.builtin_skills), if exclude and skill_name in exclude:
)
for label, source, root in groups:
entries = [
entry
for entry in all_skills
if entry["source"] == source and (not exclude or entry["name"] not in exclude)
]
if not entries:
continue continue
meta = self._get_skill_meta(skill_name)
lines = [f"### {label} (`{root.expanduser().resolve()}`)"] available = self._check_requirements(meta)
for entry in entries: desc = self._get_skill_description(skill_name)
skill_name = entry["name"] if available:
meta = self._get_skill_meta(skill_name) lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
available = self._check_requirements(meta) else:
desc = self._get_skill_description(skill_name) missing = self._get_missing_requirements(meta)
suffix = "" suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
if not available: lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
missing = self._get_missing_requirements(meta) return "\n".join(lines)
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) -> tuple[list[str], list[str]]:
"""Return (bins, env) lists from skill metadata, tolerating null/wrong shapes."""
requires = skill_meta.get("requires") or {}
if not isinstance(requires, dict):
return [], []
bins_raw = requires.get("bins") or []
env_raw = requires.get("env") or []
bins = [str(v) for v in bins_raw if isinstance(v, str) and v.strip()] if isinstance(bins_raw, list) else []
env = [str(v) for v in env_raw if isinstance(v, str) and v.strip()] if isinstance(env_raw, list) else []
return bins, env
def _get_missing_requirements(self, skill_meta: dict) -> str: def _get_missing_requirements(self, skill_meta: dict) -> str:
"""Get a description of missing requirements.""" """Get a description of missing requirements."""
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( return ", ".join(
[f"CLI: {command_name}" for command_name in required_bins if not shutil.which(command_name)] [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)] + [f"ENV: {env_name}" for env_name in required_env_vars if not os.environ.get(env_name)]
@@ -182,7 +159,9 @@ class SkillsLoader:
def get_skill_requirements(self, name: str) -> dict[str, list[str]]: def get_skill_requirements(self, name: str) -> dict[str, list[str]]:
"""Return explicit command/env requirements and currently missing entries.""" """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 { return {
"bins": bins, "bins": bins,
"env": env, "env": env,
@@ -227,7 +206,9 @@ class SkillsLoader:
def _check_requirements(self, skill_meta: dict) -> bool: def _check_requirements(self, skill_meta: dict) -> bool:
"""Check if skill requirements are met (bins, env vars).""" """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( return all(shutil.which(cmd) for cmd in required_bins) and all(
os.environ.get(var) for var in required_env_vars os.environ.get(var) for var in required_env_vars
) )
+18 -113
View File
@@ -13,7 +13,6 @@ from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.context import ( from nanobot.agent.tools.context import (
RequestContext, RequestContext,
ToolContext, ToolContext,
@@ -147,7 +146,7 @@ class SubagentManager:
self.runner = AgentRunner() self.runner = AgentRunner()
self._exec_session_manager = ExecSessionManager() self._exec_session_manager = ExecSessionManager()
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[str]] = {} self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {} self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
@@ -276,68 +275,6 @@ class SubagentManager:
logger.info("Spawned subagent [{}]: {}", task_id, display_label) logger.info("Spawned subagent [{}]: {}", task_id, display_label)
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
async def run_inline(
self,
task: str,
label: str | None = None,
origin_channel: str = "cli",
origin_chat_id: str = "direct",
session_key: str | None = None,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
*,
runtime: LLMRuntime | None = None,
) -> str:
"""Run a subagent synchronously and return its result to the caller."""
if runtime is None:
runtime = self._compat_spawn_runtime()
if temperature is not None:
runtime = runtime.with_generation_overrides(temperature=temperature)
task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
"session_key": session_key,
}
status = SubagentStatus(
task_id=task_id,
label=display_label,
task_description=task,
started_at=time.monotonic(),
)
self._task_statuses[task_id] = status
logger.info("Running inline subagent [{}]: {}", task_id, display_label)
inline_task = asyncio.create_task(
self._run_subagent(
task_id,
task,
display_label,
origin,
status,
runtime,
origin_message_id,
workspace_scope,
announce=False,
)
)
self._running_tasks[task_id] = inline_task
if session_key:
self._session_tasks.setdefault(session_key, set()).add(task_id)
try:
result = await inline_task
if status.phase == "error" or status.stop_reason in {"error", "tool_error"}:
return ToolResult.error(result)
return result
finally:
self._running_tasks.pop(task_id, None)
self._task_statuses.pop(task_id, None)
if session_key and (ids := self._session_tasks.get(session_key)):
ids.discard(task_id)
if not ids:
del self._session_tasks[session_key]
async def _run_subagent( async def _run_subagent(
self, self,
task_id: str, task_id: str,
@@ -348,9 +285,7 @@ class SubagentManager:
runtime: LLMRuntime, runtime: LLMRuntime,
origin_message_id: str | None = None, origin_message_id: str | None = None,
workspace_scope: WorkspaceScope | None = None, workspace_scope: WorkspaceScope | None = None,
*, ) -> None:
announce: bool = True,
) -> str:
"""Execute the subagent task and announce the result.""" """Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label) logger.info("Subagent [{}] starting task: {}", task_id, label)
@@ -364,8 +299,7 @@ class SubagentManager:
if workspace_scope is not None: if workspace_scope is not None:
cfg = self._subagent_tools_config() cfg = self._subagent_tools_config()
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
# Construct from the agent workspace; the bound scope below supplies the project cwd. tools = self._build_tools(workspace=root, tools_config=cfg)
tools = self._build_tools(tools_config=cfg)
system_prompt = self._build_subagent_prompt(workspace=root) system_prompt = self._build_subagent_prompt(workspace=root)
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
@@ -412,43 +346,27 @@ class SubagentManager:
if result.stop_reason == "tool_error": if result.stop_reason == "tool_error":
status.tool_events = list(result.tool_events) status.tool_events = list(result.tool_events)
final_result = self._format_partial_progress(result) await self._announce_result(
final_status = "error" task_id, label, task,
self._format_partial_progress(result),
origin, "error", origin_message_id,
)
elif result.stop_reason == "error": elif result.stop_reason == "error":
final_result = result.error or "Error: subagent execution failed." await self._announce_result(
final_status = "error" task_id, label, task,
result.error or "Error: subagent execution failed.",
origin, "error", origin_message_id,
)
else: else:
final_result = result.final_content or "Task completed but no final response was generated." final_result = result.final_content or "Task completed but no final response was generated."
final_status = "ok"
logger.info("Subagent [{}] completed successfully", task_id) logger.info("Subagent [{}] completed successfully", task_id)
if announce: await self._announce_result(task_id, label, task, final_result, origin, "ok", origin_message_id)
await self._announce_result(
task_id,
label,
task,
final_result,
origin,
final_status,
origin_message_id,
)
return final_result
except Exception as e: except Exception as e:
status.phase = "error" status.phase = "error"
status.error = str(e) status.error = str(e)
logger.exception("Subagent [{}] failed", task_id) logger.exception("Subagent [{}] failed", task_id)
final_result = f"Error: {e}" await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
if announce:
await self._announce_result(
task_id,
label,
task,
final_result,
origin,
"error",
origin_message_id,
)
return final_result
async def _announce_result( async def _announce_result(
self, self,
@@ -520,17 +438,14 @@ class SubagentManager:
"""Build a focused system prompt for the subagent.""" """Build a focused system prompt for the subagent."""
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
agent_workspace = self.workspace.expanduser().resolve() root = workspace or self.workspace
project_workspace = workspace.expanduser().resolve() if workspace else agent_workspace
skills_summary = SkillsLoader( skills_summary = SkillsLoader(
self.workspace, root,
disabled_skills=self.disabled_skills, disabled_skills=self.disabled_skills,
).build_skills_summary() ).build_skills_summary()
return render_template( return render_template(
"agent/subagent_system.md", "agent/subagent_system.md",
workspace=str(project_workspace), workspace=str(root),
agent_workspace=str(agent_workspace),
history_log=str(agent_workspace / "memory" / "history.jsonl"),
skills_summary=skills_summary or "", skills_summary=skills_summary or "",
) )
@@ -542,18 +457,8 @@ class SubagentManager:
t.cancel() t.cancel()
if tasks: if tasks:
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
await self._exec_session_manager.terminate_by_owner(session_key)
return len(tasks) return len(tasks)
async def close(self) -> None:
"""Cancel running subagents and close their shared exec sessions."""
tasks = [task for task in self._running_tasks.values() if not task.done()]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
await self._exec_session_manager.close_all()
def get_running_count(self) -> int: def get_running_count(self) -> int:
"""Return the number of currently running subagents.""" """Return the number of currently running subagents."""
return len(self._running_tasks) return len(self._running_tasks)
+10 -79
View File
@@ -61,14 +61,12 @@ class _ExecSession:
cwd: str, cwd: str,
timeout: int | None, timeout: int | None,
owner_session_key: str | None = None, owner_session_key: str | None = None,
process_tree: bool = False,
) -> None: ) -> None:
self.session_id = session_id self.session_id = session_id
self.process = process self.process = process
self.command = command self.command = command
self.cwd = cwd self.cwd = cwd
self.owner_session_key = owner_session_key self.owner_session_key = owner_session_key
self._process_tree = process_tree
self.started_at = time.monotonic() self.started_at = time.monotonic()
# timeout None/0 means no limit; an infinite deadline is never reached. # timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf") self.deadline = time.monotonic() + timeout if timeout else float("inf")
@@ -173,23 +171,17 @@ class _ExecSession:
) )
async def kill(self) -> None: async def kill(self) -> None:
from nanobot.agent.tools.shell import ExecTool if self.process.returncode is not None:
return
self.process.kill()
try: try:
if self._process_tree:
await ExecTool._kill_process_tree(self.process)
else:
await ExecTool._kill_process(self.process)
finally:
with suppress(asyncio.TimeoutError): with suppress(asyncio.TimeoutError):
await asyncio.wait_for( await asyncio.wait_for(self.process.wait(), timeout=5.0)
asyncio.gather( finally:
self._stdout_task, # Safety-net waitpid — prevent zombie if asyncio's child watcher
self._stderr_task, # did not reap the process (common in containers).
return_exceptions=True, from nanobot.agent.tools.shell import _reap_pid
), _reap_pid(self.process.pid)
timeout=2.0,
)
async def _wait_for_buffered_output(self) -> None: async def _wait_for_buffered_output(self) -> None:
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
@@ -206,7 +198,6 @@ class ExecSessionManager:
self.idle_timeout = idle_timeout self.idle_timeout = idle_timeout
self._sessions: dict[str, _ExecSession] = {} self._sessions: dict[str, _ExecSession] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._closed = False
async def start( async def start(
self, self,
@@ -222,8 +213,6 @@ class ExecSessionManager:
owner_session_key: str | None = None, owner_session_key: str | None = None,
) -> tuple[str, _SessionPoll]: ) -> tuple[str, _SessionPoll]:
async with self._lock: async with self._lock:
if self._closed:
raise RuntimeError("exec session manager is closed")
await self._cleanup_locked() await self._cleanup_locked()
if len(self._sessions) >= self.max_sessions: if len(self._sessions) >= self.max_sessions:
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})") raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
@@ -236,7 +225,6 @@ class ExecSessionManager:
cwd=cwd, cwd=cwd,
timeout=timeout, timeout=timeout,
owner_session_key=owner_session_key, owner_session_key=owner_session_key,
process_tree=True,
) )
self._sessions[session_id] = session self._sessions[session_id] = session
@@ -307,61 +295,6 @@ class ExecSessionManager:
if session.owner_session_key == owner_session_key if session.owner_session_key == owner_session_key
] ]
async def close_all(self) -> int:
"""Terminate and remove all active sessions during shutdown."""
async with self._lock:
self._closed = True
sessions = list(self._sessions.values())
self._sessions.clear()
results = await asyncio.gather(
*(session.kill() for session in sessions),
return_exceptions=True,
)
failures = [
(session, result)
for session, result in zip(sessions, results, strict=True)
if isinstance(result, BaseException)
]
if failures:
async with self._lock:
for session, _ in failures:
self._sessions[session.session_id] = session
if len(failures) == 1:
raise failures[0][1]
raise BaseExceptionGroup(
"failed to close exec sessions",
[result for _, result in failures],
)
return len(sessions)
async def terminate_by_owner(self, owner_session_key: str) -> int:
"""Terminate all sessions owned by owner_session_key. Returns count."""
async with self._lock:
victims = []
for sid, s in list(self._sessions.items()):
if s.owner_session_key == owner_session_key:
victims.append(self._sessions.pop(sid))
results = await asyncio.gather(
*(s.kill() for s in victims),
return_exceptions=True,
)
failures = [
(session, result)
for session, result in zip(victims, results, strict=True)
if isinstance(result, BaseException)
]
if failures:
async with self._lock:
for session, _ in failures:
self._sessions[session.session_id] = session
if len(failures) == 1:
raise failures[0][1]
raise BaseExceptionGroup(
"failed to terminate exec sessions by owner",
[result for _, result in failures],
)
return len(victims)
async def _cleanup_locked(self) -> None: async def _cleanup_locked(self) -> None:
now = time.monotonic() now = time.monotonic()
stale = [ stale = [
@@ -370,9 +303,8 @@ class ExecSessionManager:
if now - session.last_access > self.idle_timeout if now - session.last_access > self.idle_timeout
] ]
for session_id in stale: for session_id in stale:
session = self._sessions[session_id] session = self._sessions.pop(session_id)
await session.kill() await session.kill()
self._sessions.pop(session_id, None)
async def _spawn( async def _spawn(
self, self,
@@ -387,7 +319,6 @@ class ExecSessionManager:
return await ExecTool._spawn( return await ExecTool._spawn(
command, cwd, env, shell_program, login, command, cwd, env, shell_program, login,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
process_tree=True,
) )
+6 -27
View File
@@ -51,7 +51,6 @@ class _FsTool(Tool):
file_states: FileStates | None = None, file_states: FileStates | None = None,
restrict_to_workspace: bool | None = None, restrict_to_workspace: bool | None = None,
sandbox_restricts_workspace: bool = False, sandbox_restricts_workspace: bool = False,
extra_read_allowed_files: list[Path] | None = None,
): ):
self._workspace = workspace self._workspace = workspace
self._allowed_dir = allowed_dir self._allowed_dir = allowed_dir
@@ -61,7 +60,6 @@ class _FsTool(Tool):
*(extra_allowed_dirs or []), *(extra_allowed_dirs or []),
*(extra_read_allowed_dirs or []), *(extra_read_allowed_dirs or []),
] ]
self._extra_read_allowed_files = list(extra_read_allowed_files or [])
self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or []) self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or [])
self._extra_write_allowed_files = list(extra_write_allowed_files or []) self._extra_write_allowed_files = list(extra_write_allowed_files or [])
self._restrict_to_workspace = ( self._restrict_to_workspace = (
@@ -80,21 +78,17 @@ class _FsTool(Tool):
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: Any) -> Tool:
from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.skills import BUILTIN_SKILLS_DIR
agent_workspace = Path(ctx.workspace)
resolved_agent_workspace = agent_workspace.expanduser().resolve(strict=False)
restrict = ( restrict = (
ctx.config.restrict_to_workspace ctx.config.restrict_to_workspace
or ctx.config.exec.sandbox or ctx.config.exec.sandbox
) )
sandbox_restricts = bool(ctx.config.exec.sandbox) sandbox_restricts = bool(ctx.config.exec.sandbox)
allowed_dir = agent_workspace if restrict else None allowed_dir = Path(ctx.workspace) if restrict else None
# Agent-owned skills stay available from project scopes. History is a narrower extra_read = [BUILTIN_SKILLS_DIR]
# capability: expose only the append-only log, not the surrounding memory directory.
return cls( return cls(
workspace=agent_workspace, workspace=Path(ctx.workspace),
allowed_dir=allowed_dir, allowed_dir=allowed_dir,
extra_read_allowed_dirs=[BUILTIN_SKILLS_DIR, resolved_agent_workspace / "skills"], extra_read_allowed_dirs=extra_read,
extra_read_allowed_files=[resolved_agent_workspace / "memory" / "history.jsonl"],
file_states=ctx.file_state_store, file_states=ctx.file_state_store,
restrict_to_workspace=ctx.config.restrict_to_workspace, restrict_to_workspace=ctx.config.restrict_to_workspace,
sandbox_restricts_workspace=sandbox_restricts, sandbox_restricts_workspace=sandbox_restricts,
@@ -125,20 +119,16 @@ class _FsTool(Tool):
extra_allowed_files: list[Path] | None, extra_allowed_files: list[Path] | None,
*, *,
include_media_dir: bool, include_media_dir: bool,
extra_files_require_allowed_root: bool = False,
) -> Path: ) -> Path:
access = current_tool_workspace( access = current_tool_workspace(
self._workspace, self._workspace,
restrict_to_workspace=self._restrict_to_workspace, restrict_to_workspace=self._restrict_to_workspace,
sandbox_restricts_workspace=self._sandbox_restricts_workspace, sandbox_restricts_workspace=self._sandbox_restricts_workspace,
) )
allowed_root = self._effective_allowed_root(access.allowed_root)
if extra_files_require_allowed_root and allowed_root is None:
extra_allowed_files = None
return resolve_workspace_path( return resolve_workspace_path(
path, path,
access.project_path, access.project_path,
allowed_root, self._effective_allowed_root(access.allowed_root),
extra_allowed_dirs, extra_allowed_dirs,
extra_allowed_files, extra_allowed_files,
include_media_dir=include_media_dir, include_media_dir=include_media_dir,
@@ -148,9 +138,8 @@ class _FsTool(Tool):
return self._resolve_with_extra( return self._resolve_with_extra(
path, path,
self._extra_read_allowed_dirs, self._extra_read_allowed_dirs,
self._extra_read_allowed_files, None,
include_media_dir=True, include_media_dir=True,
extra_files_require_allowed_root=True,
) )
def _resolve_write(self, path: str) -> Path: def _resolve_write(self, path: str) -> Path:
@@ -248,7 +237,6 @@ class ReadFileTool(_FsTool):
_scopes = {"core", "subagent", "memory"} _scopes = {"core", "subagent", "memory"}
_MAX_CHARS = 128_000 _MAX_CHARS = 128_000
_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024
_DEFAULT_LIMIT = 2000 _DEFAULT_LIMIT = 2000
_MAX_PDF_PAGES = 20 _MAX_PDF_PAGES = 20
@@ -302,15 +290,6 @@ class ReadFileTool(_FsTool):
if not fp.is_file(): if not fp.is_file():
return ToolResult.error(f"Error: Not a file: {path}") return ToolResult.error(f"Error: Not a file: {path}")
file_size = fp.stat().st_size
if file_size > self._MAX_FILE_SIZE_BYTES:
size_mib = file_size / (1024 * 1024)
max_mib = self._MAX_FILE_SIZE_BYTES // (1024 * 1024)
return ToolResult.error(
f"Error: File too large to read ({size_mib:.1f} MiB). "
f"Maximum is {max_mib} MiB."
)
# PDF support # PDF support
if fp.suffix.lower() == ".pdf": if fp.suffix.lower() == ".pdf":
return self._read_pdf(fp, pages) return self._read_pdf(fp, pages)
-121
View File
@@ -2,34 +2,24 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
IntegerSchema, IntegerSchema,
StringSchema, StringSchema,
tool_parameters_schema, tool_parameters_schema,
) )
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_ACK,
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
InboundMessage,
)
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base from nanobot.config_base import Base
from nanobot.providers.image_generation import ( from nanobot.providers.image_generation import (
ImageGenerationError, ImageGenerationError,
ImageGenerationProvider, ImageGenerationProvider,
get_image_gen_provider, get_image_gen_provider,
image_gen_provider_configs,
) )
from nanobot.security.workspace_access import current_tool_workspace from nanobot.security.workspace_access import current_tool_workspace
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
@@ -218,114 +208,3 @@ class ImageGenerationTool(Tool):
return generated_image_tool_result(artifacts) return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc: except (ArtifactError, ImageGenerationError, OSError) as exc:
return ToolResult.error(f"Error: {exc}") return ToolResult.error(f"Error: {exc}")
async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"""Apply the persisted image configuration to the running agent."""
try:
from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config())
tool_config = config.tools.image_generation
provider_configs = image_gen_provider_configs(config)
except Exception as exc:
logger.warning("Image generation hot reload could not read config: {}", exc)
return {
"ok": False,
"message": "Could not reload image generation config.",
"requires_restart": True,
"error": str(exc),
}
next_tool = (
ImageGenerationTool(
workspace=state.workspace,
config=tool_config,
provider_configs=provider_configs,
)
if tool_config.enabled
else None
)
state.tools_config.image_generation = tool_config
state._image_generation_provider_configs = provider_configs
if next_tool is not None:
registry.register(next_tool)
else:
registry.unregister("generate_image")
logger.info(
"Image generation config reloaded: enabled={} provider={} model={}",
tool_config.enabled,
tool_config.provider,
tool_config.model,
)
return {
"ok": True,
"message": "Image generation settings applied without restarting nanobot.",
"enabled": tool_config.enabled,
"provider": tool_config.provider,
"model": tool_config.model,
"requires_restart": False,
}
async def request_image_generation_reload(
bus: Any,
*,
timeout: float = 5.0,
) -> dict[str, Any]:
"""Ask the running agent loop to refresh its image generation tool."""
loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
await bus.publish_inbound(
InboundMessage(
channel="system",
sender_id="webui-settings",
chat_id="runtime",
content=RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
RUNTIME_CONTROL_ACK: ack,
},
)
)
try:
result = await asyncio.wait_for(ack, timeout=timeout)
except asyncio.TimeoutError:
return {
"ok": False,
"message": "Image generation hot reload timed out.",
"requires_restart": True,
}
return result if isinstance(result, dict) else {
"ok": False,
"message": "Image generation hot reload returned an unexpected response.",
"requires_restart": True,
}
async def handle_runtime_control(
state: Any,
msg: InboundMessage,
registry: ToolRegistry,
) -> bool:
"""Handle an in-process image generation reload request."""
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
if metadata.get(INBOUND_META_RUNTIME_CONTROL) != RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD:
return False
ack = metadata.get(RUNTIME_CONTROL_ACK)
try:
result = await reload_image_generation_tool(state, registry)
except Exception as exc:
logger.exception("Image generation hot reload failed")
result = {
"ok": False,
"message": "Image generation hot reload failed.",
"requires_restart": True,
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
ack.set_result(result)
return True
+15 -100
View File
@@ -315,87 +315,13 @@ def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None
return None return None
def _resolve_local_schema_ref(root: dict[str, Any], ref: str) -> Any: def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
"""Resolve a local JSON Pointer without accepting remote references.""" """Normalize only nullable JSON Schema patterns for tool definitions."""
if not ref.startswith("#"): if not isinstance(schema, dict):
raise ValueError("not a local JSON Pointer") 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 = current[part]
elif isinstance(current, list):
current = 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 value]
if not isinstance(value, dict):
return value
rewritten = dict(value)
ref = rewritten.get("$ref")
is_rewritable_ref = False
if isinstance(ref, str) 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:
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:
assert isinstance(ref, str)
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 = 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) normalized = dict(schema)
raw_type = normalized.get("type") raw_type = normalized.get("type")
if isinstance(raw_type, list): if isinstance(raw_type, list):
non_null = [item for item in raw_type if item != "null"] non_null = [item for item in raw_type if item != "null"]
@@ -413,34 +339,23 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
normalized["nullable"] = True normalized["nullable"] = True
break break
if isinstance(normalized.get("properties"), dict): if "properties" in normalized and isinstance(normalized["properties"], dict):
normalized["properties"] = { normalized["properties"] = {
name: _normalize_nullable_schema(prop) if isinstance(prop, dict) else prop name: _normalize_schema_for_openai(prop) if isinstance(prop, dict) else prop
for name, prop in normalized["properties"].items() for name, prop in normalized["properties"].items()
} }
if isinstance(normalized.get("items"), dict):
normalized["items"] = _normalize_nullable_schema(normalized["items"])
if isinstance(normalized.get("$defs"), dict):
normalized["$defs"] = {
name: _normalize_nullable_schema(definition)
if isinstance(definition, dict)
else definition
for name, definition in normalized["$defs"].items()
}
if normalized.get("type") == "object": if "items" in normalized and isinstance(normalized["items"], dict):
normalized.setdefault("properties", {}) normalized["items"] = _normalize_schema_for_openai(normalized["items"])
normalized.setdefault("required", [])
if normalized.get("type") != "object":
return normalized
normalized.setdefault("properties", {})
normalized.setdefault("required", [])
return normalized 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": {}}
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema))
class _MCPWrapperBase(Tool): class _MCPWrapperBase(Tool):
"""Common reconnect handling for wrappers bound to one MCP server session.""" """Common reconnect handling for wrappers bound to one MCP server session."""
-6
View File
@@ -60,11 +60,5 @@ class RuntimeState(Protocol):
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ... def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
def set_session_model_preset(
self,
session_key: str,
name: str,
) -> Any: ...
@property @property
def model_preset(self) -> str | None: ... def model_preset(self) -> str | None: ...
+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. and register it in _BACKENDS below.
""" """
import os
import shlex import shlex
from pathlib import Path from pathlib import Path
from typing import Iterable
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
def _normalize_bind_paths( def _bwrap(command: str, workspace: str, cwd: str) -> str:
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:
"""Wrap command in a bubblewrap sandbox (requires bwrap in container). """Wrap command in a bubblewrap sandbox (requires bwrap in container).
Only the workspace is bind-mounted read-write; its parent dir (which holds 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 "--dir", str(ws), # recreate workspace mount point
"--bind", str(ws), str(ws), "--bind", str(ws), str(ws),
"--ro-bind-try", str(media), str(media), # read-only access to media "--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) return shlex.join(args)
_BACKENDS = {"bwrap": _bwrap} _BACKENDS = {"bwrap": _bwrap}
def wrap_command( def wrap_command(sandbox: str, command: str, workspace: str, cwd: str) -> str:
sandbox: str,
command: str,
workspace: str,
cwd: str,
*,
sandbox_ro_binds: Iterable[str] | None = None,
sandbox_rw_binds: Iterable[str] | None = None,
) -> str:
"""Wrap *command* using the named sandbox backend.""" """Wrap *command* using the named sandbox backend."""
if backend := _BACKENDS.get(sandbox): if backend := _BACKENDS.get(sandbox):
return backend( return backend(command, workspace, cwd)
command,
workspace,
cwd,
sandbox_ro_binds=sandbox_ro_binds,
sandbox_rw_binds=sandbox_rw_binds,
)
raise ValueError(f"Unknown sandbox backend {sandbox!r}. Available: {list(_BACKENDS)}") raise ValueError(f"Unknown sandbox backend {sandbox!r}. Available: {list(_BACKENDS)}")
+3 -9
View File
@@ -283,7 +283,6 @@ class GrepTool(_SearchTool):
_MAX_RESULT_CHARS = 128_000 _MAX_RESULT_CHARS = 128_000
_MAX_FILE_BYTES = 2_000_000 _MAX_FILE_BYTES = 2_000_000
_MAX_EXPLICIT_FILE_BYTES = 100_000_000
@property @property
def name(self) -> str: def name(self) -> str:
@@ -296,8 +295,7 @@ class GrepTool(_SearchTool):
"Default output_mode is files_with_matches (file paths only); " "Default output_mode is files_with_matches (file paths only); "
"use content mode for matching lines with context. Prefer this " "use content mode for matching lines with context. Prefer this "
"over shell grep for ordinary workspace searches. " "over shell grep for ordinary workspace searches. "
"Binary and file-size limits are enforced by the tool; explicit file paths " "Skips binary and files >2 MB. Supports glob/type filtering."
"use a larger bounded limit than directory searches. Supports glob/type filtering."
) )
@property @property
@@ -458,9 +456,6 @@ class GrepTool(_SearchTool):
counts: dict[str, int] = {} counts: dict[str, int] = {}
file_mtimes: dict[str, float] = {} file_mtimes: dict[str, float] = {}
root = target if target.is_dir() else target.parent root = target if target.is_dir() else target.parent
max_file_bytes = (
self._MAX_EXPLICIT_FILE_BYTES if target.is_file() else self._MAX_FILE_BYTES
)
for file_path in self._iter_files(target): for file_path in self._iter_files(target):
rel_path = file_path.relative_to(root).as_posix() rel_path = file_path.relative_to(root).as_posix()
@@ -469,9 +464,8 @@ class GrepTool(_SearchTool):
if not _matches_type(file_path.name, type): if not _matches_type(file_path.name, type):
continue continue
with file_path.open("rb") as file: raw = file_path.read_bytes()
raw = file.read(max_file_bytes + 1) if len(raw) > self._MAX_FILE_BYTES:
if len(raw) > max_file_bytes:
skipped_large += 1 skipped_large += 1
continue continue
if _is_binary(raw): if _is_binary(raw):
+6 -32
View File
@@ -3,13 +3,12 @@
from __future__ import annotations from __future__ import annotations
import time import time
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import current_request_context, current_request_session_key from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.runtime_state import RuntimeState from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base from nanobot.config_base import Base
@@ -77,7 +76,6 @@ class MyTool(Tool):
"_current_iteration", # updated by runner only "_current_iteration", # updated by runner only
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked "exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked "web_config", # inspect allowed (e.g. check enable), modify blocked
"model_presets", # config-derived catalog; changes require config reload
"workspace_sandbox", # read-only view of workspace enforcement level "workspace_sandbox", # read-only view of workspace enforcement level
"request", # current message routing metadata "request", # current message routing metadata
}) })
@@ -148,8 +146,6 @@ class MyTool(Tool):
"max_iterations - _current_iteration = remaining iterations.\n" "max_iterations - _current_iteration = remaining iterations.\n"
"Current routing metadata is available read-only via request.channel, " "Current routing metadata is available read-only via request.channel, "
"request.chat_id, and request.sender_id.\n" "request.chat_id, and request.sender_id.\n"
"Use model_preset for session-scoped model or context changes; direct "
"model/context_window_tokens writes are disabled during active sessions.\n"
"Note: web_config and exec_config are readable but read-only.\n" "Note: web_config and exec_config are readable but read-only.\n"
"\n" "\n"
"When to use:\n" "When to use:\n"
@@ -214,11 +210,11 @@ class MyTool(Tool):
if part.lower() in self._SENSITIVE_NAMES: if part.lower() in self._SENSITIVE_NAMES:
return None, f"'{part}' is not accessible" return None, f"'{part}' is not accessible"
try: try:
if isinstance(obj, Mapping): if isinstance(obj, dict):
if part in obj: if part in obj:
obj = obj[part] obj = obj[part]
else: else:
return None, f"'{part}' not found in mapping" return None, f"'{part}' not found in dict"
else: else:
obj = getattr(obj, part) obj = getattr(obj, part)
except (KeyError, AttributeError) as e: except (KeyError, AttributeError) as e:
@@ -261,7 +257,7 @@ class MyTool(Tool):
# SubagentManager: delegate to its _task_statuses dict # SubagentManager: delegate to its _task_statuses dict
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict): if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
return MyTool._format_value(val._task_statuses, key) return MyTool._format_value(val._task_statuses, key)
if isinstance(val, Mapping) and val and _is_subagent_status(next(iter(val.values()))): if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
prefix = f"{key}: " if key else "" prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"] lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items(): for tid, st in val.items():
@@ -274,8 +270,8 @@ class MyTool(Tool):
if isinstance(val, (str, int, float, bool, type(None))): if isinstance(val, (str, int, float, bool, type(None))):
r = repr(val) r = repr(val)
return f"{key}: {r}" if key else r return f"{key}: {r}" if key else r
# Mapping — small: show content; large: show keys for dot-path navigation # Dict — small: show content; large: show keys for dot-path navigation
if isinstance(val, Mapping): if isinstance(val, dict):
ks = list(val.keys()) ks = list(val.keys())
if not ks: if not ks:
return f"{key}: {{}}" if key else "{}" return f"{key}: {{}}" if key else "{}"
@@ -451,23 +447,6 @@ class MyTool(Tool):
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return ToolResult.error("Error: 'model_preset' must be a non-empty string") return ToolResult.error("Error: 'model_preset' must be a non-empty string")
name = value.strip() name = value.strip()
session_key = current_request_session_key()
if session_key:
try:
runtime = self._runtime_state.set_session_model_preset(
session_key,
name,
)
except (KeyError, ValueError) as exc:
message = str(exc.args[0]) if exc.args else str(exc)
punctuation = "" if message.endswith((".", "!", "?")) else "."
return ToolResult.error(f"Error: {message}{punctuation}")
self._audit("modify", f"model_preset = {name!r}")
return (
f"Set model_preset = {name!r} for the next turn; "
f"model will be {runtime.model!r}; "
f"context_window_tokens will be {runtime.context_window_tokens!r}"
)
result = self._modify_free("model_preset", name) result = self._modify_free("model_preset", name)
if isinstance(result, ToolResult) and result.is_error: if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.") return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
@@ -493,11 +472,6 @@ class MyTool(Tool):
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}") return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
if "min_len" in spec and len(str(value)) < spec["min_len"]: if "min_len" in spec and len(str(value)) < spec["min_len"]:
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters") return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
if key in {"model", "context_window_tokens"} and current_request_session_key():
return ToolResult.error(
f"Error: direct '{key}' changes are instance-wide and disabled "
"during an active session; use a configured model_preset"
)
if key == "model": if key == "model":
self._runtime_state.set_runtime_model(value) self._runtime_state.set_runtime_model(value)
elif key == "context_window_tokens": elif key == "context_window_tokens":
+6 -179
View File
@@ -6,8 +6,6 @@ import asyncio
import os import os
import re import re
import shutil import shutil
import signal
import subprocess
import sys import sys
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
@@ -84,8 +82,6 @@ class ExecToolConfig(Base):
path_prepend: str = "" path_prepend: str = ""
path_append: str = "" path_append: str = ""
sandbox: 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) allowed_env_keys: list[str] = Field(default_factory=list)
allow_patterns: list[str] = Field(default_factory=list) allow_patterns: list[str] = Field(default_factory=list)
deny_patterns: list[str] = Field(default_factory=list) deny_patterns: list[str] = Field(default_factory=list)
@@ -189,8 +185,6 @@ class ExecTool(Tool):
sandbox=cfg.sandbox, sandbox=cfg.sandbox,
path_prepend=cfg.path_prepend, path_prepend=cfg.path_prepend,
path_append=cfg.path_append, 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, allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns, allow_patterns=cfg.allow_patterns,
deny_patterns=cfg.deny_patterns, deny_patterns=cfg.deny_patterns,
@@ -209,8 +203,6 @@ class ExecTool(Tool):
sandbox: str = "", sandbox: str = "",
path_prepend: str = "", path_prepend: str = "",
path_append: 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, allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None, session_manager: Any | None = None,
): ):
@@ -243,8 +235,6 @@ class ExecTool(Tool):
self.webui_allow_local_service_access = webui_allow_local_service_access self.webui_allow_local_service_access = webui_allow_local_service_access
self.path_prepend = path_prepend self.path_prepend = path_prepend
self.path_append = path_append 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.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@@ -472,14 +462,7 @@ class ExecTool(Tool):
) )
else: else:
workspace = workspace_root or cwd workspace = workspace_root or cwd
command = wrap_command( command = wrap_command(self.sandbox, command, workspace, cwd)
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],
)
cwd = str(Path(workspace).resolve()) cwd = str(Path(workspace).resolve())
effective_timeout = self._resolve_timeout(timeout) effective_timeout = self._resolve_timeout(timeout)
@@ -533,7 +516,6 @@ class ExecTool(Tool):
login: bool = False, login: bool = False,
*, *,
stdin: int = asyncio.subprocess.DEVNULL, stdin: int = asyncio.subprocess.DEVNULL,
process_tree: bool = False,
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell.""" """Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS: if _IS_WINDOWS:
@@ -581,7 +563,6 @@ class ExecTool(Tool):
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=env, env=env,
**({"start_new_session": True} if process_tree else {}),
) )
@staticmethod @staticmethod
@@ -674,39 +655,6 @@ class ExecTool(Tool):
finally: finally:
_reap_pid(process.pid) _reap_pid(process.pid)
@staticmethod
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
"""Kill a session process and descendants, then reap the root process."""
if process.returncode is not None:
_reap_pid(process.pid)
return
try:
if _IS_WINDOWS:
with suppress(OSError, asyncio.TimeoutError):
await asyncio.wait_for(
asyncio.to_thread(
subprocess.run,
["taskkill", "/PID", str(process.pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
),
timeout=5.0,
)
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
if process.returncode is None:
with suppress(ProcessLookupError):
process.kill()
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(process.wait(), timeout=5.0)
finally:
_reap_pid(process.pid)
def _build_env(self) -> dict[str, str]: def _build_env(self) -> dict[str, str]:
"""Build a minimal environment for subprocess execution. """Build a minimal environment for subprocess execution.
@@ -770,12 +718,9 @@ class ExecTool(Tool):
# allow_patterns take priority over deny_patterns so that users can # allow_patterns take priority over deny_patterns so that users can
# exempt specific commands (e.g. "rm -rf" inside a build directory) # exempt specific commands (e.g. "rm -rf" inside a build directory)
# from the hardcoded deny list via configuration. A chained command is # from the hardcoded deny list via configuration.
# only explicitly allowed when every top-level shell segment matches. explicitly_allowed = bool(self.allow_patterns) and any(
segments = self._split_shell_segments(lower) re.fullmatch(p, lower) for p in self.allow_patterns
explicitly_allowed = bool(self.allow_patterns) and bool(segments) and all(
any(re.fullmatch(pattern, segment) for pattern in self.allow_patterns)
for segment in segments
) )
if not explicitly_allowed: if not explicitly_allowed:
for pattern in self.deny_patterns: for pattern in self.deny_patterns:
@@ -809,9 +754,6 @@ class ExecTool(Tool):
if workspace_root if workspace_root
else None else None
) )
sandbox_bind_roots = self._active_sandbox_bind_roots(
resolved_workspace or cwd_path
)
for raw in self._extract_absolute_paths(cmd): for raw in self._extract_absolute_paths(cmd):
try: try:
@@ -835,8 +777,6 @@ class ExecTool(Tool):
) )
if not allowed and resolved_workspace is not None: if not allowed and resolved_workspace is not None:
allowed = is_path_within(p, resolved_workspace) 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: if p.is_absolute() and not allowed:
return ToolResult.error( return ToolResult.error(
"Error: Command blocked by safety guard (path outside working dir)" "Error: Command blocked by safety guard (path outside working dir)"
@@ -845,84 +785,6 @@ class ExecTool(Tool):
return None return None
@staticmethod
def _split_shell_segments(command: str) -> list[str]:
"""Split shell commands on top-level chaining operators."""
segments: list[str] = []
current: list[str] = []
quote: str | None = None
escaped = False
paren_depth = 0
i = 0
while i < len(command):
ch = command[i]
if escaped:
current.append(ch)
escaped = False
i += 1
continue
if ch == "\\" and quote != "'":
current.append(ch)
escaped = True
i += 1
continue
if quote is not None:
current.append(ch)
if ch == quote:
quote = None
i += 1
continue
if ch in {"'", '"', "`"}:
current.append(ch)
quote = ch
i += 1
continue
if ch == "(":
paren_depth += 1
current.append(ch)
i += 1
continue
if ch == ")" and paren_depth > 0:
paren_depth -= 1
current.append(ch)
i += 1
continue
operator_len = 0
if paren_depth == 0:
if command.startswith(("&&", "||"), i):
operator_len = 2
elif ch == "&" and not (
(i > 0 and command[i - 1] in "<>") or command.startswith("&>", i)
):
current.append(ch)
operator_len = 1
elif ch in {";", "|"}:
operator_len = 1
if operator_len:
segment = "".join(current).strip()
if segment:
segments.append(segment)
current = []
i += operator_len
continue
current.append(ch)
i += 1
segment = "".join(current).strip()
if segment:
segments.append(segment)
return segments
@classmethod @classmethod
def _is_benign_device_path(cls, path: str) -> bool: def _is_benign_device_path(cls, path: str) -> bool:
"""Return True for kernel device files that should never be workspace-blocked.""" """Return True for kernel device files that should never be workspace-blocked."""
@@ -938,41 +800,6 @@ class ExecTool(Tool):
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)", r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command command
) )
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+ home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths return win_paths + posix_paths + home_paths
@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)
]
+2 -18
View File
@@ -6,12 +6,7 @@ from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
BooleanSchema,
NumberSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.security.workspace_access import current_workspace_scope from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -31,14 +26,6 @@ if TYPE_CHECKING:
minimum=0.0, minimum=0.0,
maximum=2.0, maximum=2.0,
), ),
wait=BooleanSchema(
description=(
"Wait for the subagent and return its result directly. Use this for a "
"blocking consultation that must inform the current turn. Defaults to "
"false for background execution."
),
default=False,
),
required=["task"], required=["task"],
) )
) )
@@ -61,7 +48,6 @@ class SpawnTool(Tool):
return ( return (
"Spawn a subagent to handle a task in the background. " "Spawn a subagent to handle a task in the background. "
"Use this for complex or time-consuming tasks that can run independently. " "Use this for complex or time-consuming tasks that can run independently. "
"Set wait=true for a consultation whose result must inform the current turn. "
"The subagent will complete the task and report back when done. " "The subagent will complete the task and report back when done. "
"For deliverables or existing projects, inspect the workspace first " "For deliverables or existing projects, inspect the workspace first "
"and use a dedicated subdirectory when helpful." "and use a dedicated subdirectory when helpful."
@@ -72,7 +58,6 @@ class SpawnTool(Tool):
task: str, task: str,
label: str | None = None, label: str | None = None,
temperature: float | None = None, temperature: float | None = None,
wait: bool = False,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
"""Spawn a subagent to execute the given task.""" """Spawn a subagent to execute the given task."""
@@ -90,8 +75,7 @@ class SpawnTool(Tool):
origin_channel = request_ctx.channel origin_channel = request_ctx.channel
origin_chat_id = request_ctx.chat_id origin_chat_id = request_ctx.chat_id
session_key = request_ctx.session_key or f"{origin_channel}:{origin_chat_id}" session_key = request_ctx.session_key or f"{origin_channel}:{origin_chat_id}"
method = self._manager.run_inline if wait else self._manager.spawn return await self._manager.spawn(
return await method(
task=task, task=task,
runtime=request_ctx.runtime, runtime=request_ctx.runtime,
label=label, label=label,
-315
View File
@@ -1,315 +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 Any
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
RetryWaitEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_message_for_event,
)
from nanobot.bus.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
@dataclass(frozen=True)
class TurnRoute:
"""Turn delivery destination and lifecycle policy, separate from execution input."""
channel: str
chat_id: str
metadata: dict[str, Any] = field(default_factory=dict)
publish_lifecycle: bool = False
TurnRoutePolicy = Callable[[InboundMessage, str, TurnRoute], TurnRoute]
ProgressCallback = Callable[..., Awaitable[None]]
StreamCallback = Callable[[str], Awaitable[None]]
StreamEndCallback = Callable[..., Awaitable[None]]
RetryWaitCallback = Callable[[str], Awaitable[None]]
class TurnDeliveryFactory:
"""Create per-turn delivery objects from an optional edge-owned route policy."""
def __init__(
self,
bus: MessageBus,
runtime_events: RuntimeEventBus,
route_policy: TurnRoutePolicy | None = None,
) -> None:
self.bus = bus
self.runtime_events = runtime_events
self.runtime_event_publisher = RuntimeEventPublisher(runtime_events)
self.route_policy = route_policy
def create(
self,
msg: InboundMessage,
session_key: str,
*,
enable_stream: bool = False,
) -> TurnDelivery:
route = self._default_route(msg, session_key)
if self.route_policy is not None:
route = self.route_policy(msg, session_key, route)
if not isinstance(route, TurnRoute):
raise TypeError("turn route policy must return TurnRoute")
return TurnDelivery(
bus=self.bus,
runtime_event_publisher=self.runtime_event_publisher,
input_message=msg,
session_key=session_key,
route=route,
enable_stream=enable_stream,
)
def unrouted(self, msg: InboundMessage, session_key: str) -> TurnDelivery:
"""Create a lifecycle fallback without invoking edge routing policy."""
return TurnDelivery(
bus=self.bus,
runtime_event_publisher=self.runtime_event_publisher,
input_message=msg,
session_key=session_key,
route=TurnRoute(
channel=msg.channel,
chat_id=msg.chat_id,
metadata=dict(msg.metadata or {}),
),
)
@staticmethod
def _default_route(msg: InboundMessage, session_key: str) -> TurnRoute:
if msg.channel != "system":
return TurnRoute(
channel=msg.channel,
chat_id=msg.chat_id,
metadata=dict(msg.metadata or {}),
publish_lifecycle=True,
)
channel, chat_id = (
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
)
metadata: dict[str, Any] = {}
if (
channel == "slack"
and session_key.startswith("slack:")
and session_key.count(":") >= 2
):
metadata["slack"] = {"thread_ts": session_key.split(":", 2)[2]}
if origin_message_id := msg.metadata.get("origin_message_id"):
metadata["origin_message_id"] = origin_message_id
return TurnRoute(channel=channel, chat_id=chat_id, metadata=metadata)
@dataclass
class TurnDelivery:
"""Own routing, callbacks, and lifecycle publication for one turn."""
bus: MessageBus
runtime_event_publisher: RuntimeEventPublisher
input_message: InboundMessage
session_key: str
route: TurnRoute
enable_stream: bool = False
delivery_message: InboundMessage = field(init=False)
lifecycle_message: InboundMessage = field(init=False)
_stream_base_id: str | None = field(init=False, default=None)
_stream_segment: int = field(init=False, default=0)
_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: Any) -> None:
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
def record_latency(self, latency_ms: int | None) -> None:
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
def background_response(
self,
content: str | None,
*,
stop_reason: str,
streamed: bool,
latency_ms: int | None,
) -> OutboundMessage:
metadata = dict(self.route.metadata)
if self.route.publish_lifecycle and latency_ms is not None:
metadata["latency_ms"] = int(latency_ms)
event = (
StreamedResponseEvent()
if self.route.publish_lifecycle
and streamed
and stop_reason not in {"error", "tool_error"}
else None
)
return OutboundMessage(
channel=self.route.channel,
chat_id=self.route.chat_id,
content=content or "Background task completed.",
metadata=metadata,
event=event,
)
async def complete(
self,
response: OutboundMessage | None,
*,
publish_completion: bool,
) -> None:
completed_channel = self.lifecycle_message.channel
completed_chat_id = self.lifecycle_message.chat_id
if response is not None:
await self.bus.publish_outbound(response)
completed_channel = response.channel
completed_chat_id = response.chat_id
elif self.lifecycle_message.channel == "cli":
await self.bus.publish_outbound(
OutboundMessage(
channel=self.lifecycle_message.channel,
chat_id=self.lifecycle_message.chat_id,
content="",
metadata=dict(self.lifecycle_message.metadata or {}),
)
)
if publish_completion:
await self.runtime_event_publisher.turn_completed(
channel=completed_channel,
chat_id=completed_chat_id,
session_key=self.session_key,
metadata=self.lifecycle_message.metadata,
)
async def fail(self, *, publish_completion: bool) -> None:
await self.bus.publish_outbound(
OutboundMessage(
channel=self.lifecycle_message.channel,
chat_id=self.lifecycle_message.chat_id,
content="Sorry, I encountered an error.",
metadata=dict(self.lifecycle_message.metadata or {}),
)
)
if publish_completion:
await self.runtime_event_publisher.turn_completed(
channel=self.lifecycle_message.channel,
chat_id=self.lifecycle_message.chat_id,
session_key=self.session_key,
metadata=self.lifecycle_message.metadata,
)
async def idle(self) -> None:
await self.runtime_event_publisher.run_status_changed(
self.lifecycle_message,
self.session_key,
"idle",
)
self.runtime_event_publisher.clear_turn(self.session_key)
def _stream_id(self) -> str:
assert self._stream_base_id is not None
return f"{self._stream_base_id}:{self._stream_segment}"
async def _publish_stream(self, delta: str) -> None:
await self.bus.publish_outbound(
outbound_message_for_event(
channel=self.delivery_message.channel,
chat_id=self.delivery_message.chat_id,
event=StreamDeltaEvent(content=delta, stream_id=self._stream_id()),
metadata=self.delivery_message.metadata,
)
)
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()
+19 -2
View File
@@ -344,6 +344,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
return resp return resp
# -- non-streaming path (original logic) -- # -- non-streaming path (original logic) --
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
try: try:
async with session_lock: async with session_lock:
try: try:
@@ -358,9 +360,24 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
timeout=timeout_s, timeout=timeout_s,
) )
response_text = _response_text(response) response_text = _response_text(response)
if not response_text or not response_text.strip(): if not response_text or not response_text.strip():
logger.warning("Empty response for session {}, using fallback", session_key) logger.warning("Empty response for session {}, retrying", session_key)
response_text = EMPTY_FINAL_RESPONSE_MESSAGE 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: except asyncio.TimeoutError:
return _error_json(504, f"Request timed out after {timeout_s}s") return _error_json(504, f"Request timed out after {timeout_s}s")
+2 -3
View File
@@ -20,7 +20,6 @@ from nanobot.audio.transcription_registry import (
get_transcription_provider, get_transcription_provider,
resolve_transcription_provider, resolve_transcription_provider,
) )
from nanobot.config.loader import resolve_env_refs
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import find_by_name
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
@@ -83,7 +82,7 @@ def _provider_default_api_base(provider: str) -> str | None:
def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str: def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else "" api_key = getattr(provider_cfg, "api_key", None) if provider_cfg else None
if api_key: if api_key:
return api_key return api_key
@@ -98,7 +97,7 @@ def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str: def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str:
api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else "" api_base = getattr(provider_cfg, "api_base", None) if provider_cfg else None
if api_base: if api_base:
return api_base return api_base
return _provider_default_api_base(provider) or "" return _provider_default_api_base(provider) or ""
-1
View File
@@ -17,7 +17,6 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
INBOUND_META_RUNTIME_CONTROL = "_runtime_control" INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack" RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload" RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
@dataclass @dataclass
-9
View File
@@ -46,7 +46,6 @@ class StreamEndEvent(OutboundEvent):
content: str = "" content: str = ""
stream_id: str | None = None stream_id: str | None = None
resuming: bool = False resuming: bool = False
merge_next: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -82,13 +81,6 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
model_preset: str | None = None model_preset: str | None = None
@dataclass(frozen=True)
class TurnModelUpdatedEvent(OutboundEvent):
"""The fallback model currently handling one chat turn."""
model: str
def outbound_message_for_event( def outbound_message_for_event(
*, *,
channel: str, channel: str,
@@ -177,7 +169,6 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
content=msg.content, content=msg.content,
stream_id=_metadata_str(meta, "_stream_id"), stream_id=_metadata_str(meta, "_stream_id"),
resuming=bool(meta.get("_resuming")), resuming=bool(meta.get("_resuming")),
merge_next=bool(meta.get("_merge_next")),
) )
if meta.get("_stream_delta"): if meta.get("_stream_delta"):
return StreamDeltaEvent( return StreamDeltaEvent(
+1 -5
View File
@@ -29,7 +29,7 @@ class BaseChannel(ABC):
name: str = "base" name: str = "base"
display_name: str = "Base" display_name: str = "Base"
send_progress: bool = True send_progress: bool = True
send_tool_hints: bool = True send_tool_hints: bool = False
show_reasoning: bool = True show_reasoning: bool = True
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config: Any, bus: MessageBus):
@@ -110,7 +110,6 @@ class BaseChannel(ABC):
stream_id: str | None = None, stream_id: str | None = None,
stream_end: bool = False, stream_end: bool = False,
resuming: bool = False, resuming: bool = False,
merge_next: bool = False,
) -> None: ) -> None:
"""Deliver a streaming text chunk. """Deliver a streaming text chunk.
@@ -119,9 +118,6 @@ class BaseChannel(ABC):
Stateful implementations should key buffers by ``stream_id`` rather Stateful implementations should key buffers by ``stream_id`` rather
than only by ``chat_id`` when it is provided. 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 pass
+3 -46
View File
@@ -24,17 +24,6 @@ from nanobot.security.network import validate_resolved_url, validate_url_target
DINGTALK_MAX_REMOTE_MEDIA_BYTES = 20 * 1024 * 1024 DINGTALK_MAX_REMOTE_MEDIA_BYTES = 20 * 1024 * 1024
DINGTALK_MAX_REMOTE_MEDIA_REDIRECTS = 3 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
)
try: try:
from dingtalk_stream import ( from dingtalk_stream import (
@@ -186,7 +175,6 @@ class DingTalkConfig(Base):
allow_remote_media_redirects: bool = False allow_remote_media_redirects: bool = False
remote_media_redirect_allowed_hosts: list[str] = Field(default_factory=list) 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 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): class DingTalkChannel(BaseChannel):
@@ -724,20 +712,8 @@ class DingTalkChannel(BaseChannel):
if not token: if not token:
raise RuntimeError("DingTalk access token unavailable") raise RuntimeError("DingTalk access token unavailable")
content = msg.content.strip() if msg.content else "" if msg.content and msg.content.strip():
if content: if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
# 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):
raise RuntimeError("DingTalk text message was not delivered") raise RuntimeError("DingTalk text message was not delivered")
for media_ref in msg.media or []: for media_ref in msg.media or []:
@@ -757,7 +733,7 @@ class DingTalkChannel(BaseChannel):
async def _on_message( async def _on_message(
self, self,
content: str, content: str,
sender_id: str | None, sender_id: str,
sender_name: str, sender_name: str,
conversation_type: str | None = None, conversation_type: str | None = None,
conversation_id: str | None = None, conversation_id: str | None = None,
@@ -769,30 +745,11 @@ class DingTalkChannel(BaseChannel):
""" """
try: try:
self.logger.info("inbound: {} from {}", content, sender_name) 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 is_group = conversation_type == "2" and conversation_id
chat_id = f"group:{conversation_id}" if is_group else sender_id chat_id = f"group:{conversation_id}" if is_group else sender_id
session_key = None session_key = None
if is_group and self.config.group_user_isolation: if is_group and self.config.group_user_isolation:
session_key = f"{self.name}:group:{conversation_id}:{sender_id}" 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( await self._handle_message(
sender_id=sender_id, sender_id=sender_id,
chat_id=chat_id, chat_id=chat_id,
@@ -1,5 +1,4 @@
import asyncio import asyncio
import json
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from types import SimpleNamespace from types import SimpleNamespace
@@ -10,15 +9,15 @@ import pytest
# Check optional dingtalk dependencies before running tests # Check optional dingtalk dependencies before running tests
try: try:
import nanobot.channels.dingtalk.runtime as dingtalk_module from nanobot.channels import dingtalk
DINGTALK_AVAILABLE = getattr(dingtalk, "DINGTALK_AVAILABLE", False)
DINGTALK_AVAILABLE = dingtalk_module.DINGTALK_AVAILABLE
except ImportError: except ImportError:
DINGTALK_AVAILABLE = False DINGTALK_AVAILABLE = False
if not DINGTALK_AVAILABLE: if not DINGTALK_AVAILABLE:
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True) pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
import nanobot.channels.dingtalk.runtime as dingtalk_module
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.dingtalk.runtime import ( 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" 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 @pytest.mark.asyncio
async def test_group_send_uses_group_messages_api() -> None: async def test_group_send_uses_group_messages_api() -> None:
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) 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" 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 @pytest.mark.asyncio
async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatch) -> None: async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatch) -> None:
bus = MessageBus() bus = MessageBus()
-5
View File
@@ -489,7 +489,6 @@ class DiscordChannel(BaseChannel):
stream_id: str | None = None, stream_id: str | None = None,
stream_end: bool = False, stream_end: bool = False,
resuming: bool = False, resuming: bool = False,
merge_next: bool = False,
) -> None: ) -> None:
"""Progressive Discord delivery: send once, then edit until the stream ends.""" """Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client client = self._client
@@ -497,10 +496,6 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping stream delta") self.logger.warning("client not ready; dropping stream delta")
return return
if stream_end and merge_next:
if not delta:
return
stream_end = False
if stream_end: if stream_end:
buf = self._stream_bufs.get(chat_id) buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text: if not buf or buf.message is None or not buf.text:
@@ -754,36 +754,6 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
assert owner._stream_bufs == {} 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 @pytest.mark.asyncio
async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None: async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None:
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
+17 -29
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import secrets import secrets
import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -42,7 +41,6 @@ class FeishuConnectStore:
def __init__(self) -> None: def __init__(self) -> None:
self._sessions: dict[str, FeishuConnectSession] = {} self._sessions: dict[str, FeishuConnectSession] = {}
self._completion_lock = threading.Lock()
async def handle(self, action: str, query: QueryParams) -> dict[str, Any]: async def handle(self, action: str, query: QueryParams) -> dict[str, Any]:
"""Handle one generic settings connection action.""" """Handle one generic settings connection action."""
@@ -60,7 +58,7 @@ class FeishuConnectStore:
if action == "poll": if action == "poll":
return await asyncio.to_thread(self.poll, session_id) return await asyncio.to_thread(self.poll, session_id)
if action == "cancel": 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) raise ChannelConnectError(f"unsupported Feishu connect action: {action}", status=404)
def start( def start(
@@ -129,33 +127,24 @@ class FeishuConnectStore:
session.last_error = str(exc) session.last_error = str(exc)
return _pending_payload(session) return _pending_payload(session)
session.domain = str(result.get("domain") or session.domain)
status = result.get("status") status = result.get("status")
if status == "succeeded": if status == "succeeded":
with self._completion_lock: session.instance_id = feishu.save_registration_result(
if self._sessions.get(session_id) is not session: result,
return { instance_id=session.instance_id,
"session_id": session_id, name=session.instance_name,
"instance_id": session.instance_id, )
"status": "cancelled", self._sessions.pop(session_id, None)
"message": "Feishu connection cancelled.", return {
} "session_id": session_id,
session.domain = str(result.get("domain") or session.domain) "instance_id": session.instance_id,
session.instance_id = feishu.save_registration_result( "status": "succeeded",
result, "message": "Feishu is connected.",
instance_id=session.instance_id, "domain": session.domain,
name=session.instance_name, "app_id": result.get("app_id"),
) }
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": if status == "failed":
self._sessions.pop(session_id, None) self._sessions.pop(session_id, None)
return { return {
@@ -169,8 +158,7 @@ class FeishuConnectStore:
return _pending_payload(session) return _pending_payload(session)
def cancel(self, session_id: str) -> dict[str, Any]: 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 { return {
"session_id": session_id, "session_id": session_id,
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID, "instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
+13 -43
View File
@@ -269,7 +269,7 @@ def _extract_element_content(element: dict) -> list[str]:
parts.append(text_content) parts.append(text_content)
elif isinstance(text, str): elif isinstance(text, str):
parts.append(text) parts.append(text)
for field in element.get("fields") or []: for field in element.get("fields", []):
if isinstance(field, dict): if isinstance(field, dict):
field_text = field.get("text", {}) field_text = field.get("text", {})
if isinstance(field_text, dict): if isinstance(field_text, dict):
@@ -291,10 +291,7 @@ def _extract_element_content(element: dict) -> list[str]:
c = text.get("content", "") c = text.get("content", "")
if c: if c:
parts.append(c) parts.append(c)
multi_url = element.get("multi_url") or {} url = element.get("url", "") or element.get("multi_url", {}).get("url", "")
url = element.get("url", "") or (
multi_url.get("url", "") if isinstance(multi_url, dict) else ""
)
if url: if url:
parts.append(f"link: {url}") parts.append(f"link: {url}")
@@ -303,14 +300,12 @@ def _extract_element_content(element: dict) -> list[str]:
parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]") parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]")
elif tag == "note": elif tag == "note":
for ne in element.get("elements") or []: for ne in element.get("elements", []):
parts.extend(_extract_element_content(ne)) parts.extend(_extract_element_content(ne))
elif tag == "column_set": elif tag == "column_set":
for col in element.get("columns") or []: for col in element.get("columns", []):
if not isinstance(col, dict): for ce in col.get("elements", []):
continue
for ce in col.get("elements") or []:
parts.extend(_extract_element_content(ce)) parts.extend(_extract_element_content(ce))
elif tag == "plain_text": elif tag == "plain_text":
@@ -324,7 +319,7 @@ def _extract_element_content(element: dict) -> list[str]:
for column in (element.get("columns") or []) for column in (element.get("columns") or [])
if isinstance(column, dict) and column.get("name") if isinstance(column, dict) and column.get("name")
] ]
rows = element.get("rows") or [] rows = element.get("rows", [])
if columns: if columns:
parts.append(" | ".join(header for _, header in columns)) parts.append(" | ".join(header for _, header in columns))
if isinstance(rows, list): if isinstance(rows, list):
@@ -342,7 +337,7 @@ def _extract_element_content(element: dict) -> list[str]:
parts.append(row_text) parts.append(row_text)
else: else:
for ne in element.get("elements") or []: for ne in element.get("elements", []):
parts.extend(_extract_element_content(ne)) parts.extend(_extract_element_content(ne))
return parts return parts
@@ -361,8 +356,7 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
if not isinstance(block, dict) or not isinstance(block.get("content"), list): if not isinstance(block, dict) or not isinstance(block.get("content"), list):
return None, [] return None, []
texts, images = [], [] texts, images = [], []
title = block.get("title") if title := block.get("title"):
if isinstance(title, str) and title:
texts.append(title) texts.append(title)
for row in block["content"]: for row in block["content"]:
if not isinstance(row, list): if not isinstance(row, list):
@@ -372,19 +366,12 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
continue continue
tag = el.get("tag") tag = el.get("tag")
if tag in ("text", "a"): if tag in ("text", "a"):
text = el.get("text", "") texts.append(el.get("text", ""))
if isinstance(text, str):
texts.append(text)
elif tag == "at": elif tag == "at":
user = el.get("user_name", "user") texts.append(f"@{el.get('user_name', 'user')}")
texts.append(f"@{user if isinstance(user, str) and user else 'user'}")
elif tag == "code_block": elif tag == "code_block":
lang = el.get("language", "") lang = el.get("language", "")
code_text = el.get("text", "") code_text = el.get("text", "")
if not isinstance(lang, str):
lang = ""
if not isinstance(code_text, str):
code_text = ""
texts.append(f"\n```{lang}\n{code_text}\n```\n") texts.append(f"\n```{lang}\n{code_text}\n```\n")
elif tag == "img" and (key := el.get("image_key")): elif tag == "img" and (key := el.get("image_key")):
images.append(key) images.append(key)
@@ -1401,30 +1388,18 @@ class FeishuChannel(BaseChannel):
def _build_card_elements(self, content: str) -> list[dict]: def _build_card_elements(self, content: str) -> list[dict]:
"""Split content into div/markdown + table elements for Feishu card.""" """Split content into div/markdown + table elements for Feishu card."""
protected = content
code_blocks: list[str] = []
for m in self._CODE_BLOCK_RE.finditer(content):
code_blocks.append(m.group(1))
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
elements, last_end = [], 0 elements, last_end = [], 0
for m in self._TABLE_RE.finditer(protected): for m in self._TABLE_RE.finditer(content):
before = protected[last_end : m.start()] before = content[last_end : m.start()]
if before.strip(): if before.strip():
elements.extend(self._split_headings(before)) elements.extend(self._split_headings(before))
elements.append( elements.append(
self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)} self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}
) )
last_end = m.end() last_end = m.end()
remaining = protected[last_end:] remaining = content[last_end:]
if remaining.strip(): if remaining.strip():
elements.extend(self._split_headings(remaining)) elements.extend(self._split_headings(remaining))
for i, cb in enumerate(code_blocks):
for el in elements:
if el.get("tag") == "markdown":
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
return elements or [{"tag": "markdown", "content": content}] return elements or [{"tag": "markdown", "content": content}]
@staticmethod @staticmethod
@@ -2216,7 +2191,6 @@ class FeishuChannel(BaseChannel):
stream_id: str | None = None, stream_id: str | None = None,
stream_end: bool = False, stream_end: bool = False,
resuming: bool = False, resuming: bool = False,
merge_next: bool = False,
) -> None: ) -> None:
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent. """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
@@ -2232,10 +2206,6 @@ class FeishuChannel(BaseChannel):
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id" rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback --- # --- stream end: final update or fallback ---
if stream_end and merge_next:
if not delta:
return
stream_end = False
if stream_end: if stream_end:
message_id = meta.get("message_id") message_id = meta.get("message_id")
# Only finalize the OnIt -> DONE reaction transition on the truly # Only finalize the OnIt -> DONE reaction transition on the truly
@@ -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 import json
from nanobot.channels.feishu.runtime import ( from nanobot.channels.feishu.runtime import _extract_share_card_content
_extract_element_content,
_extract_post_content,
_extract_share_card_content,
)
def test_extract_interactive_card_reads_user_dsl_body_elements() -> None: 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" 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"
@@ -1,6 +1,7 @@
# Check optional Feishu dependencies before running tests # Check optional Feishu dependencies before running tests
try: try:
from nanobot.channels.feishu.runtime import FEISHU_AVAILABLE from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError: except ImportError:
FEISHU_AVAILABLE = False FEISHU_AVAILABLE = False
@@ -65,23 +66,3 @@ def test_split_headings_keeps_markdown_body_and_code_blocks_intact() -> None:
assert elements[1]["tag"] == "markdown" assert elements[1]["tag"] == "markdown"
assert "Body with **bold** text." in elements[1]["content"] assert "Body with **bold** text." in elements[1]["content"]
assert "```python\nprint('hi')\n```" in elements[1]["content"] assert "```python\nprint('hi')\n```" in elements[1]["content"]
def test_build_card_elements_keeps_fenced_markdown_tables_intact() -> None:
channel = FeishuChannel.__new__(FeishuChannel)
text = "Before\n\n```\n| a | b |\n| - | - |\n| 1 | 2 |\n```\n\nAfter"
elements = channel._build_card_elements(text)
assert all(el.get("tag") != "table" for el in elements)
joined = "\n".join(el["content"] for el in elements if el.get("tag") == "markdown")
assert "```\n| a | b |\n| - | - |\n| 1 | 2 |\n```" in joined
def test_build_card_elements_still_parses_unfenced_markdown_tables() -> None:
channel = FeishuChannel.__new__(FeishuChannel)
text = "Before\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter"
elements = channel._build_card_elements(text)
assert any(el.get("tag") == "table" for el in elements)
@@ -285,27 +285,6 @@ class TestSendDelta:
settings_call = ch._client.cardkit.v1.card.settings.call_args[0][0] settings_call = ch._client.cardkit.v1.card.settings.call_args[0][0]
assert settings_call.body.sequence == 5 # after final content seq 4 assert settings_call.body.sequence == 5 # after final content seq 4
@pytest.mark.asyncio
async def test_stream_end_merge_next_preserves_buffer(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="first-",
card_id="card_1",
sequence=3,
last_edit=time.monotonic(),
)
await ch.send_delta(
"oc_chat1",
"boundary",
stream_end=True,
merge_next=True,
)
assert ch._stream_bufs["oc_chat1"].text == "first-boundary"
ch._client.cardkit.v1.card_element.content.assert_not_called()
ch._client.cardkit.v1.card.settings.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_end_fallback_when_no_card_id(self): async def test_stream_end_fallback_when_no_card_id(self):
"""If card creation failed, stream_end falls back to a plain card message.""" """If card creation failed, stream_end falls back to a plain card message."""
+3 -21
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import inspect
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -764,29 +763,13 @@ class ChannelManager:
msg: OutboundMessage, msg: OutboundMessage,
event: StreamDeltaEvent | StreamEndEvent, event: StreamDeltaEvent | StreamEndEvent,
) -> None: ) -> None:
kwargs: dict[str, Any] = {
"stream_id": event.stream_id,
"stream_end": isinstance(event, StreamEndEvent),
"resuming": event.resuming if isinstance(event, StreamEndEvent) else False,
}
if isinstance(event, StreamEndEvent) and event.merge_next:
try:
signature = inspect.signature(channel.send_delta)
if (
"merge_next" in signature.parameters
or any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in signature.parameters.values()
)
):
kwargs["merge_next"] = True
except (TypeError, ValueError):
pass
await channel.send_delta( await channel.send_delta(
msg.chat_id, msg.chat_id,
msg.content, msg.content,
msg.metadata, msg.metadata,
**kwargs, stream_id=event.stream_id,
stream_end=isinstance(event, StreamEndEvent),
resuming=event.resuming if isinstance(event, StreamEndEvent) else False,
) )
@staticmethod @staticmethod
@@ -867,7 +850,6 @@ class ChannelManager:
final_event = StreamEndEvent( final_event = StreamEndEvent(
stream_id=next_stream_id, stream_id=next_stream_id,
resuming=next_event.resuming, resuming=next_event.resuming,
merge_next=next_event.merge_next,
) )
# Stream ended - stop coalescing this stream # Stream ended - stop coalescing this stream
break break
-5
View File
@@ -598,14 +598,9 @@ class MatrixChannel(BaseChannel):
stream_id: str | None = None, stream_id: str | None = None,
stream_end: bool = False, stream_end: bool = False,
resuming: bool = False, resuming: bool = False,
merge_next: bool = False,
) -> None: ) -> None:
relates_to = self._build_thread_relates_to(metadata) relates_to = self._build_thread_relates_to(metadata)
if stream_end and merge_next:
if not delta:
return
stream_end = False
if stream_end: if stream_end:
stream_key = _matrix_stream_key(chat_id, stream_id) stream_key = _matrix_stream_key(chat_id, stream_id)
buf = self._stream_bufs.pop(stream_key, None) buf = self._stream_bufs.pop(stream_key, None)
@@ -1937,29 +1937,6 @@ async def test_send_delta_stream_end_replaces_existing_message() -> None:
} }
@pytest.mark.asyncio
async def test_send_delta_merge_next_preserves_buffer() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf(
text="first-",
event_id="event-1",
last_edit=100.0,
)
channel.monotonic_time = lambda: 100.1
await channel.send_delta(
"!room:matrix.org",
"boundary",
stream_end=True,
merge_next=True,
)
assert channel._stream_bufs["!room:matrix.org"].text == "first-boundary"
assert client.room_send_calls == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_keeps_same_room_stream_ids_independent(monkeypatch) -> None: async def test_send_delta_keeps_same_room_stream_ids_independent(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus()) channel = MatrixChannel(_make_config(), MessageBus())
+2 -7
View File
@@ -56,7 +56,7 @@ class MattermostConfig(Base):
react_emoji: str = "eyes" react_emoji: str = "eyes"
done_emoji: str = "white_check_mark" done_emoji: str = "white_check_mark"
send_progress: bool = True send_progress: bool = True
send_tool_hints: bool = True send_tool_hints: bool = False
dm: MattermostDMConfig = Field(default_factory=MattermostDMConfig) dm: MattermostDMConfig = Field(default_factory=MattermostDMConfig)
@@ -515,7 +515,6 @@ class MattermostChannel(BaseChannel):
stream_id: str | None = None, stream_id: str | None = None,
stream_end: bool = False, stream_end: bool = False,
resuming: bool = False, resuming: bool = False,
merge_next: bool = False,
) -> None: ) -> None:
if not self._http_client: if not self._http_client:
return return
@@ -533,11 +532,7 @@ class MattermostChannel(BaseChannel):
final += delta final += delta
if resuming: if resuming:
if merge_next: self._clear_stream_state(stream_id)
self._stream_buffers[stream_id] = final
self._stream_committed[stream_id] = final
else:
self._clear_stream_state(stream_id)
return return
if final and not meta.get("_progress"): if final and not meta.get("_progress"):
@@ -119,7 +119,6 @@ def test_config_defaults():
assert config.token == "" assert config.token == ""
assert config.streaming is True assert config.streaming is True
assert config.streaming_max_chars == 16000 assert config.streaming_max_chars == 16000
assert config.send_tool_hints is True
assert config.dm.enabled is True assert config.dm.enabled is True
assert config.dm.policy == "open" assert config.dm.policy == "open"
assert config.reply_in_thread is True assert config.reply_in_thread is True
@@ -132,7 +131,6 @@ def test_config_camelcase_aliases():
"allowFromMatchMode": "username", "allowFromMatchMode": "username",
"streamingMaxChars": 8000, "streamingMaxChars": 8000,
"replyInThread": False, "replyInThread": False,
"sendToolHints": False,
} }
config = MattermostConfig.model_validate(raw) config = MattermostConfig.model_validate(raw)
assert config.server_url == "https://mm.example.com" assert config.server_url == "https://mm.example.com"
@@ -140,13 +138,11 @@ def test_config_camelcase_aliases():
assert config.allow_from_match_mode == "username" assert config.allow_from_match_mode == "username"
assert config.streaming_max_chars == 8000 assert config.streaming_max_chars == 8000
assert config.reply_in_thread is False assert config.reply_in_thread is False
assert config.send_tool_hints is False
def test_config_default_config_classmethod(): def test_config_default_config_classmethod():
d = MattermostChannel.default_config() d = MattermostChannel.default_config()
assert d["enabled"] is False assert d["enabled"] is False
assert d["sendToolHints"] is True
assert d["serverUrl"] == "" assert d["serverUrl"] == ""
assert d["token"] == "" assert d["token"] == ""
@@ -582,33 +578,6 @@ async def test_stream_end_keyword_resuming_does_not_post_or_mark_done():
assert "s1" not in channel._stream_buffers assert "s1" not in channel._stream_buffers
@pytest.mark.asyncio
async def test_stream_end_merge_next_preserves_buffer_until_final_end():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
await channel.send_delta("chan_1", "first ", stream_id="s1")
await channel.send_delta(
"chan_1",
"boundary ",
stream_id="s1",
stream_end=True,
resuming=True,
merge_next=True,
)
assert channel._stream_buffers["s1"] == "first boundary "
await channel.send_delta("chan_1", "second", stream_id="s1")
await channel.send_delta("chan_1", "", stream_id="s1", stream_end=True)
posts = [call for call in fake.post_calls if call["path"] == "/api/v4/posts"]
assert len(posts) == 1
assert posts[0]["json"]["message"] == "first boundary second"
assert "s1" not in channel._stream_buffers
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_end_failure_keeps_buffer_for_retry(): async def test_stream_end_failure_keeps_buffer_for_retry():
channel, fake = _make_channel() channel, fake = _make_channel()
+5 -59
View File
@@ -48,14 +48,12 @@ except Exception: # pragma: no cover
try: try:
import botpy import botpy
from botpy.gateway import BotWebSocket
from botpy.http import Route from botpy.http import Route
QQ_AVAILABLE = True QQ_AVAILABLE = True
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
QQ_AVAILABLE = False QQ_AVAILABLE = False
botpy = None botpy = None
BotWebSocket = None
Route = None Route = None
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -106,28 +104,14 @@ def _guess_send_file_type(filename: str) -> int:
return QQ_FILE_TYPE_FILE return QQ_FILE_TYPE_FILE
_RECONNECT_BACKOFF_START = 5
_RECONNECT_BACKOFF_MAX = 300
def _is_network_error(exc: BaseException) -> bool:
"""Check whether an exception is a transient network/DNS error."""
return isinstance(
exc,
(aiohttp.ClientConnectorError, OSError),
)
def _make_bot_class(channel: QQChannel) -> type[botpy.Client]: def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
"""Create a botpy client with per-session reconnect backoff.""" """Create a botpy Client subclass bound to the given channel."""
intents = botpy.Intents(public_messages=True, direct_message=True) intents = botpy.Intents(public_messages=True, direct_message=True)
class _Bot(botpy.Client): class _Bot(botpy.Client):
def __init__(self): def __init__(self):
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs # Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
super().__init__(intents=intents, ext_handlers=False) super().__init__(intents=intents, ext_handlers=False)
self._ws_backoff: dict[int, int] = {}
self._ws_retry_at: dict[int, float] = {}
async def on_ready(self): async def on_ready(self):
logger.info("QQ bot ready: {}", self.robot.name) logger.info("QQ bot ready: {}", self.robot.name)
@@ -141,35 +125,6 @@ def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
async def on_direct_message_create(self, message): async def on_direct_message_create(self, message):
await channel._on_message(message, is_group=False) await channel._on_message(message, is_group=False)
async def bot_connect(self, session):
"""Connect a botpy session with exponential retry backoff."""
session_id = id(session)
retry_at = self._ws_retry_at.pop(session_id, None)
if retry_at is not None:
remaining = retry_at - time.monotonic()
if remaining > 0:
await asyncio.sleep(remaining)
client = BotWebSocket(session, self._connection)
backoff = self._ws_backoff.get(session_id, _RECONNECT_BACKOFF_START)
try:
await client.ws_connect()
self._ws_backoff.pop(session_id, None)
except (Exception, KeyboardInterrupt, SystemExit) as e:
if _is_network_error(e):
channel.logger.warning(
"QQ bot network error (retry in {}s): {}",
backoff,
e,
)
# Count botpy's post-connect pacing toward the retry delay.
self._ws_retry_at[session_id] = time.monotonic() + backoff
self._ws_backoff[session_id] = min(backoff * 2, _RECONNECT_BACKOFF_MAX)
else:
channel.logger.exception("QQ bot WebSocket error: {}", e)
self._connection.add(session)
return _Bot return _Bot
@@ -255,24 +210,15 @@ class QQChannel(BaseChannel):
await self._run_bot() await self._run_bot()
async def _run_bot(self) -> None: async def _run_bot(self) -> None:
"""Run botpy with fallback backoff for errors escaping start().""" """Run the bot connection with auto-reconnect."""
backoff = 5
max_backoff = 300
while self._running: while self._running:
try: try:
await self._client.start(appid=self.config.app_id, secret=self.config.secret) await self._client.start(appid=self.config.app_id, secret=self.config.secret)
backoff = 5
except Exception as e: except Exception as e:
if _is_network_error(e): self.logger.warning("bot error: {}", e)
self.logger.warning(
"QQ bot network error (retry in {}s): {}", backoff, e
)
else:
self.logger.warning("bot error: {}", e)
if self._running: if self._running:
self.logger.info("Reconnecting bot in {} seconds...", backoff) self.logger.info("Reconnecting bot in 5 seconds...")
await asyncio.sleep(backoff) await asyncio.sleep(5)
backoff = min(backoff * 2, max_backoff)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop bot and cleanup resources.""" """Stop bot and cleanup resources."""
+1 -2
View File
@@ -21,8 +21,7 @@ if TYPE_CHECKING:
@cache @cache
def _warn_legacy_channel_entry_points() -> None: def _warn_legacy_channel_entry_points() -> None:
# TODO(v0.3.1): Remove this detection and warning. v0.3.0 is the final # TODO: Remove this legacy entry-point detection and warning after the migration window.
# migration window for installed legacy channel entry points.
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")}) names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
if not names: if not names:
return return
-9
View File
@@ -701,16 +701,7 @@ class SlackChannel(BaseChannel):
"""Convert Markdown to Slack mrkdwn, including tables.""" """Convert Markdown to Slack mrkdwn, including tables."""
if not text: if not text:
return "" return ""
code_blocks: list[str] = []
def _save_fence(m: re.Match) -> str:
code_blocks.append(m.group(0))
return f"\x00CB{len(code_blocks) - 1}\x00"
text = cls._CODE_FENCE_RE.sub(_save_fence, text)
text = cls._TABLE_RE.sub(cls._convert_table, text) text = cls._TABLE_RE.sub(cls._convert_table, text)
for i, block in enumerate(code_blocks):
text = text.replace(f"\x00CB{i}\x00", block)
return cls._fixup_mrkdwn(slackify_markdown(text)).rstrip("\n") return cls._fixup_mrkdwn(slackify_markdown(text)).rstrip("\n")
@classmethod @classmethod
@@ -714,19 +714,3 @@ def test_group_require_mention_accepts_camel_case_alias() -> None:
) )
assert config.group_require_mention is True assert config.group_require_mention is True
assert config.group_allow_from == ["C_OK"] assert config.group_allow_from == ["C_OK"]
def test_to_mrkdwn_keeps_fenced_markdown_tables_intact() -> None:
text = "Intro\n\n```\n| a | b |\n| - | - |\n| 1 | 2 |\n```\n\nOutro"
out = SlackChannel._to_mrkdwn(text)
assert "```\n| a | b |\n| - | - |\n| 1 | 2 |\n```" in out
assert "**a**: 1" not in out
assert "*a*: 1" not in out
def test_to_mrkdwn_still_converts_unfenced_markdown_tables() -> None:
out = SlackChannel._to_mrkdwn("| a | b |\n| - | - |\n| 1 | 2 |")
assert "| a | b |" not in out
assert "a" in out and "1" in out and "b" in out and "2" in out
-1
View File
@@ -8,7 +8,6 @@ from nanobot.channels.telegram.validation import validate
SETUP_SPEC = ChannelSetupSpec( SETUP_SPEC = ChannelSetupSpec(
fields={ fields={
"token": field("secret"), "token": field("secret"),
"proxy": field("secret"),
"allowFrom": field("list"), "allowFrom": field("list"),
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"), "groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"),
}, },
+12 -30
View File
@@ -90,34 +90,21 @@ def _split_telegram_markdown(content: str, max_len: int) -> list[str]:
min_code_pos = len(fence) min_code_pos = len(fence)
if content.startswith(fence + "\n"): if content.startswith(fence + "\n"):
min_code_pos += 1 min_code_pos += 1
# When the only break in range is the opening fence newline, if pos < min_code_pos and min_code_pos + len(closing) > max_len:
# cutting there re-emits the same fence and never advances.
if pos < min_code_pos:
if min_code_pos + len(closing) >= max_len:
chunks.append(content[:max_len])
content = content[max_len:].lstrip()
continue
budget = max_len - len(closing)
recut = content[:budget]
adjusted = recut.rfind("\n", min_code_pos)
if adjusted < min_code_pos:
adjusted = recut.rfind(" ", min_code_pos)
pos = adjusted if adjusted > min_code_pos else budget
elif pos + len(closing) > max_len:
budget = max_len - len(closing)
if budget <= min_code_pos:
chunks.append(content[:max_len])
content = content[max_len:].lstrip()
continue
recut = content[:budget]
adjusted = recut.rfind("\n", min_code_pos)
if adjusted < min_code_pos:
adjusted = recut.rfind(" ", min_code_pos)
pos = adjusted if adjusted > min_code_pos else budget
if pos <= min_code_pos:
chunks.append(content[:max_len]) chunks.append(content[:max_len])
content = content[max_len:].lstrip() content = content[max_len:].lstrip()
continue continue
if pos + len(closing) > max_len:
budget = max_len - len(closing)
if budget > 0:
recut = content[:budget]
adjusted = recut.rfind("\n")
if adjusted <= 0:
adjusted = recut.rfind(" ")
pos = adjusted if adjusted > 0 else budget
else:
closing = "```"
pos = max_len - len(closing)
chunks.append(content[:pos] + closing) chunks.append(content[:pos] + closing)
remainder = content[pos:] remainder = content[pos:]
if remainder.startswith("\n"): if remainder.startswith("\n"):
@@ -923,7 +910,6 @@ class TelegramChannel(BaseChannel):
stream_id: str | None = None, stream_id: str | None = None,
stream_end: bool = False, stream_end: bool = False,
resuming: bool = False, resuming: bool = False,
merge_next: bool = False,
) -> None: ) -> None:
"""Progressive message editing: send on first delta, edit on subsequent ones.""" """Progressive message editing: send on first delta, edit on subsequent ones."""
if not self._app: if not self._app:
@@ -931,10 +917,6 @@ class TelegramChannel(BaseChannel):
meta = metadata or {} meta = metadata or {}
int_chat_id = int(chat_id) int_chat_id = int(chat_id)
if stream_end and merge_next:
if not delta:
return
stream_end = False
if stream_end: if stream_end:
buf = self._stream_bufs.get(chat_id) buf = self._stream_bufs.get(chat_id)
if not buf or not buf.message_id or not buf.text: if not buf or not buf.message_id or not buf.text:
@@ -15,7 +15,6 @@ from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.telegram.runtime import ( from nanobot.channels.telegram.runtime import (
TELEGRAM_MAX_MESSAGE_LEN,
TELEGRAM_REPLY_CONTEXT_MAX_LEN, TELEGRAM_REPLY_CONTEXT_MAX_LEN,
TelegramChannel, TelegramChannel,
TelegramConfig, TelegramConfig,
@@ -244,69 +243,6 @@ def test_split_telegram_markdown_leading_whitespace_before_fence() -> None:
_assert_code_blocks_render_balanced(chunks) _assert_code_blocks_render_balanced(chunks)
def test_split_telegram_markdown_long_single_line_code_body() -> None:
"""Long fence bodies with no interior newlines must still advance."""
body = "a" * 4500
content = f"```\n{body}\n```"
chunks = _split_telegram_markdown(content, TELEGRAM_MAX_MESSAGE_LEN)
assert len(chunks) > 1
assert all(len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN for chunk in chunks)
assert chunks[0].startswith("```\n")
assert chunks[0].endswith("\n```")
assert chunks[1].startswith("```\n")
reassembled = []
for chunk in chunks:
part = chunk.split("\n", 1)[1]
if part.endswith("\n```"):
part = part[:-4]
elif part.endswith("```"):
part = part[:-3]
reassembled.append(part)
assert "".join(reassembled) == body
_assert_code_blocks_render_balanced(chunks)
def test_split_telegram_markdown_tiny_limit_hard_cuts_fence_prefix() -> None:
"""Adaptive HTML limits can shrink max_len to the fence+closer size."""
body = "a" * 100
content = f"```\n{body}"
chunks = _split_telegram_markdown(content, max_len=8)
assert chunks
assert all(len(chunk) <= 8 for chunk in chunks)
assert "".join(chunks).replace("```", "").replace("\n", "") == body
def test_split_telegram_markdown_tiny_limit_with_early_body_newline() -> None:
body = "a" * 100
content = f"```\na\n{body}"
chunks = _split_telegram_markdown(content, max_len=8)
assert chunks
assert all(len(chunk) <= 8 for chunk in chunks)
plain = "".join(chunks).replace("```", "")
assert "a" in plain
assert plain.count("a") >= 100
def test_split_telegram_markdown_leading_space_in_fence_body() -> None:
body = "a" * 4500
content = f"```\n {body}"
chunks = _split_telegram_markdown(content, TELEGRAM_MAX_MESSAGE_LEN)
assert chunks
assert all(len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN for chunk in chunks)
plain = "".join(chunks).replace("```", "").replace("\n", "")
assert plain.count("a") == 4500
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None: async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None:
_FakeHTTPXRequest.clear() _FakeHTTPXRequest.clear()
@@ -675,33 +611,6 @@ async def test_send_delta_stream_end_raises_and_keeps_buffer_on_failure() -> Non
assert "123" in channel._stream_bufs assert "123" in channel._stream_bufs
@pytest.mark.asyncio
async def test_send_delta_merge_next_preserves_buffer() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._stream_bufs["123"] = _StreamBuf(
text="first-",
message_id=7,
last_edit=float("inf"),
stream_id="s:0",
)
await channel.send_delta(
"123",
"boundary",
stream_id="s:0",
stream_end=True,
merge_next=True,
)
assert channel._stream_bufs["123"].text == "first-boundary"
channel._app.bot.edit_message_text.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_stream_end_treats_not_modified_as_success() -> None: async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
from telegram.error import BadRequest from telegram.error import BadRequest
@@ -4,60 +4,11 @@ import httpx
import pytest import pytest
from nanobot.channels.telegram import validation as telegram_validation from nanobot.channels.telegram import validation as telegram_validation
from nanobot.channels.telegram.manifest import SETUP_SPEC
from nanobot.channels.validation import validate_channel_config from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import save_config from nanobot.config.loader import save_config
from nanobot.config.schema import Config from nanobot.config.schema import Config
def test_telegram_setup_exposes_proxy_as_an_optional_secret() -> None:
proxy = SETUP_SPEC.fields["proxy"]
assert proxy.kind == "secret"
assert "proxy" not in SETUP_SPEC.simple_required_fields
def test_get_me_builds_http_client_with_explicit_proxy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "socks5://proxy-user:proxy-pass@127.0.0.1:1080"
captured: dict[str, object] = {}
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return {"ok": True, "result": {"id": 42}}
class FakeClient:
def __init__(self, **kwargs) -> None:
captured["kwargs"] = kwargs
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
return None
def get(self, url: str) -> FakeResponse:
captured["url"] = url
return FakeResponse()
monkeypatch.setattr(telegram_validation.httpx, "Client", FakeClient)
result = telegram_validation._get_me(token, proxy)
assert result["ok"] is True
assert captured["kwargs"] == {
"timeout": 4.0,
"proxy": proxy,
"trust_env": False,
}
assert captured["url"] == f"https://api.telegram.org/bot{token}/getMe"
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
save_config(Config(), config_path) save_config(Config(), config_path)
@@ -70,38 +21,7 @@ def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.Mo
assert result["missing_fields"] == [] assert result["missing_fields"] == []
@pytest.mark.parametrize("status_code", [401, 404]) def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
def test_validate_telegram_rejects_denied_tokens_without_exposing_them(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
status_code: int,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"telegram": {"token": token}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(token_value: str, _proxy: str | None) -> dict:
request = httpx.Request("GET", f"https://api.telegram.org/bot{token_value}/getMe")
response = httpx.Response(status_code, request=request)
raise httpx.HTTPStatusError("rejected", request=request, response=response)
monkeypatch.setattr(telegram_validation, "_get_me", raise_http_error)
result = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert result["status"] == "invalid"
assert result["can_enable"] is False
assert token not in str(result)
assert any(
f"HTTP {status_code}" in check.get("message", "") for check in result["checks"]
)
def test_validate_telegram_keeps_transient_http_failures_retryable(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -113,197 +33,14 @@ def test_validate_telegram_keeps_transient_http_failures_retryable(
) )
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(token_value: str, _proxy: str | None) -> dict: def raise_http_error(url: str, **_kwargs) -> dict:
request = httpx.Request("GET", f"https://api.telegram.org/bot{token_value}/getMe") request = httpx.Request("GET", url)
response = httpx.Response(503, request=request) response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("unavailable", request=request, response=response) raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
monkeypatch.setattr(telegram_validation, "_get_me", raise_http_error) monkeypatch.setattr(telegram_validation, "http_get", raise_http_error)
result = validate_channel_config("telegram", {"channels.telegram.token": ""}) result = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert result["status"] == "configured"
assert result["can_enable"] is True
assert token not in str(result) assert token not in str(result)
assert any("HTTP 503" in check.get("message", "") for check in result["checks"]) assert any("HTTP 401" in check.get("message", "") for check in result["checks"])
def test_validate_telegram_marks_proxy_transport_failures_without_exposing_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "http://proxy-user:proxy-pass@127.0.0.1:7890"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token, "proxy": proxy}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_proxy_error(_token: str, _proxy: str | None) -> dict:
raise httpx.ProxyError("proxy credentials rejected")
monkeypatch.setattr(telegram_validation, "_get_me", raise_proxy_error)
result = validate_channel_config("telegram")
assert result["status"] == "configured"
assert result["can_enable"] is True
assert proxy not in str(result)
assert any(check["id"] == "proxy_connection" for check in result["checks"])
def test_validate_telegram_uses_saved_proxy_without_exposing_it(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "socks5://proxy-user:proxy-pass@127.0.0.1:1080"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token, "proxy": proxy}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
captured: dict[str, str | None] = {}
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
captured.update(token=token_value, proxy=proxy_value)
return {"ok": True, "result": {"id": 42, "username": "working_bot"}}
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
result = validate_channel_config("telegram")
assert result["status"] == "connected"
assert captured == {"token": token, "proxy": proxy}
assert proxy not in str(result)
def test_validate_telegram_resolves_saved_secret_env_refs_without_exposing_them(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
token_ref = "${TELEGRAM_TOKEN_TEST}"
proxy_ref = "${TELEGRAM_PROXY_TEST}"
proxy = "http://proxy-user:proxy-pass@127.0.0.1:7890"
monkeypatch.setenv("TELEGRAM_TOKEN_TEST", token)
monkeypatch.setenv("TELEGRAM_PROXY_TEST", proxy)
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token_ref, "proxy": proxy_ref}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
captured: dict[str, str | None] = {}
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
captured.update(token=token_value, proxy=proxy_value)
return {"ok": True, "result": {"id": 42, "username": "working_bot"}}
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
result = validate_channel_config("telegram")
assert result["status"] == "connected"
assert captured == {"token": token, "proxy": proxy}
assert token_ref not in str(result)
assert token not in str(result)
assert proxy_ref not in str(result)
assert proxy not in str(result)
def test_validate_telegram_rejects_unset_proxy_env_ref_without_connecting(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy_ref = "${TELEGRAM_MISSING_PROXY_TEST}"
monkeypatch.delenv("TELEGRAM_MISSING_PROXY_TEST", raising=False)
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{"channels": {"telegram": {"token": token, "proxy": proxy_ref}}}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fail_get_me(*_args) -> dict:
pytest.fail("an unresolved proxy reference must not fall back to direct access")
monkeypatch.setattr(telegram_validation, "_get_me", fail_get_me)
result = validate_channel_config("telegram")
assert result["status"] == "invalid"
assert result["can_enable"] is False
assert proxy_ref not in str(result)
assert any(check["id"] == "proxy_env" for check in result["checks"])
def test_validate_telegram_uses_proxy_submitted_with_new_token(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
proxy = "http://127.0.0.1:7890"
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
captured: dict[str, str | None] = {}
def fake_get_me(token_value: str, proxy_value: str | None) -> dict:
captured.update(token=token_value, proxy=proxy_value)
return {"ok": True, "result": {"id": 42, "username": "new_bot"}}
monkeypatch.setattr(telegram_validation, "_get_me", fake_get_me)
result = validate_channel_config(
"telegram",
{
"channels.telegram.token": token,
"channels.telegram.proxy": proxy,
},
)
assert result["status"] == "connected"
assert captured == {"token": token, "proxy": proxy}
@pytest.mark.parametrize("proxy", ["127.0.0.1:7890", "http://[", "http://localhost:not-a-port"])
def test_validate_telegram_rejects_invalid_proxy_without_trying_token(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
proxy: str,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fail_get_me(*_args) -> dict:
pytest.fail("invalid proxy must stop before getMe")
monkeypatch.setattr(telegram_validation, "_get_me", fail_get_me)
result = validate_channel_config(
"telegram",
{
"channels.telegram.token": token,
"channels.telegram.proxy": proxy,
},
)
assert result["status"] == "invalid"
assert result["can_enable"] is False
assert proxy not in str(result)
assert any(check["id"] == "proxy_format" for check in result["checks"])
+5 -82
View File
@@ -2,82 +2,24 @@
import re import re
from typing import Any from typing import Any
from urllib.parse import urlparse
import httpx import httpx
from nanobot.channels.contracts import ChannelValidationContext from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import ( from nanobot.channels.validation import (
check, check,
http_get,
message_from_response, message_from_response,
payload, payload,
required_checks, required_checks,
status_from_checks, status_from_checks,
string_value, string_value,
) )
from nanobot.config.loader import resolve_env_refs
_TIMEOUT_SECONDS = 4.0
_SUPPORTED_PROXY_SCHEMES = {"http", "https", "socks5", "socks5h"}
def _proxy_url_is_valid(proxy: str) -> bool:
try:
parsed = urlparse(proxy)
hostname = parsed.hostname
parsed.port
except ValueError:
return False
return parsed.scheme.lower() in _SUPPORTED_PROXY_SCHEMES and bool(hostname)
def _get_me(token: str, proxy: str | None) -> dict[str, Any]:
client_kwargs: dict[str, Any] = {"timeout": _TIMEOUT_SECONDS}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(f"https://api.telegram.org/bot{token}/getMe")
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
checks, missing = required_checks("telegram", values) checks, missing = required_checks("telegram", values)
raw_token = string_value(values.get("token")) token = string_value(values.get("token"))
raw_proxy = string_value(values.get("proxy"))
token = string_value(resolve_env_refs(raw_token))
proxy = string_value(resolve_env_refs(raw_proxy))
if raw_token and not token:
checks.append(
check(
"token_env",
"Token environment variable",
"fail",
"Set every environment variable referenced by the bot token.",
)
)
if raw_proxy and not proxy:
checks.append(
check(
"proxy_env",
"Proxy environment variable",
"fail",
"Set every environment variable referenced by the network proxy.",
)
)
if (raw_token and not token) or (raw_proxy and not proxy):
return status_from_checks("telegram", checks, missing)
if proxy and not _proxy_url_is_valid(proxy):
checks.append(
check(
"proxy_format",
"Network proxy",
"fail",
"Enter a full HTTP or SOCKS proxy URL.",
)
)
return status_from_checks("telegram", checks, missing)
if token: if token:
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token): if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
checks.append( checks.append(
@@ -93,7 +35,7 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
check("token_format", "Token format", "pass", "Looks like a BotFather token.") check("token_format", "Token format", "pass", "Looks like a BotFather token.")
) )
try: try:
data = _get_me(token, proxy or None) data = http_get(f"https://api.telegram.org/bot{token}/getMe")
if data.get("ok") and isinstance(data.get("result"), dict): if data.get("ok") and isinstance(data.get("result"), dict):
bot = data["result"] bot = data["result"]
identity = { identity = {
@@ -119,31 +61,12 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
) )
) )
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
rejected = status_code in {400, 401, 403, 404}
checks.append( checks.append(
check( check(
"get_me", "get_me",
"Bot identity", "Bot identity",
"fail" if rejected else "warn",
(
f"Telegram rejected the token: HTTP {status_code}."
if rejected
else f"Telegram could not verify the token: HTTP {status_code}."
),
)
)
except httpx.TransportError:
checks.append(
check(
"proxy_connection" if proxy else "get_me",
"Network proxy" if proxy else "Bot identity",
"warn", "warn",
( f"Telegram could not verify the token: HTTP {exc.response.status_code}.",
"Could not reach Telegram through the network proxy."
if proxy
else "Could not reach Telegram now. Try again later."
),
) )
) )
except Exception: except Exception:
@@ -152,7 +75,7 @@ def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict
"get_me", "get_me",
"Bot identity", "Bot identity",
"warn", "warn",
"Could not verify Telegram now. Try again later.", "Could not reach Telegram now. Try again later.",
) )
) )
return status_from_checks("telegram", checks, missing) return status_from_checks("telegram", checks, missing)
-1
View File
@@ -12,7 +12,6 @@ export default {
docsUrl: chatAppGuideUrl("telegram"), docsUrl: chatAppGuideUrl("telegram"),
fields: [ fields: [
{ key: "channels.telegram.token" }, { key: "channels.telegram.token" },
{ key: "channels.telegram.proxy" },
{ key: "channels.telegram.allowFrom" }, { key: "channels.telegram.allowFrom" },
{ key: "channels.telegram.groupPolicy" }, { key: "channels.telegram.groupPolicy" },
], ],
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "Create it with BotFather." "help": "Create it with BotFather."
}, },
"proxy": {
"label": "Network proxy",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "Allowed users", "label": "Allowed users",
"placeholder": "* or Telegram user IDs", "placeholder": "* or Telegram user IDs",
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "Créalo con BotFather." "help": "Créalo con BotFather."
}, },
"proxy": {
"label": "Proxy de red",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "Usuarios permitidos", "label": "Usuarios permitidos",
"placeholder": "* o ID de usuario de Telegram", "placeholder": "* o ID de usuario de Telegram",
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "Créez-le avec BotFather." "help": "Créez-le avec BotFather."
}, },
"proxy": {
"label": "Proxy réseau",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "Utilisateurs autorisés", "label": "Utilisateurs autorisés",
"placeholder": "* ou ID utilisateur Telegram", "placeholder": "* ou ID utilisateur Telegram",
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "Buat dengan BotFather." "help": "Buat dengan BotFather."
}, },
"proxy": {
"label": "Proxy jaringan",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "Pengguna yang diizinkan", "label": "Pengguna yang diizinkan",
"placeholder": "* atau ID pengguna Telegram", "placeholder": "* atau ID pengguna Telegram",
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "BotFather で作成します。" "help": "BotFather で作成します。"
}, },
"proxy": {
"label": "ネットワークプロキシ",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "許可するユーザー", "label": "許可するユーザー",
"placeholder": "* または Telegram ユーザー ID", "placeholder": "* または Telegram ユーザー ID",
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "BotFather에서 생성하세요." "help": "BotFather에서 생성하세요."
}, },
"proxy": {
"label": "네트워크 프록시",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "허용된 사용자", "label": "허용된 사용자",
"placeholder": "* 또는 Telegram 사용자 ID", "placeholder": "* 또는 Telegram 사용자 ID",
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "Crie-o com o BotFather." "help": "Crie-o com o BotFather."
}, },
"proxy": {
"label": "Proxy de rede",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "Usuários permitidos", "label": "Usuários permitidos",
"placeholder": "* ou IDs de usuário do Telegram", "placeholder": "* ou IDs de usuário do Telegram",
@@ -17,10 +17,6 @@
"placeholder": "123456:ABC...", "placeholder": "123456:ABC...",
"help": "Tạo bằng BotFather." "help": "Tạo bằng BotFather."
}, },
"proxy": {
"label": "Proxy mạng",
"placeholder": "http://127.0.0.1:7890"
},
"allowFrom": { "allowFrom": {
"label": "Người dùng được phép", "label": "Người dùng được phép",
"placeholder": "* hoặc ID người dùng Telegram", "placeholder": "* hoặc ID người dùng Telegram",

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