Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3de6ea3e | ||
|
|
55405f6cd6 | ||
|
|
b0ef759e2c | ||
|
|
9a7debcb48 | ||
|
|
922c49246d | ||
|
|
df1a0ed889 | ||
|
|
3f602fbc8c | ||
|
|
d6f6bbddbf | ||
|
|
ac7b8cf4b4 | ||
|
|
88cb22dd79 | ||
|
|
5328a95add | ||
|
|
c6dbeb97d8 | ||
|
|
0bbb74b1ee | ||
|
|
944de867a0 | ||
|
|
6e0eb46705 | ||
|
|
e260d9b31c | ||
|
|
51f11a8548 | ||
|
|
7e15c4c447 | ||
|
|
3a400e0207 | ||
|
|
8e4fe9cfaf | ||
|
|
07a81d70be | ||
|
|
d3e4b35f2b | ||
|
|
5be176a6a0 | ||
|
|
9aab94c766 | ||
|
|
9957de5226 | ||
|
|
cad368f585 | ||
|
|
0b38c48399 | ||
|
|
6a9157f477 | ||
|
|
8bcab8885e | ||
|
|
aae259c790 | ||
|
|
d993c81f08 | ||
|
|
4490f8cfe4 | ||
|
|
78f4c132d9 | ||
|
|
7e9426d9bd | ||
|
|
98d661775e | ||
|
|
017a4946e2 | ||
|
|
648fc92673 | ||
|
|
274613f064 | ||
|
|
754f457a94 | ||
|
|
6c0f151f6e | ||
|
|
4b1547db7d | ||
|
|
c3ec2e665f | ||
|
|
911a7e3a82 | ||
|
|
fc9d17eb7b | ||
|
|
60ab580f8b | ||
|
|
96eb965aae | ||
|
|
4188ffc88d | ||
|
|
089216f9c7 | ||
|
|
464f71b488 | ||
|
|
15de6be0af | ||
|
|
01cdfc8100 | ||
|
|
3647875aba | ||
|
|
299bcf491b | ||
|
|
0191c0db73 | ||
|
|
5851bd432a | ||
|
|
8195181783 | ||
|
|
9cf2fb19c2 | ||
|
|
f3099286ea | ||
|
|
5f054c0e74 | ||
|
|
536e8db324 | ||
|
|
2f4f00bb9f | ||
|
|
8bd951a06f | ||
|
|
e875f29185 | ||
|
|
1616fa9f14 | ||
|
|
c7393c785e | ||
|
|
c22efb5f7a | ||
|
|
66690fdb0c | ||
|
|
aa8387fb4d | ||
|
|
b189a37648 | ||
|
|
4cd6eb6c38 | ||
|
|
80085085d9 | ||
|
|
ebf1ef5cab | ||
|
|
7b1d81a868 | ||
|
|
254497c02e | ||
|
|
96abb4d2c4 | ||
|
|
63bc6e98a7 | ||
|
|
3748f664b2 | ||
|
|
7bf7469d90 | ||
|
|
79d9455313 | ||
|
|
a9867a5a4e | ||
|
|
c6a4d46a2a | ||
|
|
be1cc769d5 | ||
|
|
9abad4746e | ||
|
|
b32d673ead | ||
|
|
79b89f4f4c | ||
|
|
89d8c055a8 | ||
|
|
b81c05581f | ||
|
|
1d7bad3909 | ||
|
|
b46e7f4377 | ||
|
|
4cfc99f4b3 | ||
|
|
b2cf37da4a | ||
|
|
28102382af | ||
|
|
93571149db | ||
|
|
052f671b3c | ||
|
|
cdb2df4982 | ||
|
|
d5658dbc91 | ||
|
|
ab6ceef1a1 | ||
|
|
12c52c11d3 | ||
|
|
f4a7079e65 | ||
|
|
bbca32fea9 | ||
|
|
8981995474 | ||
|
|
7cf3c71e3a | ||
|
|
fde55d06e2 | ||
|
|
b6156fdd79 | ||
|
|
0b1b02f187 | ||
|
|
dfc3919b52 | ||
|
|
afc65c086e | ||
|
|
4a79cbb6e7 | ||
|
|
9db0d9f3c9 | ||
|
|
b67f4b1371 | ||
|
|
ab0d28103b | ||
|
|
9d830fb6b6 | ||
|
|
8423cf3eeb | ||
|
|
76f3eead42 | ||
|
|
949cfad548 | ||
|
|
e3de01c9f6 | ||
|
|
462a0dfb0f | ||
|
|
7aaac37bca | ||
|
|
91514ad0b1 | ||
|
|
a6b68178aa | ||
|
|
2099cb009e | ||
|
|
39a952ecce | ||
|
|
b1232fdaf4 | ||
|
|
cea8617096 | ||
|
|
ffb7ddfa1e | ||
|
|
c2071594cf | ||
|
|
cf96c4d5e9 | ||
|
|
cf00f537bd | ||
|
|
cfa49c6e78 | ||
|
|
c062e1af14 | ||
|
|
c77379099b | ||
|
|
ca873e4d17 | ||
|
|
63895fc101 | ||
|
|
770d89b430 | ||
|
|
afed32b013 | ||
|
|
8c68c6fe1e | ||
|
|
b76d54aae1 | ||
|
|
d35f99abfc | ||
|
|
d4f5abe004 | ||
|
|
07ad0bafa8 | ||
|
|
995cc44e89 | ||
|
|
7ac9a46978 | ||
|
|
fe0e65928d | ||
|
|
85097aa143 | ||
|
|
6de5a0c5ca | ||
|
|
8a48af7c74 | ||
|
|
b4adb29c2b | ||
|
|
6519737860 | ||
|
|
d4e0294734 | ||
|
|
681edfa6f3 | ||
|
|
ba86dccc8d | ||
|
|
5ed28a6744 | ||
|
|
aa70aa48f9 | ||
|
|
0fd4d0ab29 | ||
|
|
c1fd76add3 | ||
|
|
63a6d5d07d | ||
|
|
dcb37259fa | ||
|
|
88c38e9b38 | ||
|
|
37165b0db0 | ||
|
|
2116e32013 | ||
|
|
06f47fa540 | ||
|
|
905da8e34a | ||
|
|
5365bab088 | ||
|
|
f718c69b2d | ||
|
|
07f54c25e3 | ||
|
|
1a1e666625 | ||
|
|
297a9e5939 | ||
|
|
86f6558707 | ||
|
|
4916fc07ab | ||
|
|
11eb9d8cc8 | ||
|
|
6c9e3a2cc3 | ||
|
|
b7048cf76a | ||
|
|
b2759e8a6b | ||
|
|
1643aa7ef5 | ||
|
|
9f8c2cb1bf | ||
|
|
61afbffc89 | ||
|
|
9cdf17f5d5 | ||
|
|
3b14d59dcd | ||
|
|
a335ce07db | ||
|
|
87478b6e92 | ||
|
|
67648774e2 |
@@ -27,9 +27,3 @@ A bugfix should make the protected invariant clear, change the smallest surface
|
||||
## Explicit over magical
|
||||
|
||||
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
||||
|
||||
## Configuration has an explicit owner
|
||||
|
||||
`FileConfigRepository` owns config-file reads, validation, revisions, and atomic writes. Process entry points may use the explicit functions in `config/loader.py`; components with an explicit config path should keep their own repository instance instead of importing a mutable global config object.
|
||||
|
||||
Persisted and runtime views are separate: `load_raw_config()` / `load_raw()` preserve `${VAR}` references for editing, while `load_effective_config()` / `load_effective()` return an isolated snapshot with references resolved. Read-modify-write flows must use `update()` / `update_config()` so a stale object cannot silently overwrite a newer change. Loading config is side-effect free; process-wide policies are applied explicitly during runtime startup.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Config `${VAR}` References
|
||||
|
||||
`load_raw_config()` and `FileConfigRepository.load_raw()` preserve `${VAR}` patterns so Settings can safely edit and save the persisted representation. Runtime entry points use `load_effective_config()` / `load_effective()` to resolve them in an isolated snapshot. This is **not** a shell-like default-value syntax. If a referenced variable is missing, effective loading raises `ValueError`.
|
||||
`config/loader.py` resolves `${VAR}` patterns in `config.json` at load time. This is **not** a shell-like default-value syntax. If the environment variable is missing, `load_config` raises `ValueError` and the agent falls back to default configuration.
|
||||
|
||||
Example valid usage:
|
||||
```json
|
||||
|
||||
@@ -16,7 +16,7 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
|
||||
|
||||
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`).
|
||||
|
||||
The only escape hatch is `configure_ssrf_whitelist(cidrs)`. Runtime entry points explicitly apply `config.tools.ssrf_whitelist` after loading the effective config; ordinary config reads must not mutate this process-wide 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.
|
||||
|
||||
|
||||
@@ -18,15 +18,57 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
python_required: ${{ steps.paths.outputs.python_required }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect Python-relevant changes
|
||||
id: paths
|
||||
shell: bash
|
||||
env:
|
||||
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:
|
||||
name: Python (${{ matrix.name }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python_required == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
|
||||
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
||||
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
||||
include:
|
||||
- name: minimum, 3.11
|
||||
os: ubuntu-latest
|
||||
python-version: "3.11"
|
||||
coverage: false
|
||||
- name: latest, 3.14 + coverage
|
||||
os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
coverage: true
|
||||
- name: Windows, 3.14
|
||||
os: windows-latest
|
||||
python-version: "3.14"
|
||||
coverage: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -46,11 +88,27 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Lint with ruff
|
||||
run: uv run ruff check nanobot --select F
|
||||
- name: Install channel dependencies
|
||||
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
|
||||
|
||||
- name: Run tests
|
||||
run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered
|
||||
# Channel requirements live in manifests rather than uv.lock. Avoid a
|
||||
# later uv run sync pruning the packages installed by the previous step.
|
||||
- name: Lint with ruff
|
||||
if: matrix.coverage
|
||||
run: uv run --no-sync ruff check nanobot tests conftest.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: matrix.coverage
|
||||
run: >-
|
||||
uv run --no-sync python -m pytest
|
||||
--cov=nanobot --cov-report=term-missing:skip-covered
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
- name: Run compatibility tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: >-
|
||||
uv run --no-sync python -m pytest
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
webui:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -64,9 +122,13 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Verify npm lockfile
|
||||
working-directory: webui
|
||||
run: npm ci --ignore-scripts --dry-run
|
||||
|
||||
- name: Install WebUI dependencies
|
||||
working-directory: webui
|
||||
run: bun install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint WebUI
|
||||
working-directory: webui
|
||||
@@ -79,3 +141,22 @@ jobs:
|
||||
- name: Build WebUI
|
||||
working-directory: webui
|
||||
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"'
|
||||
|
||||
@@ -100,3 +100,4 @@ temp/
|
||||
exp/
|
||||
.playwright-mcp/
|
||||
bridge/node_modules/
|
||||
webui/.verify-*
|
||||
|
||||
@@ -36,7 +36,7 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
|
||||
|
||||
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
|
||||
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
|
||||
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are self-contained packages auto-discovered via `pkgutil` scanning.
|
||||
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
|
||||
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
||||
|
||||
@@ -15,29 +15,63 @@ RUN apt-get update && \
|
||||
|
||||
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
|
||||
# hook from hatch_build.py even for this metadata-only install.
|
||||
ARG NANOBOT_EXTRAS=whatsapp
|
||||
ARG NANOBOT_EXTRAS=
|
||||
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
||||
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]" && \
|
||||
if [ -n "$NANOBOT_EXTRAS" ]; then \
|
||||
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
|
||||
|
||||
# Copy the full source and install
|
||||
COPY nanobot/ nanobot/
|
||||
COPY scripts/install_channel_dependencies.py scripts/
|
||||
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
|
||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache .
|
||||
|
||||
# Create non-root user and config directory
|
||||
# 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
|
||||
# secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved
|
||||
# at startup). Lives in the code dir (/app), not the data dir, so a mounted disk
|
||||
# won't shadow it. Only used when RENDER=true; ignored by local runs.
|
||||
COPY render-config.json ./
|
||||
|
||||
# Create the non-root user and hand ownership of the writable virtualenv to it.
|
||||
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
||||
mkdir -p /home/nanobot/.nanobot && \
|
||||
chown -R nanobot:nanobot /home/nanobot /app
|
||||
chown -R nanobot:nanobot /home/nanobot /app/.venv
|
||||
|
||||
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
|
||||
|
||||
USER nanobot
|
||||
# Start as root so the entrypoint can chown the data dir (on Render, the
|
||||
# freshly-mounted root-owned persistent disk) before dropping to the non-root
|
||||
# nanobot user via setpriv. The entrypoint drops privileges on every root start
|
||||
# and fails closed if it cannot, so the agent never runs as root (see
|
||||
# entrypoint.sh).
|
||||
USER root
|
||||
ENV HOME=/home/nanobot
|
||||
# Ensure crash output reaches Render logs (app output is otherwise swallowed on
|
||||
# non-graceful exit).
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONFAULTHANDLER=1
|
||||
|
||||
# Gateway health endpoint and optional WebUI/WebSocket channel ports
|
||||
EXPOSE 18790 8765
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.png">
|
||||
<img alt="nanobot README cover" src="./images/readme-cover-light.png">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.svg">
|
||||
<img alt="nanobot README cover" src="./images/readme-cover-light.svg">
|
||||
</picture>
|
||||
|
||||
<div align="center">
|
||||
@@ -46,6 +46,7 @@
|
||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
||||
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
||||
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
||||
| Deploy to the cloud or keep nanobot running as a service | [Deployment](./docs/deployment.md), including [one-click Render setup](./docs/deployment.md#render) |
|
||||
|
||||
## What can nanobot do?
|
||||
|
||||
@@ -59,19 +60,18 @@ nanobot is a self-hosted personal AI agent runtime. It can:
|
||||
- expose a Python SDK and OpenAI-compatible API for integrations
|
||||
- deploy as a long-running local or server-side agent gateway
|
||||
|
||||
## Latest Release
|
||||
## Releases
|
||||
|
||||
**v0.2.2 - Durability Release**
|
||||
**Latest release: [v0.3.0 - The Agency Release](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)**
|
||||
|
||||
Highlights:
|
||||
The Agency Release turns nanobot from a durable workbench into an agent runtime that can coordinate helpers, switch models per session, and carry authorized work through to completion.
|
||||
|
||||
- Segmented WebUI transcripts
|
||||
- Python SDK runtime controls
|
||||
- Automation management
|
||||
- Search/STT provider improvements
|
||||
- Gateway/session/provider reliability
|
||||
- Consult inline subagents without leaving the current task
|
||||
- Switch model presets per session directly from the composer
|
||||
- Start from a guided WebUI setup with clearer execution controls
|
||||
- Apply configuration changes live across a more reliable provider, channel, and tool runtime
|
||||
|
||||
[See full changelog](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
|
||||
[Read the v0.3.0 release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)
|
||||
|
||||
## Open Source Partners
|
||||
|
||||
@@ -82,11 +82,11 @@ Highlights:
|
||||
|
||||
## Recent Updates
|
||||
|
||||
- **2026-07-12** Explicit `/goal` activation, safer runtime and workspace access.
|
||||
- **2026-07-11** Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
||||
- **2026-07-10** Stable model routing, multiline CLI input, new automation guide.
|
||||
- **2026-07-09** Live file-edit diffs, safer localhost setup, Matrix image fixes.
|
||||
- **2026-07-08** Safer WebUI/API setup, onboard refresh, responsive prompt rail.
|
||||
- **2026-07-24** Guided first-run setup, inline subagents, and model switching from the composer.
|
||||
- **2026-07-23** Grok OAuth with hosted X Search, live image settings, and clearer fallback models.
|
||||
- **2026-07-22** Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI.
|
||||
- **2026-07-21** Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup.
|
||||
- **2026-07-20** Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects.
|
||||
|
||||
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
|
||||
|
||||
@@ -107,7 +107,7 @@ For older updates, see the [release archive](./docs/release-archive.md) or [GitH
|
||||
|
||||
Pick **one** install method:
|
||||
|
||||
Prerequisites: Python 3.11 or newer. Git is only needed for a source install; Node.js/Bun are only needed if you are developing the WebUI itself.
|
||||
Prerequisites: Python 3.11 or newer. Git is only needed for a source install. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it.
|
||||
|
||||
If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path.
|
||||
|
||||
@@ -125,7 +125,7 @@ Windows PowerShell:
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, 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 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`.
|
||||
|
||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||
|
||||
@@ -165,111 +165,84 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c
|
||||
|
||||
**Install from source**
|
||||
|
||||
`bun` or `npm` must be available. From an activated virtual environment:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
python -m pip install .
|
||||
```
|
||||
|
||||
On Windows, if pip 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. Contributors who need an editable checkout should follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`webui/README.md`](./webui/README.md).
|
||||
|
||||
Verify the install:
|
||||
|
||||
```bash
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If `nanobot` is not on `PATH`, invoke it through the method that installed it: reuse the recommended installer's command, use `uv tool run --from nanobot-ai nanobot ...` or `pipx run --spec nanobot-ai nanobot ...`, or use the Python executable from the environment where pip installed the package.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
Skip this step if the one-command setup already started the wizard and Quick Start finished there.
|
||||
|
||||
```bash
|
||||
nanobot onboard
|
||||
```
|
||||
|
||||
Use `nanobot onboard --wizard` if you prefer an interactive setup.
|
||||
|
||||
**2. Configure** (`~/.nanobot/config.json`)
|
||||
|
||||
Skip this step if you already configured provider and model settings in the wizard.
|
||||
|
||||
`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.
|
||||
|
||||
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).
|
||||
|
||||
*Set your API key*:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
*Set a model preset and make it active*:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Primary",
|
||||
"provider": "custom",
|
||||
"model": "model-id-from-your-provider",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 200000,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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**
|
||||
|
||||
Start the browser workbench:
|
||||
**Open nanobot in your browser**
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. It binds the first-run WebUI to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot webui --background`, then manage the gateway with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
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.
|
||||
|
||||
For manual or terminal-only setup, test one CLI message:
|
||||
**Your first three steps**
|
||||
|
||||
1. Open **Settings → Models** and choose a provider, credential, and model.
|
||||
2. Start a new topic and send `Hello!` to verify the connection.
|
||||
3. Before project work, choose the intended workspace and access mode from the composer.
|
||||
|
||||
Any normal reply means the provider, model, workspace, and browser gateway are working together.
|
||||
|
||||
**Keep nanobot running after you close the terminal**
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
nanobot webui --background
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
If that works, start an interactive chat:
|
||||
```bash
|
||||
nanobot gateway status
|
||||
nanobot gateway logs
|
||||
nanobot gateway restart
|
||||
nanobot gateway stop
|
||||
```
|
||||
|
||||
**Prefer a gateway-first workflow?**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
This skips WebUI setup and browser opening, then runs the same complete gateway in the current terminal. It is the familiar entry point if you are coming from OpenClaw or already operate agents as long-lived services. The WebUI remains available when its channel is configured; open it manually when needed.
|
||||
|
||||
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).
|
||||
|
||||
**Prefer to work entirely in the terminal?**
|
||||
|
||||
```bash
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
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).
|
||||
This opens an interactive terminal chat with the same configured model, workspace, and tools while keeping its own CLI session history. It does not open a browser or keep chat channels and automations running after you exit. Type `exit` or press `Ctrl+C` when you are done.
|
||||
|
||||
For one request and an immediate exit, use:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
The one-shot form is useful for a quick provider check, shell scripts, and local automation. If you have not configured a model yet, run `nanobot webui` and open **Settings → Models** first.
|
||||
|
||||
Need manual JSON, another device on your LAN, or help with provider/model matching? Continue with [Install and Quick Start](./docs/quick-start.md), [WebUI](./docs/webui.md), or [Troubleshooting](./docs/troubleshooting.md).
|
||||
|
||||
- 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)
|
||||
@@ -280,24 +253,20 @@ Need help with `PATH`, API keys, provider/model matching, or JSON errors? See th
|
||||
|
||||
## 🌐 WebUI
|
||||
|
||||
The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for chat sessions, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md).
|
||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||
|
||||
<p align="center">
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
</p>
|
||||
|
||||
**Open it**
|
||||
Use it to:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
- keep separate topics for different tasks and projects;
|
||||
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||
- switch models and workspaces without leaving the conversation;
|
||||
- configure providers, chat channels, Apps, Skills, and Automations from one place.
|
||||
|
||||
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). 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.
|
||||
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).
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
@@ -366,7 +335,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, review, and contribution gui
|
||||
|
||||
## Contact
|
||||
|
||||
This project was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and continues to be maintained in an individual capacity using personal resources, with contributions from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
||||
Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
||||
|
||||
### Contributors
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ We aim to respond to security reports within 48 hours.
|
||||
**CRITICAL**: Never commit API keys to version control.
|
||||
|
||||
```bash
|
||||
# ✅ Best: Use environment variable references in config (never writes the key to disk)
|
||||
# In ~/.nanobot/config.json:
|
||||
# "apiKey": "${ANTHROPIC_API_KEY}"
|
||||
# Then supply the key at runtime via env var or Docker secret.
|
||||
|
||||
# ✅ Good: Store in config file with restricted permissions
|
||||
chmod 600 ~/.nanobot/config.json
|
||||
|
||||
@@ -28,9 +33,9 @@ chmod 600 ~/.nanobot/config.json
|
||||
```
|
||||
|
||||
**Recommendations:**
|
||||
- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600`
|
||||
- Consider using environment variables for sensitive keys
|
||||
- Use OS keyring/credential manager for production deployments
|
||||
- **Prefer environment variable references** (`${VAR}`) in config — the config file stores the `${VAR}` placeholder, and the plaintext value only exists in memory at runtime. See [Configuration: Environment Variables for Secrets](https://nanobot.wiki/docs/latest/use-nanobot/configuration/#environment-variables-for-secrets) for details.
|
||||
- When plaintext keys are stored in `~/.nanobot/config.json`, set file permissions to `0600` (`chmod 600`)
|
||||
- Consider using an OS keyring/credential manager for production deployments
|
||||
- Rotate API keys regularly
|
||||
- Use separate API keys for development and production
|
||||
|
||||
@@ -129,7 +134,7 @@ pip install --upgrade nanobot-ai
|
||||
|
||||
**Important Notes:**
|
||||
- Keep `litellm` updated to the latest version for security fixes
|
||||
- Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
|
||||
- Run `pip-audit` regularly after enabling the channels used in production; their manifest-declared dependencies are installed into the same environment
|
||||
- Subscribe to security advisories for nanobot and its dependencies
|
||||
|
||||
### 7. Production Deployment
|
||||
@@ -237,7 +242,7 @@ If you suspect a security breach:
|
||||
⚠️ **Current Security Limitations:**
|
||||
|
||||
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
|
||||
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
|
||||
2. **Plain Text Config** - API keys stored in plain text in `config.json` (prefer `${VAR}` env references when possible, or use keyring for production)
|
||||
3. **No Session Management** - No automatic session expiry
|
||||
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)
|
||||
@@ -260,7 +265,7 @@ Before deploying nanobot:
|
||||
|
||||
## Updates
|
||||
|
||||
**Last Updated**: 2026-04-05
|
||||
**Last Updated**: 2026-07-21
|
||||
|
||||
For the latest security updates and announcements, check:
|
||||
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Cross-suite test infrastructure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
|
||||
import certifi
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
|
||||
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
|
||||
|
||||
Loading certifi takes roughly 0.7 seconds per client on Windows. The test
|
||||
suite constructs hundreds of clients while mocking their I/O. System roots
|
||||
preserve certificate verification for accidental local requests; explicit
|
||||
``cafile``, ``capath``, and ``cadata`` arguments still use the real loader.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
yield
|
||||
return
|
||||
|
||||
original = ssl.create_default_context
|
||||
certifi_path = os.path.normcase(os.path.abspath(certifi.where()))
|
||||
|
||||
def create_default_context(
|
||||
purpose: ssl.Purpose = ssl.Purpose.SERVER_AUTH,
|
||||
*,
|
||||
cafile: str | None = None,
|
||||
capath: str | None = None,
|
||||
cadata: str | bytes | None = None,
|
||||
) -> ssl.SSLContext:
|
||||
requested_path = os.path.normcase(os.path.abspath(cafile)) if cafile else None
|
||||
if requested_path == certifi_path and capath is None and cadata is None:
|
||||
return original(purpose)
|
||||
return original(
|
||||
purpose,
|
||||
cafile=cafile,
|
||||
capath=capath,
|
||||
cadata=cadata,
|
||||
)
|
||||
|
||||
ssl.create_default_context = create_default_context
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
ssl.create_default_context = original
|
||||
@@ -0,0 +1,16 @@
|
||||
x-bwrap-security: &bwrap-security
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- seccomp=unconfined
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
<<: *bwrap-security
|
||||
|
||||
nanobot-api:
|
||||
<<: *bwrap-security
|
||||
|
||||
nanobot-cli:
|
||||
<<: *bwrap-security
|
||||
@@ -2,15 +2,12 @@ x-common-config: &common-config
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NANOBOT_CHANNELS: ${NANOBOT_CHANNELS:-whatsapp}
|
||||
volumes:
|
||||
- ~/.nanobot:/home/nanobot/.nanobot
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- seccomp=unconfined
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
|
||||
@@ -1,150 +1,84 @@
|
||||
# nanobot Docs
|
||||
# nanobot Documentation
|
||||
|
||||
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
|
||||
Use these docs to get a working agent first, then open a task guide only when you need the next capability. Source-level design and extension details are kept in the contributor section.
|
||||
|
||||
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md), open the browser workbench with `nanobot webui`, and use terminal checks when you need lower-level diagnosis.
|
||||
|
||||
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
|
||||
|
||||
Provider examples are concrete walkthroughs, not rankings or endorsements. Use the provider whose key, endpoint, and model ID you actually control.
|
||||
|
||||
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
|
||||
|
||||
## Pick a Track
|
||||
|
||||
| You are | Start with | Then use |
|
||||
|---|---|---|
|
||||
| New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails |
|
||||
| Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups |
|
||||
| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) |
|
||||
| Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
Repository docs follow the current source tree and can be newer than the latest package release. For published release docs, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview).
|
||||
|
||||
## Start Here
|
||||
|
||||
| Goal | Read | Outcome |
|
||||
| Your situation | Read this | You are done when... |
|
||||
|---|---|---|
|
||||
| Start with no technical background | [`start-without-technical-background.md`](./start-without-technical-background.md) | One-command setup, terminal basics, config, API keys, and the first reply |
|
||||
| Install and get the first reply | [`quick-start.md`](./quick-start.md) | A working CLI agent and a known-good config path |
|
||||
| Understand how the pieces fit | [`concepts.md`](./concepts.md) | Mental model for config, workspace, gateway, channels, tools, memory, and sessions |
|
||||
| Choose or change a model provider | [`providers.md`](./providers.md) | Correct provider/model pairing without reading the full config reference |
|
||||
| Copy a provider setup recipe | [`provider-cookbook.md`](./provider-cookbook.md) | Pasteable OpenRouter, OpenAI, Anthropic, local model, fallback, and Langfuse setups |
|
||||
| Fix a first-run or runtime problem | [`troubleshooting.md`](./troubleshooting.md) | A diagnosis order and targeted checks for common failures |
|
||||
| Terminals, Python, or API keys are new to you | [Beginner walkthrough](./start-without-technical-background.md) | The browser can send `Hello!` and receive a reply |
|
||||
| You are comfortable running commands | [Install and Quick Start](./quick-start.md) | `nanobot status` is healthy and the WebUI or CLI can get one reply |
|
||||
| Something already failed | [Troubleshooting](./troubleshooting.md) | You have isolated the problem to install, config, model, gateway, channel, or tool access |
|
||||
|
||||
## Task Guides
|
||||
The recommended first-run path is:
|
||||
|
||||
Use these pages when you know the workflow you want and do not want to scan the
|
||||
full reference first.
|
||||
1. Install nanobot.
|
||||
2. Let the installer open `nanobot webui` on a fresh local desktop.
|
||||
3. Configure a provider and model in **Settings → Models**.
|
||||
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.
|
||||
|
||||
## Add One Capability
|
||||
|
||||
Pick the row that matches what you want to accomplish next:
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Build a personal AI agent | [`guides/build-a-personal-ai-agent.md`](./guides/build-a-personal-ai-agent.md) |
|
||||
| Run a self-hosted AI agent | [`guides/self-hosted-ai-agent.md`](./guides/self-hosted-ai-agent.md) |
|
||||
| Use a browser AI agent WebUI | [`guides/ai-agent-webui.md`](./guides/ai-agent-webui.md) |
|
||||
| Connect an AI agent to chat apps | [`guides/chat-app-ai-agent.md`](./guides/chat-app-ai-agent.md) |
|
||||
| Run long-running agent tasks | [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) |
|
||||
| Schedule or trigger agent turns | [`automations.md`](./automations.md) |
|
||||
| Add long-term agent memory | [`guides/ai-agent-memory.md`](./guides/ai-agent-memory.md) |
|
||||
| Add MCP tools to an agent | [`guides/mcp-tools-for-ai-agents.md`](./guides/mcp-tools-for-ai-agents.md) |
|
||||
| Run an agent from Python | [`guides/python-ai-agent-sdk.md`](./guides/python-ai-agent-sdk.md) |
|
||||
| Expose an OpenAI-compatible agent API | [`guides/openai-compatible-agent-api.md`](./guides/openai-compatible-agent-api.md) |
|
||||
| Deploy a long-running agent gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
||||
| Learn the browser workbench | [WebUI](./webui.md) |
|
||||
| Connect Telegram, Discord, Slack, Feishu, WeChat, Email, or another chat app | [Chat Apps](./chat-apps.md) |
|
||||
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
||||
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||
| Generate images | [Image Generation](./image-generation.md) |
|
||||
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
||||
| Understand and manage long-term memory | [Memory](./memory.md) |
|
||||
| Run nanobot continuously | [Deployment](./deployment.md) |
|
||||
| Run separate bots or workspaces | [Multiple Instances](./multiple-instances.md) |
|
||||
| Call nanobot from Python | [Python SDK](./python-sdk.md) |
|
||||
| Expose an OpenAI-compatible endpoint | [OpenAI-Compatible API](./openai-api.md) |
|
||||
|
||||
Platform-specific chat guides:
|
||||
[`Telegram`](./guides/telegram-ai-agent.md),
|
||||
[`Discord`](./guides/discord-ai-agent.md),
|
||||
[`Slack`](./guides/slack-ai-agent.md),
|
||||
[`Feishu`](./guides/feishu-ai-agent.md),
|
||||
[`WhatsApp`](./guides/whatsapp-ai-agent.md),
|
||||
[`WeChat`](./guides/wechat-ai-agent.md),
|
||||
[`QQ`](./guides/qq-ai-agent.md),
|
||||
[`Email`](./guides/email-ai-agent.md), and
|
||||
[`Mattermost`](./guides/mattermost-ai-agent.md).
|
||||
For shorter, outcome-focused walkthroughs, browse the [task guide index](./guides/README.md).
|
||||
|
||||
Configuration guides:
|
||||
[`MCP tools`](./guides/configure-mcp-tools.md),
|
||||
[`web search`](./guides/configure-web-search.md),
|
||||
[`model fallback`](./guides/configure-model-fallback.md),
|
||||
[`OpenAI-compatible providers`](./guides/configure-openai-compatible-provider.md),
|
||||
[`Langfuse`](./guides/configure-langfuse-observability.md),
|
||||
[`local security`](./guides/secure-local-ai-agent.md), and
|
||||
[`gateway deployment`](./guides/deploy-nanobot-gateway.md).
|
||||
## Operate nanobot
|
||||
|
||||
## After the First Reply Works
|
||||
|
||||
Do not configure everything at once. Pick one next surface:
|
||||
|
||||
If a local `nanobot agent` session can already answer normally, you can also ask nanobot to help configure itself: have it read the relevant docs, inspect your current config, make one specific next change, and tell you when to run `/restart`.
|
||||
|
||||
| Next goal | Read | First check |
|
||||
|---|---|---|
|
||||
| Use nanobot in a browser | [`webui.md`](./webui.md) | Run `nanobot webui` and open the local browser workbench |
|
||||
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
|
||||
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
|
||||
| Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn |
|
||||
| Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean |
|
||||
| Diagnose a new failure | [`troubleshooting.md`](./troubleshooting.md) | Start with `nanobot status`, then `nanobot agent -m "Hello!"` |
|
||||
|
||||
## Use nanobot
|
||||
|
||||
| Goal | Read | Outcome |
|
||||
|---|---|---|
|
||||
| Open the bundled browser UI | [`webui.md`](./webui.md) | `nanobot webui`, chat workspace, Apps, Skills, Automations, and settings |
|
||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
||||
| Use automations | [`automations.md`](./automations.md) | Scheduled automations, local triggers, heartbeat, WebUI management, and delivery behavior |
|
||||
| Use slash commands | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
|
||||
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
||||
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
|
||||
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
|
||||
| Join agent communities | [`agent-social-network.md`](./agent-social-network.md) | External agent-community setup |
|
||||
| Need | Read |
|
||||
|---|---|
|
||||
| Commands and flags | [CLI Reference](./cli-reference.md) |
|
||||
| In-chat slash commands | [In-Chat Commands](./chat-commands.md) |
|
||||
| Config, workspace, gateway, sessions, tools, and memory in plain language | [Concepts](./concepts.md) |
|
||||
| Provider/model matching and selection | [Providers and Models](./providers.md) |
|
||||
| Setup and runtime diagnosis | [Troubleshooting](./troubleshooting.md) |
|
||||
| Older development highlights | [Release Archive](./release-archive.md) |
|
||||
|
||||
## Reference
|
||||
|
||||
| Area | Read | Best for |
|
||||
|---|---|---|
|
||||
| Full configuration schema | [`configuration.md`](./configuration.md) | Exact fields, defaults, provider tables, web tools, MCP, security, and runtime options |
|
||||
| CLI commands | [`cli-reference.md`](./cli-reference.md) | Command names, common flags, and entrypoints |
|
||||
| Architecture | [`architecture.md`](./architecture.md) | Source-level runtime map for core flow, providers, channels, tools, WebUI, memory, security, and extension points |
|
||||
| Release archive | [`release-archive.md`](./release-archive.md) | Older release and daily update highlights moved out of the README |
|
||||
| Development | [`development.md`](./development.md) | Contributor notes for adding providers and transcription adapters |
|
||||
| Memory | [`memory.md`](./memory.md) | Session history, Dream consolidation, memory files, and versioning |
|
||||
| Observability | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | Langfuse tracing setup and required environment variables |
|
||||
| WebSocket protocol | [`websocket.md`](./websocket.md) | Custom clients, token issuance, multiplexed chats, media, and protocol events |
|
||||
| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | `/v1/chat/completions`, `/v1/models`, file uploads, and SDK-compatible usage |
|
||||
| Python SDK | [`python-sdk.md`](./python-sdk.md) | SDK 101, sessions, streaming, model overrides, runtime helpers, and hooks |
|
||||
| Runtime self-inspection | [`my-tool.md`](./my-tool.md) | Inspecting and tuning the current agent run |
|
||||
Use reference pages to look up an exact option after you know what you are trying to configure:
|
||||
|
||||
## Fast Lookup
|
||||
|
||||
| Need | Jump to |
|
||||
| Area | Reference |
|
||||
|---|---|
|
||||
| Provider/model resolution order | [`providers.md#provider-resolution`](./providers.md#provider-resolution) |
|
||||
| Model presets and fallback chains | [`providers.md#model-presets`](./providers.md#model-presets) and [`providers.md#fallback-models`](./providers.md#fallback-models) |
|
||||
| Langfuse environment variables | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) |
|
||||
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
|
||||
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
|
||||
| Python SDK usage | [`python-sdk.md`](./python-sdk.md) |
|
||||
| Scheduled automations and local triggers | [`automations.md`](./automations.md) |
|
||||
| Multiple configs, workspaces, and ports | [`multiple-instances.md`](./multiple-instances.md) |
|
||||
| Security, sandboxing, and SSRF controls | [`configuration.md#security`](./configuration.md#security) |
|
||||
| Channel plugin development | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
| Every configuration field and default | [Configuration](./configuration.md) |
|
||||
| Provider and model behavior | [Providers and Models](./providers.md) |
|
||||
| Chat channel prerequisites and manual JSON | [Chat Apps](./chat-apps.md) |
|
||||
| WebSocket authentication and wire protocol | [WebSocket](./websocket.md) |
|
||||
| Python SDK classes, events, sessions, and hooks | [Python SDK](./python-sdk.md) |
|
||||
| OpenAI-compatible HTTP routes and payloads | [OpenAI-Compatible API](./openai-api.md) |
|
||||
| Runtime self-inspection and tuning | [My Tool](./my-tool.md) |
|
||||
|
||||
## Extend nanobot
|
||||
Configuration examples are usually snippets to merge into `~/.nanobot/config.json`, not complete replacement files. The docs use camelCase because nanobot writes config that way. Keep real API keys, bot tokens, and passwords out of issues and public logs.
|
||||
|
||||
| Goal | Read | Outcome |
|
||||
|---|---|---|
|
||||
| Add a provider or transcription adapter | [`development.md`](./development.md) | A registry/schema-aligned implementation path |
|
||||
| Add a chat channel plugin | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | A packaged channel discovered through entry points |
|
||||
| Add custom MCP servers | [`configuration.md#mcp-model-context-protocol`](./configuration.md#mcp-model-context-protocol) | External tools exposed to the agent through MCP |
|
||||
| Tune tool safety | [`configuration.md#security`](./configuration.md#security) | Shell sandboxing, workspace restriction, and SSRF policy |
|
||||
## Extend or Contribute
|
||||
|
||||
## Reading Strategy
|
||||
These pages explain implementation and extension points. You do not need them to install or operate nanobot.
|
||||
|
||||
Use the docs in this order when you are unsure where to go:
|
||||
| Goal | Read |
|
||||
|---|---|
|
||||
| Understand source ownership and runtime flow | [Architecture](./architecture.md) |
|
||||
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
|
||||
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
|
||||
| Build the WebUI source | [WebUI Development](../webui/README.md) |
|
||||
|
||||
1. If terminal commands or config files are new to you, [`start-without-technical-background.md`](./start-without-technical-background.md) explains the setup words and uses one concrete provider example so there is only one decision at a time.
|
||||
2. [`quick-start.md`](./quick-start.md) proves installation, config loading, and provider access.
|
||||
3. [`concepts.md`](./concepts.md) explains the runtime model so later pages are easier to scan.
|
||||
4. [`provider-cookbook.md`](./provider-cookbook.md) gives pasteable provider, fallback, local model, and Langfuse recipes.
|
||||
5. A task guide, such as [`chat-apps.md`](./chat-apps.md), [`image-generation.md`](./image-generation.md), or [`deployment.md`](./deployment.md), gets one workflow working.
|
||||
6. [`configuration.md`](./configuration.md) is the source of truth when you need a specific field, default value, or advanced option.
|
||||
7. [`troubleshooting.md`](./troubleshooting.md) helps isolate whether a failure is install, config, provider, gateway, channel, or tool related.
|
||||
If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction.
|
||||
|
||||
@@ -81,11 +81,11 @@ Main files:
|
||||
| Area | Files |
|
||||
|---|---|
|
||||
| Base channel contract | `nanobot/channels/base.py` |
|
||||
| Built-in channels | `nanobot/channels/*.py` |
|
||||
| Channel packages | `nanobot/channels/<channel>/` |
|
||||
| Discovery and lifecycle | `nanobot/channels/manager.py` |
|
||||
| WebSocket/WebUI channel | `nanobot/channels/websocket.py` |
|
||||
| WebSocket/WebUI channel | `nanobot/channels/websocket/` |
|
||||
|
||||
Channels are discovered through built-in module scanning and plugin entry points. A custom channel should follow [`channel-plugin-guide.md`](./channel-plugin-guide.md).
|
||||
Channels are discovered by scanning self-contained packages under `nanobot/channels/`. Add a channel by contributing one package that follows [`channel-package-guide.md`](./channel-package-guide.md).
|
||||
|
||||
## WebUI and Gateway
|
||||
|
||||
@@ -149,6 +149,24 @@ Defaults:
|
||||
|
||||
The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
|
||||
|
||||
### Agent-Owned State vs Effective Project Context
|
||||
|
||||
Runtime code distinguishes the configured agent workspace from the effective
|
||||
project workspace carried by a session scope. They are often the same path, but
|
||||
a WebUI chat may select a separate project:
|
||||
|
||||
| Concern | Path owner |
|
||||
|---|---|
|
||||
| Sessions, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace |
|
||||
| Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace |
|
||||
| Workspace access mode and project metadata | Session workspace scope |
|
||||
|
||||
`ContextBuilder` combines project instructions with agent-owned profile and
|
||||
memory. Filesystem and search tools use the project as their ordinary boundary
|
||||
and receive only capability-specific read access to built-in/agent skills and
|
||||
the exact agent history file. Keep those cross-root capabilities read-only and
|
||||
explicit; do not treat the entire agent workspace as an allowed root.
|
||||
|
||||
## Memory and Sessions
|
||||
|
||||
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
|
||||
@@ -181,7 +199,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Extension | How |
|
||||
|---|---|
|
||||
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
|
||||
| Channel | Implement `BaseChannel`, expose an entry point, follow [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
<!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. -->
|
||||
|
||||
Automations are agent turns that run later in a linked chat/session. Use them
|
||||
Automations are agent turns that run later in a linked topic. Use them
|
||||
when nanobot should do work without someone actively typing: reminders,
|
||||
recurring checks, nightly summaries, CI follow-ups, local script reports, or
|
||||
webhook-driven events.
|
||||
|
||||
Create automations from the chat, channel, or WebUI session where the result
|
||||
should appear. That lets nanobot keep the right session history, workspace, and
|
||||
reply target.
|
||||
Create automations from the chat channel or WebUI topic where the
|
||||
result should appear. That lets nanobot keep the right session history,
|
||||
workspace, and reply target.
|
||||
|
||||
## Choose an Automation Type
|
||||
|
||||
| Type | Starts from | Best for | Created with |
|
||||
|---|---|---|---|
|
||||
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target session to schedule it with the `cron` tool |
|
||||
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target session |
|
||||
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target topic to schedule it with the `cron` tool |
|
||||
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target topic |
|
||||
| Heartbeat | Protected system schedule | Quiet recurring checks that should only report useful results | Edit `<workspace>/HEARTBEAT.md` |
|
||||
|
||||
The two user-created automation types are scheduled automations and local
|
||||
@@ -26,21 +26,21 @@ protected from normal automation edits.
|
||||
## Before You Create One
|
||||
|
||||
Keep `nanobot gateway` running. The gateway owns background delivery for chat
|
||||
apps, WebUI sessions, scheduled automations, local triggers, heartbeat, and
|
||||
apps, WebUI topics, scheduled automations, local triggers, heartbeat, and
|
||||
Dream jobs.
|
||||
|
||||
Use the same workspace and config for the gateway and any process that sends
|
||||
local trigger messages. If you run multiple nanobot instances, pass the matching
|
||||
`--config` or `--workspace` option to `nanobot trigger`.
|
||||
|
||||
Create each automation from the target session. An automation without a linked
|
||||
chat/session cannot be enabled or run from the WebUI because nanobot would not
|
||||
know where to deliver the turn.
|
||||
Create each automation from the target topic. An automation without a linked
|
||||
topic cannot be enabled or run from the WebUI because nanobot would not know
|
||||
where to deliver the turn.
|
||||
|
||||
## Scheduled Automations
|
||||
|
||||
Scheduled automations are created by the agent's `cron` tool. In practice, ask
|
||||
nanobot from the target chat or WebUI session:
|
||||
nanobot from the target chat or WebUI topic:
|
||||
|
||||
```text
|
||||
Every weekday at 9am, check open pull requests and summarize blockers here.
|
||||
@@ -68,7 +68,7 @@ report, use heartbeat instead of a user-created scheduled automation.
|
||||
Local triggers let a local script or external service send a message into a
|
||||
specific nanobot session later.
|
||||
|
||||
Create the trigger from the chat or WebUI session where future messages should
|
||||
Create the trigger from the chat or WebUI topic where future messages should
|
||||
arrive:
|
||||
|
||||
```text
|
||||
@@ -120,7 +120,7 @@ Heartbeat is enabled by default when `nanobot gateway` starts. Configure it in
|
||||
Use the WebUI Automations view to:
|
||||
|
||||
- filter by all, active, paused, needs-attention, or system jobs;
|
||||
- search by task name, message, trigger command, linked chat, schedule, or
|
||||
- search by task name, message, trigger command, linked topic, schedule, or
|
||||
status;
|
||||
- sort by next run, last run, updated time, or name;
|
||||
- run scheduled automations now;
|
||||
@@ -138,7 +138,7 @@ Automation delivery is workspace-local. Scheduled jobs and local trigger
|
||||
deliveries use the same workspace as the gateway.
|
||||
|
||||
Local trigger messages are written to a durable queue. If the gateway is not
|
||||
running yet, the message waits in that workspace. If the linked session is
|
||||
running yet, the message waits in that workspace. If the linked topic is
|
||||
already running a turn, the trigger waits until the session becomes idle instead
|
||||
of being injected into the active turn.
|
||||
|
||||
@@ -154,7 +154,7 @@ queue is not a distributed multi-consumer queue.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
For a nightly report, ask from the target session:
|
||||
For a nightly report, ask from the target topic:
|
||||
|
||||
```text
|
||||
Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow.
|
||||
@@ -181,7 +181,7 @@ generate-report | nanobot trigger <trigger-id>
|
||||
## Troubleshooting
|
||||
|
||||
If an automation does not run, check that `nanobot gateway` is running, the
|
||||
automation is enabled, and it was created from a linked chat/session.
|
||||
automation is enabled, and it was created from a linked topic.
|
||||
|
||||
If a local trigger waits forever, confirm the command uses the same workspace or
|
||||
config as the gateway.
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
# Channel Package Guide
|
||||
|
||||
Use this guide to add a self-contained channel package to the nanobot repository. A channel is part of nanobot when its package lives at `nanobot/channels/<channel>/`; there is no separate external channel-plugin path.
|
||||
|
||||
> **Breaking change:** nanobot no longer discovers the `nanobot.channels` Python entry-point group. Move an entry-point implementation into `nanobot/channels/<channel>/` with a package-owned manifest, runtime, tests, and optional WebUI contribution.
|
||||
|
||||
## How It Works
|
||||
|
||||
When `nanobot gateway` starts, nanobot scans the packages under `nanobot/channels/` and loads each dependency-free `ChannelPlugin` descriptor from `manifest.py`.
|
||||
|
||||
If a matching config section has `"enabled": true`, the channel is instantiated and started.
|
||||
|
||||
## Ownership and Sources of Truth
|
||||
|
||||
| Concern | Owner and source of truth |
|
||||
|---------|---------------------------|
|
||||
| Runtime behavior and platform SDK use | `runtime.py` and package-local helpers |
|
||||
| Python package requirements | `ChannelPlugin.dependencies` in `manifest.py` |
|
||||
| Writable settings fields, types, defaults, requirements, secret handling, and validation | `ChannelPlugin.setup` in `manifest.py` |
|
||||
| Persisted config expansion, instance updates, and runtime naming | `ChannelPlugin.management` backed by a dependency-free module |
|
||||
| Interactive setup connections and their short-lived state | `ChannelPlugin.connector` backed by package-local `connect.py` |
|
||||
| Reusable local login-state detection | `ChannelPlugin.management.local_state_present` backed by package-local code |
|
||||
| Discovery metadata and lazy runtime target | `PLUGIN` in `manifest.py` |
|
||||
| WebUI structure, components, URLs, field keys, actions, and preset values | `webui/index.ts` or `webui/index.tsx` |
|
||||
| Channel-specific user-facing copy | `webui/locales/<locale>.json` |
|
||||
| Generic settings-shell copy shared by every channel | `webui/src/i18n/locales/<locale>/common.json` |
|
||||
|
||||
Keep one source of truth for each concern. In particular, the backend setup contract decides what may be written, the TypeScript contribution decides how those fields are presented, and locale JSON supplies the channel-specific words shown to users.
|
||||
|
||||
## Quick Start
|
||||
|
||||
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
nanobot/channels/webhook/
|
||||
├── __init__.py # lightweight package marker; do not import the runtime
|
||||
├── manifest.py # dependency-free ChannelPlugin descriptor
|
||||
├── runtime.py # channel implementation and optional SDK imports
|
||||
├── tests/ # package-local tests
|
||||
└── webui/ # optional settings UI and translations
|
||||
```
|
||||
|
||||
### 1. Create Your Channel
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/__init__.py
|
||||
"""Webhook channel package."""
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/manifest.py
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="webhook",
|
||||
display_name="Webhook",
|
||||
runtime=f"{__package__}.runtime:WebhookChannel",
|
||||
dependencies=("aiohttp>=3.9.0,<4.0.0",),
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||
},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/runtime.py
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebhookConfig().model_dump(by_alias=True)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start an HTTP server that listens for incoming messages.
|
||||
|
||||
IMPORTANT: start() must block forever (or until stop() is called).
|
||||
If it returns, the channel is considered dead.
|
||||
"""
|
||||
self._running = True
|
||||
port = self.config.port
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/message", self._on_request)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
logger.info("Webhook listening on :{}", port)
|
||||
|
||||
# Block until stopped
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await runner.cleanup()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Deliver an outbound message.
|
||||
|
||||
msg.content — markdown text (convert to platform format as needed)
|
||||
msg.media — list of local file paths to attach
|
||||
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||
msg.metadata — channel routing context such as message/thread ids
|
||||
msg.event — typed runtime event for progress/status messages
|
||||
"""
|
||||
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||
|
||||
async def _on_request(self, request: web.Request) -> web.Response:
|
||||
"""Handle an incoming HTTP POST."""
|
||||
body = await request.json()
|
||||
sender = body.get("sender", "unknown")
|
||||
chat_id = body.get("chat_id", sender)
|
||||
text = body.get("text", "")
|
||||
media = body.get("media", []) # list of URLs
|
||||
|
||||
# This is the key call: validates allowFrom, then puts the
|
||||
# message onto the bus for the agent to process.
|
||||
await self._handle_message(
|
||||
sender_id=sender,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
media=media,
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True})
|
||||
```
|
||||
|
||||
The package directory, `PLUGIN.name`, runtime class name, and config section must all use `webhook`. Channel names use a portable ASCII package identifier: they start with a letter and contain only letters, digits, or underscores.
|
||||
|
||||
Declare runtime requirements directly in `ChannelPlugin.dependencies`. Do not add channel requirements to the root `pyproject.toml`: the package manifest is the source of truth used by the CLI, WebUI, and gateway startup. Keep the manifest and anything it imports free of the optional SDK itself.
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
nanobot plugins list # verify the channel package appears as "webhook"
|
||||
nanobot onboard # add default config for detected channels
|
||||
```
|
||||
|
||||
Edit `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"port": 9000,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nanobot always loads the dependency-free descriptor during discovery. When the WebUI gateway starts, it installs missing requirements for enabled channels before importing their runtimes. It also installs them when a channel is enabled from the CLI or WebUI. Status, configuration, and disable operations do not need the runtime. Single-instance and multi-instance channels use the same activation rules.
|
||||
|
||||
### 3. Run & Test
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9000/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
|
||||
```
|
||||
|
||||
The agent receives the message and processes it. Replies arrive in your `send()` method.
|
||||
|
||||
## Channel Package Requirements
|
||||
|
||||
Every channel is a self-contained package at `nanobot/channels/<channel>/`; channel-specific runtime code, setup metadata, tests, WebUI structure, components, and translations stay under that directory.
|
||||
|
||||
### Package Layout
|
||||
|
||||
```text
|
||||
nanobot/channels/<channel>/
|
||||
├── __init__.py # package marker only; no runtime or SDK imports
|
||||
├── manifest.py # dependency-free ChannelPlugin and ChannelSetupSpec
|
||||
├── config.py # optional dependency-free config model and defaults
|
||||
├── connect.py # optional interactive setup connector
|
||||
├── instances.py # optional dependency-free multi-instance management adapter
|
||||
├── state.py # optional persisted login-state detection
|
||||
├── validation.py # optional package-owned setup checks
|
||||
├── runtime.py # BaseChannel implementation and platform SDK imports
|
||||
├── tests/ # channel-specific Python tests
|
||||
└── webui/ # optional, compiled into the shared WebUI
|
||||
├── index.ts or index.tsx # structure and optional React components
|
||||
└── locales/
|
||||
├── en.json # canonical locale shape
|
||||
└── <locale>.json # one file for every supported WebUI locale
|
||||
```
|
||||
|
||||
Do not add a runtime module directly under `nanobot/channels/`, create a parallel manifest tree, or add a central per-channel UI catalog. If existing channel files move, use `git mv` so history remains traceable.
|
||||
|
||||
### Manifest and Runtime Boundary
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
Use the small constructors in [`nanobot/channels/_manifest.py`](../nanobot/channels/_manifest.py) for declarative field and requirement definitions. Use [`nanobot/channels/dingtalk/manifest.py`](../nanobot/channels/dingtalk/manifest.py) as a compact single-instance example and [`nanobot/channels/feishu/`](../nanobot/channels/feishu/) as a multi-instance example.
|
||||
|
||||
### Package-owned WebUI
|
||||
|
||||
Set `webui="webui/index.ts"` or `webui="webui/index.tsx"` in the channel manifest. Candidate modules are bundled from channel packages, but the settings UI activates only the exact path returned by the backend feature payload.
|
||||
|
||||
The entry module exports one default `ChannelUiContribution`. Channel identity comes from the package directory, so do not repeat a `channel` field in TypeScript. Keep only structure and executable UI data in this module: presentation metadata, icons or logo URLs, docs URLs, config field keys, action payloads, preset values, aliases, and optional `Panel` or `ConnectFlow` components.
|
||||
|
||||
Do not put static descriptions, setup steps, labels, placeholders, help text, action labels, or preset labels in TSX. Those strings belong in the channel's locale JSON. TSX remains appropriate for dynamic rendering, interpolation, conditions, and rich component composition.
|
||||
|
||||
### Channel-owned i18n
|
||||
|
||||
Create `webui/locales/<locale>.json` for every locale code declared in [`webui/src/i18n/config.ts`](../webui/src/i18n/config.ts). Treat `en.json` as the canonical shape; every other locale must contain the same message keys and the same interpolation variables. `displayName` may be omitted when the product name should remain unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Use nanobot from Example chats.",
|
||||
"requirements": "Example app credentials and gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Open Example setup",
|
||||
"officialLabel": "Open Example console",
|
||||
"summary": "Example needs app credentials.",
|
||||
"tryIt": "Send a test message.",
|
||||
"steps": [
|
||||
"Create an Example app.",
|
||||
"Add the credentials.",
|
||||
"Save, enable, and test the channel."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Example client ID",
|
||||
"help": "Copy it from the Example console."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"copyManifest": "Copy manifest"
|
||||
},
|
||||
"presets": {
|
||||
"default": "Default"
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"connected": "{{name}} is connected."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field messages are keyed by the config path after `channels.<channel>.`, with remaining punctuation converted to underscores. For example, `channels.signal.dm.allowFrom` maps to `setup.fields.dm_allowFrom`. Action and preset messages use the IDs declared in the TypeScript contribution.
|
||||
|
||||
Custom channel components should read dynamic copy with `channelTranslator(t, "<channel>")`; keep the English fallback adjacent to the call so an incomplete translation still renders useful text. Aliases reuse the owning channel's locale namespace rather than duplicating translations.
|
||||
|
||||
The dependency direction is intentional:
|
||||
|
||||
- [`webui/src/i18n/index.ts`](../webui/src/i18n/index.ts) imports the pure JSON [`channel-plugins/locale-registry.ts`](../webui/src/channel-plugins/locale-registry.ts).
|
||||
- The locale registry discovers only `nanobot/channels/*/webui/locales/*.json` and must not import the UI registry, React, or TSX.
|
||||
- Settings components may consume both the UI registry and locale registry.
|
||||
- Channel UI code may use shared types and generic settings components, but core settings code must not add `if (feature.name === "...")` branches for individual channels.
|
||||
|
||||
This separation prevents i18n initialization from eagerly loading every channel React component and keeps channel-specific ownership below the channel package.
|
||||
|
||||
### Tests and Definition of Done
|
||||
|
||||
Put channel-specific Python tests in `nanobot/channels/<channel>/tests/`. Keep only shared registry, manager, base-class, and cross-channel contract tests in `tests/channels/`. Release builds exclude package-local tests while the repository test configuration discovers both trees.
|
||||
|
||||
For a focused channel change, run the smallest relevant set:
|
||||
|
||||
```bash
|
||||
uv run pytest nanobot/channels/<channel>/tests -q
|
||||
|
||||
cd webui
|
||||
bun run test -- src/tests/channel-locale-registry.test.ts src/tests/channel-ui-registry.test.ts src/tests/channel-identity.test.ts
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
Before considering the change complete, verify all of the following:
|
||||
|
||||
- The manifest can be discovered without importing the runtime or optional platform SDK.
|
||||
- `ChannelSetupSpec` contains every writable field and rejects unknown fields.
|
||||
- The TypeScript field, action, and preset IDs have matching English locale messages.
|
||||
- Every supported locale matches the English key shape and interpolation variables.
|
||||
- Generic settings copy remains in core `common.json`; channel-specific copy remains inside the channel package.
|
||||
- User-facing WebUI changes work through the built frontend served by a real gateway, including language switching and refresh persistence.
|
||||
- Markdown prose paragraphs and individual list items remain on one source line; let the renderer handle visual wrapping.
|
||||
|
||||
## BaseChannel API
|
||||
|
||||
### Required (abstract)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
|
||||
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
|
||||
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. Raise when the transport does not accept it. |
|
||||
|
||||
#### Outbound delivery contract
|
||||
|
||||
A normal return from `send()` means either the visible payload was accepted by the platform transport/API, or the channel deliberately had nothing to deliver, such as an empty progress event. Do not log and return when the client is disconnected, still starting, or the platform rejects the request. Raise an exception so `ChannelManager` can apply the shared retry policy.
|
||||
|
||||
`send()` may run as soon as `is_running` becomes true. If a channel sets `_running` before its transport is ready, it must keep raising until delivery can be attempted safely. Small platform-specific retries are fine, but the final failure must still reach the manager.
|
||||
|
||||
### Interactive Login
|
||||
|
||||
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
||||
|
||||
```python
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Perform channel-specific interactive login.
|
||||
|
||||
Args:
|
||||
force: If True, ignore existing credentials and re-authenticate.
|
||||
|
||||
Returns True if already authenticated or login succeeds.
|
||||
"""
|
||||
# For QR-code-based login:
|
||||
# 1. If force, clear saved credentials
|
||||
# 2. Check if already authenticated (load from disk/state)
|
||||
# 3. If not, show QR code and poll for confirmation
|
||||
# 4. Save token on success
|
||||
```
|
||||
|
||||
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
|
||||
|
||||
Users trigger interactive login via:
|
||||
```bash
|
||||
nanobot channels login <channel_name>
|
||||
nanobot channels login <channel_name> --force # re-authenticate
|
||||
```
|
||||
|
||||
### Provided by Base
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
||||
| `default_config()` (classmethod) | Returns runtime-local defaults for callers that construct the class directly. Discovery and onboarding use the descriptor instead. |
|
||||
| `refresh_feature_metadata(config_path, instance_id)` (classmethod) | Optionally refreshes saved display metadata after an explicit settings action. It is never called by a read-only feature GET. |
|
||||
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
|
||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||
| `is_running` | Returns `self._running`. |
|
||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||
|
||||
### Optional management contract
|
||||
|
||||
Persisted-state management belongs to `ChannelPlugin.management`, not `BaseChannel`. Keep the adapter and anything it imports free of optional platform SDKs so status, settings, and disable operations still work when the runtime cannot be imported. Runtime classes own network lifecycle, message delivery, interactive login, enable-time availability checks, and explicit runtime-only actions such as metadata refresh.
|
||||
|
||||
```python
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec, SetupRequirement
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
from .instances import MANAGEMENT
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="webhook",
|
||||
display_name="Webhook",
|
||||
runtime=f"{__package__}.channel:WebhookChannel",
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"token": ChannelFieldSpec(kind="secret"),
|
||||
"region": ChannelFieldSpec(
|
||||
kind="enum",
|
||||
choices=frozenset({"us", "eu"}),
|
||||
default="us",
|
||||
),
|
||||
},
|
||||
required=(SetupRequirement.field("token"),),
|
||||
),
|
||||
management=MANAGEMENT,
|
||||
)
|
||||
```
|
||||
|
||||
`instances.py` then exports the dependency-free adapter assembled from channel-owned callbacks:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
|
||||
|
||||
from .config import default_config
|
||||
|
||||
|
||||
def instance_specs(section: Any, *, enabled_only: bool = True) -> list[ChannelInstanceSpec]:
|
||||
... # Expand the persisted channel-owned envelope.
|
||||
|
||||
|
||||
def update_instance_config(
|
||||
section: Any,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
... # Update one instance without discarding sibling data.
|
||||
|
||||
|
||||
MANAGEMENT = ChannelManagementSpec(
|
||||
multi_instance=True,
|
||||
default_config=default_config,
|
||||
instance_specs=instance_specs,
|
||||
update_instance_config=update_instance_config,
|
||||
)
|
||||
```
|
||||
|
||||
`ChannelSetupSpec` is authoritative for writable field names, field types, choices, defaults, required setup, secret redaction, and optional backend validation. The settings API rejects fields outside this contract. A validator receives `(values, context)`; use `context.allow_local_service_access` for host network policy instead of loading global config from the channel package.
|
||||
|
||||
The dependency-free `MANAGEMENT` value is a `ChannelManagementSpec`. Multi-instance plugins provide `instance_specs(section, enabled_only=True)` and `update_instance_config(section, values, instance_id=...)`; they may also provide `default_config`, `runtime_name`, presentation-only `feature_instances`, and `local_state_present`. Single-instance plugins normally derive onboarding defaults from `ChannelSetupSpec`; use `default_config` only when persisted defaults include fields that are not part of generic setup.
|
||||
|
||||
Multi-instance adapters return `ChannelInstanceSpec` objects and preserve their persisted envelope when updating one instance. Their descriptor sets `ChannelManagementSpec(multi_instance=True)`. The shared contract enforces these invariants:
|
||||
|
||||
- every `instance_id` is non-empty and unique;
|
||||
- the management adapter's `runtime_name(channel_name, instance_id)` is the single source of routing names, and every derived name is unique and is either the channel name or starts with `<channel-name>.`;
|
||||
- runtime names cannot overwrite a runtime already owned by another channel;
|
||||
- settings instance summaries are generated from `instance_specs()` and `ChannelPlugin.setup`. They contain the authoritative `enabled` and `configured` state plus secret-safe `config_values` and `configured_fields` for the generic instance editor;
|
||||
- the management adapter's `feature_instances()` may return `None` or presentation overrides containing an `id` plus `name`, `display_name`, or `avatar_url`. It cannot override runtime state or the configuration snapshot.
|
||||
|
||||
`ChannelInstanceSpec` contains only `instance_id` and the instance config; nanobot derives its runtime name through the adapter. Single-instance plugins keep ownership of their entire config, including a field named `instances`. Only plugins whose management spec sets `multi_instance=True` opt into instance expansion.
|
||||
|
||||
The package/config section name owns every runtime produced from that section. Class inheritance does not transfer runtime ownership to another package.
|
||||
|
||||
Return a concrete iterable or generator from the adapter's `instance_specs()`; nanobot materializes and validates it before constructing any runtime. Raise an exception for malformed persisted data rather than silently changing instance identity. Keep network-backed metadata refresh behind the runtime's `refresh_feature_metadata()` so feature GET requests remain dependency-free and read-only.
|
||||
|
||||
For package layout, WebUI ownership, and localization rules, see [Channel Package Requirements](#channel-package-requirements).
|
||||
|
||||
### Optional (streaming)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
|
||||
### Message Types
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class OutboundMessage:
|
||||
channel: str # your channel name
|
||||
chat_id: str # recipient (same value you passed to _handle_message)
|
||||
content: str # markdown text — convert to platform format as needed
|
||||
media: list[str] # local file paths to attach (images, audio, docs)
|
||||
metadata: dict # channel routing context, e.g. "message_id" for threading
|
||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
||||
```
|
||||
|
||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
||||
|
||||
## Streaming Support
|
||||
|
||||
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
||||
|
||||
### How It Works
|
||||
|
||||
When **both** conditions are met, the agent streams content through your channel:
|
||||
|
||||
1. Config has `"streaming": true`
|
||||
2. Your subclass overrides `send_delta()`
|
||||
|
||||
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||
|
||||
### Implementing `send_delta`
|
||||
|
||||
Override `send_delta` to handle two types of calls:
|
||||
|
||||
```python
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
# Streaming finished — do final formatting, cleanup, etc.
|
||||
return
|
||||
|
||||
# Regular delta — append text, update the message on screen
|
||||
# delta contains a small chunk of text (a few tokens)
|
||||
```
|
||||
|
||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
||||
|
||||
### Example: Webhook with Streaming
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._buffers: dict[str, str] = {}
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
text = self._buffers.pop(buffer_key, "")
|
||||
# Final delivery — format and send the complete message
|
||||
await self._deliver(chat_id, text, final=True)
|
||||
return
|
||||
|
||||
self._buffers.setdefault(buffer_key, "")
|
||||
self._buffers[buffer_key] += delta
|
||||
# Incremental update — push partial text to the client
|
||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Non-streaming path — unchanged
|
||||
await self._deliver(msg.chat_id, msg.content, final=True)
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Enable streaming per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"streaming": true,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
||||
|
||||
### BaseChannel Streaming API
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||
|
||||
## Progress, Tool Hints, and Reasoning
|
||||
|
||||
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||
|
||||
### Progress and Tool Hints
|
||||
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
||||
|
||||
```python
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = msg.event
|
||||
|
||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||
return
|
||||
|
||||
if isinstance(event, ProgressEvent):
|
||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||
return
|
||||
|
||||
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||
```
|
||||
|
||||
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"sendToolHints": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"sendToolHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._reasoning_buffers: dict[str, str] = {}
|
||||
|
||||
async def send_reasoning_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
||||
|
||||
async def send_reasoning_end(
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
||||
if text:
|
||||
await self._update_reasoning_block(chat_id, text, final=True)
|
||||
```
|
||||
|
||||
**Reasoning arguments:**
|
||||
|
||||
| Argument | Meaning |
|
||||
|------|---------|
|
||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
||||
|
||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"showReasoning": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"showReasoning": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommended rendering:
|
||||
|
||||
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||
|
||||
## Config
|
||||
|
||||
### Why Pydantic model is required
|
||||
|
||||
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
|
||||
|
||||
Channel runtimes use Pydantic config models by subclassing `Base` from `nanobot.config.schema`.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
|
||||
|
||||
2. Convert `dict` → model in `__init__`:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
```
|
||||
|
||||
3. Access config as attributes (not `.get()`):
|
||||
|
||||
```python
|
||||
async def start(self) -> None:
|
||||
port = self.config.port
|
||||
token = self.config.token
|
||||
```
|
||||
|
||||
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||
|
||||
`nanobot onboard` reads the descriptor without importing the runtime. Put writable defaults in `ChannelSetupSpec`:
|
||||
|
||||
```python
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
String and secret fields default to `""`, list fields to `[]`, and boolean fields to `false` when no explicit default is declared. For non-setup or multi-instance persisted defaults, provide `ChannelManagementSpec.default_config` from a dependency-free package-local module.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Package directory | `nanobot/channels/{name}` | `nanobot/channels/webhook` |
|
||||
| Manifest name | `{name}` | `webhook` |
|
||||
| Config section | `channels.{name}` | `channels.webhook` |
|
||||
| Runtime import | `nanobot.channels.{name}.runtime` | `nanobot.channels.webhook.runtime` |
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # should show the package as "webhook"
|
||||
nanobot plugins enable webhook
|
||||
nanobot gateway # test end-to-end
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
$ nanobot plugins list
|
||||
|
||||
Name Type Enabled
|
||||
discord channel no
|
||||
telegram channel yes
|
||||
webhook channel yes
|
||||
```
|
||||
@@ -1,568 +0,0 @@
|
||||
# Channel Plugin Guide
|
||||
|
||||
Build a custom nanobot channel in three steps: subclass, package, install.
|
||||
|
||||
> **Note:** We recommend developing channel plugins against a source checkout of nanobot (`python -m pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
|
||||
|
||||
## How It Works
|
||||
|
||||
nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
|
||||
|
||||
1. Built-in channels in `nanobot/channels/`
|
||||
2. External packages registered under the `nanobot.channels` entry point group
|
||||
|
||||
If a matching config section has `"enabled": true`, the channel is instantiated and started.
|
||||
|
||||
## Quick Start
|
||||
|
||||
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
nanobot-channel-webhook/
|
||||
├── nanobot_channel_webhook/
|
||||
│ ├── __init__.py # re-export WebhookChannel
|
||||
│ └── channel.py # channel implementation
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
### 1. Create Your Channel
|
||||
|
||||
```python
|
||||
# nanobot_channel_webhook/__init__.py
|
||||
from nanobot_channel_webhook.channel import WebhookChannel
|
||||
|
||||
__all__ = ["WebhookChannel"]
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot_channel_webhook/channel.py
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebhookConfig().model_dump(by_alias=True)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start an HTTP server that listens for incoming messages.
|
||||
|
||||
IMPORTANT: start() must block forever (or until stop() is called).
|
||||
If it returns, the channel is considered dead.
|
||||
"""
|
||||
self._running = True
|
||||
port = self.config.port
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/message", self._on_request)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
logger.info("Webhook listening on :{}", port)
|
||||
|
||||
# Block until stopped
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await runner.cleanup()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Deliver an outbound message.
|
||||
|
||||
msg.content — markdown text (convert to platform format as needed)
|
||||
msg.media — list of local file paths to attach
|
||||
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||
msg.metadata — channel routing context such as message/thread ids
|
||||
msg.event — typed runtime event for progress/status messages
|
||||
"""
|
||||
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||
|
||||
async def _on_request(self, request: web.Request) -> web.Response:
|
||||
"""Handle an incoming HTTP POST."""
|
||||
body = await request.json()
|
||||
sender = body.get("sender", "unknown")
|
||||
chat_id = body.get("chat_id", sender)
|
||||
text = body.get("text", "")
|
||||
media = body.get("media", []) # list of URLs
|
||||
|
||||
# This is the key call: validates allowFrom, then puts the
|
||||
# message onto the bus for the agent to process.
|
||||
await self._handle_message(
|
||||
sender_id=sender,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
media=media,
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True})
|
||||
```
|
||||
|
||||
### 2. Register the Entry Point
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[project]
|
||||
name = "nanobot-channel-webhook"
|
||||
version = "0.1.0"
|
||||
dependencies = ["nanobot-ai", "aiohttp"]
|
||||
|
||||
[project.entry-points."nanobot.channels"]
|
||||
webhook = "nanobot_channel_webhook:WebhookChannel"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["nanobot_channel_webhook"]
|
||||
```
|
||||
|
||||
The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
|
||||
|
||||
### 3. Install & Configure
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # verify the installed example plugin appears as "webhook"
|
||||
nanobot onboard # auto-adds default config for detected plugins
|
||||
```
|
||||
|
||||
Edit `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"port": 9000,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Run & Test
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9000/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
|
||||
```
|
||||
|
||||
The agent receives the message and processes it. Replies arrive in your `send()` method.
|
||||
|
||||
## BaseChannel API
|
||||
|
||||
### Required (abstract)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
|
||||
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
|
||||
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
|
||||
|
||||
### Interactive Login
|
||||
|
||||
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
||||
|
||||
```python
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Perform channel-specific interactive login.
|
||||
|
||||
Args:
|
||||
force: If True, ignore existing credentials and re-authenticate.
|
||||
|
||||
Returns True if already authenticated or login succeeds.
|
||||
"""
|
||||
# For QR-code-based login:
|
||||
# 1. If force, clear saved credentials
|
||||
# 2. Check if already authenticated (load from disk/state)
|
||||
# 3. If not, show QR code and poll for confirmation
|
||||
# 4. Save token on success
|
||||
```
|
||||
|
||||
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
|
||||
|
||||
Users trigger interactive login via:
|
||||
```bash
|
||||
nanobot channels login <channel_name>
|
||||
nanobot channels login <channel_name> --force # re-authenticate
|
||||
```
|
||||
|
||||
### Provided by Base
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
||||
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
|
||||
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
|
||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||
| `is_running` | Returns `self._running`. |
|
||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||
|
||||
### Optional (streaming)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
|
||||
### Message Types
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class OutboundMessage:
|
||||
channel: str # your channel name
|
||||
chat_id: str # recipient (same value you passed to _handle_message)
|
||||
content: str # markdown text — convert to platform format as needed
|
||||
media: list[str] # local file paths to attach (images, audio, docs)
|
||||
metadata: dict # channel routing context, e.g. "message_id" for threading
|
||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
||||
```
|
||||
|
||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
||||
|
||||
## Streaming Support
|
||||
|
||||
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
||||
|
||||
### How It Works
|
||||
|
||||
When **both** conditions are met, the agent streams content through your channel:
|
||||
|
||||
1. Config has `"streaming": true`
|
||||
2. Your subclass overrides `send_delta()`
|
||||
|
||||
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||
|
||||
### Implementing `send_delta`
|
||||
|
||||
Override `send_delta` to handle two types of calls:
|
||||
|
||||
```python
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
# Streaming finished — do final formatting, cleanup, etc.
|
||||
return
|
||||
|
||||
# Regular delta — append text, update the message on screen
|
||||
# delta contains a small chunk of text (a few tokens)
|
||||
```
|
||||
|
||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
||||
|
||||
### Example: Webhook with Streaming
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._buffers: dict[str, str] = {}
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
text = self._buffers.pop(buffer_key, "")
|
||||
# Final delivery — format and send the complete message
|
||||
await self._deliver(chat_id, text, final=True)
|
||||
return
|
||||
|
||||
self._buffers.setdefault(buffer_key, "")
|
||||
self._buffers[buffer_key] += delta
|
||||
# Incremental update — push partial text to the client
|
||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Non-streaming path — unchanged
|
||||
await self._deliver(msg.chat_id, msg.content, final=True)
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Enable streaming per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"streaming": true,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
||||
|
||||
### BaseChannel Streaming API
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||
|
||||
## Progress, Tool Hints, and Reasoning
|
||||
|
||||
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||
|
||||
### Progress and Tool Hints
|
||||
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
||||
|
||||
```python
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = msg.event
|
||||
|
||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||
return
|
||||
|
||||
if isinstance(event, ProgressEvent):
|
||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||
return
|
||||
|
||||
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||
```
|
||||
|
||||
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"sendToolHints": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"sendToolHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._reasoning_buffers: dict[str, str] = {}
|
||||
|
||||
async def send_reasoning_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
||||
|
||||
async def send_reasoning_end(
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
||||
if text:
|
||||
await self._update_reasoning_block(chat_id, text, final=True)
|
||||
```
|
||||
|
||||
**Reasoning arguments:**
|
||||
|
||||
| Argument | Meaning |
|
||||
|------|---------|
|
||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
||||
|
||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"showReasoning": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"showReasoning": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommended rendering:
|
||||
|
||||
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||
|
||||
## Config
|
||||
|
||||
### Why Pydantic model is required
|
||||
|
||||
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
|
||||
|
||||
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
|
||||
|
||||
2. Convert `dict` → model in `__init__`:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
```
|
||||
|
||||
3. Access config as attributes (not `.get()`):
|
||||
|
||||
```python
|
||||
async def start(self) -> None:
|
||||
port = self.config.port
|
||||
token = self.config.token
|
||||
```
|
||||
|
||||
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||
|
||||
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebhookConfig().model_dump(by_alias=True)
|
||||
```
|
||||
|
||||
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
|
||||
|
||||
If not overridden, the base class returns `{"enabled": false}`.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
|
||||
| Entry point key | `{name}` | `webhook` |
|
||||
| Config section | `channels.{name}` | `channels.webhook` |
|
||||
| Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/you/nanobot-channel-webhook
|
||||
cd nanobot-channel-webhook
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # should show the installed example plugin as "webhook"
|
||||
nanobot gateway # test end-to-end
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
$ nanobot plugins list
|
||||
|
||||
Name Type Enabled
|
||||
discord channel no
|
||||
telegram channel yes
|
||||
webhook channel yes
|
||||
```
|
||||
@@ -16,7 +16,7 @@ a focused setup path for one platform, start with a guide:
|
||||
| Email | [Build an Email AI Agent with nanobot](./guides/email-ai-agent.md) |
|
||||
| Mattermost | [Build a Mattermost AI Agent with nanobot](./guides/mattermost-ai-agent.md) |
|
||||
|
||||
Want to build your own channel? See the [Channel Plugin Guide](./channel-plugin-guide.md).
|
||||
Want to build your own channel? See the [Channel Package Guide](./channel-package-guide.md).
|
||||
|
||||
Before configuring a chat app, make sure the local CLI path works:
|
||||
|
||||
@@ -26,16 +26,28 @@ nanobot agent -m "Hello!"
|
||||
|
||||
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
|
||||
|
||||
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a
|
||||
snippet includes `allowFrom`, it is showing a static allowlist. For
|
||||
pairing-based access on supported channels, omit `allowFrom`; Slack and
|
||||
Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing
|
||||
codes.
|
||||
## Recommended Setup in the WebUI
|
||||
|
||||
For normal local setup, let the WebUI write and validate the channel config:
|
||||
|
||||
1. Run `nanobot webui`.
|
||||
2. Open **Settings → Channels**.
|
||||
3. Search for the platform and open its setup panel.
|
||||
4. Follow the credential fields or QR flow. The screen tells you which platform-side token, permission, account, or URL it needs.
|
||||
5. Let nanobot install the optional channel support when prompted.
|
||||
6. Restart from the WebUI if it reports that a restart is required.
|
||||
7. Send a private test message. If the channel returns a pairing code, approve the pending request in the WebUI and send the message again.
|
||||
|
||||
If your installed stable release does not show **Settings → Channels**, continue with the [manual setup pattern](#manual-setup-pattern) below or install current source.
|
||||
|
||||
Optional package installation is available to a same-machine WebUI by default. Remote browser clients cannot change the Python environment unless an administrator explicitly enables that capability. Run `nanobot plugins enable <channel>` locally when the guided install is unavailable.
|
||||
|
||||
The sections below explain what each chat platform requires and provide manual config for deployments that manage `config.json` directly.
|
||||
|
||||
> [!NOTE]
|
||||
> If you are upgrading from a version where chat app SDKs were installed by default,
|
||||
> install the channel extra in the same Python environment before enabling or
|
||||
> restarting that channel:
|
||||
> enable the channel in the same Python environment so nanobot installs its
|
||||
> manifest-declared dependencies:
|
||||
>
|
||||
> ```bash
|
||||
> nanobot plugins enable <channel>
|
||||
@@ -47,7 +59,9 @@ codes.
|
||||
> nanobot keeps the saved settings, but stops loading that channel after the
|
||||
> next restart.
|
||||
|
||||
## Common Setup Pattern
|
||||
## Manual Setup Pattern
|
||||
|
||||
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a snippet includes `allowFrom`, it is showing a static allowlist. For pairing-based access on supported channels, omit `allowFrom`; Slack and Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing codes.
|
||||
|
||||
Every chat app uses the same shape:
|
||||
|
||||
@@ -95,7 +109,24 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
||||
<details>
|
||||
<summary><b>Telegram</b></summary>
|
||||
|
||||
**Install the optional channel dependency**
|
||||
**Recommended WebUI setup**
|
||||
|
||||
1. Create a bot with `@BotFather` and copy its token.
|
||||
2. Run `nanobot webui`, then open **Settings → Channels → Telegram**.
|
||||
3. Paste the token. If the gateway cannot reach Telegram directly, expand
|
||||
**Advanced** and add an HTTP or SOCKS proxy.
|
||||
4. Save and enable Telegram, then send the bot a direct message.
|
||||
|
||||
The configuration badge means nanobot found a saved token. The live connection
|
||||
check is separate, so a temporary Telegram or proxy outage does not make an
|
||||
existing configuration disappear. Saved tokens and proxy URLs remain masked.
|
||||
|
||||
See the [step-by-step Telegram guide](./guides/telegram-ai-agent.md) for pairing
|
||||
and troubleshooting.
|
||||
|
||||
**Manual setup**
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable telegram
|
||||
@@ -120,6 +151,21 @@ nanobot plugins enable telegram
|
||||
}
|
||||
```
|
||||
|
||||
If the gateway cannot reach Telegram directly, add a proxy to the same section:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
HTTP, HTTPS, SOCKS5, and SOCKS5H proxy URLs are accepted. Treat a proxy URL
|
||||
containing a username or password as a secret.
|
||||
|
||||
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
|
||||
>
|
||||
> `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.
|
||||
@@ -171,7 +217,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
|
||||
nanobot plugins enable mochat
|
||||
```
|
||||
|
||||
Without this extra, Mochat still works through HTTP polling.
|
||||
Without these dependencies, Mochat still works through HTTP polling.
|
||||
|
||||
**1. Ask nanobot to set up Mochat for you**
|
||||
|
||||
@@ -379,6 +425,10 @@ nanobot channels login whatsapp
|
||||
}
|
||||
```
|
||||
|
||||
For groups, `allowFrom` can contain either a participant sender ID/LID or a
|
||||
group JID/bare group ID. A participant entry allows that sender wherever the bot
|
||||
can see them; a group entry allows replies in that group.
|
||||
|
||||
Optional session database path:
|
||||
|
||||
```json
|
||||
|
||||
@@ -9,7 +9,7 @@ These commands work inside chat channels and interactive agent sessions:
|
||||
| `/restart` | Restart the bot |
|
||||
| `/status` | Show bot status |
|
||||
| `/model` | Show the current model and available model presets |
|
||||
| `/model <preset>` | Switch the runtime model preset for future turns |
|
||||
| `/model <preset>` | Switch and persist the model preset for the current session |
|
||||
| `/dream` | Run Dream memory consolidation now |
|
||||
| `/dream-log` | Show the latest Dream memory change |
|
||||
| `/dream-log <sha>` | Show a specific Dream memory change |
|
||||
@@ -47,7 +47,7 @@ Use `/model` to inspect the current runtime model:
|
||||
/model
|
||||
```
|
||||
|
||||
The response shows the current model, the current preset, and the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
|
||||
The response shows the current session's model and preset, plus the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
|
||||
|
||||
To switch presets for future turns:
|
||||
|
||||
@@ -57,7 +57,7 @@ To switch presets for future turns:
|
||||
/model default
|
||||
```
|
||||
|
||||
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
|
||||
Preset names come from the top-level `modelPresets` config. Switching affects only the current session and persists the selection in that session, so later turns keep using it across process restarts. It does not rewrite `config.json`, does not change other sessions, and does not alter an in-progress turn's captured model. Sessions without a saved selection follow `agents.defaults.modelPreset` (or the implicit `default` preset when it is omitted). See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
|
||||
|
||||
## Local triggers
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Use this page when you know what you want to run and need the command shape. For
|
||||
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
||||
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
|
||||
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
||||
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
|
||||
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers |
|
||||
|
||||
## Global
|
||||
|
||||
@@ -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 --port <port>` | Set the WebUI/WebSocket port |
|
||||
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
|
||||
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; provider credentials still require interactive setup |
|
||||
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; configure provider credentials in **Settings → Models** |
|
||||
|
||||
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,8 +287,10 @@ remain accepted as no-op compatibility aliases.
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
||||
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support |
|
||||
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
||||
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
||||
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
|
||||
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
|
||||
|
||||
See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection.
|
||||
|
||||
@@ -38,6 +38,23 @@ nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||
|
||||
The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance.
|
||||
|
||||
### Agent Workspace and Project Workspace
|
||||
|
||||
The configured workspace is the **agent workspace**. A WebUI chat can also select
|
||||
a different **project workspace** for repository-specific work without moving the
|
||||
agent's identity or durable state.
|
||||
|
||||
| Resource | Owner when a project is selected |
|
||||
|---|---|
|
||||
| Project instructions | `AGENTS.md` from the selected project; there is no fallback to the agent workspace's `AGENTS.md` |
|
||||
| Agent profile | `SOUL.md` and `USER.md` from the agent workspace; project-local files with those names are ignored |
|
||||
| Memory and custom skills | `memory/` and `skills/` from the agent workspace |
|
||||
| Relative file paths and shell working directory | The selected project workspace |
|
||||
|
||||
When no separate project is selected, one directory normally serves both roles.
|
||||
Selecting a project changes the working context for that chat; it does not create
|
||||
a second agent or relocate the configured agent workspace.
|
||||
|
||||
## Config Format
|
||||
|
||||
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
|
||||
@@ -49,7 +66,7 @@ Most examples are partial snippets. Merge them into the existing file created by
|
||||
A normal turn follows this flow:
|
||||
|
||||
1. A channel receives a user message and publishes it to the message bus.
|
||||
2. The agent loop chooses a session key and builds context from the workspace, skills, memory, recent messages, channel metadata, and runtime settings.
|
||||
2. The agent loop chooses a session key and builds context from the effective project workspace, agent-owned profile/skills/memory, recent messages, channel metadata, and runtime settings.
|
||||
3. The provider receives the model request.
|
||||
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.
|
||||
@@ -64,9 +81,9 @@ That flow is the same whether the message starts in the CLI, WebUI, Telegram, Di
|
||||
| CLI interactive | `nanobot agent` | Terminal chat with persistent session history |
|
||||
| Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode |
|
||||
| OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` |
|
||||
| WebUI | `nanobot gateway` plus WebSocket channel | Browser workbench served by the WebSocket channel on port `8765` |
|
||||
| WebUI | `nanobot webui` | Prepare the local WebUI, start the gateway, and open the browser workbench |
|
||||
|
||||
The gateway health endpoint is on `gateway.port` (`18790` by default). The browser WebUI is served by the WebSocket channel (`8765` by default), not by the health endpoint.
|
||||
The WebUI launcher is the normal browser entry point. Underneath, the gateway keeps the WebSocket channel and other long-running services alive. The gateway health endpoint is on `gateway.port` (`18790` by default); the browser WebUI is served on `8765` by default, not by the health endpoint.
|
||||
|
||||
## Provider and Model Selection
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ Config file: `~/.nanobot/config.json`
|
||||
|
||||
This is the full reference. If this is your first install, start with [`quick-start.md`](./quick-start.md). If you are trying to choose a model or fix provider/model matching, use [`providers.md`](./providers.md) first and come back here for exact fields and advanced options.
|
||||
|
||||
For normal local use, prefer the WebUI before editing JSON: **Settings → Models** manages model choices and provider credentials, **Settings → Channels** guides chat-platform setup, other Settings pages cover built-in capabilities, and **Apps** manages CLI App and MCP integrations. Edit `config.json` directly when you need an advanced field, automate deployment, or intentionally manage configuration as code.
|
||||
|
||||
The JSON examples below are usually partial snippets to merge into your existing config, not full replacement files. For the mental model behind config, workspace, gateway, channels, sessions, tools, and memory, see [`concepts.md`](./concepts.md).
|
||||
|
||||
The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`. snake_case keys are also accepted for compatibility, but the docs prefer camelCase because that is what nanobot writes back to disk.
|
||||
@@ -11,47 +13,7 @@ The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`
|
||||
For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once.
|
||||
|
||||
> [!NOTE]
|
||||
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
|
||||
|
||||
## Python Configuration API
|
||||
|
||||
> [!WARNING]
|
||||
> `nanobot.config.load_config` and `nanobot.config.loader.load_config` have been
|
||||
> removed. There is intentionally no compatibility alias because the old name
|
||||
> did not distinguish the persisted representation from the runtime view.
|
||||
|
||||
Python embedders and plugins must choose the view they need explicitly:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config import (
|
||||
apply_config_runtime_policies,
|
||||
load_effective_config,
|
||||
load_raw_config,
|
||||
)
|
||||
|
||||
path = Path.home() / ".nanobot" / "config.json"
|
||||
|
||||
# For settings editors and persistence flows: preserves ${VAR} placeholders.
|
||||
persisted = load_raw_config(path)
|
||||
|
||||
# For agents, providers, channels, and tools: resolves ${VAR} placeholders.
|
||||
runtime = load_effective_config(path)
|
||||
apply_config_runtime_policies(runtime)
|
||||
```
|
||||
|
||||
Use this migration mapping:
|
||||
|
||||
| Previous call | Replacement |
|
||||
|---|---|
|
||||
| `load_config(path)` used to inspect or edit persisted values | `load_raw_config(path)` |
|
||||
| `resolve_config_env_vars(load_config(path))` used at runtime | `load_effective_config(path)` |
|
||||
| Loading followed by implicit process-policy setup | `load_effective_config(path)`, then `apply_config_runtime_policies(config)` at the runtime boundary |
|
||||
|
||||
This is a Python API breaking change for embedders and plugins that import the
|
||||
old function. The `config.json` format is unchanged, and the bundled CLI,
|
||||
gateway, and WebUI already use the explicit APIs.
|
||||
> If your config file is older than the current schema, run `nanobot onboard --refresh`. nanobot adds missing default fields while preserving your existing values.
|
||||
|
||||
## Configuration Guides
|
||||
|
||||
@@ -87,9 +49,9 @@ the focused guides first and come back here for exact fields and defaults.
|
||||
| Control access and pairing | [Pairing](#pairing) |
|
||||
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
|
||||
|
||||
## Where to Edit First
|
||||
## Where a Setting Lives
|
||||
|
||||
If you are not sure where a setting belongs, start from the task you are trying to complete. Most changes touch one config section and one verification command.
|
||||
If the WebUI does not expose the option you need, start from the task below. Most advanced changes touch one config section and one verification command.
|
||||
|
||||
| Task | First keys to check | Verify with | Deep dive |
|
||||
|---|---|---|---|
|
||||
@@ -225,7 +187,7 @@ These variables are process-level switches. Set them in the same terminal, servi
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. |
|
||||
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds, around ordinary LLM requests. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
|
||||
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
|
||||
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
|
||||
| `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. |
|
||||
| `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` | unset | Marks that an external workspace sandbox is already enforced. Truthy values (`1`, `true`, `yes`, `on`, `enabled`) use `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` as the label; any other non-false value is treated as the provider name. |
|
||||
@@ -239,9 +201,11 @@ These variables are process-level switches. Set them in the same terminal, servi
|
||||
|----------|---------|-------------|
|
||||
| `NANOBOT_BIN_DIR` | `$HOME/.local/bin` | Installer launcher directory on macOS/Linux. |
|
||||
| `NANOBOT_VENV` | `$HOME/.nanobot/venv` | Managed virtual environment path used by the installer fallback. |
|
||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
|
||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip automatic WebUI or wizard setup after one-command install. |
|
||||
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
|
||||
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
|
||||
| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. |
|
||||
| `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. |
|
||||
|
||||
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
|
||||
@@ -290,12 +254,13 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
> - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
|
||||
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
||||
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
||||
> - **ModelScope**: If you're using ModelScope's OpenAI-compatible endpoint, set `"apiBase": "https://api-inference.modelscope.cn/v1"` in your modelscope provider config.
|
||||
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
||||
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
|
||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers, `openai_codex`, and `xai_grok`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
@@ -324,6 +289,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `modelscope` | LLM (ModelScope/魔搭社区) + Image generation | [modelscope.cn](https://modelscope.cn) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
@@ -339,6 +305,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
||||
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
|
||||
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` |
|
||||
| `xai_grok` | LLM (Grok, OAuth) | `nanobot provider login xai-grok --set-main` |
|
||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
||||
|
||||
@@ -712,11 +679,75 @@ Then run:
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
To opt in to Codex Fast mode, merge this provider setting into `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openaiCodex": {
|
||||
"extraBody": {
|
||||
"service_tier": "priority"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`priority` is the Responses API request value used by Codex Fast mode. The setting only works
|
||||
for models and accounts that support Fast mode; remove `service_tier` to return to standard
|
||||
processing. Fast mode consumes Codex credits at a higher rate. See the
|
||||
[OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details.
|
||||
|
||||
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>xAI Grok (OAuth)</b></summary>
|
||||
|
||||
Use an eligible X Premium / Grok subscription without putting an API key in
|
||||
`config.json`:
|
||||
|
||||
```bash
|
||||
nanobot provider login xai-grok --set-main
|
||||
nanobot agent -m "Hello from Grok."
|
||||
```
|
||||
|
||||
The default model is `xai-grok/grok-4.5` with a 500,000-token context window.
|
||||
The provider reads xAI's model catalog and includes the server-hosted `x_search`
|
||||
tool only when the selected model advertises `supportsBackendSearch`. Models
|
||||
without that capability continue normally without hosted X Search. When enabled,
|
||||
searches run inside xAI's Responses API and citations arrive as inline links.
|
||||
|
||||
This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the
|
||||
public OAuth client and proxy contract used by
|
||||
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md).
|
||||
The browser flow uses a random loopback callback and PKCE. The resulting token
|
||||
is stored in the active instance's `auth/xai.json` (normally
|
||||
`~/.nanobot/auth/xai.json`), separately from Grok Build so rotating refresh
|
||||
tokens cannot invalidate one another.
|
||||
|
||||
To use a provider-specific proxy, merge this into `config.json` before login:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"xaiGrok": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The proxy applies to OAuth discovery, token exchange/refresh, model-catalog
|
||||
lookups, and subscription model requests. Because this integration depends on
|
||||
xAI's public Grok Build client contract, an upstream contract change may require
|
||||
a nanobot update.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
||||
|
||||
@@ -1313,7 +1344,7 @@ Contributor notes for adding new providers live in [`development.md`](./developm
|
||||
|
||||
## Model Presets
|
||||
|
||||
Model presets let you name a complete model configuration and switch it at runtime with `/model <preset>`. They are the recommended way to configure models because the same names can be reused for startup selection, chat-command switching, and fallback chains.
|
||||
Model presets let you name a complete model configuration and select one per session with `/model <preset>`. They are the recommended way to configure models because the same names can be reused for new-session defaults, chat-command switching, and fallback chains.
|
||||
|
||||
Existing configs do not need to change. Direct `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and `reasoningEffort` fields still define the implicit `default` preset. For new configs, prefer top-level `modelPresets` plus `agents.defaults.modelPreset`.
|
||||
|
||||
@@ -1377,7 +1408,7 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
|
||||
|
||||
`default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config.
|
||||
|
||||
Set `agents.defaults.modelPreset` to choose the startup preset. When `modelPreset` is `null` or omitted, startup uses the implicit `default` preset from direct `agents.defaults.*` fields. Runtime changes made with `/model <preset>` are not written back to `config.json`; they affect future turns until the process restarts or another model/config change replaces them.
|
||||
Set `agents.defaults.modelPreset` to choose the preset followed by sessions that have no saved model selection. When `modelPreset` is `null` or omitted, such sessions follow the implicit `default` preset from direct `agents.defaults.*` fields. `/model <preset>` saves an override in the current session, so its future turns keep that preset across process restarts while other sessions remain unchanged. The command does not write the selection back to `config.json`.
|
||||
|
||||
### Model Fallbacks
|
||||
|
||||
@@ -1455,7 +1486,7 @@ Inline fallback object:
|
||||
|
||||
Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries.
|
||||
|
||||
Failover normally runs when the primary provider returns a retryable model/provider error before any answer text has been streamed. Stream-stall timeouts are the recovery exception: if the provider already emitted partial answer text and then stalls, nanobot closes the current stream segment and retries/fails over in a new segment. Typical fallback cases include timeouts, connection errors, 5xx server errors, 429 rate limits, overloads, and quota/balance exhaustion. It does not run for malformed requests, authentication/permission errors, content filtering/refusals, or context-length/message-format errors.
|
||||
Failover normally runs when the primary provider returns a fallbackable model/provider error before any answer text has been streamed. Stream-stall timeouts are the recovery exception: if the provider already emitted partial answer text and then stalls, nanobot closes the current stream segment and retries/fails over in a new segment. Typical fallback cases include timeouts, connection errors, 5xx server errors, 429 rate limits, overloads, authentication/permission failures such as invalid or expired credentials, and quota/balance exhaustion. It does not run for malformed requests, content filtering/refusals, or context-length/message-format errors.
|
||||
|
||||
If fallback candidates use smaller `contextWindowTokens` values, nanobot builds context using the smallest window in the active chain so every candidate can receive the same prompt.
|
||||
|
||||
@@ -1945,6 +1976,16 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
||||
|
||||
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
|
||||
|
||||
> [!NOTE]
|
||||
> When a restricted WebUI chat selects a project outside the configured agent
|
||||
> workspace, that project becomes the normal file and shell boundary. Nanobot
|
||||
> adds capability-specific, read-only access for built-in skills, the agent
|
||||
> workspace's `skills/` directory, and the exact agent
|
||||
> `memory/history.jsonl` file. Neighboring memory/profile files and all
|
||||
> cross-workspace writes remain denied. Agent-owned `SOUL.md` and `USER.md` are
|
||||
> assembled into model context directly; this does not grant file tools broader
|
||||
> access to the agent workspace.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. |
|
||||
@@ -1957,7 +1998,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
||||
| `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. |
|
||||
|
||||
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
|
||||
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. The default `docker-compose.yml` drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable `"tools.exec.sandbox": "bwrap"` inside Docker, start Compose with `docker-compose.bwrap.yml` as an additional override so bubblewrap can create nested namespaces.
|
||||
|
||||
|
||||
## Pairing
|
||||
@@ -2069,6 +2110,10 @@ The heartbeat job is backed by the same cron service as user-created reminders.
|
||||
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
||||
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
|
||||
|
||||
### Custom heartbeat evaluator prompt
|
||||
|
||||
The notification gate runs on a built-in system prompt. Advanced users can override it, but you rarely need to — it's strongly advised to first read the evaluator code and the default `evaluator.md`. To override, drop your prompt at `<workspace>/prompts/evaluator.md`. It must still instruct the model to call the `evaluate_notification` tool; otherwise the gate fails closed and stays silent.
|
||||
|
||||
|
||||
## Subagent Concurrency
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Use this page after `nanobot agent -m "Hello!"` works locally. Deployment keeps
|
||||
|
||||
## Before You Deploy
|
||||
|
||||
Check these once before Docker, systemd, or LaunchAgent:
|
||||
Check these once before Render, Docker, systemd, or LaunchAgent:
|
||||
|
||||
| Check | Why it matters |
|
||||
|---|---|
|
||||
@@ -22,11 +22,23 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
||||
|
||||
| Runtime | Use it for | State location | Useful first command |
|
||||
|---|---|---|---|
|
||||
| Render | One-click hosted gateway and WebUI | Persistent disk at `/home/nanobot/.nanobot` | [Deploy to Render](#render) |
|
||||
| Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` |
|
||||
| Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` |
|
||||
| systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` |
|
||||
| macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` |
|
||||
|
||||
## Render
|
||||
|
||||
Run nanobot online without managing a server. The blueprint deploys the gateway and bundled WebUI together, with a persistent disk so sessions, memory, and chat history survive restarts.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This setup requires a paid Render service because persistent disks are not available on the free tier. During setup, provide `ANTHROPIC_API_KEY` and set `NANOBOT_WEB_TOKEN` to a strong private password (for example, generate one with `openssl rand -hex 32`).
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
|
||||
|
||||
[Review the deployment blueprint](../render.yaml)
|
||||
|
||||
## Docker
|
||||
|
||||
> [!TIP]
|
||||
@@ -62,6 +74,22 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
||||
|
||||
### 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
|
||||
docker compose run --rm nanobot-cli onboard # first-time setup
|
||||
vim ~/.nanobot/config.json # add API keys
|
||||
@@ -74,12 +102,32 @@ docker compose logs -f nanobot-gateway # view logs
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
The default Compose file drops all Linux capabilities and keeps Docker's default
|
||||
AppArmor/seccomp profiles enabled. If you explicitly set
|
||||
`"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap
|
||||
override file when starting containers:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanobot-gateway
|
||||
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanobot-cli agent -m "Hello!"
|
||||
```
|
||||
|
||||
The override grants `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for
|
||||
the container so bubblewrap can create its nested namespaces. Use it only when the
|
||||
bwrap sandbox is enabled.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
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)
|
||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
||||
|
||||
@@ -87,12 +135,17 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
||||
vim ~/.nanobot/config.json
|
||||
|
||||
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
|
||||
# Mirrors the security caps and port mappings declared in docker-compose.yml:
|
||||
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
|
||||
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
|
||||
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
|
||||
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
|
||||
# endpoint on 18790.
|
||||
# `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway
|
||||
# health endpoint on 18790.
|
||||
docker run \
|
||||
--cap-drop ALL \
|
||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||
-p 18790:18790 -p 8765:8765 \
|
||||
nanobot gateway
|
||||
|
||||
# If `tools.exec.sandbox: "bwrap"` is enabled, run with the extra permissions
|
||||
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
|
||||
# `clone3: Operation not permitted`.
|
||||
docker run \
|
||||
--cap-drop ALL --cap-add SYS_ADMIN \
|
||||
--security-opt apparmor=unconfined \
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
# nanobot Guides
|
||||
# nanobot Task Guides
|
||||
|
||||
These guides are short task entry points. Use them when you know what you want
|
||||
to build, then follow the linked reference docs for complete option tables and
|
||||
edge cases.
|
||||
Start with [Install and Quick Start](../quick-start.md) and get one reply before using a guide below. Each guide targets one outcome; linked reference pages hold the complete option tables and edge cases.
|
||||
|
||||
## Build and operate
|
||||
## Start and Use
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Build a personal AI agent | [Build a personal AI agent](./build-a-personal-ai-agent.md) |
|
||||
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
||||
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
|
||||
| Run long-running tasks | [Long-running AI agent](./long-running-ai-agent.md) |
|
||||
| Add memory | [AI agent memory](./ai-agent-memory.md) |
|
||||
| Deploy a gateway | [Deploy a long-running nanobot AI agent gateway](./deploy-nanobot-gateway.md) |
|
||||
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
||||
| Run a sustained goal | [Long-running AI agent](./long-running-ai-agent.md) |
|
||||
| Add long-term memory | [AI agent memory](./ai-agent-memory.md) |
|
||||
|
||||
## Connect and integrate
|
||||
## Connect a Chat App
|
||||
|
||||
Use **Settings → Channels** in the WebUI for guided setup. These guides explain the account, bot, token, permission, and test-message steps on each platform.
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
@@ -29,10 +28,15 @@ edge cases.
|
||||
| Connect QQ | [QQ AI agent](./qq-ai-agent.md) |
|
||||
| Connect Email | [Email AI agent](./email-ai-agent.md) |
|
||||
| Connect Mattermost | [Mattermost AI agent](./mattermost-ai-agent.md) |
|
||||
|
||||
## Integrate from Code
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Run from Python | [Python AI agent SDK](./python-ai-agent-sdk.md) |
|
||||
| Expose `/v1/chat/completions` | [OpenAI-compatible agent API](./openai-compatible-agent-api.md) |
|
||||
|
||||
## Configure
|
||||
## Configure and Operate
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
@@ -40,6 +44,7 @@ edge cases.
|
||||
| Enable web search | [Configure web search](./configure-web-search.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) |
|
||||
| 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) |
|
||||
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
|
||||
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |
|
||||
|
||||
@@ -21,10 +21,10 @@ private DMs, team channels, group chats, email threads, or bot workspaces.
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Then choose one platform guide:
|
||||
Send `Hello!` in the WebUI before adding a channel. Then choose one platform guide for the bot/account prerequisites:
|
||||
|
||||
- [Telegram AI agent](./telegram-ai-agent.md)
|
||||
- [Discord AI agent](./discord-ai-agent.md)
|
||||
@@ -38,28 +38,31 @@ Then choose one platform guide:
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Every channel follows the same pattern:
|
||||
Use the guided channel setup:
|
||||
|
||||
1. Get the platform token, login state, webhook, or mailbox credentials.
|
||||
2. Merge the channel snippet into `~/.nanobot/config.json`.
|
||||
3. Prefer pairing for DM-capable channels: omit `allowFrom`, then approve the
|
||||
first DM's pairing code.
|
||||
4. For channels without pairing, such as Email, keep access narrow with
|
||||
`allowFrom` or platform-specific allow lists.
|
||||
5. Check status:
|
||||
2. Open **Settings → Channels** in the WebUI.
|
||||
3. Choose the platform and open its setup panel.
|
||||
4. Complete the credential or QR flow and install optional support if prompted.
|
||||
5. Restart when the WebUI requests it.
|
||||
6. Send a private test message.
|
||||
7. Approve the pairing request in the WebUI when a DM-capable channel asks for one.
|
||||
|
||||
If your installed release does not show **Settings → Channels**, use the full [Chat Apps reference](../chat-apps.md#manual-setup-pattern) to configure the channel manually.
|
||||
|
||||
Check status from the terminal when you need a lower-level confirmation:
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
```
|
||||
|
||||
6. Start the gateway:
|
||||
The `nanobot webui` command already runs the gateway. For a chat-only or server deployment, start it directly:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
7. Send a test DM, approve the pairing code when prompted, then send the test
|
||||
message again.
|
||||
Use the full [Chat Apps reference](../chat-apps.md) when you manage `config.json` directly or need platform-specific advanced settings.
|
||||
|
||||
## Production notes
|
||||
|
||||
@@ -80,8 +83,7 @@ nanobot gateway
|
||||
|
||||
- If `nanobot channels status` does not show the channel, the config key or
|
||||
optional dependency is likely missing.
|
||||
- If the first DM returns a pairing code, approve it with
|
||||
`/pairing approve <code>` before expecting normal replies.
|
||||
- If the first DM returns a pairing code, approve the pending request in the WebUI or use `/pairing approve <code>` from an authorized chat.
|
||||
- If messages do not arrive, run `nanobot gateway --verbose` and compare
|
||||
platform credentials, event permissions, and allow lists.
|
||||
- If group replies are unexpected, review that channel's group policy.
|
||||
|
||||
@@ -6,7 +6,7 @@ through the Model Context Protocol.
|
||||
## What you will build
|
||||
|
||||
- a working nanobot agent
|
||||
- one MCP server entry in `~/.nanobot/config.json`
|
||||
- one MCP integration configured through Apps or `~/.nanobot/config.json`
|
||||
- a restricted set of MCP tools exposed to the model
|
||||
|
||||
## When to use this
|
||||
@@ -27,7 +27,15 @@ remote HTTP endpoint.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Add this to `~/.nanobot/config.json`:
|
||||
For local interactive setup:
|
||||
|
||||
1. Run `nanobot webui` and open **Apps**.
|
||||
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
|
||||
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||
4. Save and restart when prompted.
|
||||
5. Mention the integration with `@` in the next message and ask for a small test action.
|
||||
|
||||
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# 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)
|
||||
@@ -7,7 +7,7 @@ providers.
|
||||
## What you will build
|
||||
|
||||
- web tools enabled in nanobot
|
||||
- one search provider selected in `config.json`
|
||||
- one search provider selected in the WebUI or `config.json`
|
||||
- optional web fetch settings for page reading
|
||||
|
||||
## When to use this
|
||||
@@ -28,7 +28,15 @@ provider, API key, proxy, fetch behavior, or SSRF allowlist.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Use the default search provider:
|
||||
For local interactive setup:
|
||||
|
||||
1. Run `nanobot webui`.
|
||||
2. Open **Settings → Web**.
|
||||
3. Enable web search, choose a provider, and enter its API key if required.
|
||||
4. Save and restart when prompted.
|
||||
5. Ask a question that requires current information and inspect the cited sources.
|
||||
|
||||
For manual or deployment-managed config, use the default search provider:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Build a Telegram AI Agent with nanobot
|
||||
# Connect Telegram to nanobot
|
||||
|
||||
This guide connects nanobot to Telegram so a paired Telegram user can message a
|
||||
self-hosted AI agent backed by your normal nanobot config, tools, memory, and
|
||||
workspace.
|
||||
This guide connects one Telegram bot to nanobot. Messages sent to that bot use
|
||||
your normal nanobot model, tools, memory, and workspace.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
@@ -29,27 +28,55 @@ python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the Telegram channel
|
||||
## Connect Telegram in the WebUI
|
||||
|
||||
Install the optional channel dependency:
|
||||
Start the WebUI:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Open **Settings → Channels → Telegram**:
|
||||
|
||||
1. If Telegram support is not installed, turn on its switch and confirm the
|
||||
installation.
|
||||
2. Paste the token from BotFather.
|
||||
3. If the gateway cannot reach Telegram directly, expand **Advanced** and enter
|
||||
an HTTP or SOCKS proxy such as `http://127.0.0.1:7890`.
|
||||
4. Save and enable Telegram.
|
||||
|
||||
The configuration badge appears as soon as a bot token is saved. A connection
|
||||
check is separate: if Telegram is temporarily unreachable, the saved
|
||||
configuration remains valid and the bot can continue working in environments
|
||||
where the gateway has network access.
|
||||
|
||||
Saved tokens and proxy URLs are masked. A proxy entered here is used both for
|
||||
the connection check and for normal Telegram traffic.
|
||||
|
||||
## Manual setup
|
||||
|
||||
For a headless installation, install Telegram support:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable telegram
|
||||
```
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json`:
|
||||
Then merge this snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"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
|
||||
gets a pairing code instead of agent access.
|
||||
|
||||
@@ -95,8 +122,13 @@ workspace as your local CLI check.
|
||||
|
||||
- If the channel is not listed, run `nanobot plugins enable telegram` again in
|
||||
the same Python environment.
|
||||
- If messages do not arrive, run `nanobot gateway --verbose` and check the bot
|
||||
token.
|
||||
- If the WebUI shows a saved configuration but the live check cannot reach Telegram,
|
||||
the token is still saved. Confirm the gateway can reach `api.telegram.org`,
|
||||
or open **Advanced → Network proxy** and enter a proxy.
|
||||
- If Telegram rejects the token, copy the current token from BotFather or
|
||||
regenerate it.
|
||||
- If messages do not arrive, run `nanobot gateway --verbose` and confirm the
|
||||
Telegram channel is enabled.
|
||||
- If a first DM returns a pairing code, that is expected. Approve the code before
|
||||
testing normal agent replies.
|
||||
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
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. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
|
||||
The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, and save. The running gateway applies the change immediately. If that screen is not available in your installed version, use the manual config below.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
**WebUI**
|
||||
|
||||
1. Add the image provider credential under **Settings → Models** if it is not already configured.
|
||||
2. Open **Settings → Image**.
|
||||
3. Select the provider and image model, then enable image generation.
|
||||
4. Save and ask for a simple test image. If the gateway cannot apply the change live, WebUI will prompt you to restart it.
|
||||
|
||||
**Manual config**
|
||||
|
||||
This snippet uses the current built-in image-generation default so the JSON has concrete names. It is not a provider recommendation; replace `provider` and `model` with any supported image provider and model you intend to use.
|
||||
|
||||
```json
|
||||
@@ -25,7 +34,7 @@ This snippet uses the current built-in image-generation default so the JSON has
|
||||
}
|
||||
```
|
||||
|
||||
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
|
||||
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, Zhipu, and ModelScope configuration examples.
|
||||
|
||||
> [!TIP]
|
||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
||||
@@ -46,7 +55,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
|
||||
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, `modelscope` |
|
||||
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
|
||||
| `tools.imageGeneration.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` |
|
||||
@@ -310,6 +319,29 @@ Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be speci
|
||||
|
||||
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
|
||||
|
||||
### ModelScope
|
||||
|
||||
ModelScope (魔搭社区) API-Inference supports text-to-image generation and image editing via an async task pattern.
|
||||
|
||||
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1664x928`) or using aspect ratio presets.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"modelscope": {
|
||||
"apiKey": "${MODELSCOPE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"imageGeneration": {
|
||||
"enabled": true,
|
||||
"provider": "modelscope",
|
||||
"model": "Qwen/Qwen-Image-2512"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Artifacts
|
||||
|
||||
Generated images are stored under the active nanobot instance's media directory:
|
||||
@@ -362,9 +394,9 @@ Use the reference image. Keep the same robot and composition, change the palette
|
||||
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
|
||||
| `generate_image` is not available | Enable image generation in **Settings → Image** and save. For manual config changes, restart the gateway |
|
||||
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
||||
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
|
||||
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, or `modelscope` |
|
||||
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
|
||||
| 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 |
|
||||
|
||||
@@ -64,6 +64,11 @@ This is why nanobot's memory is not just archival. It is interpretive.
|
||||
|
||||
## The Files
|
||||
|
||||
In this page, `workspace` means the configured **agent workspace** (the default
|
||||
is `~/.nanobot/workspace/`, or the path passed with `--workspace`). Selecting a
|
||||
different project in the WebUI changes that chat's project context and tool
|
||||
working directory; it does not relocate the files below.
|
||||
|
||||
```text
|
||||
workspace/
|
||||
├── SOUL.md # The bot's long-term voice and communication style
|
||||
@@ -79,6 +84,11 @@ workspace/
|
||||
└── .git/ # Version history for long-term memory files
|
||||
```
|
||||
|
||||
A selected project may provide its own `AGENTS.md`, but project-local `SOUL.md`,
|
||||
`USER.md`, and `memory/` do not replace the agent-owned files above. This keeps
|
||||
one agent's profile and memory continuous while it works across projects. Use a
|
||||
separate configured agent workspace when identity or memory must be isolated.
|
||||
|
||||
These files play different roles:
|
||||
|
||||
- `SOUL.md` remembers how nanobot should sound.
|
||||
|
||||
@@ -27,7 +27,8 @@ To allow the agent to set its configuration (e.g. switch models, adjust paramete
|
||||
|
||||
Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and rewritten in-place the next time `nanobot onboard` refreshes the config.
|
||||
|
||||
All modifications are held in memory only — restart restores defaults.
|
||||
Most modifications are held in memory only. `model_preset` is the exception: it is
|
||||
stored in the current session so the selection survives a restart.
|
||||
|
||||
---
|
||||
|
||||
@@ -77,20 +78,18 @@ my(action="check", key="web_config.enable")
|
||||
|
||||
## set — Runtime tuning
|
||||
|
||||
Changes take effect immediately, no restart required.
|
||||
Changes do not require a restart. `model_preset` is saved for the current session and
|
||||
applies to its next turn; other writable runtime tuning takes effect immediately.
|
||||
Direct `model` and `context_window_tokens` writes are rejected during an active session
|
||||
because those setters change the shared instance default. Configure a named preset for
|
||||
model or context-window changes instead.
|
||||
|
||||
```text
|
||||
my(action="set", key="max_iterations", value=80)
|
||||
# → Bump iteration limit from 40 to 80
|
||||
|
||||
my(action="set", key="model_preset", value="fast")
|
||||
# → 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
|
||||
# → Use a configured model preset for this session's next turn
|
||||
```
|
||||
|
||||
You can also store custom state in your scratchpad:
|
||||
@@ -109,9 +108,9 @@ These parameters have type and range validation — invalid values are rejected:
|
||||
| Parameter | Type | Range | Purpose |
|
||||
|-----------|------|-------|---------|
|
||||
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
||||
| `context_window_tokens` | int | 4,096–1,000,000 | Context window size |
|
||||
| `model` | str | non-empty | LLM model to use |
|
||||
| `model_preset` | str | configured preset name | Named preset to use |
|
||||
| `context_window_tokens` | int | 4,096–1,000,000 | Instance default; during a session, select through a preset |
|
||||
| `model` | str | non-empty | Instance default; during a session, select through a preset |
|
||||
| `model_preset` | str | configured preset name | Current session's preset for its next turn |
|
||||
|
||||
Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
|
||||
|
||||
@@ -122,8 +121,8 @@ Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_char
|
||||
### "This task is complex, I need more room"
|
||||
|
||||
```text
|
||||
Agent: This codebase is large, let me expand my context window to handle it.
|
||||
→ my(action="set", key="context_window_tokens", value=262144)
|
||||
Agent: This codebase is large, let me switch this session to the configured deep preset.
|
||||
→ my(action="set", key="model_preset", value="deep")
|
||||
```
|
||||
|
||||
### "Simple question, don't waste compute"
|
||||
@@ -180,7 +179,9 @@ Agent: The code review is progressing well. The test task hasn't started yet.
|
||||
|
||||
## Safety Mechanisms
|
||||
|
||||
Core design principle: **All modifications live in memory only. Restart restores defaults.** The agent cannot cause persistent damage.
|
||||
Core design principle: **The tool does not rewrite `config.json`.** Instance-wide
|
||||
changes live in memory only, while `model_preset` persists only as the current
|
||||
session's selector.
|
||||
|
||||
### Off-limits (BLOCKED)
|
||||
|
||||
|
||||
@@ -431,7 +431,13 @@ curl -sS http://localhost:11434/v1/models
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
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 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 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
|
||||
|
||||
@@ -604,7 +610,9 @@ In chat:
|
||||
/model fast
|
||||
```
|
||||
|
||||
`/model` switching is runtime-only. It does not rewrite `config.json`, and an in-progress turn keeps using the model it started with.
|
||||
`/model` stores the selection in the current session without rewriting `config.json`.
|
||||
The selection survives restarts, does not affect other sessions, and an in-progress
|
||||
turn keeps using the model it started with.
|
||||
|
||||
## Quick Failure Map
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md).
|
||||
|
||||
For normal local setup, open **Settings → Models** in the WebUI to add provider credentials, create a model preset, and select the active model. Use the JSON below for manual deployments, local endpoints, provider-specific fields, or diagnosis.
|
||||
|
||||
For every setup, answer three questions:
|
||||
|
||||
1. Which provider owns the credential or endpoint?
|
||||
@@ -61,11 +63,11 @@ These fields answer different questions:
|
||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
||||
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
|
||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers, OpenAI Codex, and xAI OAuth. |
|
||||
|
||||
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
||||
|
||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex` and `xai_grok`, including OAuth token exchange/refresh and model requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
||||
|
||||
## Common Provider Patterns
|
||||
|
||||
@@ -329,6 +331,13 @@ Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
|
||||
|
||||
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
|
||||
|
||||
```json
|
||||
@@ -424,6 +433,25 @@ For OpenAI Codex:
|
||||
nanobot provider login openai-codex --set-main
|
||||
```
|
||||
|
||||
For an eligible X Premium / Grok subscription:
|
||||
|
||||
```bash
|
||||
nanobot provider login xai-grok --set-main
|
||||
```
|
||||
|
||||
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and
|
||||
exposes the hosted `x_search` tool only when the selected model advertises
|
||||
`supportsBackendSearch`; otherwise the model runs without hosted X Search.
|
||||
When enabled, Grok can search current X posts and return inline source links
|
||||
without invoking a local nanobot tool. Credentials are stored under the
|
||||
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
|
||||
`config.json` and not in Grok Build's credential file.
|
||||
|
||||
The login is xAI subscription OAuth, not X Developer OAuth. It follows the
|
||||
public client contract documented and implemented by
|
||||
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md);
|
||||
xAI may change that upstream contract independently of nanobot.
|
||||
|
||||
For GitHub Copilot:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -494,8 +494,10 @@ Run the agent once and return a `RunResult`.
|
||||
| `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` and `model_preset` are per-run overrides and do not change
|
||||
`bot.runtime.model` after the run completes. They are mutually exclusive.
|
||||
Without an override, a run uses the preset saved in its session, or the configured
|
||||
default when that session has no saved selection. `model` and `model_preset` are
|
||||
mutually exclusive per-run overrides; they do not change the saved session selection
|
||||
or `bot.runtime.model` after the run completes.
|
||||
|
||||
### `await bot.run_streamed(...)`
|
||||
|
||||
@@ -531,9 +533,9 @@ async for event in bot.stream("Generate a long answer"):
|
||||
| `await cancel()` | Cancel the run and release stream resources. |
|
||||
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
|
||||
|
||||
Normal SDK runs with different session keys may overlap. Runs that use per-run
|
||||
`model` or `model_preset` overrides are exclusive while the override is active,
|
||||
because the current `AgentLoop` provider/model state is mutable.
|
||||
SDK runs with different session keys may overlap, including runs with per-run
|
||||
`model` or `model_preset` overrides. Each run receives an immutable runtime without
|
||||
mutating the instance default. Runs sharing one session key remain serialized.
|
||||
|
||||
### `StreamEvent`
|
||||
|
||||
|
||||
@@ -1,153 +1,196 @@
|
||||
# Install and Quick Start
|
||||
|
||||
This page gets one local nanobot reply working. After that, you can add the WebUI, chat apps, local models, web search, MCP, deployment, or custom plugins.
|
||||
This guide has one goal: get a normal nanobot reply in your browser. Do not add chat apps, MCP servers, fallback models, or deployment until this path works.
|
||||
|
||||
If you have never used a terminal or edited a config file before, use [`start-without-technical-background.md`](./start-without-technical-background.md) first. This page assumes you are comfortable pasting commands and editing JSON snippets.
|
||||
If terminals, Python, or API keys are unfamiliar, use the [beginner walkthrough](./start-without-technical-background.md), which explains each term and screen.
|
||||
|
||||
## Before You Start
|
||||
These repository docs follow current `main`. The recommended installer uses the stable package, so a newly documented WebUI screen may not appear until the next release. Each advanced guide also provides a CLI or manual config path.
|
||||
|
||||
You need:
|
||||
## What You Need
|
||||
|
||||
- Python 3.11 or newer.
|
||||
- One LLM provider, company endpoint, subscription endpoint, or local model server you can call. The examples below use a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service; any supported provider works when the key, provider name, and model ID match.
|
||||
- Git only if you install from source.
|
||||
- Node.js or Bun only if you are developing the WebUI itself.
|
||||
- Access to one supported AI provider, company endpoint, or local model server.
|
||||
- The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Repository docs may describe features that are available first in source. Install from PyPI or `uv` for the stable day-to-day release; install from source when you want the newest repository behavior or plan to contribute.
|
||||
Git is only needed for a source install. The published package already contains the WebUI. A current-source install needs `bun` or `npm` so its WebUI bundle can be built.
|
||||
|
||||
## 1. Install
|
||||
## 1. Install nanobot
|
||||
|
||||
Pick one install method.
|
||||
The recommended installer keeps nanobot out of the system Python environment. On a fresh local desktop, it starts the WebUI when installation finishes.
|
||||
|
||||
**One-command setup:**
|
||||
**macOS / Linux**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
On Windows PowerShell:
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
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, go straight to [Open the WebUI](#5-open-the-webui).
|
||||
The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release unless you explicitly pass `--dev`. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below.
|
||||
|
||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
|
||||
|
||||
## 2. Configure Your Model
|
||||
|
||||
Keep the installer terminal open. The browser opens the local WebUI; go to **Settings → Models** and:
|
||||
|
||||
1. Choose the provider or endpoint that owns your credential.
|
||||
2. Enter its API key or base URL when required.
|
||||
3. Create or select a model preset using a model ID that provider can run.
|
||||
4. Save the configuration.
|
||||
|
||||
The WebUI launcher creates or updates:
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
||||
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
|
||||
|
||||
If the installer did not open the browser, run:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
||||
```
|
||||
|
||||
To install the current `main` branch instead, pass `--dev`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||
```
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
||||
```
|
||||
|
||||
If `curl` or `irm` is unavailable, or GitHub raw downloads are blocked on your network, use one of the manual install methods below.
|
||||
|
||||
If you prefer to inspect the script first, open [`../scripts/install.sh`](../scripts/install.sh) or [`../scripts/install.ps1`](../scripts/install.ps1).
|
||||
|
||||
**Stable release with `uv`:**
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
**Stable release with pip:**
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
Use pip only inside an environment you control. If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment first.
|
||||
|
||||
**Latest source checkout:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If your shell cannot find `nanobot` after a pip install, run the module form:
|
||||
|
||||
```bash
|
||||
python -m nanobot --version
|
||||
python -m nanobot onboard
|
||||
```
|
||||
|
||||
On Windows, `~` in the docs means your user profile directory, for example `C:\Users\you`.
|
||||
|
||||
The docs use `python` in commands. If your system exposes Python 3.11+ as `python3` or `py`, use that command in the same place, for example `python3 -m pip install nanobot-ai` or `py -m nanobot --version`.
|
||||
|
||||
## 2. Initialize
|
||||
|
||||
Skip this section if the one-command setup already started the wizard and Quick Start finished there.
|
||||
|
||||
```bash
|
||||
nanobot onboard
|
||||
```
|
||||
|
||||
Use the wizard if you prefer prompts instead of editing JSON by hand:
|
||||
SSH, headless, existing-config, and older-release installs retain the terminal setup path:
|
||||
|
||||
```bash
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
Initialization creates:
|
||||
## 3. Check the Setup
|
||||
|
||||
| Path | What it is |
|
||||
|------|------------|
|
||||
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API |
|
||||
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values. Use `nanobot onboard --refresh` to do the same refresh without an interactive prompt.
|
||||
You want:
|
||||
|
||||
## 3. Configure a Provider
|
||||
- a check mark for **Config** and **Workspace**;
|
||||
- the model or preset you selected;
|
||||
- a configured state for the provider used by that model.
|
||||
|
||||
Skip this section if you already configured provider and model settings in the wizard.
|
||||
Most other providers can say `not set`. This command validates local setup but does not call the model.
|
||||
|
||||
Open `~/.nanobot/config.json`. Add or merge these blocks into the file created by `nanobot onboard`; do not replace the whole file unless you want to reset the config.
|
||||
## 4. Get the First Reply
|
||||
|
||||
**API key:**
|
||||
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.
|
||||
|
||||
Send:
|
||||
|
||||
```text
|
||||
Hello!
|
||||
```
|
||||
|
||||
Any normal assistant answer is success. It proves that nanobot can load the config, reach the selected model, use the workspace, and serve the browser UI.
|
||||
|
||||
Leave the terminal open while using the WebUI. If you prefer a managed background process, stop the foreground process with `Ctrl+C`, then run:
|
||||
|
||||
```bash
|
||||
nanobot gateway --background
|
||||
nanobot gateway status
|
||||
```
|
||||
|
||||
Use `nanobot gateway logs`, `restart`, and `stop` to manage that background gateway.
|
||||
|
||||
## Terminal-Only Check
|
||||
|
||||
If you do not want the browser or need to isolate a WebUI problem, send one message directly:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Then start an interactive terminal chat with:
|
||||
|
||||
```bash
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
In interactive mode, `Enter` sends and `Alt+Enter` inserts a newline. Exit with `exit`, `/exit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
## Choose One Next Step
|
||||
|
||||
After the first reply works, add one capability and test again:
|
||||
|
||||
| Goal | Recommended path |
|
||||
|---|---|
|
||||
| Learn sessions, workspaces, tools, and access modes | [WebUI guide](./webui.md) |
|
||||
| Connect a chat platform | Open **Settings → Channels**, then use [Chat Apps](./chat-apps.md) for platform prerequisites |
|
||||
| Change or add a model | Open **Settings → Models**; use the [Provider Cookbook](./provider-cookbook.md) for a recipe |
|
||||
| Add web search, voice, or image generation | Use the matching WebUI Settings page, then consult [Configuration](./configuration.md) for advanced fields |
|
||||
| Add an App or MCP integration | Open **Apps** or follow [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||
| Schedule agent work | Read [Automations](./automations.md) |
|
||||
| Run continuously or remotely | Read [Deployment](./deployment.md) |
|
||||
| Integrate from code | Use the [Python SDK](./python-sdk.md) or [OpenAI-Compatible API](./openai-api.md) |
|
||||
|
||||
## Other Install Methods
|
||||
|
||||
Use one method, then continue at [Configure Your Model](#2-configure-your-model).
|
||||
|
||||
**uv**
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
**pip in a virtual environment**
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**Current source**
|
||||
|
||||
`bun` or `npm` must be available. Activate a virtual environment first, then run:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install .
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
The source path follows current `main` and can be newer than the published package. A non-editable install triggers the build hook that bundles the current WebUI. For editable Python or frontend development, follow [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../webui/README.md`](../webui/README.md).
|
||||
|
||||
If the package is installed but the shell cannot find `nanobot`, use the runner that owns the installation. The recommended installer prints the exact command to reuse. Common forms are:
|
||||
|
||||
```bash
|
||||
uv tool run --from nanobot-ai nanobot --version
|
||||
pipx run --spec nanobot-ai nanobot --version
|
||||
~/.nanobot/venv/bin/python -m nanobot --version
|
||||
```
|
||||
|
||||
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `webui`, `onboard --wizard`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
|
||||
|
||||
## Manual Configuration Fallback
|
||||
|
||||
Use this only when the wizard is unavailable or you intentionally manage JSON. First run `nanobot onboard`, then merge a provider and a named model preset into `~/.nanobot/config.json`.
|
||||
|
||||
A generic OpenAI-compatible setup has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiKey": "${PROVIDER_API_KEY}",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Model preset:**
|
||||
|
||||
```json
|
||||
{
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Primary",
|
||||
"provider": "custom",
|
||||
"model": "model-id-from-your-provider",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
"model": "model-id-from-your-provider"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
@@ -158,192 +201,48 @@ Open `~/.nanobot/config.json`. Add or merge these blocks into the file created b
|
||||
}
|
||||
```
|
||||
|
||||
The provider and model inside a preset must match. The snippet above is only an example. For another provider, replace these values together:
|
||||
|
||||
| Replace | Where |
|
||||
|---|---|
|
||||
| Provider config key, such as `custom` | `providers.<provider>` |
|
||||
| API key or environment variable | `providers.<provider>.apiKey` |
|
||||
| Preset provider name | `modelPresets.primary.provider` |
|
||||
| Model ID | `modelPresets.primary.model` |
|
||||
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
|
||||
|
||||
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 fallback chains. For provider-specific examples across direct, gateway, OAuth, cloud, and local setups, see [`providers.md`](./providers.md).
|
||||
|
||||
**What about `apiBase` / base URL?**
|
||||
|
||||
`apiBase` is the HTTP base URL of the provider endpoint, not the model name. Most hosted providers in nanobot already know their default endpoint, so you usually only set `apiKey` and a model preset. Set `apiBase` when you are using:
|
||||
|
||||
- `custom` for a third-party or self-hosted OpenAI-compatible API;
|
||||
- a local OpenAI-compatible server such as Ollama, vLLM, or LM Studio;
|
||||
- a provider-specific alternate endpoint, regional endpoint, proxy, or subscription endpoint.
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "${CUSTOM_API_KEY}",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"apiBase": "http://localhost:11434/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the provider's docs say the endpoint is `/v1`, include `/v1` in `apiBase`. The model ID still belongs in the active `modelPresets` entry.
|
||||
|
||||
If you prefer not to store secrets in `config.json`, reference an environment variable and set it before starting nanobot:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "${PROVIDER_API_KEY}",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Check the Setup
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
This should show the config path, workspace path, active model or preset, and provider summary. It does not send a message to the model, so use it as a quick config check before the first real request.
|
||||
|
||||
Read it like this:
|
||||
|
||||
| Status line | What you want |
|
||||
|---|---|
|
||||
| `Config` | A check mark. |
|
||||
| `Workspace` | A check mark. |
|
||||
| `Model` | The model or preset you expect. |
|
||||
| Provider list | Most providers can say `not set`; the provider used by the active preset should show a check mark, OAuth status, or local URL. |
|
||||
|
||||
## 5. Open the WebUI
|
||||
|
||||
Start the browser workbench:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
`nanobot webui` prepares the local WebSocket channel and WebUI bootstrap secret if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal.
|
||||
|
||||
## 6. Test One CLI Message
|
||||
|
||||
Use this path if you skipped Quick Start, declined the WebSocket channel, or want a terminal-only check.
|
||||
|
||||
Run a one-shot CLI message:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
A successful first run proves that:
|
||||
|
||||
- the `nanobot` command is installed;
|
||||
- `~/.nanobot/config.json` can be loaded;
|
||||
- the selected provider and model can answer;
|
||||
- the default workspace can be created and used.
|
||||
|
||||
The reply text itself will vary. Any normal assistant answer means the install, config, provider, model, and workspace path are all usable.
|
||||
|
||||
If that works, start an interactive CLI chat:
|
||||
|
||||
```bash
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
After the interactive session can answer normally, nanobot can help with its own next setup step. Ask it to read the relevant docs, inspect your current `~/.nanobot/config.json`, and make one concrete change such as enabling WebUI, adding a provider preset, or configuring one chat channel. When nanobot says the config is updated, run `/restart` in the chat or restart the nanobot process manually so long-running processes reload `config.json`.
|
||||
|
||||
Example prompt:
|
||||
|
||||
```text
|
||||
Read docs/quick-start.md, docs/providers.md, and docs/configuration.md in this checkout.
|
||||
Then update ~/.nanobot/config.json to add a model preset named "primary" for my provider.
|
||||
Tell me exactly what changed and whether I need to run /restart.
|
||||
```
|
||||
|
||||
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
||||
|
||||
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
## 7. Choose Your Next Step
|
||||
|
||||
| Want to... | Go to |
|
||||
|---|---|
|
||||
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
|
||||
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
|
||||
| Understand provider/model matching | [`providers.md`](./providers.md) |
|
||||
| Open the bundled browser UI | [`webui.md`](./webui.md) |
|
||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [`chat-apps.md`](./chat-apps.md) |
|
||||
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
|
||||
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
|
||||
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
|
||||
Replace the provider, endpoint, and model together. Do not pair a credential from one service with a model ID from another. See [Provider Cookbook](./provider-cookbook.md) for hosted, OAuth, company, and local examples, and [Configuration](./configuration.md) for exact fields.
|
||||
|
||||
## Updating
|
||||
|
||||
**pip:**
|
||||
Upgrade with the same method you used to install:
|
||||
|
||||
```bash
|
||||
python -m pip install -U nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
# Recommended installer
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||
|
||||
If pip reports `externally-managed-environment`, upgrade with the same isolated method you used to install nanobot, such as `uv tool upgrade nanobot-ai`, `pipx upgrade nanobot-ai`, or the managed venv created by the one-command installer.
|
||||
|
||||
**uv:**
|
||||
|
||||
```bash
|
||||
# Or one of these
|
||||
uv tool upgrade nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
**pipx:**
|
||||
|
||||
```bash
|
||||
pipx upgrade nanobot-ai
|
||||
nanobot --version
|
||||
python -m pip install -U nanobot-ai
|
||||
```
|
||||
|
||||
**Source checkout:**
|
||||
For a source checkout:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
python -m pip install -e .
|
||||
nanobot --version
|
||||
python -m pip install .
|
||||
```
|
||||
|
||||
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
||||
Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings.
|
||||
|
||||
## If the First Reply Fails
|
||||
|
||||
Do not change several settings at once. Start with:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable whatsapp
|
||||
nanobot --version
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## First-Run Troubleshooting
|
||||
| Symptom | First check |
|
||||
|---|---|
|
||||
| `nanobot: command not found` | Reuse the installer command or method-specific runner described under [Other Install Methods](#other-install-methods) |
|
||||
| JSON parse error | Check commas and braces; remember that docs examples are usually snippets |
|
||||
| `401` or invalid API key | Verify the selected provider owns that key and remove accidental spaces |
|
||||
| Model not found | Use a model ID available from the provider selected in the active preset |
|
||||
| CLI works but WebUI does not open | Use port `8765`, not gateway health port `18790` |
|
||||
| WebUI works but a chat app does not | Check **Settings → Channels**, then run `nanobot channels status` |
|
||||
|
||||
| Symptom | What to check |
|
||||
|---------|---------------|
|
||||
| `nanobot: command not found` | Use `python -m nanobot ...`, or add your Python scripts directory to `PATH`. |
|
||||
| `ModuleNotFoundError: nanobot` | Confirm you installed into the same Python environment that is running the command. |
|
||||
| JSON parse errors | Check commas and braces in `~/.nanobot/config.json`; examples above are partial snippets to merge. |
|
||||
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
|
||||
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
|
||||
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
|
||||
| WebUI does not open | Run `nanobot webui`; the browser UI uses port `8765`, not the gateway health port `18790`. |
|
||||
|
||||
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).
|
||||
Continue with the ordered [Troubleshooting guide](./troubleshooting.md) if the cause is still unclear.
|
||||
|
||||
@@ -6,6 +6,18 @@ For tagged releases, see [GitHub Releases](https://github.com/HKUDS/nanobot/rele
|
||||
|
||||
## Highlights
|
||||
|
||||
- **2026-07-24** 🧭 Guided first-run setup, inline subagents, and model switching from the composer.
|
||||
- **2026-07-23** 🔎 Grok OAuth with hosted X Search, live image settings, and clearer fallback models.
|
||||
- **2026-07-22** 🔌 Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI.
|
||||
- **2026-07-21** ⚡ Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup.
|
||||
- **2026-07-20** 💬 Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects.
|
||||
- **2026-07-19** 🔀 Cross-provider failover, safer local triggers, WhatsApp group allowlists, and sturdier workspace staging.
|
||||
- **2026-07-18** 🧰 More resilient automation recovery and UTF-8 CLI App installs.
|
||||
- **2026-07-17** 🌙 Kimi K3 support, more reliable scheduled jobs, and cleaner provider behavior.
|
||||
- **2026-07-16** 📁 Native folder picker bridges, tighter Docker defaults, and bounded session caching.
|
||||
- **2026-07-15** 🔐 Short-lived Render access, safer gateway shutdown, validated file previews, and highlighted app mentions.
|
||||
- **2026-07-14** 📎 Document attachments, one-click Render deployment, clearer workflow docs, and stronger Windows support.
|
||||
- **2026-07-13** 🌍 Guided WebUI setup, Brazilian Portuguese, and steadier Dream, gateway, and Discord behavior.
|
||||
- **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access.
|
||||
- **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
||||
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
|
||||
|
||||
@@ -1,76 +1,62 @@
|
||||
# Start Without Technical Background
|
||||
|
||||
This page is for you if you have never used a terminal, edited a JSON file, or configured an AI model before.
|
||||
This walkthrough is for people who have not used a terminal, API key, or JSON config file before. The goal is only to get one reply in a browser. You do not need to understand nanobot's architecture or edit its config by hand.
|
||||
|
||||
The goal is small: get one local nanobot reply in your browser. Do not connect Telegram, Discord, Docker, local models, or deployment yet. Those are easier after the first reply works.
|
||||
## What You Will Need
|
||||
|
||||
## What You Are Setting Up
|
||||
- A Windows, macOS, or Linux computer.
|
||||
- Python 3.11 or newer.
|
||||
- An account or endpoint that can run an AI model.
|
||||
- The API key, login, endpoint, and model name required by that service. A local model such as Ollama may not require an API key.
|
||||
|
||||
You only need these words for Quick Start:
|
||||
An API key is password-like. Do not post it in an issue, screenshot, chat, or public config file.
|
||||
|
||||
| Word | Plain meaning |
|
||||
## A Few Useful Words
|
||||
|
||||
| Word | Meaning |
|
||||
|---|---|
|
||||
| Terminal | A text window where you paste commands and press Enter. |
|
||||
| Command | One line of text you run in the terminal. |
|
||||
| API key | A password-like token from an AI provider. Do not share it publicly. |
|
||||
| Config file | The settings file nanobot reads when it starts. |
|
||||
| Wizard | An interactive terminal menu that edits the config file for you. |
|
||||
| Browser UI | The local web page where you chat with nanobot. |
|
||||
| Terminal | A text window where you paste a command and press Enter |
|
||||
| Command | One instruction typed into the terminal |
|
||||
| Provider | The service or local server that runs the AI model |
|
||||
| Model ID | The exact model name expected by that provider |
|
||||
| API key | A secret credential that lets software call the provider |
|
||||
| Wizard | A question-and-answer setup menu |
|
||||
| WebUI | The local browser page where you use nanobot |
|
||||
|
||||
## 1. Open a Terminal
|
||||
## 1. Install Python
|
||||
|
||||
You will paste commands into a terminal. Copy only the command text inside each code block; do not copy the ``` marks.
|
||||
Download Python from [python.org](https://www.python.org/downloads/) if you do not already have version 3.11 or newer. On Windows, enable **Add python.exe to PATH** if the installer shows that option.
|
||||
|
||||
| System | How to open it |
|
||||
Open a terminal:
|
||||
|
||||
| System | How |
|
||||
|---|---|
|
||||
| Windows | Press `Win`, type `PowerShell`, then open **Windows PowerShell**. |
|
||||
| macOS | Press `Command` + `Space`, type `Terminal`, then press `Enter`. |
|
||||
| Linux | Open your app launcher, search for `Terminal`, then open it. |
|
||||
| Windows | Press `Win`, type `PowerShell`, and open Windows PowerShell |
|
||||
| macOS | Press `Command+Space`, type `Terminal`, and press Enter |
|
||||
| Linux | Open your application menu and search for Terminal |
|
||||
|
||||
When the terminal opens, click inside it, paste the command, and press `Enter`. If a command prints text and returns to a prompt, that is usually normal.
|
||||
|
||||
## 2. Install Python
|
||||
|
||||
Install Python 3.11 or newer from [python.org](https://www.python.org/downloads/).
|
||||
|
||||
On Windows, enable **Add python.exe to PATH** during installation if the installer shows that option.
|
||||
|
||||
In that terminal, check Python:
|
||||
Check Python:
|
||||
|
||||
```bash
|
||||
python --version
|
||||
```
|
||||
|
||||
If Windows says `python` is not found, close and reopen PowerShell. If it still does not work, try:
|
||||
The result should start with `Python 3.11` or a newer number. If the command is not found, close and reopen the terminal. You can also try `python3 --version` on macOS/Linux or `py --version` on Windows.
|
||||
|
||||
```bash
|
||||
py --version
|
||||
```
|
||||
## 2. Prepare Your Model Details
|
||||
|
||||
If `py` works but `python` does not, replace `python` with `py` in the commands below.
|
||||
nanobot does not create an AI provider account for you. Before setup, have these details nearby:
|
||||
|
||||
If macOS or Linux says `python` is not found, try:
|
||||
1. The provider or company endpoint name.
|
||||
2. Its API key, if it requires one.
|
||||
3. Its base URL, if its documentation gives you one.
|
||||
4. A model ID your account can use.
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
The provider, credential, endpoint, and model must belong together. For example, an API key from one provider usually cannot call a model name copied from a different provider.
|
||||
|
||||
If `python3` works but `python` does not, replace `python` with `python3` in the manual commands below. The one-command installer already checks both `python3` and `python`.
|
||||
## 3. Install nanobot
|
||||
|
||||
## 3. Get a Provider API Key
|
||||
|
||||
nanobot does not create AI accounts or API keys for you. Use an AI provider account, company endpoint, subscription endpoint, or local model server that you already control. If the provider has an OpenAI-compatible base URL in its docs, keep that nearby too.
|
||||
|
||||
For the setup path:
|
||||
|
||||
1. Open your provider's API key page.
|
||||
2. Create or copy an API key.
|
||||
3. Keep the key private.
|
||||
4. Keep the provider's base URL nearby if the provider docs show one.
|
||||
|
||||
## 4. Install nanobot
|
||||
|
||||
The easiest path is the one-command installer. It installs or upgrades nanobot, then starts the setup wizard. On macOS and Linux it avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`.
|
||||
Copy the command for your system, paste it into the terminal, and press Enter. Copy only the text inside the code block.
|
||||
|
||||
**macOS / Linux**
|
||||
|
||||
@@ -84,292 +70,89 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`:
|
||||
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.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
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.
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
||||
```
|
||||
## 4. Configure Your Model in the WebUI
|
||||
|
||||
Use the development installer only when a maintainer asks you to test the current `main` branch:
|
||||
In the browser, open **Settings → Models**. Then:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||
```
|
||||
1. Choose your provider.
|
||||
2. Enter its API key and base URL when required.
|
||||
3. Create or select a model preset.
|
||||
4. Enter a model ID available to your provider account.
|
||||
5. Save the configuration.
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
||||
```
|
||||
Treat every API key like a password. Do not include it in screenshots or support requests.
|
||||
|
||||
If the command says `curl` or `irm` is not found, or it cannot download from GitHub, use one of the manual install commands below.
|
||||
|
||||
If `uv` is installed, use:
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
```
|
||||
|
||||
If you prefer pip, use it only inside an environment you control:
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
```
|
||||
|
||||
If pip reports `externally-managed-environment` on macOS or Linux, go back to the one-command installer, use `uv tool install nanobot-ai`, use `pipx install nanobot-ai`, or create a virtual environment first.
|
||||
|
||||
Then check that nanobot is installed:
|
||||
|
||||
```bash
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If the terminal cannot find `nanobot`, use the module form:
|
||||
|
||||
```bash
|
||||
python -m nanobot --version
|
||||
```
|
||||
|
||||
Use `python3 -m nanobot --version` or `py -m nanobot --version` if that is the Python command that worked in step 2.
|
||||
|
||||
## 5. Run the Setup Wizard
|
||||
|
||||
The one-command installer starts this for you after installation. If you installed manually, run:
|
||||
|
||||
```bash
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
If `nanobot` is not found, run:
|
||||
|
||||
```bash
|
||||
python -m nanobot onboard --wizard
|
||||
```
|
||||
|
||||
Use `python3 -m nanobot onboard --wizard` or `py -m nanobot onboard --wizard` if that is the Python command that worked in step 2.
|
||||
|
||||
The wizard is a terminal menu. It is not a graphical app, but it lets you choose options instead of hand-editing every JSON field.
|
||||
|
||||
You will see a menu like this:
|
||||
|
||||
```text
|
||||
> What would you like to do?
|
||||
[Q] Quick Start
|
||||
[A] Advanced Settings
|
||||
[X] Exit
|
||||
```
|
||||
|
||||
Move through the wizard like this:
|
||||
|
||||
| When you see | Do this |
|
||||
|---|---|
|
||||
| A menu | Use the arrow keys to highlight an option, then press `Enter`. |
|
||||
| The provider menu | Choose the company or service you want to use. |
|
||||
| An endpoint menu | Choose the standard API or subscription plan endpoint that matches your key. |
|
||||
| An API key field | Paste the key, then press `Enter`. |
|
||||
| A provider base URL field | Paste the provider base URL from its docs, then press `Enter`. |
|
||||
| The Model ID field | Paste a model name from your provider, then press `Enter`. |
|
||||
| A back option in Advanced Settings | Choose it to return to the previous menu. |
|
||||
|
||||
For the first setup, choose `[Q] Quick Start`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a chat app, a tool setup, or provider-specific fields.
|
||||
|
||||
1. Choose `[Q] Quick Start`.
|
||||
2. Choose the provider you want to use.
|
||||
3. Choose the endpoint if the wizard asks, such as Standard API, Coding Plan, Token Plan, or Step Plan.
|
||||
4. Paste your API key if the wizard asks for one.
|
||||
5. Paste the provider base URL if the wizard asks for one.
|
||||
6. Paste a model ID that provider can run.
|
||||
7. Confirm that Quick Start should configure the local WebUI.
|
||||
8. Set the WebUI password when prompted.
|
||||
9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
|
||||
|
||||
The recommended path configures the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
|
||||
|
||||
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
|
||||
|
||||
The wizard creates or updates:
|
||||
|
||||
| Path | Meaning |
|
||||
|---|---|
|
||||
| `~/.nanobot/config.json` | Settings file. |
|
||||
| `~/.nanobot/workspace/` | Working folder for memory, sessions, and generated files. |
|
||||
|
||||
If Quick Start finished successfully, skip to [Open the WebUI](#7-open-the-webui). The next two sections are only for manual setup.
|
||||
|
||||
## Manual Setup: How to Merge JSON Snippets
|
||||
|
||||
Most docs examples are snippets, not whole files. Your `config.json` has one outer `{ ... }`. Add new top-level sections such as `providers`, `modelPresets`, `agents`, or `channels` inside that same outer object.
|
||||
|
||||
Do not paste two separate JSON objects into one file:
|
||||
|
||||
```text
|
||||
{
|
||||
"providers": { "...": "..." }
|
||||
}
|
||||
{
|
||||
"channels": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
Merge them into one object:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"tokenIssueSecret": "your-webui-password",
|
||||
"websocketRequiresToken": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard --wizard` whenever possible.
|
||||
|
||||
## 6. Manual Setup: Config Fallback
|
||||
|
||||
Use this only if the wizard is unavailable or you prefer opening the file yourself.
|
||||
|
||||
Run `nanobot onboard` first if `~/.nanobot/config.json` does not exist yet.
|
||||
|
||||
Use one of these commands:
|
||||
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
notepad "$env:USERPROFILE\.nanobot\config.json"
|
||||
```
|
||||
|
||||
**macOS**
|
||||
|
||||
```bash
|
||||
open -e ~/.nanobot/config.json
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
xdg-open ~/.nanobot/config.json
|
||||
```
|
||||
|
||||
If this is a brand-new install and you have not configured anything else yet, replace the file with this minimal config:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Primary",
|
||||
"provider": "custom",
|
||||
"model": "model-id-from-your-provider",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"tokenIssueSecret": "your-webui-password",
|
||||
"websocketRequiresToken": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `your-api-key`, `https://api.example.com/v1`, `model-id-from-your-provider`, and `your-webui-password` with your own values.
|
||||
|
||||
For copyable provider-specific examples, use [`provider-cookbook.md`](./provider-cookbook.md).
|
||||
|
||||
Save the file.
|
||||
|
||||
## 7. Open the WebUI
|
||||
|
||||
First check that nanobot can read the saved setup:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
This should show the config file path, workspace path, and the active model or preset. If `nanobot` is not found, use `python -m nanobot status`, `python3 -m nanobot status`, or `py -m nanobot status`, matching the Python command that worked in step 2.
|
||||
|
||||
It is normal for most providers to say `not set`. Only the provider you selected for the active preset needs to look configured.
|
||||
|
||||
Start the local browser UI:
|
||||
If the installer finishes without opening the browser and `nanobot` is available, run:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
This starts nanobot and opens `http://127.0.0.1:8765` in your browser. Leave the terminal open while you use the WebUI. Enter the WebUI password you set in the wizard if the browser asks for one.
|
||||
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.
|
||||
|
||||
Send this first message in the browser:
|
||||
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.
|
||||
|
||||
## 5. Get the First Reply
|
||||
|
||||
Leave the WebUI terminal open. If the browser did not open automatically, visit `http://127.0.0.1:8765`.
|
||||
|
||||
Send this message:
|
||||
|
||||
```text
|
||||
Hello!
|
||||
```
|
||||
|
||||
If that works, nanobot is installed and can call the model. You should see a normal assistant reply in the browser. The exact words will differ, but it should look like this shape:
|
||||
A normal assistant reply means setup is complete. The exact reply does not matter.
|
||||
|
||||
```text
|
||||
Hello! How can I help you today?
|
||||
```
|
||||
The first-run address is local to your computer. It is not automatically available to other computers on your network.
|
||||
|
||||
If `nanobot` is not found, run:
|
||||
## 6. Add One Thing at a Time
|
||||
|
||||
Do not configure every feature immediately. Choose one next goal:
|
||||
|
||||
| Goal | What to do |
|
||||
|---|---|
|
||||
| Change the AI model | Open **Settings → Models** |
|
||||
| Add a provider credential | Open **Settings → Models**, then find the provider |
|
||||
| Connect Telegram, Discord, Slack, Feishu, WeChat, or another chat app | Open **Settings → Channels**, choose the platform, and follow its connection steps |
|
||||
| Add a tool integration | Open **Apps** and choose an App or MCP integration |
|
||||
| Schedule a reminder or recurring task | Ask nanobot in the target chat, then manage it in **Automations** |
|
||||
| Work with project files | Start a new chat, choose the project workspace, and review the access setting before sending the task |
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
## If Something Fails
|
||||
|
||||
Run these commands one at a time:
|
||||
|
||||
```bash
|
||||
python -m nanobot webui
|
||||
nanobot --version
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Use `python3 -m nanobot webui` or `py -m nanobot webui` if that is the Python command that worked in step 2.
|
||||
|
||||
Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app.
|
||||
|
||||
## 8. If Something Fails
|
||||
|
||||
Do not change many things at once. Check the exact error:
|
||||
|
||||
| Error or symptom | What it usually means |
|
||||
| What you see | What it usually means |
|
||||
|---|---|
|
||||
| `JSON parse error` | The config file has a missing comma, extra comma, or mismatched brace. Copy the example again. |
|
||||
| `401`, `unauthorized`, or `invalid API key` | The API key is wrong, expired, has extra spaces, or was pasted under the wrong provider. |
|
||||
| `model not found` | Your account cannot use the default model. Return to `nanobot onboard --wizard`, choose `Advanced Settings`, then edit `Model Presets`. |
|
||||
| `nanobot: command not found` | The install worked in Python, but your shell cannot find the script. Use `python -m nanobot ...`, `python3 -m nanobot ...`, or `py -m nanobot ...`, matching the Python command that worked earlier. |
|
||||
| No response after editing config | Restart the command. Long-running processes read config when they start. |
|
||||
| `nanobot: command not found` | Reuse the exact nanobot command printed by the installer; it points to the isolated environment that contains the package |
|
||||
| `401`, unauthorized, or invalid API key | The key is wrong, expired, or belongs to a different provider |
|
||||
| Model not found | The model ID is misspelled or unavailable to your provider account |
|
||||
| Browser does not open | Open `http://127.0.0.1:8765` yourself and keep the terminal running |
|
||||
| Browser opens but messages fail | Test `nanobot agent -m "Hello!"` to separate a model problem from a WebUI problem |
|
||||
| A change was saved but nothing changed | Restart nanobot so the running process reloads the config |
|
||||
|
||||
For a fuller diagnosis path, see [`troubleshooting.md`](./troubleshooting.md).
|
||||
If you ask for help, include your operating system, `nanobot --version`, `nanobot status`, the exact command, and the exact error. Remove every API key, bot token, password, OAuth token, and private account ID first.
|
||||
|
||||
## What Not to Configure Yet
|
||||
Continue with the full [Troubleshooting guide](./troubleshooting.md) for an ordered diagnosis.
|
||||
|
||||
Skip these until the first local message works:
|
||||
|
||||
- `apiBase`: hosted built-in providers often already have default endpoints. You only need `apiBase` for local models, proxies, custom OpenAI-compatible providers, or special regional/subscription endpoints.
|
||||
- chat apps: first prove the local browser UI can answer.
|
||||
- fallback models: useful later, but not needed for the first reply.
|
||||
- Langfuse: useful for observability, but not needed for first setup.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot webui` open whenever you use the WebUI. Chat apps use the same gateway service underneath.
|
||||
|
||||
### Open the Browser UI Again
|
||||
## Open nanobot Later
|
||||
|
||||
Run:
|
||||
|
||||
@@ -377,43 +160,4 @@ Run:
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Leave that terminal open; the browser should open automatically.
|
||||
|
||||
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
|
||||
|
||||
If `nanobot` is not found, run `python -m nanobot webui`, `python3 -m nanobot webui`, or `py -m nanobot webui`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
|
||||
|
||||
### Connect a Chat App
|
||||
|
||||
1. Read the section for one app in [`chat-apps.md`](./chat-apps.md).
|
||||
2. Add only that app's config snippet. Merge it into the existing file instead of replacing the whole file.
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
4. Leave the gateway terminal open, then send a message from the allowed account.
|
||||
|
||||
Start with a private chat or a test server. Do not set `allowFrom` to `["*"]` unless you intentionally want anyone who can reach that channel to talk to the bot.
|
||||
|
||||
### Change Models or Add Backups
|
||||
|
||||
Use [`providers.md`](./providers.md) when a provider/model pair fails, and [`provider-cookbook.md`](./provider-cookbook.md) when you want copyable snippets. Keep model choices in `modelPresets`, then select the active one with `agents.defaults.modelPreset`.
|
||||
|
||||
### Ask for Help
|
||||
|
||||
When you ask for help, include:
|
||||
|
||||
- your operating system;
|
||||
- the command you ran;
|
||||
- `nanobot --version`;
|
||||
- `nanobot status`;
|
||||
- whether the browser UI can answer `Hello!`;
|
||||
- the exact error text;
|
||||
- a config snippet with API keys and tokens removed.
|
||||
|
||||
Never paste real API keys, bot tokens, OAuth tokens, or private chat IDs into a public issue or chat.
|
||||
|
||||
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
|
||||
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`.
|
||||
|
||||
@@ -135,12 +135,17 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
|
||||
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
|
||||
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
|
||||
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
||||
| OAuth provider fails | Run `nanobot provider login openai-codex --set-main` or `nanobot provider login github-copilot --set-main`. |
|
||||
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
|
||||
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
|
||||
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
|
||||
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
|
||||
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
|
||||
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
|
||||
| xAI OAuth needs a proxy | Set `providers.xaiGrok.proxy` before login. It applies to OAuth discovery, token exchange/refresh, and Grok subscription requests. |
|
||||
| xAI login runs on a remote/headless machine | In the WebUI, finish sign-in in your local browser; if the loopback redirect cannot reach the server, copy the final URL from the address bar into the WebUI dialog. From the CLI, run `nanobot provider login xai-grok` interactively, open the printed URL elsewhere, and paste the final callback URL or authorization code when prompted. |
|
||||
| xAI returns 403 or subscription access denied | Confirm the signed-in account has an eligible X Premium / Grok subscription, then run `nanobot provider login xai-grok` again. This provider does not use an xAI API key or X Developer OAuth. |
|
||||
| xAI returns 400 `invalid-argument` | Read the bounded `Response body` appended to the provider error. Hosted `x_search` is sent only when xAI's model catalog advertises `supportsBackendSearch`; the model ID `grok-4.5` itself is valid. |
|
||||
| xAI model or X Search stops working after an upstream release | The integration follows Grok Build's public OAuth/proxy client contract. Update nanobot if xAI changes that contract. |
|
||||
|
||||
## Langfuse Problems
|
||||
|
||||
@@ -178,9 +183,50 @@ nanobot gateway --verbose
|
||||
| Port already in use | Change `gateway.port`, `channels.websocket.port`, or the `--port` CLI flag for the relevant command. |
|
||||
| WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. |
|
||||
| Config changes ignored | Restart the gateway. |
|
||||
| Startup pauses at `Installing optional feature` | An enabled channel is missing its Python dependencies. See [Slow Optional Channel Dependency Installation](#slow-optional-channel-dependency-installation). |
|
||||
| Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. |
|
||||
| Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. |
|
||||
|
||||
### Slow Optional Channel Dependency Installation
|
||||
|
||||
Before loading enabled channels, the gateway checks the dependencies declared by their
|
||||
channel manifests. The CLI and WebUI normally install these dependencies when a channel is
|
||||
enabled. Installation during startup is a recovery path for an enabled config whose Python
|
||||
environment no longer has the required packages, for example after manually editing the
|
||||
config, upgrading nanobot, or recreating an isolated `uv tool`/`pipx` environment. The
|
||||
gateway waits for the install so an enabled channel is not silently skipped; later starts
|
||||
skip the installation once the dependencies are present.
|
||||
|
||||
If access to PyPI is slow in your region, configure pip to use a trusted package index. The
|
||||
installer honors the standard `PIP_INDEX_URL` environment variable, including when nanobot
|
||||
itself was installed with `uv tool`:
|
||||
|
||||
```bash
|
||||
PIP_INDEX_URL=https://your-trusted-mirror.example/simple nanobot gateway
|
||||
```
|
||||
|
||||
For the systemd user service created by `nanobot gateway install-service`, add a drop-in:
|
||||
|
||||
```bash
|
||||
systemctl --user edit nanobot-gateway.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment="PIP_INDEX_URL=https://your-trusted-mirror.example/simple"
|
||||
```
|
||||
|
||||
Then reload and restart the service:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user restart nanobot-gateway.service
|
||||
```
|
||||
|
||||
For a system-level or custom service, use `sudo systemctl edit <unit>` instead. Prefer an
|
||||
HTTPS index operated by an organization you trust, and do not put index credentials in
|
||||
commands or logs.
|
||||
|
||||
## WebUI Problems
|
||||
|
||||
The packaged WebUI is served by the WebSocket channel.
|
||||
@@ -229,7 +275,9 @@ Then check:
|
||||
|---|---|
|
||||
| Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. |
|
||||
| Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. |
|
||||
| Telegram fails | Confirm the BotFather token and `allowFrom` user ID. |
|
||||
| Telegram shows a saved configuration but cannot complete a live check | The token is saved. Confirm the gateway can reach `api.telegram.org`, or open **Settings → Channels → Telegram → Advanced → Network proxy** and enter an HTTP or SOCKS proxy. |
|
||||
| Telegram rejects the token | Copy the current token from BotFather or regenerate it. |
|
||||
| Telegram receives no messages | Confirm the channel is enabled, the gateway is running, and the sender is paired or listed in `allowFrom`. |
|
||||
| Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. |
|
||||
| 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. |
|
||||
|
||||
@@ -152,7 +152,8 @@ All frames are JSON text. Each message has an `event` field.
|
||||
|
||||
Reasoning frames only flow when the channel's `showReasoning` is `true` (default) and the model returns reasoning content (DeepSeek-R1 / Kimi / MiMo / OpenAI reasoning models, Anthropic extended thinking, or inline `<think>` / `<thought>` tags). Models without reasoning produce zero `reasoning_delta` frames.
|
||||
|
||||
**`runtime_model_updated`** — broadcast when the gateway runtime model changes, for example after `/model <preset>`:
|
||||
**`runtime_model_updated`** — broadcast when the gateway default runtime changes or
|
||||
when a config reload requires clients to refresh their model catalog:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -162,7 +163,10 @@ Reasoning frames only flow when the channel's `showReasoning` is `true` (default
|
||||
}
|
||||
```
|
||||
|
||||
`model_preset` is omitted when no named preset is active. WebUI clients use this event to keep the displayed model badge in sync across slash commands, config reloads, and settings changes.
|
||||
`model_preset` is omitted when no named preset is active. WebUI clients use this event
|
||||
to refresh model settings after default-runtime and config changes. `/model <preset>`
|
||||
is session-scoped; its selection is reflected through `session_updated` and the
|
||||
session row's `model_preset` field instead of this global event.
|
||||
|
||||
**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||
|
||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent chat sessions, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||
|
||||
The WebUI is nanobot's browser workbench for persistent chat sessions, visible
|
||||
The WebUI is nanobot's browser workbench for persistent topics, visible
|
||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||
one place.
|
||||
|
||||
@@ -17,12 +17,12 @@ Use the launcher:
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
`nanobot webui` creates the config/workspace when needed, checks provider setup,
|
||||
offers Quick Start when the model provider is not ready, enables the local
|
||||
`nanobot webui` creates the config/workspace when needed, enables the local
|
||||
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
|
||||
one is missing, starts the gateway, and opens the browser. The first-run path
|
||||
binds the WebUI to `127.0.0.1` by default, so it is not available from other
|
||||
devices on your LAN.
|
||||
one is missing, starts the gateway, and opens the browser. With a fresh config,
|
||||
it can open before a model is configured so you can finish setup in **Settings
|
||||
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
|
||||
it is not available from other devices on your LAN.
|
||||
|
||||
Run it in the background when you do not want to keep a terminal open:
|
||||
|
||||
@@ -30,6 +30,9 @@ Run it in the background when you do not want to keep a terminal open:
|
||||
nanobot webui --background
|
||||
```
|
||||
|
||||
Complete first-time model setup in a foreground `nanobot webui` session before using
|
||||
`--background`.
|
||||
|
||||
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||
|
||||
@@ -53,24 +56,37 @@ WebUI beyond localhost or want a browser password:
|
||||
The WebUI is served by the WebSocket channel on port `8765` by default. The
|
||||
gateway health endpoint, `18790` by default, is not the browser UI.
|
||||
|
||||
## First 10 Minutes
|
||||
|
||||
Use the WebUI as the primary setup surface:
|
||||
|
||||
1. Open **Settings → Models** and configure a provider, credential, and active model preset.
|
||||
2. Send `Hello!` in a new topic to prove the selected model works.
|
||||
3. Start a separate topic before project work, then choose the intended workspace and access mode.
|
||||
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.
|
||||
|
||||
This path avoids hand-editing `config.json` for normal setup. Use the reference docs when you need an option the WebUI does not expose or when you manage config as code.
|
||||
|
||||
## What It Is For
|
||||
|
||||
| Area | Use it for |
|
||||
|---|---|
|
||||
| Chat | Start, switch, search, fork, and delete browser sessions |
|
||||
| Topics | Start, switch, search, fork, and delete browser topics |
|
||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||
| 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 |
|
||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
||||
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||
| 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 |
|
||||
|
||||
## Chat Workspace
|
||||
## Topic Workspace
|
||||
|
||||
The sidebar is the session switcher. A session keeps its own history, title,
|
||||
workspace metadata, and linked automations. Use a new session when you want a
|
||||
The sidebar is the topic switcher. Each topic keeps its own history, title,
|
||||
workspace selection, and linked automations. Use a new topic when you want a
|
||||
separate context; use fork when you want to continue from an existing point
|
||||
without changing the original thread.
|
||||
|
||||
@@ -93,12 +109,34 @@ Use the workspace picker before starting project-specific work. This gives the
|
||||
agent the right project context for file paths, shell commands, and session
|
||||
metadata.
|
||||
|
||||
Selecting a project does not replace the configured agent workspace. The two
|
||||
paths have different responsibilities:
|
||||
|
||||
| Selected project provides | Agent workspace continues to provide |
|
||||
|---|---|
|
||||
| Project `AGENTS.md` | `SOUL.md` and `USER.md` |
|
||||
| Relative file paths and shell working directory | Long-term memory and history |
|
||||
| The normal read/write boundary in Restricted mode | Custom skills and instance state |
|
||||
|
||||
Project-local `SOUL.md` and `USER.md` files are ignored, and the agent workspace's
|
||||
`AGENTS.md` is not inherited by a separately selected project. When the selected
|
||||
project is the configured agent workspace, both roles naturally use the same
|
||||
directory.
|
||||
|
||||
The access control in the composer controls the local capability level for the
|
||||
chat. It does not bypass your gateway, provider, shell sandbox, or operating
|
||||
system configuration; it only selects among the capabilities that are already
|
||||
available to this WebUI session.
|
||||
available to the current topic.
|
||||
|
||||
Remote WebUI sessions may reduce access for the current workspace. Selecting a
|
||||
In Restricted mode, ordinary file and shell work stays inside the selected
|
||||
project. To preserve agent continuity, filesystem/search tools receive narrow,
|
||||
read-only access to built-in skills, custom skills in the agent workspace, and
|
||||
the exact agent `memory/history.jsonl` file. This does not grant access to
|
||||
neighboring memory or profile files, and it does not allow writes outside the
|
||||
selected project. These tool exceptions do not broaden the browser's file
|
||||
preview boundary.
|
||||
|
||||
Remote WebUI connections may reduce access for the current workspace. Selecting a
|
||||
different workspace or enabling Full Access remains limited to local and native
|
||||
clients.
|
||||
|
||||
@@ -113,6 +151,20 @@ For image generation, configure an image provider first and then use the WebUI
|
||||
image mode from the composer. See [`image-generation.md`](./image-generation.md)
|
||||
for provider setup and output behavior.
|
||||
|
||||
## Channels
|
||||
|
||||
Open **Settings → Channels** to connect chat apps without assembling JSON by hand. Search for a platform, open its setup panel, and follow the fields or QR flow shown for that channel. The guided setup can:
|
||||
|
||||
- install missing optional channel support when the WebUI is running locally;
|
||||
- collect platform credentials while preserving previously saved values;
|
||||
- handle supported QR-based login flows;
|
||||
- validate the connection and show actionable setup errors;
|
||||
- tell you when the gateway needs to restart.
|
||||
|
||||
The platform itself may still require you to create a bot, enable event permissions, copy a token, or configure a webhook. Use [`chat-apps.md`](./chat-apps.md) for those platform-side prerequisites and for manual JSON/reference options.
|
||||
|
||||
Test a new channel with a private DM. When a supported channel sends a pairing code, the WebUI surfaces the pending request so you can approve the sender. Keep access narrow; do not use a wildcard allowlist unless public access is intentional.
|
||||
|
||||
## Apps
|
||||
|
||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||
@@ -138,6 +190,11 @@ extraction tools without requiring an API key. This does not replace nanobot's
|
||||
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
|
||||
turn needs Firecrawl's richer web data tools.
|
||||
|
||||
The Parallel Search preset connects to the free, anonymous Parallel Search MCP
|
||||
endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
|
||||
It is an optional integration and does not replace nanobot's built-in web search
|
||||
provider; mention `@parallel-search` when a turn should use it.
|
||||
|
||||
After an App or integration is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message.
|
||||
|
||||
@@ -150,10 +207,10 @@ to perform that task.
|
||||
|
||||
## Automations
|
||||
|
||||
Automations are agent turns that run later in a linked chat/session. They should
|
||||
be created from the chat, channel, or session where they are supposed to run so
|
||||
nanobot keeps the correct target context. When an automation runs, it normally
|
||||
delivers the result back to that linked chat.
|
||||
Automations are agent turns that run later in a linked topic. Create them from
|
||||
the topic or channel where they are supposed to run so nanobot keeps the
|
||||
correct target context. When an automation runs, it normally delivers the
|
||||
result back to that topic.
|
||||
|
||||
For the full automation model, creation flow, trigger CLI usage, and delivery
|
||||
semantics, see [`automations.md`](./automations.md).
|
||||
@@ -172,7 +229,7 @@ instead of creating a chat automation.
|
||||
Use the Automations view to:
|
||||
|
||||
- Filter by all, active, paused, needs-attention, or system jobs.
|
||||
- Search by task name, message, trigger command, linked chat, schedule, or status.
|
||||
- Search by task name, message, trigger command, linked topic, schedule, or status.
|
||||
- Sort by next run, last run, updated time, or name.
|
||||
- Run scheduled automations now.
|
||||
- Pause or resume, rename, or delete user-created automations.
|
||||
@@ -183,9 +240,9 @@ Search accepts plain text and field filters such as `name:backup`,
|
||||
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
|
||||
`status:paused`.
|
||||
|
||||
An automation without a linked chat cannot be enabled or run from the WebUI,
|
||||
An automation without a linked topic cannot be enabled or run from the WebUI,
|
||||
because nanobot would not know where to deliver the scheduled turn. Recreate it
|
||||
from the target chat or channel so the automation has complete context.
|
||||
from the target topic or channel so the automation has complete context.
|
||||
|
||||
Local triggers do not have a WebUI "Run now" action because each run needs a
|
||||
message. Use the copied `nanobot trigger ...` command and replace `"message"`
|
||||
@@ -194,9 +251,9 @@ with the content that should be delivered.
|
||||
## Settings
|
||||
|
||||
Settings is the control surface for the browser session and gateway-backed
|
||||
runtime configuration. Use it to review or adjust model presets, provider
|
||||
visibility, image generation, voice transcription, web tools, Apps, Automations,
|
||||
Skills, runtime identity, and advanced safety controls.
|
||||
runtime configuration. Use it to review or adjust model presets, providers,
|
||||
image generation, voice transcription, web tools, chat channels, Apps,
|
||||
Automations, Skills, runtime identity, and advanced safety controls.
|
||||
|
||||
Some settings take effect immediately. Runtime settings that affect the gateway
|
||||
or agent process may require a restart; the WebUI shows that requirement next to
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
#!/bin/sh
|
||||
dir="$HOME/.nanobot"
|
||||
|
||||
# Render deploy path (see render.yaml + render-config.json). Gated on Render's
|
||||
# automatic RENDER=true env var so local Docker/podman usage is unaffected.
|
||||
# Initializes the on-disk config from the committed template (wiring secrets via
|
||||
# ${VAR} env vars, keeping runtime data on the persistent disk) and appends the
|
||||
# --config flag. Logs each decision so a failed start is diagnosable in Render's
|
||||
# logs. Privilege dropping is handled below, for every root start (not just here).
|
||||
if [ "$RENDER" = "true" ]; then
|
||||
echo "[entrypoint] Render deploy — starting as $(id)"
|
||||
mkdir -p "$dir" || echo "[entrypoint] warning: mkdir $dir failed"
|
||||
config="$dir/config.json"
|
||||
# Initialize config only when it does not already exist, so WebUI/provider
|
||||
# settings edited at runtime survive restarts. The disk persists config.json
|
||||
# across deploys; overwriting it every boot would discard those changes.
|
||||
if [ ! -f "$config" ]; then
|
||||
echo "[entrypoint] initializing $config from render-config.json"
|
||||
cp /app/render-config.json "$config" || echo "[entrypoint] warning: cp config failed"
|
||||
else
|
||||
echo "[entrypoint] existing $config found — leaving it in place"
|
||||
fi
|
||||
set -- "$@" --config "$config"
|
||||
fi
|
||||
|
||||
# Drop privileges whenever the container starts as root. Render mounts the
|
||||
# persistent disk root-owned, and a plain `docker run` also defaults to root now,
|
||||
# so this covers both. Chown the data dir so the non-root user can write it, then
|
||||
# re-exec as nanobot. Fail closed: if the privilege drop cannot be performed,
|
||||
# exit rather than run the agent as root.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R nanobot:nanobot "$dir" 2>/dev/null || echo "[entrypoint] warning: chown $dir failed"
|
||||
if setpriv --reuid=nanobot --regid=nanobot --init-groups true 2>/dev/null; then
|
||||
echo "[entrypoint] dropping privileges to nanobot via setpriv"
|
||||
exec setpriv --reuid=nanobot --regid=nanobot --init-groups nanobot "$@"
|
||||
fi
|
||||
echo "[entrypoint] error: started as root but setpriv privilege drop failed — refusing to run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Already non-root: make sure the data dir is writable before starting.
|
||||
if [ -d "$dir" ] && [ ! -w "$dir" ]; then
|
||||
owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null)
|
||||
cat >&2 <<EOF
|
||||
@@ -12,4 +51,5 @@ Fix (pick one):
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec nanobot "$@"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<svg
|
||||
width="1060"
|
||||
height="220"
|
||||
viewBox="0 0 1060 220"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>nanobot</title>
|
||||
<g transform="translate(16 20) scale(0.2507)">
|
||||
<path d="M229.029 127.134C308.64 112.113 354.143 106.879 379.029 108.134V716.634L272.029 715.634C251.029 715.634 243.029 702.634 201.529 678.134L54.5291 581.634C30.0291 565.134 23.9802 560.075 13.0291 549.134C3.52914 537.634 -1.97086 526.634 3.52914 481.634L28.0291 340.634L29.5291 27.1337C31.0291 -2.36625 53.0291 -6.86625 77.0291 12.6337L229.029 127.134Z" fill="#F4A949" stroke="#F4A949"/>
|
||||
<path d="M529.842 126.817C450.231 111.796 404.728 106.562 379.842 107.817V716.317L486.842 715.317C509.342 714.317 570.342 661.817 611.842 637.317L704.342 581.317C728.842 564.817 734.891 559.759 745.842 548.817C755.342 537.317 760.842 526.317 755.342 481.317L730.842 340.317L729.342 26.817C727.842 -2.68287 705.842 -7.18287 681.842 12.3171L529.842 126.817Z" fill="#EF8E30" stroke="#EF8E30"/>
|
||||
<path d="M143.342 497.317H1.84164C-6.15857 550.317 22.8417 557.817 56.3419 582.817L143.342 497.317Z" fill="#E27223" stroke="#DF6E22"/>
|
||||
<path d="M615.342 496.817H757.001C765.002 549.817 735.842 557.317 702.342 582.317L615.342 496.817Z" fill="#D96016" stroke="#D45F16"/>
|
||||
<path d="M379.342 716.317V517.817H288.342C239.842 517.817 243.342 531.817 144.842 640.817L233.342 698.817C245.302 707.847 260.342 717.317 275.342 715.817L379.342 716.317Z" fill="#FBCB89" stroke="#FBCB8A"/>
|
||||
<path d="M566.842 382.817C561.842 348.817 509.842 341.317 501.342 382.817V439.317C509.842 477.317 559.342 478.317 566.842 439.317V382.817Z" fill="#B94D0B" stroke="#B5490B"/>
|
||||
<path d="M379.342 716.317V517.817H470.342C518.842 517.817 513.342 528.317 611.842 637.317L522.842 698.817C510.881 707.847 495.342 715.817 483.342 715.817L379.342 716.317Z" fill="#F7B066" stroke="#F8B166"/>
|
||||
<path d="M258.842 383.199C253.842 349.199 201.842 341.699 193.342 383.199V439.699C201.842 477.699 251.342 478.699 258.842 439.699V383.199Z" fill="#B94D0B" stroke="#B94D0B"/>
|
||||
<path d="M439.342 517.817H318.342L379.842 583.317L439.342 517.817Z" fill="#C85513" stroke="#C85513"/>
|
||||
<path d="M379.342 583.317V517.817H438.842L379.342 583.317Z" fill="#BA470A" stroke="#B94D0B"/>
|
||||
<path d="M367.842 304.817L339.842 109.817C369.864 107.082 387.219 106.437 420.842 109.817L391.342 304.817C382.555 322.184 376.628 321.255 367.842 304.817Z" fill="#D35E14" stroke="#D35E14"/>
|
||||
<path d="M446.412 112.822C473.271 116.662 491.893 119.703 529.928 126.325L530.604 126.442L530.284 127.05L529.842 126.817L530.283 127.051C530.283 127.051 530.282 127.054 530.281 127.055C530.279 127.059 530.276 127.064 530.273 127.071C530.265 127.085 530.254 127.107 530.239 127.135C530.209 127.193 530.164 127.279 530.105 127.391C529.986 127.617 529.81 127.951 529.581 128.387C529.122 129.261 528.449 130.543 527.59 132.177C525.872 135.444 523.413 140.12 520.448 145.753C514.519 157.018 506.565 172.113 498.471 187.426C490.377 202.738 482.142 218.27 475.65 230.412C469.165 242.538 464.401 251.316 463.262 253.088C460.97 256.653 457.712 259.067 454.529 259.067C451.263 259.067 448.386 256.547 446.859 250.949C446.467 249.511 446.169 246.271 445.938 241.776C445.705 237.256 445.537 231.406 445.42 224.701C445.186 211.289 445.154 194.441 445.217 177.94C445.279 161.438 445.436 145.281 445.576 133.249C445.647 127.233 445.713 122.248 445.762 118.767C445.786 117.027 445.806 115.662 445.82 114.733C445.827 114.268 445.832 113.912 445.836 113.673C445.838 113.553 445.839 113.462 445.84 113.401C445.84 113.371 445.841 113.348 445.841 113.333C445.841 113.325 445.842 113.319 445.842 113.315C445.842 113.313 445.842 113.311 445.842 113.31C445.845 113.31 445.882 113.31 446.342 113.317L445.842 113.309L445.851 112.742L446.412 112.822Z" fill="#D35E14" stroke="#D35C15"/>
|
||||
<path d="M311.842 251.317C314.842 240.317 313.842 112.817 313.842 112.817C281.05 117.181 262.657 120.321 229.842 126.817C229.842 126.817 291.842 246.317 296.342 253.317C300.842 260.317 308.842 262.317 311.842 251.317Z" fill="#DF6E23" stroke="#DA6D1F"/>
|
||||
<path d="M562.842 166.317L686.842 67.8171V278.317L562.842 166.317Z" fill="#D66114" stroke="#D86116"/>
|
||||
<path d="M196.342 166.317L72.3416 67.8171V278.317L196.342 166.317Z" fill="#E17125" stroke="#E27326"/>
|
||||
<path d="M752.342 465.817L625.342 432.817L737.497 377.487L752.342 465.817Z" fill="#D66015"/>
|
||||
<path d="M737.842 377.317L737.497 377.487M737.497 377.487L625.342 432.817L752.342 465.817L737.497 377.487Z" stroke="#D66115"/>
|
||||
<path d="M6.34164 464.817L134.342 432.004L21.3031 376.986L6.34164 464.817Z" fill="#E06B1F"/>
|
||||
<path d="M20.9558 376.817L21.3031 376.986M21.3031 376.986L134.342 432.004L6.34164 464.817L21.3031 376.986Z" stroke="#DF6E1E"/>
|
||||
<path d="M379.842 317.775C376.246 317.475 372.636 313.145 368.342 305.112L340.342 110.112C355.495 108.732 367.422 107.884 379.842 107.817V317.775Z" fill="#E16D22" stroke="#E27225"/>
|
||||
</g>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#B94D0B"
|
||||
stroke-width="26"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M260 164V78M260 118C260 91 276 77 299 77C323 77 339 93 339 119V164"/>
|
||||
<path d="M450 164V78M450 121C450 95 433 77 408 77C383 77 366 95 366 121C366 146 383 164 408 164C433 164 450 146 450 121"/>
|
||||
<path d="M490 164V78M490 118C490 91 506 77 529 77C553 77 569 93 569 119V164"/>
|
||||
<path d="M686 121C686 147 670 164 644 164C618 164 602 147 602 121C602 94 618 77 644 77C670 77 686 94 686 121Z"/>
|
||||
</g>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#D96016"
|
||||
stroke-width="26"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M730 34V164M731 121C731 94 747 77 773 77C799 77 815 94 815 121C815 147 799 164 773 164C747 164 731 147 731 121Z"/>
|
||||
<path d="M934 121C934 147 918 164 892 164C866 164 850 147 850 121C850 94 866 77 892 77C918 77 934 94 934 121Z"/>
|
||||
<path d="M1000 47V138C1000 156 1011 164 1028 164M969 78H1028"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="759" height="718" viewBox="0 0 759 718" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>nanobot mark</title>
|
||||
<path d="M229.029 127.134C308.64 112.113 354.143 106.879 379.029 108.134V716.634L272.029 715.634C251.029 715.634 243.029 702.634 201.529 678.134L54.5291 581.634C30.0291 565.134 23.9802 560.075 13.0291 549.134C3.52914 537.634 -1.97086 526.634 3.52914 481.634L28.0291 340.634L29.5291 27.1337C31.0291 -2.36625 53.0291 -6.86625 77.0291 12.6337L229.029 127.134Z" fill="#F4A949" stroke="#F4A949"/>
|
||||
<path d="M529.842 126.817C450.231 111.796 404.728 106.562 379.842 107.817V716.317L486.842 715.317C509.342 714.317 570.342 661.817 611.842 637.317L704.342 581.317C728.842 564.817 734.891 559.759 745.842 548.817C755.342 537.317 760.842 526.317 755.342 481.317L730.842 340.317L729.342 26.817C727.842 -2.68287 705.842 -7.18287 681.842 12.3171L529.842 126.817Z" fill="#EF8E30" stroke="#EF8E30"/>
|
||||
<path d="M143.342 497.317H1.84164C-6.15857 550.317 22.8417 557.817 56.3419 582.817L143.342 497.317Z" fill="#E27223" stroke="#DF6E22"/>
|
||||
<path d="M615.342 496.817H757.001C765.002 549.817 735.842 557.317 702.342 582.317L615.342 496.817Z" fill="#D96016" stroke="#D45F16"/>
|
||||
<path d="M379.342 716.317V517.817H288.342C239.842 517.817 243.342 531.817 144.842 640.817L233.342 698.817C245.302 707.847 260.342 717.317 275.342 715.817L379.342 716.317Z" fill="#FBCB89" stroke="#FBCB8A"/>
|
||||
<path d="M566.842 382.817C561.842 348.817 509.842 341.317 501.342 382.817V439.317C509.842 477.317 559.342 478.317 566.842 439.317V382.817Z" fill="#B94D0B" stroke="#B5490B"/>
|
||||
<path d="M379.342 716.317V517.817H470.342C518.842 517.817 513.342 528.317 611.842 637.317L522.842 698.817C510.881 707.847 495.342 715.817 483.342 715.817L379.342 716.317Z" fill="#F7B066" stroke="#F8B166"/>
|
||||
<path d="M258.842 383.199C253.842 349.199 201.842 341.699 193.342 383.199V439.699C201.842 477.699 251.342 478.699 258.842 439.699V383.199Z" fill="#B94D0B" stroke="#B94D0B"/>
|
||||
<path d="M439.342 517.817H318.342L379.842 583.317L439.342 517.817Z" fill="#C85513" stroke="#C85513"/>
|
||||
<path d="M379.342 583.317V517.817H438.842L379.342 583.317Z" fill="#BA470A" stroke="#B94D0B"/>
|
||||
<path d="M367.842 304.817L339.842 109.817C369.864 107.082 387.219 106.437 420.842 109.817L391.342 304.817C382.555 322.184 376.628 321.255 367.842 304.817Z" fill="#D35E14" stroke="#D35E14"/>
|
||||
<path d="M446.412 112.822C473.271 116.662 491.893 119.703 529.928 126.325L530.604 126.442L530.284 127.05L529.842 126.817L530.283 127.051C530.283 127.051 530.282 127.054 530.281 127.055C530.279 127.059 530.276 127.064 530.273 127.071C530.265 127.085 530.254 127.107 530.239 127.135C530.209 127.193 530.164 127.279 530.105 127.391C529.986 127.617 529.81 127.951 529.581 128.387C529.122 129.261 528.449 130.543 527.59 132.177C525.872 135.444 523.413 140.12 520.448 145.753C514.519 157.018 506.565 172.113 498.471 187.426C490.377 202.738 482.142 218.27 475.65 230.412C469.165 242.538 464.401 251.316 463.262 253.088C460.97 256.653 457.712 259.067 454.529 259.067C451.263 259.067 448.386 256.547 446.859 250.949C446.467 249.511 446.169 246.271 445.938 241.776C445.705 237.256 445.537 231.406 445.42 224.701C445.186 211.289 445.154 194.441 445.217 177.94C445.279 161.438 445.436 145.281 445.576 133.249C445.647 127.233 445.713 122.248 445.762 118.767C445.786 117.027 445.806 115.662 445.82 114.733C445.827 114.268 445.832 113.912 445.836 113.673C445.838 113.553 445.839 113.462 445.84 113.401C445.84 113.371 445.841 113.348 445.841 113.333C445.841 113.325 445.842 113.319 445.842 113.315C445.842 113.313 445.842 113.311 445.842 113.31C445.845 113.31 445.882 113.31 446.342 113.317L445.842 113.309L445.851 112.742L446.412 112.822Z" fill="#D35E14" stroke="#D35C15"/>
|
||||
<path d="M311.842 251.317C314.842 240.317 313.842 112.817 313.842 112.817C281.05 117.181 262.657 120.321 229.842 126.817C229.842 126.817 291.842 246.317 296.342 253.317C300.842 260.317 308.842 262.317 311.842 251.317Z" fill="#DF6E23" stroke="#DA6D1F"/>
|
||||
<path d="M562.842 166.317L686.842 67.8171V278.317L562.842 166.317Z" fill="#D66114" stroke="#D86116"/>
|
||||
<path d="M196.342 166.317L72.3416 67.8171V278.317L196.342 166.317Z" fill="#E17125" stroke="#E27326"/>
|
||||
<path d="M752.342 465.817L625.342 432.817L737.497 377.487L752.342 465.817Z" fill="#D66015"/>
|
||||
<path d="M737.842 377.317L737.497 377.487M737.497 377.487L625.342 432.817L752.342 465.817L737.497 377.487Z" stroke="#D66115"/>
|
||||
<path d="M6.34164 464.817L134.342 432.004L21.3031 376.986L6.34164 464.817Z" fill="#E06B1F"/>
|
||||
<path d="M20.9558 376.817L21.3031 376.986M21.3031 376.986L134.342 432.004L6.34164 464.817L21.3031 376.986Z" stroke="#DF6E1E"/>
|
||||
<path d="M379.842 317.775C376.246 317.475 372.636 313.145 368.342 305.112L340.342 110.112C355.495 108.732 367.422 107.884 379.842 107.817V317.775Z" fill="#E16D22" stroke="#E27225"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -22,7 +22,7 @@ def _resolve_version() -> str:
|
||||
return _pkg_version("nanobot-ai")
|
||||
except PackageNotFoundError:
|
||||
# Source checkouts often import nanobot without installed dist-info.
|
||||
return _read_pyproject_version() or "0.2.2"
|
||||
return _read_pyproject_version() or "0.3.0"
|
||||
|
||||
|
||||
__version__ = _resolve_version()
|
||||
|
||||
@@ -66,7 +66,7 @@ class AutoCompact:
|
||||
def check_expired(
|
||||
self,
|
||||
schedule_background: Callable[[Coroutine], None],
|
||||
resolve_runtime: Callable[[], LLMRuntime],
|
||||
resolve_runtime: Callable[[Session], LLMRuntime],
|
||||
active_session_keys: Collection[str] = (),
|
||||
) -> None:
|
||||
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
||||
@@ -79,7 +79,12 @@ class AutoCompact:
|
||||
continue
|
||||
updated_at = info.get("updated_at")
|
||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||
runtime = resolve_runtime()
|
||||
session = self.sessions.get_or_create(key)
|
||||
try:
|
||||
runtime = resolve_runtime(session)
|
||||
except (KeyError, ValueError):
|
||||
# Invalid session selections remain recoverable through /model.
|
||||
continue
|
||||
self._archiving.add(key)
|
||||
schedule_background(self._archive(key, runtime=runtime))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Mapping, Sequence
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.agent.tools import image_generation as image_generation_tools
|
||||
from nanobot.agent.tools import mcp as mcp_tools
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.cli import utils as cli_app_utils
|
||||
@@ -41,13 +42,20 @@ async def close_mcp(state: Any) -> None:
|
||||
|
||||
|
||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||
return await mcp_tools.handle_runtime_control(state, msg, tools)
|
||||
for handler in (
|
||||
image_generation_tools.handle_runtime_control,
|
||||
mcp_tools.handle_runtime_control,
|
||||
):
|
||||
if await handler(state, msg, tools):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the context (system prompt + messages) for the agent."""
|
||||
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||
_SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
|
||||
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
||||
_MAX_RECENT_HISTORY = 50
|
||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||
@@ -116,12 +124,14 @@ class ContextBuilder:
|
||||
"""Get the core identity section."""
|
||||
root = workspace or self.workspace
|
||||
workspace_path = str(root.expanduser().resolve())
|
||||
agent_workspace_path = str(self.workspace.expanduser().resolve())
|
||||
system = platform.system()
|
||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||
|
||||
return render_template(
|
||||
"agent/identity.md",
|
||||
workspace_path=workspace_path,
|
||||
agent_workspace_path=agent_workspace_path,
|
||||
runtime=runtime,
|
||||
platform_policy=render_template("agent/platform_policy.md", system=system),
|
||||
channel=channel or "",
|
||||
@@ -146,14 +156,30 @@ class ContextBuilder:
|
||||
return _to_blocks(left) + _to_blocks(right)
|
||||
|
||||
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
|
||||
"""Load all bootstrap files from workspace."""
|
||||
"""Load project instructions plus the agent's global profile files."""
|
||||
parts = []
|
||||
root = workspace or self.workspace
|
||||
project_root = workspace or self.workspace
|
||||
sources = [
|
||||
("AGENTS.md", project_root),
|
||||
("SOUL.md", self.workspace),
|
||||
("USER.md", self.workspace),
|
||||
]
|
||||
|
||||
for filename in self.BOOTSTRAP_FILES:
|
||||
for filename, root in sources:
|
||||
file_path = root / filename
|
||||
if file_path.exists():
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
if filename == "SOUL.md" and self._is_template_content(
|
||||
content,
|
||||
"legacy/SOUL.md",
|
||||
):
|
||||
content = load_bundled_template("SOUL.md") or content
|
||||
if not content.strip():
|
||||
continue
|
||||
if filename in self._SKIPPABLE_DEFAULTS and self._is_template_content(
|
||||
content, filename
|
||||
):
|
||||
continue
|
||||
parts.append(f"## {filename}\n\n{content}")
|
||||
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
@@ -232,8 +232,9 @@ class ContextGovernor:
|
||||
def drop_orphan_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop tool results that have no matching assistant tool_call earlier in history."""
|
||||
"""Drop invalid tool results before history is sent back to providers."""
|
||||
declared: set[str] = set()
|
||||
fulfilled: set[str] = set()
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
@@ -243,10 +244,12 @@ class ContextGovernor:
|
||||
declared.add(str(tc["id"]))
|
||||
if role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid and str(tid) not in declared:
|
||||
tid_str = str(tid) if tid else ""
|
||||
if not tid_str or tid_str not in declared or tid_str in fulfilled:
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
continue
|
||||
fulfilled.add(tid_str)
|
||||
if updated is not None:
|
||||
updated.append(dict(msg))
|
||||
|
||||
@@ -425,9 +428,14 @@ class ContextGovernor:
|
||||
return system_messages + self._legal_history_tail(kept, non_system)
|
||||
|
||||
@staticmethod
|
||||
def _summary_for(message: dict[str, Any]) -> str:
|
||||
def _tool_result_compaction_message(message: dict[str, Any]) -> str:
|
||||
name = message.get("name", "tool")
|
||||
return f"[Prior {name} result compacted to fit context; the tool call already completed.]"
|
||||
return (
|
||||
f"Error: The previous {name} result was compacted to fit context because it was too "
|
||||
"large. Do not repeat the same call unchanged. Retry with a narrower path, query, "
|
||||
"range, or result limit, use another tool, or tell the user the task cannot fit in "
|
||||
"the available context."
|
||||
)
|
||||
|
||||
def _legal_history_tail(
|
||||
self,
|
||||
@@ -462,12 +470,12 @@ class ContextGovernor:
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
|
||||
continue
|
||||
summary = self._summary_for(msg)
|
||||
if msg.get("content") == summary:
|
||||
compaction_message = self._tool_result_compaction_message(msg)
|
||||
if msg.get("content") == compaction_message:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = summary
|
||||
updated[idx]["content"] = compaction_message
|
||||
return updated
|
||||
|
||||
def _inflight_compaction_candidates(
|
||||
@@ -500,4 +508,4 @@ class ContextGovernor:
|
||||
return primary + fallback
|
||||
|
||||
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
||||
messages[idx]["content"] = self._summary_for(messages[idx])
|
||||
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
|
||||
|
||||
@@ -90,6 +90,14 @@ class AgentHook:
|
||||
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||
pass
|
||||
|
||||
async def on_provider_tool_event(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
event: dict[str, Any],
|
||||
) -> None:
|
||||
"""Observe a provider-hosted tool lifecycle event."""
|
||||
pass
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
pass
|
||||
|
||||
@@ -192,6 +200,13 @@ class CompositeHook(AgentHook):
|
||||
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||
await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
|
||||
|
||||
async def on_provider_tool_event(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
event: dict[str, Any],
|
||||
) -> None:
|
||||
await self._for_each_hook_safe("on_provider_tool_event", context, event)
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
await self._for_each_hook_safe("before_execute_tools", context)
|
||||
|
||||
|
||||
@@ -28,20 +28,19 @@ from nanobot.agent.model_runtime import ModelRuntimeResolver
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||
from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.turn_delivery import (
|
||||
TurnDelivery,
|
||||
TurnDeliveryFactory,
|
||||
)
|
||||
from nanobot.agent.turn_delivery import TurnRoute as TurnRoute
|
||||
from nanobot.agent.turn_hooks import AgentTurnHookSpec, build_agent_turn_hook
|
||||
from nanobot.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.outbound_events import StreamedResponseEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import (
|
||||
RuntimeEventBus,
|
||||
@@ -59,6 +58,7 @@ from nanobot.runtime_context import (
|
||||
RuntimeContextProvider,
|
||||
append_runtime_context,
|
||||
resolve_runtime_context,
|
||||
runtime_context_blocks_from_metadata,
|
||||
)
|
||||
from nanobot.security.workspace_access import (
|
||||
WorkspaceScopeResolver,
|
||||
@@ -79,7 +79,12 @@ from nanobot.session.manager import (
|
||||
SessionManager,
|
||||
replay_max_messages_for_context,
|
||||
)
|
||||
from nanobot.session.model_selection import (
|
||||
SESSION_MODEL_PRESET_METADATA_KEY,
|
||||
model_preset_from_metadata,
|
||||
)
|
||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
||||
from nanobot.utils.cancellation import task_is_cancelling
|
||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
@@ -108,6 +113,11 @@ class TurnState(Enum):
|
||||
DONE = auto()
|
||||
|
||||
|
||||
class TurnKind(Enum):
|
||||
USER = auto()
|
||||
SYSTEM = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateTraceEntry:
|
||||
state: TurnState
|
||||
@@ -123,7 +133,9 @@ class TurnContext:
|
||||
session_key: str
|
||||
state: TurnState
|
||||
turn_id: str
|
||||
runtime: LLMRuntime
|
||||
runtime: LLMRuntime | None
|
||||
kind: TurnKind
|
||||
delivery: TurnDelivery
|
||||
original_user_text: str | None = None
|
||||
session: Session | None = None
|
||||
|
||||
@@ -137,8 +149,9 @@ class TurnContext:
|
||||
all_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
stop_reason: str = ""
|
||||
had_injections: bool = False
|
||||
streamed_content: bool = False
|
||||
|
||||
user_persisted_early: bool = False
|
||||
input_persisted_early: bool = False
|
||||
save_skip: int = 0
|
||||
|
||||
outbound: OutboundMessage | None = None
|
||||
@@ -147,6 +160,7 @@ class TurnContext:
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None
|
||||
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None
|
||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None
|
||||
|
||||
pending_queue: asyncio.Queue | None = None
|
||||
@@ -217,11 +231,7 @@ class AgentLoop:
|
||||
def llm_runtime(self) -> LLMRuntime:
|
||||
"""Resolve the immutable default used to admit the next turn."""
|
||||
previous = self.runtime_resolver.runtime
|
||||
try:
|
||||
runtime = self.runtime_resolver.current(refresh=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh model runtime")
|
||||
return previous
|
||||
runtime = self.runtime_resolver.admit()
|
||||
if (
|
||||
runtime.model != previous.model
|
||||
or runtime.model_preset != previous.model_preset
|
||||
@@ -278,9 +288,11 @@ class AgentLoop:
|
||||
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None = None,
|
||||
provider_signature: tuple[object, ...] | None = None,
|
||||
model_presets: dict[str, ModelPresetConfig] | None = None,
|
||||
preset_catalog_loader: preset_helpers.PresetCatalogLoader | None = None,
|
||||
model_preset: str | None = None,
|
||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||
runtime_events: RuntimeEventBus | None = None,
|
||||
turn_delivery_factory: TurnDeliveryFactory | None = None,
|
||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||
restart_mode: str = "auto",
|
||||
local_trigger_store: Any | None = None,
|
||||
@@ -290,8 +302,20 @@ class AgentLoop:
|
||||
_tc = tools_config or ToolsConfig()
|
||||
defaults = AgentDefaults()
|
||||
self.bus = bus
|
||||
self.runtime_events = runtime_events or RuntimeEventBus()
|
||||
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
||||
if turn_delivery_factory is not None:
|
||||
if turn_delivery_factory.bus is not bus:
|
||||
raise ValueError("turn delivery factory must use the agent message bus")
|
||||
if (
|
||||
runtime_events is not None
|
||||
and turn_delivery_factory.runtime_events is not runtime_events
|
||||
):
|
||||
raise ValueError("turn delivery factory must use the agent runtime event bus")
|
||||
self.turn_delivery_factory = turn_delivery_factory
|
||||
self.runtime_events = turn_delivery_factory.runtime_events
|
||||
else:
|
||||
self.runtime_events = runtime_events or RuntimeEventBus()
|
||||
self.turn_delivery_factory = TurnDeliveryFactory(bus, self.runtime_events)
|
||||
self.runtime_event_publisher = self.turn_delivery_factory.runtime_event_publisher
|
||||
self.channels_config = channels_config
|
||||
self.restart_mode = restart_mode
|
||||
self._runtime_model_publisher = runtime_model_publisher
|
||||
@@ -314,6 +338,8 @@ class AgentLoop:
|
||||
snapshot_signature=provider_signature,
|
||||
),
|
||||
model_presets=configured_presets,
|
||||
preset_catalog_loader=preset_catalog_loader,
|
||||
configured_default_preset=model_preset,
|
||||
provider_snapshot_loader=provider_snapshot_loader,
|
||||
preset_snapshot_loader=preset_snapshot_loader,
|
||||
)
|
||||
@@ -351,10 +377,12 @@ class AgentLoop:
|
||||
|
||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||
self.sessions = session_manager or SessionManager(workspace)
|
||||
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
|
||||
self.tools = ToolRegistry()
|
||||
# One file-read/write tracker per logical session. The tool registry is
|
||||
# shared by this loop, so tools resolve the active state via contextvars.
|
||||
self._file_state_store = FileStateStore()
|
||||
self._exec_session_manager = ExecSessionManager()
|
||||
self.runner = AgentRunner()
|
||||
self.subagents = SubagentManager(
|
||||
workspace=workspace,
|
||||
@@ -485,6 +513,47 @@ class AgentLoop:
|
||||
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
||||
self.subagents.max_iterations = self.max_iterations
|
||||
|
||||
def invalidate_runtime_config(self) -> None:
|
||||
"""Invalidate runtime config and notify clients to refresh its catalog."""
|
||||
self.runtime_resolver.invalidate()
|
||||
self._publish_runtime_selection(self.runtime_resolver.runtime)
|
||||
|
||||
def runtime_for_session(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
recover_removed: bool = True,
|
||||
) -> LLMRuntime:
|
||||
"""Resolve the immutable runtime selected by one session."""
|
||||
name = model_preset_from_metadata(session.metadata)
|
||||
if name is None:
|
||||
return self.llm_runtime()
|
||||
try:
|
||||
return self.runtime_resolver.resolve_preset(name)
|
||||
except KeyError:
|
||||
if not recover_removed or name in self.runtime_resolver.model_presets:
|
||||
raise
|
||||
logger.warning(
|
||||
"Session '{}' references removed model preset '{}'; falling back to default",
|
||||
session.key,
|
||||
name,
|
||||
)
|
||||
session.metadata.pop(SESSION_MODEL_PRESET_METADATA_KEY, None)
|
||||
self.sessions.save(session)
|
||||
return self.llm_runtime()
|
||||
|
||||
def set_session_model_preset(
|
||||
self,
|
||||
session_key: str,
|
||||
name: str,
|
||||
) -> LLMRuntime:
|
||||
"""Validate and persist one session's preset selection."""
|
||||
runtime = self.runtime_resolver.resolve_preset(name)
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = runtime.model_preset
|
||||
self.sessions.save(session)
|
||||
return runtime
|
||||
|
||||
def _publish_runtime_selection(
|
||||
self,
|
||||
runtime: LLMRuntime,
|
||||
@@ -540,6 +609,7 @@ class AgentLoop:
|
||||
bus=self.bus,
|
||||
subagent_manager=self.subagents,
|
||||
cron_service=self.cron_service,
|
||||
exec_session_manager=self._exec_session_manager,
|
||||
sessions=self.sessions,
|
||||
provider_snapshot_loader=provider_snapshot_loader,
|
||||
image_generation_provider_configs=self._image_generation_provider_configs,
|
||||
@@ -571,34 +641,6 @@ class AgentLoop:
|
||||
if provider not in self._runtime_context_providers:
|
||||
self._runtime_context_providers.append(provider)
|
||||
|
||||
@staticmethod
|
||||
def _runtime_chat_id(msg: InboundMessage) -> str:
|
||||
"""Return the chat id shown in runtime metadata for the model."""
|
||||
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
||||
|
||||
async def _build_bus_progress_callback(
|
||||
self, msg: InboundMessage
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
"""Build a progress callback that publishes to the message bus."""
|
||||
return build_bus_progress_callback(self.bus, msg)
|
||||
|
||||
async def _build_retry_wait_callback(
|
||||
self, msg: InboundMessage
|
||||
) -> Callable[[str], Awaitable[None]]:
|
||||
"""Build a retry-wait callback that publishes to the message bus."""
|
||||
|
||||
async def _on_retry_wait(content: str) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
event=RetryWaitEvent(content=content),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
return _on_retry_wait
|
||||
|
||||
def _runtime_events(self) -> RuntimeEventPublisher:
|
||||
return ensure_runtime_event_publisher(self)
|
||||
|
||||
@@ -656,38 +698,39 @@ class AgentLoop:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_initial_messages(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
session: Session,
|
||||
history: list[dict[str, Any]],
|
||||
pending_summary: str | None,
|
||||
include_memory_recent_history: bool = True,
|
||||
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
def _build_initial_messages(self, ctx: TurnContext) -> list[dict[str, Any]]:
|
||||
"""Build the initial message list for the LLM turn."""
|
||||
scope = self.workspace_scopes.for_message(msg, session.metadata)
|
||||
assert ctx.session is not None
|
||||
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
||||
return self.context.build_messages(
|
||||
history=history,
|
||||
current_message=msg.content,
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel,
|
||||
chat_id=self._runtime_chat_id(msg),
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending_summary,
|
||||
session_metadata=session.metadata,
|
||||
history=ctx.history,
|
||||
current_message=ctx.msg.content,
|
||||
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
||||
channel=ctx.delivery.route.channel,
|
||||
chat_id=str(
|
||||
ctx.msg.metadata.get("context_chat_id") or ctx.delivery.route.chat_id
|
||||
),
|
||||
current_role="user",
|
||||
sender_id=ctx.msg.sender_id,
|
||||
session_summary=ctx.pending_summary,
|
||||
session_metadata=ctx.session.metadata,
|
||||
workspace=scope.project_path,
|
||||
runtime_context_blocks=runtime_context_blocks,
|
||||
include_memory_recent_history=include_memory_recent_history,
|
||||
session_key=session.key,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
include_memory_recent_history=not ctx.ephemeral,
|
||||
session_key=ctx.session.key,
|
||||
unified_session=self._unified_session,
|
||||
)
|
||||
|
||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
||||
assert ctx.session is not None
|
||||
scope = self.workspace_scopes.for_turn(
|
||||
channel=ctx.delivery.route.channel,
|
||||
message_metadata=ctx.msg.metadata,
|
||||
session_metadata=ctx.session.metadata,
|
||||
)
|
||||
return RequestContext(
|
||||
channel=ctx.msg.channel,
|
||||
chat_id=ctx.msg.chat_id,
|
||||
channel=ctx.delivery.route.channel,
|
||||
chat_id=ctx.delivery.route.chat_id,
|
||||
message_id=ctx.msg.metadata.get("message_id"),
|
||||
session_key=ctx.session_key,
|
||||
original_user_text=ctx.original_user_text,
|
||||
@@ -708,7 +751,9 @@ class AgentLoop:
|
||||
*self._runtime_context_providers,
|
||||
]
|
||||
assert ctx.request_context is not None
|
||||
return await resolve_runtime_context(providers, ctx.request_context)
|
||||
blocks = runtime_context_blocks_from_metadata(ctx.request_context.metadata)
|
||||
blocks.extend(await resolve_runtime_context(providers, ctx.request_context))
|
||||
return blocks
|
||||
|
||||
async def _dispatch_command_inline(
|
||||
self,
|
||||
@@ -988,15 +1033,18 @@ class AgentLoop:
|
||||
except asyncio.TimeoutError:
|
||||
self.auto_compact.check_expired(
|
||||
self._schedule_background,
|
||||
self.llm_runtime,
|
||||
self.runtime_for_session,
|
||||
active_session_keys=self._pending_queues.keys(),
|
||||
)
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
# Preserve real task cancellation so shutdown can complete cleanly.
|
||||
# Only ignore non-task CancelledError signals that may leak from integrations.
|
||||
if not self._running or asyncio.current_task().cancelling():
|
||||
if not self._running or task_is_cancelling():
|
||||
raise
|
||||
logger.warning(
|
||||
"Ignoring leaked CancelledError while consuming inbound messages"
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning("Error consuming inbound message: {}, continuing...", e)
|
||||
@@ -1081,6 +1129,7 @@ class AgentLoop:
|
||||
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||
gate = self._concurrency_gate or nullcontext()
|
||||
|
||||
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
|
||||
pending: asyncio.Queue | None = None
|
||||
try:
|
||||
async with lock, gate:
|
||||
@@ -1089,66 +1138,23 @@ class AgentLoop:
|
||||
pending = asyncio.Queue(maxsize=20)
|
||||
self._pending_queues[session_key] = pending
|
||||
try:
|
||||
on_stream = on_stream_end = None
|
||||
if msg.metadata.get("_wants_stream"):
|
||||
# Split one answer into distinct stream segments.
|
||||
stream_base_id = f"{msg.session_key}:{time.time_ns()}"
|
||||
stream_segment = 0
|
||||
|
||||
def _current_stream_id() -> str:
|
||||
return f"{stream_base_id}:{stream_segment}"
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
event=StreamDeltaEvent(
|
||||
content=delta,
|
||||
stream_id=_current_stream_id(),
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
async def on_stream_end(*, resuming: bool = False) -> None:
|
||||
nonlocal stream_segment
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
event=StreamEndEvent(
|
||||
stream_id=_current_stream_id(),
|
||||
resuming=resuming,
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
stream_segment += 1
|
||||
|
||||
delivery = self.turn_delivery_factory.create(
|
||||
msg,
|
||||
session_key,
|
||||
enable_stream=True,
|
||||
)
|
||||
response = await self._process_message(
|
||||
msg, on_stream=on_stream, on_stream_end=on_stream_end,
|
||||
msg,
|
||||
on_stream=delivery.on_stream,
|
||||
on_stream_end=delivery.on_stream_end,
|
||||
pending_queue=pending,
|
||||
delivery=delivery,
|
||||
)
|
||||
completed_channel = msg.channel
|
||||
completed_chat_id = msg.chat_id
|
||||
if response is not None:
|
||||
await self.bus.publish_outbound(response)
|
||||
completed_channel = response.channel
|
||||
completed_chat_id = response.chat_id
|
||||
elif msg.channel == "cli":
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="", metadata=msg.metadata or {},
|
||||
))
|
||||
continuing = turn_continuation.internal_continuation_pending(msg.metadata)
|
||||
if not continuing:
|
||||
await self._runtime_events().turn_completed(
|
||||
channel=completed_channel,
|
||||
chat_id=completed_chat_id,
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
await delivery.complete(
|
||||
response,
|
||||
publish_completion=not continuing,
|
||||
)
|
||||
for _, coordinator in self._automation_turn_coordinators:
|
||||
coordinator.complete(msg, response=response)
|
||||
except asyncio.CancelledError:
|
||||
@@ -1181,17 +1187,11 @@ class AgentLoop:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Error processing message for session {}", session_key)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="Sorry, I encountered an error.",
|
||||
))
|
||||
if not turn_continuation.internal_continuation_pending(msg.metadata):
|
||||
await self._runtime_events().turn_completed(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
await delivery.fail(
|
||||
publish_completion=not turn_continuation.internal_continuation_pending(
|
||||
msg.metadata
|
||||
)
|
||||
)
|
||||
for _, coordinator in self._automation_turn_coordinators:
|
||||
coordinator.complete(msg, error=exc)
|
||||
finally:
|
||||
@@ -1220,25 +1220,33 @@ class AgentLoop:
|
||||
leftover, session_key,
|
||||
)
|
||||
if not turn_continuation.internal_continuation_pending(msg.metadata):
|
||||
await self._runtime_events().run_status_changed(
|
||||
msg, session_key, "idle"
|
||||
)
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
await delivery.idle()
|
||||
await self._publish_next_deferred_automation_turn(session_key)
|
||||
finally:
|
||||
if pending is None:
|
||||
await self._runtime_events().run_status_changed(
|
||||
msg, session_key, "idle"
|
||||
)
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
await delivery.idle()
|
||||
await self._publish_next_deferred_automation_turn(session_key)
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
"""Drain pending background archives, then close MCP connections."""
|
||||
"""Drain background work, stop exec sessions, then close MCP connections."""
|
||||
if self._background_tasks:
|
||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||
self._background_tasks.clear()
|
||||
await agent_context.close_mcp(self)
|
||||
errors: list[BaseException] = []
|
||||
cleanup_steps = (
|
||||
self.subagents.close,
|
||||
self._exec_session_manager.close_all,
|
||||
lambda: agent_context.close_mcp(self),
|
||||
)
|
||||
for cleanup in cleanup_steps:
|
||||
try:
|
||||
await cleanup()
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
if len(errors) == 1:
|
||||
raise errors[0]
|
||||
if errors:
|
||||
raise BaseExceptionGroup("failed to close agent resources", errors)
|
||||
|
||||
def _schedule_background(self, coro) -> None:
|
||||
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
||||
@@ -1251,110 +1259,6 @@ class AgentLoop:
|
||||
self._running = False
|
||||
logger.info("Agent loop stopping")
|
||||
|
||||
async def _process_system_message(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
session_key: str | None = None,
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
pending_queue: asyncio.Queue | None = None,
|
||||
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
"""Process a system inbound message (e.g. subagent announce)."""
|
||||
channel, chat_id = (
|
||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||
)
|
||||
logger.info("Processing system message from {}", msg.sender_id)
|
||||
key = msg.session_key_override or f"{channel}:{chat_id}"
|
||||
session = self.sessions.get_or_create(key)
|
||||
self._runtime_events().record_turn_runtime(key, runtime)
|
||||
if self._restore_runtime_checkpoint(session):
|
||||
self.sessions.save(session)
|
||||
if self._restore_pending_user_turn(session):
|
||||
self.sessions.save(session)
|
||||
|
||||
session, pending = self.auto_compact.prepare_session(session, key)
|
||||
if pending:
|
||||
logger.info("Memory compact triggered for session {}", key)
|
||||
|
||||
await self.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
replay_max_messages=replay_max_messages_for_context(
|
||||
runtime.context_window_tokens
|
||||
),
|
||||
)
|
||||
is_subagent = msg.sender_id == "subagent"
|
||||
if is_subagent and self._persist_subagent_followup(session, msg):
|
||||
logger.debug("Subagent result persisted for session {}", key)
|
||||
self.sessions.save(session)
|
||||
current_role = "assistant" if is_subagent else "user"
|
||||
_hist_kwargs: dict[str, Any] = {
|
||||
"max_messages": replay_max_messages_for_context(runtime.context_window_tokens),
|
||||
"max_tokens": self._replay_token_budget(runtime),
|
||||
"extend_to_user": is_subagent,
|
||||
}
|
||||
history = session.get_history(**_hist_kwargs)
|
||||
workspace_scope = self.workspace_scopes.for_message(msg, session.metadata)
|
||||
|
||||
messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message="" if is_subagent else msg.content,
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
current_role=current_role,
|
||||
sender_id=msg.sender_id,
|
||||
session_summary=pending,
|
||||
session_metadata=session.metadata,
|
||||
workspace=workspace_scope.project_path,
|
||||
session_key=key,
|
||||
unified_session=self._unified_session,
|
||||
)
|
||||
t_wall = time.time()
|
||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||
messages, session=session, channel=channel, chat_id=chat_id,
|
||||
runtime=runtime,
|
||||
message_id=msg.metadata.get("message_id"),
|
||||
metadata=msg.metadata,
|
||||
session_key=key,
|
||||
original_user_text=None,
|
||||
pending_queue=pending_queue,
|
||||
hook_factories=hook_factories,
|
||||
)
|
||||
wall_done = time.time()
|
||||
latency_ms = max(0, int((wall_done - t_wall) * 1000))
|
||||
self._save_turn(session, all_msgs, 1 + len(history), turn_latency_ms=latency_ms)
|
||||
self._runtime_events().record_turn_latency(key, latency_ms)
|
||||
session.enforce_file_cap(
|
||||
on_archive=partial(self.context.memory.raw_archive, session_key=key)
|
||||
)
|
||||
self._clear_runtime_checkpoint(session)
|
||||
self.sessions.save(session)
|
||||
self._schedule_background(
|
||||
self.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
replay_max_messages=replay_max_messages_for_context(
|
||||
runtime.context_window_tokens
|
||||
),
|
||||
)
|
||||
)
|
||||
content = final_content or "Background task completed."
|
||||
outbound_metadata: dict[str, Any] = {}
|
||||
if channel == "slack" and key.startswith("slack:") and key.count(":") >= 2:
|
||||
outbound_metadata["slack"] = {"thread_ts": key.split(":", 2)[2]}
|
||||
if origin_message_id := msg.metadata.get("origin_message_id"):
|
||||
outbound_metadata["origin_message_id"] = origin_message_id
|
||||
return OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
metadata=outbound_metadata,
|
||||
)
|
||||
|
||||
async def _process_message(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
@@ -1369,24 +1273,26 @@ class AgentLoop:
|
||||
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||
tools: ToolRegistry | None = None,
|
||||
runtime: LLMRuntime | None = None,
|
||||
delivery: TurnDelivery | None = None,
|
||||
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
"""Process a single inbound message and return the response."""
|
||||
if runtime is None:
|
||||
runtime = self.llm_runtime()
|
||||
|
||||
if msg.channel == "system":
|
||||
return await self._process_system_message(
|
||||
msg,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
on_progress=on_progress,
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
pending_queue=pending_queue,
|
||||
hook_factories=hook_factories,
|
||||
kind = TurnKind.SYSTEM if msg.channel == "system" else TurnKind.USER
|
||||
if kind is TurnKind.SYSTEM:
|
||||
destination = (
|
||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||
)
|
||||
|
||||
key = session_key or msg.session_key
|
||||
key = session_key or msg.session_key_override or f"{destination[0]}:{destination[1]}"
|
||||
else:
|
||||
key = session_key or msg.session_key
|
||||
if delivery is None:
|
||||
delivery = self.turn_delivery_factory.create(msg, key)
|
||||
elif delivery.session_key != key:
|
||||
raise ValueError("turn delivery session does not match the processing session")
|
||||
if on_stream is None:
|
||||
on_stream = delivery.on_stream
|
||||
if on_stream_end is None:
|
||||
on_stream_end = delivery.on_stream_end
|
||||
t0 = time.time()
|
||||
ctx = TurnContext(
|
||||
msg=msg,
|
||||
@@ -1395,9 +1301,12 @@ class AgentLoop:
|
||||
state=TurnState.RESTORE,
|
||||
turn_id=f"{key}:{time.time_ns()}",
|
||||
runtime=runtime,
|
||||
kind=kind,
|
||||
delivery=delivery,
|
||||
original_user_text=(
|
||||
None
|
||||
if turn_continuation.internal_continuation_inbound(msg.metadata)
|
||||
if kind is TurnKind.SYSTEM
|
||||
or turn_continuation.internal_continuation_inbound(msg.metadata)
|
||||
else msg.content
|
||||
),
|
||||
turn_wall_started_at=t0,
|
||||
@@ -1407,6 +1316,7 @@ class AgentLoop:
|
||||
on_progress=on_progress,
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
on_runtime_admitted=on_runtime_admitted,
|
||||
pending_queue=pending_queue,
|
||||
ephemeral=ephemeral,
|
||||
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
||||
@@ -1414,6 +1324,29 @@ class AgentLoop:
|
||||
hook_factories=list(hook_factories or []),
|
||||
tools=tools,
|
||||
)
|
||||
# A streaming callback may be present even when the final text comes from a
|
||||
# non-streaming recovery. Only the last completed segment can suppress the
|
||||
# regular outbound message.
|
||||
if ctx.on_stream is not None:
|
||||
stream_callback = ctx.on_stream
|
||||
stream_end_callback = ctx.on_stream_end
|
||||
segment_streamed_content = False
|
||||
|
||||
async def _tracked_stream(delta: str) -> None:
|
||||
nonlocal segment_streamed_content
|
||||
if delta:
|
||||
segment_streamed_content = True
|
||||
await stream_callback(delta)
|
||||
|
||||
async def _tracked_stream_end(*, resuming: bool = False) -> None:
|
||||
nonlocal segment_streamed_content
|
||||
ctx.streamed_content = segment_streamed_content
|
||||
segment_streamed_content = False
|
||||
if stream_end_callback is not None:
|
||||
await stream_end_callback(resuming=resuming)
|
||||
|
||||
ctx.on_stream = _tracked_stream
|
||||
ctx.on_stream_end = _tracked_stream_end
|
||||
|
||||
while ctx.state is not TurnState.DONE:
|
||||
handler_name = f"_state_{ctx.state.name.lower()}"
|
||||
@@ -1476,7 +1409,7 @@ class AgentLoop:
|
||||
all_msgs: list[dict[str, Any]],
|
||||
stop_reason: str,
|
||||
had_injections: bool,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None,
|
||||
streamed_content: bool,
|
||||
*,
|
||||
turn_latency_ms: int | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
@@ -1491,7 +1424,7 @@ class AgentLoop:
|
||||
|
||||
event = None
|
||||
meta = dict(msg.metadata or {})
|
||||
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
|
||||
if streamed_content and stop_reason not in {"error", "tool_error"}:
|
||||
event = StreamedResponseEvent()
|
||||
if turn_latency_ms is not None:
|
||||
meta["latency_ms"] = int(turn_latency_ms)
|
||||
@@ -1508,20 +1441,24 @@ class AgentLoop:
|
||||
"""Restore checkpoint / pending user turn; extract documents."""
|
||||
msg = ctx.msg
|
||||
|
||||
if msg.media:
|
||||
if ctx.kind is TurnKind.USER and msg.media:
|
||||
new_content, image_only = self._prepare_message_media(msg.content, msg.media)
|
||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only)
|
||||
msg = ctx.msg
|
||||
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
if ctx.kind is TurnKind.SYSTEM:
|
||||
logger.info("Processing system message from {}", msg.sender_id)
|
||||
else:
|
||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
|
||||
# Session is already fetched by the caller (_process_message) but
|
||||
# ensure it exists in case this handler is invoked independently.
|
||||
if ctx.session is None:
|
||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||
await self._runtime_events().session_turn_started(msg, ctx.session_key)
|
||||
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
||||
await ctx.delivery.started()
|
||||
if ctx.kind is TurnKind.USER:
|
||||
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
||||
|
||||
if self._restore_runtime_checkpoint(ctx.session):
|
||||
self.sessions.save(ctx.session)
|
||||
@@ -1546,6 +1483,8 @@ class AgentLoop:
|
||||
return "ok"
|
||||
|
||||
async def _state_command(self, ctx: TurnContext) -> str:
|
||||
if ctx.kind is TurnKind.SYSTEM:
|
||||
return "dispatch"
|
||||
raw = ctx.msg.content.strip()
|
||||
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
||||
is_user_turn = (
|
||||
@@ -1573,7 +1512,7 @@ class AgentLoop:
|
||||
# them out of LLM context. /new is excluded because it
|
||||
# intentionally clears the session.
|
||||
if cmd_ctx.raw.lower() != "/new":
|
||||
ctx.user_persisted_early = self._persist_user_message_early(
|
||||
ctx.input_persisted_early = self._persist_user_message_early(
|
||||
ctx.msg, ctx.session, _command=True
|
||||
)
|
||||
ctx.session.add_message(
|
||||
@@ -1585,62 +1524,67 @@ class AgentLoop:
|
||||
return "dispatch"
|
||||
|
||||
async def _state_build(self, ctx: TurnContext) -> str:
|
||||
runtime = ctx.runtime
|
||||
if runtime is None:
|
||||
runtime = self.runtime_for_session(ctx.session)
|
||||
ctx.runtime = runtime
|
||||
if ctx.on_runtime_admitted is not None:
|
||||
await ctx.on_runtime_admitted(runtime)
|
||||
replay_max_messages = replay_max_messages_for_context(
|
||||
ctx.runtime.context_window_tokens
|
||||
runtime.context_window_tokens
|
||||
)
|
||||
if not ctx.ephemeral:
|
||||
await self.consolidator.maybe_consolidate_by_tokens(
|
||||
ctx.session,
|
||||
runtime=ctx.runtime,
|
||||
runtime=runtime,
|
||||
replay_max_messages=replay_max_messages,
|
||||
)
|
||||
if message_tool := self.tools.get("message"):
|
||||
is_subagent = ctx.kind is TurnKind.SYSTEM and ctx.msg.sender_id == "subagent"
|
||||
|
||||
if ctx.kind is TurnKind.USER and (message_tool := self.tools.get("message")):
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.start_turn()
|
||||
|
||||
_hist_kwargs: dict[str, Any] = {
|
||||
"max_messages": replay_max_messages,
|
||||
"max_tokens": self._replay_token_budget(ctx.runtime),
|
||||
"extend_to_user": False,
|
||||
"max_tokens": self._replay_token_budget(runtime),
|
||||
"extend_to_user": is_subagent,
|
||||
}
|
||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||
self._runtime_events().record_turn_runtime(
|
||||
ctx.session_key,
|
||||
ctx.runtime,
|
||||
)
|
||||
if is_subagent:
|
||||
# Keep the durable internal delivery as an assistant record, but
|
||||
# present this completion to the model as fresh follow-up input.
|
||||
# Providers without assistant-prefill support drop trailing
|
||||
# assistant messages, so using the persisted record as the current
|
||||
# prompt would hide an independently dispatched subagent result.
|
||||
if self._persist_subagent_followup(ctx.session, ctx.msg):
|
||||
logger.debug("Subagent result persisted for session {}", ctx.session_key)
|
||||
self.sessions.save(ctx.session)
|
||||
ctx.input_persisted_early = True
|
||||
ctx.delivery.record_runtime(ctx.runtime)
|
||||
|
||||
ctx.request_context = self._request_context_for_turn(ctx)
|
||||
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
||||
ctx.initial_messages = self._build_initial_messages(
|
||||
ctx.msg,
|
||||
ctx.session,
|
||||
ctx.history,
|
||||
ctx.pending_summary,
|
||||
include_memory_recent_history=not ctx.ephemeral,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
)
|
||||
ctx.user_persisted_early = self._persist_user_message_early(
|
||||
ctx.msg,
|
||||
ctx.session,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
)
|
||||
if ctx.kind is TurnKind.USER:
|
||||
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
||||
ctx.initial_messages = self._build_initial_messages(ctx)
|
||||
if ctx.kind is TurnKind.USER:
|
||||
ctx.input_persisted_early = self._persist_user_message_early(
|
||||
ctx.msg,
|
||||
ctx.session,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
)
|
||||
|
||||
if ctx.on_progress is None:
|
||||
ctx.on_progress = await self._build_bus_progress_callback(ctx.msg)
|
||||
ctx.on_progress = ctx.delivery.progress_callback()
|
||||
if ctx.on_retry_wait is None:
|
||||
ctx.on_retry_wait = await self._build_retry_wait_callback(ctx.msg)
|
||||
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
||||
|
||||
return "ok"
|
||||
|
||||
async def _state_run(self, ctx: TurnContext) -> str:
|
||||
if ctx.visible_run_started_at is None:
|
||||
ctx.visible_run_started_at = time.time()
|
||||
await self._runtime_events().run_status_changed(
|
||||
ctx.msg,
|
||||
ctx.session_key,
|
||||
"running",
|
||||
started_at=ctx.visible_run_started_at,
|
||||
)
|
||||
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
||||
result = await self._run_agent_loop(
|
||||
ctx.initial_messages,
|
||||
runtime=ctx.runtime,
|
||||
@@ -1649,8 +1593,8 @@ class AgentLoop:
|
||||
on_stream_end=ctx.on_stream_end,
|
||||
on_retry_wait=ctx.on_retry_wait,
|
||||
session=ctx.session,
|
||||
channel=ctx.msg.channel,
|
||||
chat_id=ctx.msg.chat_id,
|
||||
channel=ctx.delivery.route.channel,
|
||||
chat_id=ctx.delivery.route.chat_id,
|
||||
message_id=ctx.msg.metadata.get("message_id"),
|
||||
metadata=ctx.msg.metadata,
|
||||
session_key=ctx.session_key,
|
||||
@@ -1670,21 +1614,26 @@ class AgentLoop:
|
||||
ctx.all_messages = all_msgs
|
||||
ctx.stop_reason = stop_reason
|
||||
ctx.had_injections = had_injections
|
||||
await turn_continuation.maybe_continue_turn(ctx)
|
||||
if ctx.kind is TurnKind.USER:
|
||||
await turn_continuation.maybe_continue_turn(ctx)
|
||||
return "ok"
|
||||
|
||||
async def _state_save(self, ctx: TurnContext) -> str:
|
||||
turn_continuation.prepare_save_boundary(ctx)
|
||||
|
||||
if (
|
||||
(ctx.final_content is None or not ctx.final_content.strip())
|
||||
ctx.kind is TurnKind.USER
|
||||
and (ctx.final_content is None or not ctx.final_content.strip())
|
||||
and not ctx.suppress_response
|
||||
):
|
||||
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
latency_started_at = (
|
||||
ctx.visible_run_started_at
|
||||
if turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
|
||||
if (
|
||||
ctx.kind is TurnKind.SYSTEM
|
||||
or turn_continuation.internal_continuation_inbound(ctx.msg.metadata)
|
||||
)
|
||||
and ctx.visible_run_started_at is not None
|
||||
else ctx.turn_wall_started_at
|
||||
)
|
||||
@@ -1693,10 +1642,7 @@ class AgentLoop:
|
||||
ctx.session, ctx.all_messages, ctx.save_skip,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
)
|
||||
self._runtime_events().record_turn_latency(
|
||||
ctx.session_key,
|
||||
ctx.turn_latency_ms,
|
||||
)
|
||||
ctx.delivery.record_latency(ctx.turn_latency_ms)
|
||||
if not ctx.ephemeral:
|
||||
ctx.session.enforce_file_cap(
|
||||
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
||||
@@ -1719,13 +1665,21 @@ class AgentLoop:
|
||||
if ctx.suppress_response:
|
||||
ctx.outbound = None
|
||||
return "ok"
|
||||
if ctx.kind is TurnKind.SYSTEM:
|
||||
ctx.outbound = ctx.delivery.background_response(
|
||||
ctx.final_content,
|
||||
stop_reason=ctx.stop_reason,
|
||||
streamed=ctx.streamed_content,
|
||||
latency_ms=ctx.turn_latency_ms,
|
||||
)
|
||||
return "ok"
|
||||
ctx.outbound = self._assemble_outbound(
|
||||
ctx.msg,
|
||||
ctx.final_content,
|
||||
ctx.all_messages,
|
||||
ctx.stop_reason,
|
||||
ctx.had_injections,
|
||||
ctx.on_stream,
|
||||
ctx.streamed_content,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
)
|
||||
if ctx.ephemeral and ctx.outbound is not None:
|
||||
@@ -1781,6 +1735,11 @@ class AgentLoop:
|
||||
for tc in m.get("tool_calls") or []
|
||||
if isinstance(tc, dict) and tc.get("id")
|
||||
}
|
||||
fulfilled_tool_call_ids = {
|
||||
str(m["tool_call_id"])
|
||||
for m in session.messages
|
||||
if m.get("role") == "tool" and m.get("tool_call_id")
|
||||
}
|
||||
last_assistant_idx: int | None = None
|
||||
for m in messages[skip:]:
|
||||
entry = dict(m)
|
||||
@@ -1795,14 +1754,20 @@ class AgentLoop:
|
||||
continue # skip empty assistant messages — they poison session context
|
||||
if role == "tool":
|
||||
tool_call_id = entry.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) not in declared_tool_call_ids:
|
||||
tool_call_id_str = str(tool_call_id) if tool_call_id else ""
|
||||
if (
|
||||
not tool_call_id_str
|
||||
or tool_call_id_str not in declared_tool_call_ids
|
||||
or tool_call_id_str in fulfilled_tool_call_ids
|
||||
):
|
||||
# Undeclared tool results corrupt future provider requests.
|
||||
logger.warning(
|
||||
"Dropping orphaned tool result {} from session {} during persistence",
|
||||
tool_call_id or "(missing id)",
|
||||
"Dropping invalid tool result {} from session {} during persistence",
|
||||
tool_call_id_str or "(missing id)",
|
||||
session.key,
|
||||
)
|
||||
continue
|
||||
fulfilled_tool_call_ids.add(tool_call_id_str)
|
||||
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
|
||||
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
|
||||
elif isinstance(content, list):
|
||||
@@ -1977,8 +1942,11 @@ class AgentLoop:
|
||||
tools: ToolRegistry | None = None,
|
||||
persist_user_message: bool = True,
|
||||
runtime: LLMRuntime | None = None,
|
||||
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
"""Process a message directly and return the outbound payload."""
|
||||
"""Process an external message directly and return the outbound payload."""
|
||||
if channel == "system":
|
||||
raise ValueError("channel 'system' is reserved for internal messages")
|
||||
await self._connect_mcp()
|
||||
metadata: dict[str, Any] = {}
|
||||
if not persist_user_message:
|
||||
@@ -2008,6 +1976,8 @@ class AgentLoop:
|
||||
kwargs["tools"] = tools
|
||||
if runtime is not None:
|
||||
kwargs["runtime"] = runtime
|
||||
if on_runtime_admitted is not None:
|
||||
kwargs["on_runtime_admitted"] = on_runtime_admitted
|
||||
return await self._process_message(
|
||||
msg,
|
||||
**kwargs,
|
||||
|
||||
@@ -29,6 +29,12 @@ from nanobot.utils.helpers import (
|
||||
truncate_text_to_tokens,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.workspace_prompts import (
|
||||
WORKSPACE_PROMPT_MAX_CHARS,
|
||||
has_workspace_prompt_override,
|
||||
load_workspace_prompt_override,
|
||||
workspace_prompt_file,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
@@ -492,14 +498,10 @@ class MemoryStore:
|
||||
|
||||
@property
|
||||
def dream_prompt_file(self) -> Path:
|
||||
return self.workspace / "prompts" / "dream.md"
|
||||
return workspace_prompt_file(self.workspace, "dream")
|
||||
|
||||
def has_dream_prompt_override(self) -> bool:
|
||||
with suppress(OSError):
|
||||
return self.dream_prompt_file.is_file() and bool(
|
||||
self.dream_prompt_file.read_text(encoding="utf-8").strip()
|
||||
)
|
||||
return False
|
||||
return has_workspace_prompt_override(self.dream_prompt_file)
|
||||
|
||||
@staticmethod
|
||||
def default_dream_prompt() -> str:
|
||||
@@ -512,20 +514,19 @@ class MemoryStore:
|
||||
)
|
||||
|
||||
def _dream_template(self) -> str:
|
||||
with suppress(OSError):
|
||||
text = self.dream_prompt_file.read_text(encoding="utf-8")
|
||||
if text.strip():
|
||||
text = text.rstrip()
|
||||
if len(text) > _DREAM_PROMPT_MAX_CHARS:
|
||||
if not self._dream_prompt_oversize_logged:
|
||||
self._dream_prompt_oversize_logged = True
|
||||
logger.warning(
|
||||
"workspace Dream prompt exceeds {} chars ({}); truncating. "
|
||||
"Further occurrences suppressed.",
|
||||
_DREAM_PROMPT_MAX_CHARS, len(text),
|
||||
)
|
||||
return truncate_text(text, _DREAM_PROMPT_MAX_CHARS)
|
||||
return text
|
||||
text, original_chars = load_workspace_prompt_override(self.dream_prompt_file)
|
||||
if text is not None:
|
||||
if (
|
||||
original_chars > WORKSPACE_PROMPT_MAX_CHARS
|
||||
and not self._dream_prompt_oversize_logged
|
||||
):
|
||||
self._dream_prompt_oversize_logged = True
|
||||
logger.warning(
|
||||
"workspace Dream prompt exceeds {} chars ({}); truncating. "
|
||||
"Further occurrences suppressed.",
|
||||
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
|
||||
)
|
||||
return text
|
||||
return self.default_dream_prompt()
|
||||
|
||||
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
||||
@@ -734,7 +735,6 @@ class MemoryStore:
|
||||
# that catches any new caller that forgot to set its own cap.
|
||||
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
|
||||
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
|
||||
_DREAM_PROMPT_MAX_CHARS = 32_000 # workspace-local Dream prompt override
|
||||
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
||||
|
||||
|
||||
@@ -941,18 +941,19 @@ class Consolidator:
|
||||
messages_to_summarize = public_history_messages(
|
||||
summary_messages if summary_messages is not None else messages
|
||||
)
|
||||
formatted = MemoryStore._format_messages(messages_to_summarize)
|
||||
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
||||
system_prompt = render_template(
|
||||
"agent/consolidator_archive.md",
|
||||
strip=True,
|
||||
)
|
||||
try:
|
||||
formatted = MemoryStore._format_messages(messages_to_summarize)
|
||||
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
||||
response = await runtime.provider.chat_with_retry(
|
||||
model=runtime.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": render_template(
|
||||
"agent/consolidator_archive.md",
|
||||
strip=True,
|
||||
),
|
||||
"content": system_prompt,
|
||||
},
|
||||
{"role": "user", "content": formatted},
|
||||
],
|
||||
@@ -962,19 +963,21 @@ class Consolidator:
|
||||
max_tokens=runtime.generation.max_tokens,
|
||||
reasoning_effort=runtime.generation.reasoning_effort,
|
||||
)
|
||||
if response.finish_reason == "error":
|
||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||
summary = response.content or "[no summary]"
|
||||
self.store.append_history(
|
||||
summary,
|
||||
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||
session_key=session_key,
|
||||
)
|
||||
return summary
|
||||
except Exception:
|
||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
||||
logger.warning("Consolidation provider call failed, raw-dumping to history")
|
||||
self.store.raw_archive(messages, session_key=session_key)
|
||||
return None
|
||||
if response.finish_reason == "error":
|
||||
logger.warning("Consolidation provider returned an error, raw-dumping to history")
|
||||
self.store.raw_archive(messages, session_key=session_key)
|
||||
return None
|
||||
summary = response.content or "[no summary]"
|
||||
self.store.append_history(
|
||||
summary,
|
||||
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||
session_key=session_key,
|
||||
)
|
||||
return summary
|
||||
|
||||
async def maybe_consolidate_by_tokens(
|
||||
self,
|
||||
@@ -1007,14 +1010,10 @@ class Consolidator:
|
||||
replay_max_messages,
|
||||
runtime=runtime,
|
||||
)
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Token estimation failed for {}", session.key)
|
||||
estimated, source = 0, "error"
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
if estimated <= 0:
|
||||
self._persist_last_summary(session, last_summary)
|
||||
return
|
||||
@@ -1077,14 +1076,10 @@ class Consolidator:
|
||||
# the next invocation can retry a fresh chunk.
|
||||
break
|
||||
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Token estimation failed for {}", session.key)
|
||||
estimated, source = 0, "error"
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
if estimated <= 0:
|
||||
break
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
@@ -10,16 +12,31 @@ from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
|
||||
|
||||
PresetSnapshotLoader = Callable[[str], ProviderSnapshot]
|
||||
PresetCatalogLoader = Callable[[], Mapping[str, ModelPresetConfig]]
|
||||
|
||||
|
||||
def default_selection_signature(signature: tuple[object, ...] | None) -> tuple[object, ...] | None:
|
||||
return signature[:2] if signature else None
|
||||
def default_selection_signature(
|
||||
signature: tuple[object, ...] | None,
|
||||
model_preset: str | None = None,
|
||||
) -> tuple[object, ...] | None:
|
||||
return (model_preset, *signature[:2]) if signature else None
|
||||
|
||||
|
||||
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
|
||||
return {**config.model_presets, "default": config.resolve_default_preset()}
|
||||
|
||||
|
||||
def load_model_preset_catalog(
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, ModelPresetConfig]:
|
||||
"""Load the current preset catalog from the configured file."""
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
return configured_model_presets(
|
||||
resolve_config_env_vars(load_config(config_path)),
|
||||
)
|
||||
|
||||
|
||||
def make_preset_snapshot_loader(
|
||||
config: Any,
|
||||
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
|
||||
@@ -40,6 +57,7 @@ def build_static_preset_snapshot(
|
||||
context_window_tokens=preset.context_window_tokens,
|
||||
signature=("model_preset", name, preset.model_dump_json()),
|
||||
generation=preset.to_generation_settings(),
|
||||
model_preset=name,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,7 +69,7 @@ def build_runtime_preset_snapshot(
|
||||
loader: PresetSnapshotLoader | None,
|
||||
) -> ProviderSnapshot:
|
||||
if loader is not None:
|
||||
return loader(name)
|
||||
return replace(loader(name), model_preset=name)
|
||||
return build_static_preset_snapshot(provider, name, presets[name])
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import replace
|
||||
from types import MappingProxyType
|
||||
|
||||
from nanobot.agent import model_presets as preset_helpers
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
@@ -24,16 +25,23 @@ class ModelRuntimeResolver:
|
||||
initial_runtime: LLMRuntime,
|
||||
*,
|
||||
model_presets: Mapping[str, ModelPresetConfig] | None = None,
|
||||
preset_catalog_loader: preset_helpers.PresetCatalogLoader | None = None,
|
||||
configured_default_preset: str | None = None,
|
||||
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||
) -> None:
|
||||
self._runtime = initial_runtime
|
||||
self._model_presets = dict(model_presets or {})
|
||||
self._preset_catalog_loader = preset_catalog_loader
|
||||
self._preset_catalog_refresh_required = False
|
||||
self._provider_snapshot_loader = provider_snapshot_loader
|
||||
self._preset_snapshot_loader = preset_snapshot_loader
|
||||
self._refresh_required = False
|
||||
self._resolved_presets: dict[str, LLMRuntime] = {}
|
||||
self._tracks_provider_generation = initial_runtime.model_preset is None
|
||||
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||
initial_runtime.snapshot_signature
|
||||
initial_runtime.snapshot_signature,
|
||||
configured_default_preset,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -43,7 +51,11 @@ class ModelRuntimeResolver:
|
||||
|
||||
@property
|
||||
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
|
||||
return self._model_presets
|
||||
self._refresh_preset_catalog()
|
||||
return MappingProxyType({
|
||||
name: preset.model_copy(deep=True)
|
||||
for name, preset in self._model_presets.items()
|
||||
})
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None:
|
||||
@@ -60,40 +72,63 @@ class ModelRuntimeResolver:
|
||||
self._refresh_provider_generation()
|
||||
return self._runtime
|
||||
|
||||
def admit(self) -> LLMRuntime:
|
||||
"""Resolve the immutable runtime for the next turn admission."""
|
||||
if self._refresh_required:
|
||||
self.refresh()
|
||||
self._refresh_provider_generation()
|
||||
return self._runtime
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Refresh configured runtime state on the next admission."""
|
||||
self._refresh_required = True
|
||||
self._preset_catalog_refresh_required = True
|
||||
self._resolved_presets.clear()
|
||||
|
||||
def _refresh_preset_catalog(self) -> None:
|
||||
if not self._preset_catalog_refresh_required:
|
||||
return
|
||||
if self._preset_catalog_loader is not None:
|
||||
self._model_presets = dict(self._preset_catalog_loader())
|
||||
self._preset_catalog_refresh_required = False
|
||||
|
||||
def resolve_snapshot(
|
||||
self,
|
||||
snapshot: ProviderSnapshot,
|
||||
*,
|
||||
model_preset: str | None = None,
|
||||
) -> LLMRuntime:
|
||||
"""Resolve a factory snapshot without changing the selected default."""
|
||||
return runtime_from_provider_snapshot(snapshot, model_preset=model_preset)
|
||||
return runtime_from_provider_snapshot(snapshot)
|
||||
|
||||
def adopt_snapshot(
|
||||
self,
|
||||
snapshot: ProviderSnapshot,
|
||||
*,
|
||||
model_preset: str | None = None,
|
||||
) -> LLMRuntime:
|
||||
"""Select a snapshot as the default for future turns."""
|
||||
runtime = self.resolve_snapshot(snapshot, model_preset=model_preset)
|
||||
runtime = self.resolve_snapshot(snapshot)
|
||||
self._runtime = runtime
|
||||
self._tracks_provider_generation = model_preset is None
|
||||
self._tracks_provider_generation = runtime.model_preset is None
|
||||
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||
runtime.snapshot_signature
|
||||
runtime.snapshot_signature,
|
||||
runtime.model_preset,
|
||||
)
|
||||
return runtime
|
||||
|
||||
def resolve_preset(self, name: str | None) -> LLMRuntime:
|
||||
"""Resolve a named preset without changing the selected default."""
|
||||
self._refresh_preset_catalog()
|
||||
normalized = preset_helpers.normalize_preset_name(name, self._model_presets)
|
||||
cached = self._resolved_presets.get(normalized)
|
||||
if cached is not None:
|
||||
return cached
|
||||
snapshot = preset_helpers.build_runtime_preset_snapshot(
|
||||
name=normalized,
|
||||
presets=self._model_presets,
|
||||
provider=self._runtime.provider,
|
||||
loader=self._preset_snapshot_loader,
|
||||
)
|
||||
return self.resolve_snapshot(snapshot, model_preset=normalized)
|
||||
runtime = self.resolve_snapshot(snapshot)
|
||||
self._resolved_presets[normalized] = runtime
|
||||
return runtime
|
||||
|
||||
def select_preset(self, name: str | None) -> LLMRuntime:
|
||||
"""Select a named preset as the default for future turns."""
|
||||
@@ -146,21 +181,26 @@ class ModelRuntimeResolver:
|
||||
def refresh(self) -> LLMRuntime | None:
|
||||
"""Refresh configured defaults and return the replacement when changed."""
|
||||
if self._provider_snapshot_loader is None:
|
||||
self._refresh_required = False
|
||||
return None
|
||||
|
||||
self._resolved_presets.clear()
|
||||
snapshot = self._provider_snapshot_loader()
|
||||
default_selection = preset_helpers.default_selection_signature(snapshot.signature)
|
||||
default_selection = preset_helpers.default_selection_signature(
|
||||
snapshot.signature,
|
||||
snapshot.model_preset,
|
||||
)
|
||||
active_preset = self._runtime.model_preset
|
||||
if active_preset and self._default_selection_signature in (None, default_selection):
|
||||
runtime = self.resolve_preset(active_preset)
|
||||
else:
|
||||
active_preset = None
|
||||
runtime = self.resolve_snapshot(snapshot)
|
||||
|
||||
unchanged = (
|
||||
runtime.snapshot_signature == self._runtime.snapshot_signature
|
||||
and runtime.model_preset == self._runtime.model_preset
|
||||
)
|
||||
self._refresh_required = False
|
||||
if unchanged:
|
||||
self._default_selection_signature = default_selection
|
||||
return None
|
||||
@@ -170,7 +210,7 @@ class ModelRuntimeResolver:
|
||||
self._default_selection_signature,
|
||||
) = (
|
||||
runtime,
|
||||
active_preset is None,
|
||||
runtime.model_preset is None,
|
||||
default_selection,
|
||||
)
|
||||
return runtime
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, Awaitable, Callable
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.providers.base import ToolCallRequest
|
||||
from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think
|
||||
from nanobot.utils.progress_events import (
|
||||
build_tool_event_finish_payloads,
|
||||
@@ -97,6 +98,61 @@ class AgentProgressHook(AgentHook):
|
||||
self._session_key,
|
||||
)
|
||||
|
||||
async def on_provider_tool_event(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
event: dict[str, Any],
|
||||
) -> None:
|
||||
if not self._on_progress:
|
||||
return
|
||||
phase = event.get("phase")
|
||||
name = event.get("name")
|
||||
call_id = event.get("call_id")
|
||||
if (
|
||||
phase not in {"start", "end", "error"}
|
||||
or not isinstance(name, str)
|
||||
or not name
|
||||
or not call_id
|
||||
):
|
||||
return
|
||||
arguments = event.get("arguments")
|
||||
if not isinstance(arguments, dict):
|
||||
arguments = {}
|
||||
payload = {
|
||||
"version": 1,
|
||||
"phase": phase,
|
||||
"call_id": str(call_id),
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
"result": event.get("result") if phase == "end" else None,
|
||||
"error": event.get("error") if phase == "error" else None,
|
||||
"files": [],
|
||||
"embeds": [],
|
||||
}
|
||||
if phase == "start":
|
||||
await self.emit_reasoning_end()
|
||||
tool_call = ToolCallRequest(id=str(call_id), name=name, arguments=arguments)
|
||||
tool_hint = self._strip_think(self._tool_hint([tool_call])) or name
|
||||
await invoke_on_progress(
|
||||
self._on_progress,
|
||||
tool_hint,
|
||||
tool_hint=True,
|
||||
tool_events=[payload],
|
||||
)
|
||||
logger.info(
|
||||
"Provider-hosted tool call: {}({})",
|
||||
name,
|
||||
json.dumps(arguments, ensure_ascii=False)[:200],
|
||||
)
|
||||
return
|
||||
if on_progress_accepts_tool_events(self._on_progress):
|
||||
await invoke_on_progress(
|
||||
self._on_progress,
|
||||
"",
|
||||
tool_hint=False,
|
||||
tool_events=[payload],
|
||||
)
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
if self._on_progress:
|
||||
if not self._on_stream and not context.streamed_content:
|
||||
@@ -114,6 +170,7 @@ class AgentProgressHook(AgentHook):
|
||||
for tc in context.tool_calls:
|
||||
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
||||
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
"""Publish a reasoning chunk; channel plugins decide whether to render."""
|
||||
if (
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -353,37 +352,16 @@ class AgentRunner:
|
||||
)
|
||||
|
||||
for iteration in range(spec.max_iterations):
|
||||
try:
|
||||
# Keep the persisted conversation untouched. Context governance
|
||||
# may repair or compact historical messages for the model, but
|
||||
# those synthetic edits must not shift the append boundary used
|
||||
# later when the caller saves only the new turn.
|
||||
messages_for_model = self.context_governor.prepare_for_model(
|
||||
governance_config,
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Context governance failed on turn {} for {}; applying minimal repair",
|
||||
iteration,
|
||||
spec.session_key or "default",
|
||||
)
|
||||
try:
|
||||
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
|
||||
messages
|
||||
)
|
||||
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.drop_orphan_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
except Exception:
|
||||
messages_for_model = messages
|
||||
# Keep the persisted conversation untouched. Context governance
|
||||
# may repair or compact historical messages for the model, but
|
||||
# those synthetic edits must not shift the append boundary used
|
||||
# later when the caller saves only the new turn. A governance
|
||||
# failure must stop the run instead of sending an ungoverned copy.
|
||||
messages_for_model = self.context_governor.prepare_for_model(
|
||||
governance_config,
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
context = AgentHookContext(
|
||||
iteration=iteration,
|
||||
messages=messages,
|
||||
@@ -744,6 +722,20 @@ class AgentRunner:
|
||||
)
|
||||
|
||||
progress_state: dict[str, bool] | None = None
|
||||
active_hosted_tools: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def _provider_tool_event(event: dict[str, Any]) -> None:
|
||||
if event.get("kind") != "hosted_tool":
|
||||
return
|
||||
await hook.on_provider_tool_event(context, event)
|
||||
call_id = event.get("call_id")
|
||||
if not call_id:
|
||||
return
|
||||
call_id = str(call_id)
|
||||
if event.get("phase") == "start":
|
||||
active_hosted_tools[call_id] = dict(event)
|
||||
elif event.get("phase") in {"end", "error"}:
|
||||
active_hosted_tools.pop(call_id, None)
|
||||
|
||||
if wants_streaming:
|
||||
thinking_buf = ""
|
||||
@@ -772,6 +764,7 @@ class AgentRunner:
|
||||
**kwargs,
|
||||
on_content_delta=_stream,
|
||||
on_thinking_delta=_thinking,
|
||||
on_tool_call_delta=_provider_tool_event,
|
||||
on_stream_recover=_stream_recover,
|
||||
)
|
||||
elif wants_progress_streaming:
|
||||
@@ -802,15 +795,22 @@ class AgentRunner:
|
||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||
**kwargs,
|
||||
on_content_delta=_stream_progress,
|
||||
on_tool_call_delta=_provider_tool_event,
|
||||
)
|
||||
else:
|
||||
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
||||
|
||||
# Streaming requests already have provider-level idle timeouts
|
||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
|
||||
# LLM timeout here, or healthy long reasoning streams can be killed just
|
||||
# because total elapsed time exceeded NANOBOT_LLM_TIMEOUT_S.
|
||||
outer_timeout_s = None if (wants_streaming or wants_progress_streaming) else timeout_s
|
||||
# Streaming requests also have provider-level idle timeouts
|
||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
|
||||
# very slow deltas can still run forever. Use a more generous wall-clock
|
||||
# timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an
|
||||
# opt-out for all LLM wall-clock timeouts.
|
||||
is_streaming_request = wants_streaming or wants_progress_streaming
|
||||
outer_timeout_s = (
|
||||
max(300.0, timeout_s * 2)
|
||||
if is_streaming_request and timeout_s is not None
|
||||
else timeout_s
|
||||
)
|
||||
try:
|
||||
response = (
|
||||
await coro if outer_timeout_s is None
|
||||
@@ -818,16 +818,28 @@ class AgentRunner:
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if outer_timeout_s is None:
|
||||
return LLMResponse(
|
||||
response = LLMResponse(
|
||||
content="Error calling LLM: stream stalled",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
return LLMResponse(
|
||||
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
else:
|
||||
response = LLMResponse(
|
||||
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
# chat_stream_with_retry may recover internally, so only fail unfinished
|
||||
# hosted calls after the provider returns its final error response.
|
||||
if response.finish_reason == "error":
|
||||
for event in list(active_hosted_tools.values()):
|
||||
await _provider_tool_event({
|
||||
**event,
|
||||
"phase": "error",
|
||||
"result": None,
|
||||
"error": response.content
|
||||
or "Model request failed before the provider-hosted tool completed.",
|
||||
})
|
||||
if progress_state and progress_state.get("reasoning_open"):
|
||||
await hook.emit_reasoning_end()
|
||||
dropped, all_dropped, original_finish_reason = (
|
||||
@@ -1160,10 +1172,9 @@ class AgentRunner:
|
||||
prepare_call = getattr(spec.tools, "prepare_call", None)
|
||||
tool, params, prep_error = None, tool_call.arguments, None
|
||||
if callable(prepare_call):
|
||||
with suppress(Exception):
|
||||
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
||||
if isinstance(prepared, tuple) and len(prepared) == 3:
|
||||
tool, params, prep_error = prepared
|
||||
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
||||
if isinstance(prepared, tuple) and len(prepared) == 3:
|
||||
tool, params, prep_error = prepared
|
||||
if prep_error:
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
@@ -1190,7 +1201,7 @@ class AgentRunner:
|
||||
result = await spec.tools.execute(tool_call.name, params)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
except Exception as exc:
|
||||
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
|
||||
@@ -125,21 +125,34 @@ class SkillsLoader:
|
||||
if not all_skills:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
for entry in all_skills:
|
||||
skill_name = entry["name"]
|
||||
if exclude and skill_name in exclude:
|
||||
sections: list[str] = []
|
||||
groups = (
|
||||
("Workspace skills", "workspace", self.workspace_skills),
|
||||
("Built-in skills", "builtin", self.builtin_skills),
|
||||
)
|
||||
for label, source, root in groups:
|
||||
entries = [
|
||||
entry
|
||||
for entry in all_skills
|
||||
if entry["source"] == source and (not exclude or entry["name"] not in exclude)
|
||||
]
|
||||
if not entries:
|
||||
continue
|
||||
meta = self._get_skill_meta(skill_name)
|
||||
available = self._check_requirements(meta)
|
||||
desc = self._get_skill_description(skill_name)
|
||||
if available:
|
||||
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
|
||||
else:
|
||||
missing = self._get_missing_requirements(meta)
|
||||
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
|
||||
lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines = [f"### {label} (`{root.expanduser().resolve()}`)"]
|
||||
for entry in entries:
|
||||
skill_name = entry["name"]
|
||||
meta = self._get_skill_meta(skill_name)
|
||||
available = self._check_requirements(meta)
|
||||
desc = self._get_skill_description(skill_name)
|
||||
suffix = ""
|
||||
if not available:
|
||||
missing = self._get_missing_requirements(meta)
|
||||
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
|
||||
relative_path = Path(entry["path"]).relative_to(root).as_posix()
|
||||
lines.append(f"- **{skill_name}** — {desc}{suffix} `{relative_path}`")
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
def _get_missing_requirements(self, skill_meta: dict) -> str:
|
||||
"""Get a description of missing requirements."""
|
||||
|
||||
@@ -13,12 +13,14 @@ from loguru import logger
|
||||
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.tools.base import ToolResult
|
||||
from nanobot.agent.tools.context import (
|
||||
RequestContext,
|
||||
ToolContext,
|
||||
bind_request_context,
|
||||
reset_request_context,
|
||||
)
|
||||
from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
@@ -143,8 +145,9 @@ class SubagentManager:
|
||||
else defaults.fail_on_tool_error
|
||||
)
|
||||
self.runner = AgentRunner()
|
||||
self._exec_session_manager = ExecSessionManager()
|
||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._running_tasks: dict[str, asyncio.Task[str]] = {}
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
|
||||
@@ -204,6 +207,7 @@ class SubagentManager:
|
||||
ctx = ToolContext(
|
||||
config=cfg,
|
||||
workspace=str(root.resolve()),
|
||||
exec_session_manager=self._exec_session_manager,
|
||||
file_state_store=FileStates(),
|
||||
workspace_sandbox=workspace_sandbox_status(
|
||||
restrict_to_workspace=cfg.restrict_to_workspace,
|
||||
@@ -272,6 +276,68 @@ class SubagentManager:
|
||||
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
|
||||
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
||||
|
||||
async def run_inline(
|
||||
self,
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
origin_channel: str = "cli",
|
||||
origin_chat_id: str = "direct",
|
||||
session_key: str | None = None,
|
||||
origin_message_id: str | None = None,
|
||||
temperature: float | None = None,
|
||||
workspace_scope: WorkspaceScope | None = None,
|
||||
*,
|
||||
runtime: LLMRuntime | None = None,
|
||||
) -> str:
|
||||
"""Run a subagent synchronously and return its result to the caller."""
|
||||
if runtime is None:
|
||||
runtime = self._compat_spawn_runtime()
|
||||
if temperature is not None:
|
||||
runtime = runtime.with_generation_overrides(temperature=temperature)
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||
origin = {
|
||||
"channel": origin_channel,
|
||||
"chat_id": origin_chat_id,
|
||||
"session_key": session_key,
|
||||
}
|
||||
status = SubagentStatus(
|
||||
task_id=task_id,
|
||||
label=display_label,
|
||||
task_description=task,
|
||||
started_at=time.monotonic(),
|
||||
)
|
||||
self._task_statuses[task_id] = status
|
||||
logger.info("Running inline subagent [{}]: {}", task_id, display_label)
|
||||
inline_task = asyncio.create_task(
|
||||
self._run_subagent(
|
||||
task_id,
|
||||
task,
|
||||
display_label,
|
||||
origin,
|
||||
status,
|
||||
runtime,
|
||||
origin_message_id,
|
||||
workspace_scope,
|
||||
announce=False,
|
||||
)
|
||||
)
|
||||
self._running_tasks[task_id] = inline_task
|
||||
if session_key:
|
||||
self._session_tasks.setdefault(session_key, set()).add(task_id)
|
||||
try:
|
||||
result = await inline_task
|
||||
if status.phase == "error" or status.stop_reason in {"error", "tool_error"}:
|
||||
return ToolResult.error(result)
|
||||
return result
|
||||
finally:
|
||||
self._running_tasks.pop(task_id, None)
|
||||
self._task_statuses.pop(task_id, None)
|
||||
if session_key and (ids := self._session_tasks.get(session_key)):
|
||||
ids.discard(task_id)
|
||||
if not ids:
|
||||
del self._session_tasks[session_key]
|
||||
|
||||
async def _run_subagent(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -282,7 +348,9 @@ class SubagentManager:
|
||||
runtime: LLMRuntime,
|
||||
origin_message_id: str | None = None,
|
||||
workspace_scope: WorkspaceScope | None = None,
|
||||
) -> None:
|
||||
*,
|
||||
announce: bool = True,
|
||||
) -> str:
|
||||
"""Execute the subagent task and announce the result."""
|
||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||
|
||||
@@ -296,7 +364,8 @@ class SubagentManager:
|
||||
if workspace_scope is not None:
|
||||
cfg = self._subagent_tools_config()
|
||||
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
|
||||
tools = self._build_tools(workspace=root, tools_config=cfg)
|
||||
# Construct from the agent workspace; the bound scope below supplies the project cwd.
|
||||
tools = self._build_tools(tools_config=cfg)
|
||||
system_prompt = self._build_subagent_prompt(workspace=root)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -343,27 +412,43 @@ class SubagentManager:
|
||||
|
||||
if result.stop_reason == "tool_error":
|
||||
status.tool_events = list(result.tool_events)
|
||||
await self._announce_result(
|
||||
task_id, label, task,
|
||||
self._format_partial_progress(result),
|
||||
origin, "error", origin_message_id,
|
||||
)
|
||||
final_result = self._format_partial_progress(result)
|
||||
final_status = "error"
|
||||
elif result.stop_reason == "error":
|
||||
await self._announce_result(
|
||||
task_id, label, task,
|
||||
result.error or "Error: subagent execution failed.",
|
||||
origin, "error", origin_message_id,
|
||||
)
|
||||
final_result = result.error or "Error: subagent execution failed."
|
||||
final_status = "error"
|
||||
else:
|
||||
final_result = result.final_content or "Task completed but no final response was generated."
|
||||
final_status = "ok"
|
||||
logger.info("Subagent [{}] completed successfully", task_id)
|
||||
await self._announce_result(task_id, label, task, final_result, origin, "ok", origin_message_id)
|
||||
if announce:
|
||||
await self._announce_result(
|
||||
task_id,
|
||||
label,
|
||||
task,
|
||||
final_result,
|
||||
origin,
|
||||
final_status,
|
||||
origin_message_id,
|
||||
)
|
||||
return final_result
|
||||
|
||||
except Exception as e:
|
||||
status.phase = "error"
|
||||
status.error = str(e)
|
||||
logger.exception("Subagent [{}] failed", task_id)
|
||||
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
|
||||
final_result = f"Error: {e}"
|
||||
if announce:
|
||||
await self._announce_result(
|
||||
task_id,
|
||||
label,
|
||||
task,
|
||||
final_result,
|
||||
origin,
|
||||
"error",
|
||||
origin_message_id,
|
||||
)
|
||||
return final_result
|
||||
|
||||
async def _announce_result(
|
||||
self,
|
||||
@@ -435,14 +520,17 @@ class SubagentManager:
|
||||
"""Build a focused system prompt for the subagent."""
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
root = workspace or self.workspace
|
||||
agent_workspace = self.workspace.expanduser().resolve()
|
||||
project_workspace = workspace.expanduser().resolve() if workspace else agent_workspace
|
||||
skills_summary = SkillsLoader(
|
||||
root,
|
||||
self.workspace,
|
||||
disabled_skills=self.disabled_skills,
|
||||
).build_skills_summary()
|
||||
return render_template(
|
||||
"agent/subagent_system.md",
|
||||
workspace=str(root),
|
||||
workspace=str(project_workspace),
|
||||
agent_workspace=str(agent_workspace),
|
||||
history_log=str(agent_workspace / "memory" / "history.jsonl"),
|
||||
skills_summary=skills_summary or "",
|
||||
)
|
||||
|
||||
@@ -454,8 +542,18 @@ class SubagentManager:
|
||||
t.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await self._exec_session_manager.terminate_by_owner(session_key)
|
||||
return len(tasks)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Cancel running subagents and close their shared exec sessions."""
|
||||
tasks = [task for task in self._running_tasks.values() if not task.done()]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await self._exec_session_manager.close_all()
|
||||
|
||||
def get_running_count(self) -> int:
|
||||
"""Return the number of currently running subagents."""
|
||||
return len(self._running_tasks)
|
||||
|
||||
@@ -71,6 +71,7 @@ class ToolContext:
|
||||
bus: Any | None = None
|
||||
subagent_manager: Any | None = None
|
||||
cron_service: Any | None = None
|
||||
exec_session_manager: Any | None = None
|
||||
sessions: Any | None = None
|
||||
file_state_store: Any = field(default=None)
|
||||
provider_snapshot_loader: Callable[[], Any] | None = None
|
||||
|
||||
@@ -61,12 +61,14 @@ class _ExecSession:
|
||||
cwd: str,
|
||||
timeout: int | None,
|
||||
owner_session_key: str | None = None,
|
||||
process_tree: bool = False,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self.process = process
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.owner_session_key = owner_session_key
|
||||
self._process_tree = process_tree
|
||||
self.started_at = time.monotonic()
|
||||
# timeout None/0 means no limit; an infinite deadline is never reached.
|
||||
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
||||
@@ -171,17 +173,23 @@ class _ExecSession:
|
||||
)
|
||||
|
||||
async def kill(self) -> None:
|
||||
if self.process.returncode is not None:
|
||||
return
|
||||
self.process.kill()
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
|
||||
try:
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
||||
if self._process_tree:
|
||||
await ExecTool._kill_process_tree(self.process)
|
||||
else:
|
||||
await ExecTool._kill_process(self.process)
|
||||
finally:
|
||||
# Safety-net waitpid — prevent zombie if asyncio's child watcher
|
||||
# did not reap the process (common in containers).
|
||||
from nanobot.agent.tools.shell import _reap_pid
|
||||
_reap_pid(self.process.pid)
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
self._stdout_task,
|
||||
self._stderr_task,
|
||||
return_exceptions=True,
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
async def _wait_for_buffered_output(self) -> None:
|
||||
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
||||
@@ -198,6 +206,7 @@ class ExecSessionManager:
|
||||
self.idle_timeout = idle_timeout
|
||||
self._sessions: dict[str, _ExecSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
|
||||
async def start(
|
||||
self,
|
||||
@@ -213,6 +222,8 @@ class ExecSessionManager:
|
||||
owner_session_key: str | None = None,
|
||||
) -> tuple[str, _SessionPoll]:
|
||||
async with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("exec session manager is closed")
|
||||
await self._cleanup_locked()
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
|
||||
@@ -225,6 +236,7 @@ class ExecSessionManager:
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
owner_session_key=owner_session_key,
|
||||
process_tree=True,
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
|
||||
@@ -250,11 +262,7 @@ class ExecSessionManager:
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
raise KeyError(session_id)
|
||||
if (
|
||||
owner_session_key
|
||||
and session.owner_session_key
|
||||
and session.owner_session_key != owner_session_key
|
||||
):
|
||||
if session.owner_session_key and session.owner_session_key != owner_session_key:
|
||||
raise KeyError(session_id)
|
||||
|
||||
if chars:
|
||||
@@ -296,11 +304,64 @@ class ExecSessionManager:
|
||||
owner_session_key=session.owner_session_key,
|
||||
)
|
||||
for session_id, session in sorted(self._sessions.items())
|
||||
if not owner_session_key
|
||||
or not session.owner_session_key
|
||||
or 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:
|
||||
now = time.monotonic()
|
||||
stale = [
|
||||
@@ -309,8 +370,9 @@ class ExecSessionManager:
|
||||
if now - session.last_access > self.idle_timeout
|
||||
]
|
||||
for session_id in stale:
|
||||
session = self._sessions.pop(session_id)
|
||||
session = self._sessions[session_id]
|
||||
await session.kill()
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
async def _spawn(
|
||||
self,
|
||||
@@ -325,6 +387,7 @@ class ExecSessionManager:
|
||||
return await ExecTool._spawn(
|
||||
command, cwd, env, shell_program, login,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
process_tree=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -442,7 +505,7 @@ class WriteStdinTool(Tool):
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
return cls()
|
||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
@@ -586,7 +649,7 @@ class ListExecSessionsTool(Tool):
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
return cls()
|
||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
|
||||
@@ -51,6 +51,7 @@ class _FsTool(Tool):
|
||||
file_states: FileStates | None = None,
|
||||
restrict_to_workspace: bool | None = None,
|
||||
sandbox_restricts_workspace: bool = False,
|
||||
extra_read_allowed_files: list[Path] | None = None,
|
||||
):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
@@ -60,6 +61,7 @@ class _FsTool(Tool):
|
||||
*(extra_allowed_dirs or []),
|
||||
*(extra_read_allowed_dirs or []),
|
||||
]
|
||||
self._extra_read_allowed_files = list(extra_read_allowed_files or [])
|
||||
self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or [])
|
||||
self._extra_write_allowed_files = list(extra_write_allowed_files or [])
|
||||
self._restrict_to_workspace = (
|
||||
@@ -78,17 +80,21 @@ class _FsTool(Tool):
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
agent_workspace = Path(ctx.workspace)
|
||||
resolved_agent_workspace = agent_workspace.expanduser().resolve(strict=False)
|
||||
restrict = (
|
||||
ctx.config.restrict_to_workspace
|
||||
or ctx.config.exec.sandbox
|
||||
)
|
||||
sandbox_restricts = bool(ctx.config.exec.sandbox)
|
||||
allowed_dir = Path(ctx.workspace) if restrict else None
|
||||
extra_read = [BUILTIN_SKILLS_DIR]
|
||||
allowed_dir = agent_workspace if restrict else None
|
||||
# Agent-owned skills stay available from project scopes. History is a narrower
|
||||
# capability: expose only the append-only log, not the surrounding memory directory.
|
||||
return cls(
|
||||
workspace=Path(ctx.workspace),
|
||||
workspace=agent_workspace,
|
||||
allowed_dir=allowed_dir,
|
||||
extra_read_allowed_dirs=extra_read,
|
||||
extra_read_allowed_dirs=[BUILTIN_SKILLS_DIR, resolved_agent_workspace / "skills"],
|
||||
extra_read_allowed_files=[resolved_agent_workspace / "memory" / "history.jsonl"],
|
||||
file_states=ctx.file_state_store,
|
||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||
sandbox_restricts_workspace=sandbox_restricts,
|
||||
@@ -119,16 +125,20 @@ class _FsTool(Tool):
|
||||
extra_allowed_files: list[Path] | None,
|
||||
*,
|
||||
include_media_dir: bool,
|
||||
extra_files_require_allowed_root: bool = False,
|
||||
) -> Path:
|
||||
access = current_tool_workspace(
|
||||
self._workspace,
|
||||
restrict_to_workspace=self._restrict_to_workspace,
|
||||
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
|
||||
)
|
||||
allowed_root = self._effective_allowed_root(access.allowed_root)
|
||||
if extra_files_require_allowed_root and allowed_root is None:
|
||||
extra_allowed_files = None
|
||||
return resolve_workspace_path(
|
||||
path,
|
||||
access.project_path,
|
||||
self._effective_allowed_root(access.allowed_root),
|
||||
allowed_root,
|
||||
extra_allowed_dirs,
|
||||
extra_allowed_files,
|
||||
include_media_dir=include_media_dir,
|
||||
@@ -138,8 +148,9 @@ class _FsTool(Tool):
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
self._extra_read_allowed_dirs,
|
||||
None,
|
||||
self._extra_read_allowed_files,
|
||||
include_media_dir=True,
|
||||
extra_files_require_allowed_root=True,
|
||||
)
|
||||
|
||||
def _resolve_write(self, path: str) -> Path:
|
||||
@@ -237,6 +248,7 @@ class ReadFileTool(_FsTool):
|
||||
_scopes = {"core", "subagent", "memory"}
|
||||
|
||||
_MAX_CHARS = 128_000
|
||||
_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024
|
||||
_DEFAULT_LIMIT = 2000
|
||||
_MAX_PDF_PAGES = 20
|
||||
|
||||
@@ -290,6 +302,15 @@ class ReadFileTool(_FsTool):
|
||||
if not fp.is_file():
|
||||
return ToolResult.error(f"Error: Not a file: {path}")
|
||||
|
||||
file_size = fp.stat().st_size
|
||||
if file_size > self._MAX_FILE_SIZE_BYTES:
|
||||
size_mib = file_size / (1024 * 1024)
|
||||
max_mib = self._MAX_FILE_SIZE_BYTES // (1024 * 1024)
|
||||
return ToolResult.error(
|
||||
f"Error: File too large to read ({size_mib:.1f} MiB). "
|
||||
f"Maximum is {max_mib} MiB."
|
||||
)
|
||||
|
||||
# PDF support
|
||||
if fp.suffix.lower() == ".pdf":
|
||||
return self._read_pdf(fp, pages)
|
||||
|
||||
@@ -2,24 +2,34 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.schema import (
|
||||
ArraySchema,
|
||||
IntegerSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_ACK,
|
||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config_base import Base
|
||||
from nanobot.providers.image_generation import (
|
||||
ImageGenerationError,
|
||||
ImageGenerationProvider,
|
||||
get_image_gen_provider,
|
||||
image_gen_provider_configs,
|
||||
)
|
||||
from nanobot.security.workspace_access import current_tool_workspace
|
||||
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
|
||||
@@ -129,6 +139,7 @@ class ImageGenerationTool(Tool):
|
||||
"api_base": provider.api_base if provider else None,
|
||||
"extra_headers": provider.extra_headers if provider else None,
|
||||
"extra_body": provider.extra_body if provider else None,
|
||||
"proxy": provider.proxy if provider else None,
|
||||
}
|
||||
return cls(**kwargs)
|
||||
|
||||
@@ -207,3 +218,114 @@ class ImageGenerationTool(Tool):
|
||||
return generated_image_tool_result(artifacts)
|
||||
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
|
||||
|
||||
async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"""Apply the persisted image configuration to the running agent."""
|
||||
try:
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
config = resolve_config_env_vars(load_config())
|
||||
tool_config = config.tools.image_generation
|
||||
provider_configs = image_gen_provider_configs(config)
|
||||
except Exception as exc:
|
||||
logger.warning("Image generation hot reload could not read config: {}", exc)
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Could not reload image generation config.",
|
||||
"requires_restart": True,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
next_tool = (
|
||||
ImageGenerationTool(
|
||||
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
|
||||
|
||||
@@ -30,6 +30,7 @@ from nanobot.security.network import (
|
||||
resolve_url_target,
|
||||
validate_url_target,
|
||||
)
|
||||
from nanobot.utils.cancellation import task_is_cancelling
|
||||
|
||||
# Transient connection errors that warrant a single retry.
|
||||
# These typically happen when an MCP server restarts or a network
|
||||
@@ -487,8 +488,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
||||
except asyncio.CancelledError:
|
||||
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
|
||||
# Re-raise only if our task was externally cancelled (e.g. /stop).
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling() > 0:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
|
||||
return ToolResult.error("(MCP tool call was cancelled)")
|
||||
@@ -650,8 +650,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
|
||||
)
|
||||
return f"(MCP resource read timed out after {self._resource_timeout}s)"
|
||||
except asyncio.CancelledError:
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling() > 0:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
|
||||
return "(MCP resource read was cancelled)"
|
||||
@@ -764,8 +763,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
||||
)
|
||||
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
|
||||
except asyncio.CancelledError:
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling() > 0:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
||||
return "(MCP prompt call was cancelled)"
|
||||
@@ -1145,6 +1143,8 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
||||
else:
|
||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||
except asyncio.CancelledError:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.warning("MCP connection cancelled (will retry next message)")
|
||||
except BaseException as e:
|
||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||
@@ -1162,9 +1162,9 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"requires_restart": True,
|
||||
}
|
||||
try:
|
||||
from nanobot.config.loader import load_effective_config
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
config = load_effective_config()
|
||||
config = resolve_config_env_vars(load_config())
|
||||
next_servers = dict(config.tools.mcp_servers)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
@@ -1410,6 +1410,10 @@ async def _close_server(state: Any, server_name: str) -> None:
|
||||
return
|
||||
try:
|
||||
await stack.aclose()
|
||||
except asyncio.CancelledError:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||
|
||||
@@ -1423,5 +1427,9 @@ async def close_mcp_servers(state: Any) -> None:
|
||||
for name, connection in connections:
|
||||
try:
|
||||
await connection.aclose()
|
||||
except asyncio.CancelledError:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||
|
||||
@@ -60,5 +60,11 @@ class RuntimeState(Protocol):
|
||||
|
||||
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
|
||||
|
||||
def set_session_model_preset(
|
||||
self,
|
||||
session_key: str,
|
||||
name: str,
|
||||
) -> Any: ...
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None: ...
|
||||
|
||||
@@ -283,6 +283,7 @@ class GrepTool(_SearchTool):
|
||||
|
||||
_MAX_RESULT_CHARS = 128_000
|
||||
_MAX_FILE_BYTES = 2_000_000
|
||||
_MAX_EXPLICIT_FILE_BYTES = 100_000_000
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -295,7 +296,8 @@ class GrepTool(_SearchTool):
|
||||
"Default output_mode is files_with_matches (file paths only); "
|
||||
"use content mode for matching lines with context. Prefer this "
|
||||
"over shell grep for ordinary workspace searches. "
|
||||
"Skips binary and files >2 MB. Supports glob/type filtering."
|
||||
"Binary and file-size limits are enforced by the tool; explicit file paths "
|
||||
"use a larger bounded limit than directory searches. Supports glob/type filtering."
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -456,6 +458,9 @@ class GrepTool(_SearchTool):
|
||||
counts: dict[str, int] = {}
|
||||
file_mtimes: dict[str, float] = {}
|
||||
root = target if target.is_dir() else target.parent
|
||||
max_file_bytes = (
|
||||
self._MAX_EXPLICIT_FILE_BYTES if target.is_file() else self._MAX_FILE_BYTES
|
||||
)
|
||||
|
||||
for file_path in self._iter_files(target):
|
||||
rel_path = file_path.relative_to(root).as_posix()
|
||||
@@ -464,8 +469,9 @@ class GrepTool(_SearchTool):
|
||||
if not _matches_type(file_path.name, type):
|
||||
continue
|
||||
|
||||
raw = file_path.read_bytes()
|
||||
if len(raw) > self._MAX_FILE_BYTES:
|
||||
with file_path.open("rb") as file:
|
||||
raw = file.read(max_file_bytes + 1)
|
||||
if len(raw) > max_file_bytes:
|
||||
skipped_large += 1
|
||||
continue
|
||||
if _is_binary(raw):
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
from nanobot.agent.tools.context import current_request_context
|
||||
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||
from nanobot.config_base import Base
|
||||
|
||||
@@ -76,6 +77,7 @@ class MyTool(Tool):
|
||||
"_current_iteration", # updated by runner only
|
||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||
"model_presets", # config-derived catalog; changes require config reload
|
||||
"workspace_sandbox", # read-only view of workspace enforcement level
|
||||
"request", # current message routing metadata
|
||||
})
|
||||
@@ -146,6 +148,8 @@ class MyTool(Tool):
|
||||
"max_iterations - _current_iteration = remaining iterations.\n"
|
||||
"Current routing metadata is available read-only via request.channel, "
|
||||
"request.chat_id, and request.sender_id.\n"
|
||||
"Use model_preset for session-scoped model or context changes; direct "
|
||||
"model/context_window_tokens writes are disabled during active sessions.\n"
|
||||
"Note: web_config and exec_config are readable but read-only.\n"
|
||||
"\n"
|
||||
"When to use:\n"
|
||||
@@ -210,11 +214,11 @@ class MyTool(Tool):
|
||||
if part.lower() in self._SENSITIVE_NAMES:
|
||||
return None, f"'{part}' is not accessible"
|
||||
try:
|
||||
if isinstance(obj, dict):
|
||||
if isinstance(obj, Mapping):
|
||||
if part in obj:
|
||||
obj = obj[part]
|
||||
else:
|
||||
return None, f"'{part}' not found in dict"
|
||||
return None, f"'{part}' not found in mapping"
|
||||
else:
|
||||
obj = getattr(obj, part)
|
||||
except (KeyError, AttributeError) as e:
|
||||
@@ -257,7 +261,7 @@ class MyTool(Tool):
|
||||
# SubagentManager: delegate to its _task_statuses dict
|
||||
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
|
||||
return MyTool._format_value(val._task_statuses, key)
|
||||
if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
|
||||
if isinstance(val, Mapping) and val and _is_subagent_status(next(iter(val.values()))):
|
||||
prefix = f"{key}: " if key else ""
|
||||
lines = [f"{prefix}{len(val)} subagent(s):"]
|
||||
for tid, st in val.items():
|
||||
@@ -270,8 +274,8 @@ class MyTool(Tool):
|
||||
if isinstance(val, (str, int, float, bool, type(None))):
|
||||
r = repr(val)
|
||||
return f"{key}: {r}" if key else r
|
||||
# Dict — small: show content; large: show keys for dot-path navigation
|
||||
if isinstance(val, dict):
|
||||
# Mapping — small: show content; large: show keys for dot-path navigation
|
||||
if isinstance(val, Mapping):
|
||||
ks = list(val.keys())
|
||||
if not ks:
|
||||
return f"{key}: {{}}" if key else "{}"
|
||||
@@ -447,6 +451,23 @@ class MyTool(Tool):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||
name = value.strip()
|
||||
session_key = current_request_session_key()
|
||||
if session_key:
|
||||
try:
|
||||
runtime = self._runtime_state.set_session_model_preset(
|
||||
session_key,
|
||||
name,
|
||||
)
|
||||
except (KeyError, ValueError) as exc:
|
||||
message = str(exc.args[0]) if exc.args else str(exc)
|
||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||
self._audit("modify", f"model_preset = {name!r}")
|
||||
return (
|
||||
f"Set model_preset = {name!r} for the next turn; "
|
||||
f"model will be {runtime.model!r}; "
|
||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||
)
|
||||
result = self._modify_free("model_preset", name)
|
||||
if isinstance(result, ToolResult) and result.is_error:
|
||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||
@@ -472,6 +493,11 @@ class MyTool(Tool):
|
||||
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
|
||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
|
||||
if key in {"model", "context_window_tokens"} and current_request_session_key():
|
||||
return ToolResult.error(
|
||||
f"Error: direct '{key}' changes are instance-wide and disabled "
|
||||
"during an active session; use a configured model_preset"
|
||||
)
|
||||
if key == "model":
|
||||
self._runtime_state.set_runtime_model(value)
|
||||
elif key == "context_window_tokens":
|
||||
|
||||
@@ -6,6 +6,8 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
@@ -188,6 +190,7 @@ class ExecTool(Tool):
|
||||
allowed_env_keys=cfg.allowed_env_keys,
|
||||
allow_patterns=cfg.allow_patterns,
|
||||
deny_patterns=cfg.deny_patterns,
|
||||
session_manager=getattr(ctx, "exec_session_manager", None),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -515,6 +518,7 @@ class ExecTool(Tool):
|
||||
login: bool = False,
|
||||
*,
|
||||
stdin: int = asyncio.subprocess.DEVNULL,
|
||||
process_tree: bool = False,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Launch *command* in a platform-appropriate shell."""
|
||||
if _IS_WINDOWS:
|
||||
@@ -535,7 +539,12 @@ class ExecTool(Tool):
|
||||
env=cmd_env,
|
||||
)
|
||||
command = ExecTool._normalize_powershell_command(command)
|
||||
command = f"{command}\nif ($LASTEXITCODE -ne $null) {{ exit $LASTEXITCODE }}"
|
||||
command = (
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
||||
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
|
||||
f"{command}\n"
|
||||
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
||||
)
|
||||
return await asyncio.create_subprocess_exec(
|
||||
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
||||
stdin=stdin,
|
||||
@@ -557,6 +566,7 @@ class ExecTool(Tool):
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
**({"start_new_session": True} if process_tree else {}),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -649,6 +659,39 @@ class ExecTool(Tool):
|
||||
finally:
|
||||
_reap_pid(process.pid)
|
||||
|
||||
@staticmethod
|
||||
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
|
||||
"""Kill a session process and descendants, then reap the root process."""
|
||||
if process.returncode is not None:
|
||||
_reap_pid(process.pid)
|
||||
return
|
||||
try:
|
||||
if _IS_WINDOWS:
|
||||
with suppress(OSError, asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
subprocess.run,
|
||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
if process.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
finally:
|
||||
_reap_pid(process.pid)
|
||||
|
||||
def _build_env(self) -> dict[str, str]:
|
||||
"""Build a minimal environment for subprocess execution.
|
||||
|
||||
@@ -712,9 +755,12 @@ class ExecTool(Tool):
|
||||
|
||||
# allow_patterns take priority over deny_patterns so that users can
|
||||
# exempt specific commands (e.g. "rm -rf" inside a build directory)
|
||||
# from the hardcoded deny list via configuration.
|
||||
explicitly_allowed = bool(self.allow_patterns) and any(
|
||||
re.fullmatch(p, lower) for p in self.allow_patterns
|
||||
# from the hardcoded deny list via configuration. A chained command is
|
||||
# only explicitly allowed when every top-level shell segment matches.
|
||||
segments = self._split_shell_segments(lower)
|
||||
explicitly_allowed = bool(self.allow_patterns) and bool(segments) and all(
|
||||
any(re.fullmatch(pattern, segment) for pattern in self.allow_patterns)
|
||||
for segment in segments
|
||||
)
|
||||
if not explicitly_allowed:
|
||||
for pattern in self.deny_patterns:
|
||||
@@ -779,6 +825,84 @@ class ExecTool(Tool):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _split_shell_segments(command: str) -> list[str]:
|
||||
"""Split shell commands on top-level chaining operators."""
|
||||
segments: list[str] = []
|
||||
current: list[str] = []
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
paren_depth = 0
|
||||
i = 0
|
||||
|
||||
while i < len(command):
|
||||
ch = command[i]
|
||||
|
||||
if escaped:
|
||||
current.append(ch)
|
||||
escaped = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "\\" and quote != "'":
|
||||
current.append(ch)
|
||||
escaped = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if quote is not None:
|
||||
current.append(ch)
|
||||
if ch == quote:
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch in {"'", '"', "`"}:
|
||||
current.append(ch)
|
||||
quote = ch
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "(":
|
||||
paren_depth += 1
|
||||
current.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == ")" and paren_depth > 0:
|
||||
paren_depth -= 1
|
||||
current.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
operator_len = 0
|
||||
if paren_depth == 0:
|
||||
if command.startswith(("&&", "||"), i):
|
||||
operator_len = 2
|
||||
elif ch == "&" and not (
|
||||
(i > 0 and command[i - 1] in "<>") or command.startswith("&>", i)
|
||||
):
|
||||
current.append(ch)
|
||||
operator_len = 1
|
||||
elif ch in {";", "|"}:
|
||||
operator_len = 1
|
||||
|
||||
if operator_len:
|
||||
segment = "".join(current).strip()
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
current = []
|
||||
i += operator_len
|
||||
continue
|
||||
|
||||
current.append(ch)
|
||||
i += 1
|
||||
|
||||
segment = "".join(current).strip()
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
return segments
|
||||
|
||||
@classmethod
|
||||
def _is_benign_device_path(cls, path: str) -> bool:
|
||||
"""Return True for kernel device files that should never be workspace-blocked."""
|
||||
@@ -794,6 +918,6 @@ class ExecTool(Tool):
|
||||
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
|
||||
command
|
||||
)
|
||||
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
|
||||
posix_paths = re.findall(r"(?:^|[\s|>='\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
|
||||
home_paths = re.findall(r"(?:^|[\s>='\"])(~[/+][^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~/ or ~+
|
||||
return win_paths + posix_paths + home_paths
|
||||
|
||||
@@ -6,7 +6,12 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import current_request_context
|
||||
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
NumberSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.security.workspace_access import current_workspace_scope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,6 +31,14 @@ if TYPE_CHECKING:
|
||||
minimum=0.0,
|
||||
maximum=2.0,
|
||||
),
|
||||
wait=BooleanSchema(
|
||||
description=(
|
||||
"Wait for the subagent and return its result directly. Use this for a "
|
||||
"blocking consultation that must inform the current turn. Defaults to "
|
||||
"false for background execution."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
required=["task"],
|
||||
)
|
||||
)
|
||||
@@ -48,6 +61,7 @@ class SpawnTool(Tool):
|
||||
return (
|
||||
"Spawn a subagent to handle a task in the background. "
|
||||
"Use this for complex or time-consuming tasks that can run independently. "
|
||||
"Set wait=true for a consultation whose result must inform the current turn. "
|
||||
"The subagent will complete the task and report back when done. "
|
||||
"For deliverables or existing projects, inspect the workspace first "
|
||||
"and use a dedicated subdirectory when helpful."
|
||||
@@ -58,6 +72,7 @@ class SpawnTool(Tool):
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
temperature: float | None = None,
|
||||
wait: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Spawn a subagent to execute the given task."""
|
||||
@@ -75,7 +90,8 @@ class SpawnTool(Tool):
|
||||
origin_channel = request_ctx.channel
|
||||
origin_chat_id = request_ctx.chat_id
|
||||
session_key = request_ctx.session_key or f"{origin_channel}:{origin_chat_id}"
|
||||
return await self._manager.spawn(
|
||||
method = self._manager.run_inline if wait else self._manager.spawn
|
||||
return await method(
|
||||
task=task,
|
||||
runtime=request_ctx.runtime,
|
||||
label=label,
|
||||
|
||||
@@ -315,9 +315,8 @@ class WebSearchTool(Tool):
|
||||
config_loader = None
|
||||
if ctx.provider_snapshot_loader is not None:
|
||||
def config_loader():
|
||||
from nanobot.config.loader import load_effective_config
|
||||
|
||||
return load_effective_config().tools.web.search
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
return resolve_config_env_vars(load_config()).tools.web.search
|
||||
return cls(
|
||||
config=ctx.config.web.search,
|
||||
proxy=ctx.config.web.proxy,
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Route and publish the user-visible lifecycle of an agent turn."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
RetryWaitEvent,
|
||||
StreamDeltaEvent,
|
||||
StreamedResponseEvent,
|
||||
StreamEndEvent,
|
||||
outbound_message_for_event,
|
||||
)
|
||||
from nanobot.bus.progress import build_bus_progress_callback
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnRoute:
|
||||
"""Turn delivery destination and lifecycle policy, separate from execution input."""
|
||||
|
||||
channel: str
|
||||
chat_id: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
publish_lifecycle: bool = False
|
||||
|
||||
|
||||
TurnRoutePolicy = Callable[[InboundMessage, str, TurnRoute], TurnRoute]
|
||||
ProgressCallback = Callable[..., Awaitable[None]]
|
||||
StreamCallback = Callable[[str], Awaitable[None]]
|
||||
StreamEndCallback = Callable[..., Awaitable[None]]
|
||||
RetryWaitCallback = Callable[[str], Awaitable[None]]
|
||||
|
||||
|
||||
class TurnDeliveryFactory:
|
||||
"""Create per-turn delivery objects from an optional edge-owned route policy."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: MessageBus,
|
||||
runtime_events: RuntimeEventBus,
|
||||
route_policy: TurnRoutePolicy | None = None,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.runtime_events = runtime_events
|
||||
self.runtime_event_publisher = RuntimeEventPublisher(runtime_events)
|
||||
self.route_policy = route_policy
|
||||
|
||||
def create(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
session_key: str,
|
||||
*,
|
||||
enable_stream: bool = False,
|
||||
) -> TurnDelivery:
|
||||
route = self._default_route(msg, session_key)
|
||||
if self.route_policy is not None:
|
||||
route = self.route_policy(msg, session_key, route)
|
||||
if not isinstance(route, TurnRoute):
|
||||
raise TypeError("turn route policy must return TurnRoute")
|
||||
return TurnDelivery(
|
||||
bus=self.bus,
|
||||
runtime_event_publisher=self.runtime_event_publisher,
|
||||
input_message=msg,
|
||||
session_key=session_key,
|
||||
route=route,
|
||||
enable_stream=enable_stream,
|
||||
)
|
||||
|
||||
def unrouted(self, msg: InboundMessage, session_key: str) -> TurnDelivery:
|
||||
"""Create a lifecycle fallback without invoking edge routing policy."""
|
||||
return TurnDelivery(
|
||||
bus=self.bus,
|
||||
runtime_event_publisher=self.runtime_event_publisher,
|
||||
input_message=msg,
|
||||
session_key=session_key,
|
||||
route=TurnRoute(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
metadata=dict(msg.metadata or {}),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _default_route(msg: InboundMessage, session_key: str) -> TurnRoute:
|
||||
if msg.channel != "system":
|
||||
return TurnRoute(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
metadata=dict(msg.metadata or {}),
|
||||
publish_lifecycle=True,
|
||||
)
|
||||
|
||||
channel, chat_id = (
|
||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||
)
|
||||
metadata: dict[str, Any] = {}
|
||||
if (
|
||||
channel == "slack"
|
||||
and session_key.startswith("slack:")
|
||||
and session_key.count(":") >= 2
|
||||
):
|
||||
metadata["slack"] = {"thread_ts": session_key.split(":", 2)[2]}
|
||||
if origin_message_id := msg.metadata.get("origin_message_id"):
|
||||
metadata["origin_message_id"] = origin_message_id
|
||||
return TurnRoute(channel=channel, chat_id=chat_id, metadata=metadata)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnDelivery:
|
||||
"""Own routing, callbacks, and lifecycle publication for one turn."""
|
||||
|
||||
bus: MessageBus
|
||||
runtime_event_publisher: RuntimeEventPublisher
|
||||
input_message: InboundMessage
|
||||
session_key: str
|
||||
route: TurnRoute
|
||||
enable_stream: bool = False
|
||||
delivery_message: InboundMessage = field(init=False)
|
||||
lifecycle_message: InboundMessage = field(init=False)
|
||||
_stream_base_id: str | None = field(init=False, default=None)
|
||||
_stream_segment: int = field(init=False, default=0)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.delivery_message = dataclasses.replace(
|
||||
self.input_message,
|
||||
channel=self.route.channel,
|
||||
chat_id=self.route.chat_id,
|
||||
metadata=dict(self.route.metadata),
|
||||
)
|
||||
self.lifecycle_message = (
|
||||
self.delivery_message if self.route.publish_lifecycle else self.input_message
|
||||
)
|
||||
if self.enable_stream and self.delivery_message.metadata.get("_wants_stream"):
|
||||
self._stream_base_id = f"{self.session_key}:{time.time_ns()}"
|
||||
|
||||
@property
|
||||
def on_stream(self) -> StreamCallback | None:
|
||||
return self._publish_stream if self._stream_base_id is not None else None
|
||||
|
||||
@property
|
||||
def on_stream_end(self) -> StreamEndCallback | None:
|
||||
return self._publish_stream_end if self._stream_base_id is not None else None
|
||||
|
||||
def progress_callback(self) -> ProgressCallback | None:
|
||||
if not self.route.publish_lifecycle:
|
||||
return None
|
||||
return build_bus_progress_callback(self.bus, self.delivery_message)
|
||||
|
||||
def retry_wait_callback(self) -> RetryWaitCallback | None:
|
||||
if not self.route.publish_lifecycle:
|
||||
return None
|
||||
|
||||
async def _on_retry_wait(content: str) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=self.delivery_message.channel,
|
||||
chat_id=self.delivery_message.chat_id,
|
||||
event=RetryWaitEvent(content=content),
|
||||
metadata=self.delivery_message.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
return _on_retry_wait
|
||||
|
||||
async def started(self) -> None:
|
||||
if self.route.publish_lifecycle:
|
||||
await self.runtime_event_publisher.session_turn_started(
|
||||
self.delivery_message,
|
||||
self.session_key,
|
||||
)
|
||||
|
||||
async def running(self, *, started_at: float) -> None:
|
||||
if self.route.publish_lifecycle:
|
||||
await self.runtime_event_publisher.run_status_changed(
|
||||
self.delivery_message,
|
||||
self.session_key,
|
||||
"running",
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
def record_runtime(self, runtime: Any) -> None:
|
||||
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
|
||||
|
||||
def record_latency(self, latency_ms: int | None) -> None:
|
||||
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
|
||||
|
||||
def background_response(
|
||||
self,
|
||||
content: str | None,
|
||||
*,
|
||||
stop_reason: str,
|
||||
streamed: bool,
|
||||
latency_ms: int | None,
|
||||
) -> OutboundMessage:
|
||||
metadata = dict(self.route.metadata)
|
||||
if self.route.publish_lifecycle and latency_ms is not None:
|
||||
metadata["latency_ms"] = int(latency_ms)
|
||||
event = (
|
||||
StreamedResponseEvent()
|
||||
if self.route.publish_lifecycle
|
||||
and streamed
|
||||
and stop_reason not in {"error", "tool_error"}
|
||||
else None
|
||||
)
|
||||
return OutboundMessage(
|
||||
channel=self.route.channel,
|
||||
chat_id=self.route.chat_id,
|
||||
content=content or "Background task completed.",
|
||||
metadata=metadata,
|
||||
event=event,
|
||||
)
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
response: OutboundMessage | None,
|
||||
*,
|
||||
publish_completion: bool,
|
||||
) -> None:
|
||||
completed_channel = self.lifecycle_message.channel
|
||||
completed_chat_id = self.lifecycle_message.chat_id
|
||||
if response is not None:
|
||||
await self.bus.publish_outbound(response)
|
||||
completed_channel = response.channel
|
||||
completed_chat_id = response.chat_id
|
||||
elif self.lifecycle_message.channel == "cli":
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=self.lifecycle_message.channel,
|
||||
chat_id=self.lifecycle_message.chat_id,
|
||||
content="",
|
||||
metadata=dict(self.lifecycle_message.metadata or {}),
|
||||
)
|
||||
)
|
||||
if publish_completion:
|
||||
await self.runtime_event_publisher.turn_completed(
|
||||
channel=completed_channel,
|
||||
chat_id=completed_chat_id,
|
||||
session_key=self.session_key,
|
||||
metadata=self.lifecycle_message.metadata,
|
||||
)
|
||||
|
||||
async def fail(self, *, publish_completion: bool) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=self.lifecycle_message.channel,
|
||||
chat_id=self.lifecycle_message.chat_id,
|
||||
content="Sorry, I encountered an error.",
|
||||
metadata=dict(self.lifecycle_message.metadata or {}),
|
||||
)
|
||||
)
|
||||
if publish_completion:
|
||||
await self.runtime_event_publisher.turn_completed(
|
||||
channel=self.lifecycle_message.channel,
|
||||
chat_id=self.lifecycle_message.chat_id,
|
||||
session_key=self.session_key,
|
||||
metadata=self.lifecycle_message.metadata,
|
||||
)
|
||||
|
||||
async def idle(self) -> None:
|
||||
await self.runtime_event_publisher.run_status_changed(
|
||||
self.lifecycle_message,
|
||||
self.session_key,
|
||||
"idle",
|
||||
)
|
||||
self.runtime_event_publisher.clear_turn(self.session_key)
|
||||
|
||||
def _stream_id(self) -> str:
|
||||
assert self._stream_base_id is not None
|
||||
return f"{self._stream_base_id}:{self._stream_segment}"
|
||||
|
||||
async def _publish_stream(self, delta: str) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=self.delivery_message.channel,
|
||||
chat_id=self.delivery_message.chat_id,
|
||||
event=StreamDeltaEvent(content=delta, stream_id=self._stream_id()),
|
||||
metadata=self.delivery_message.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
async def _publish_stream_end(self, *, resuming: bool = False) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=self.delivery_message.channel,
|
||||
chat_id=self.delivery_message.chat_id,
|
||||
event=StreamEndEvent(
|
||||
stream_id=self._stream_id(),
|
||||
resuming=resuming,
|
||||
),
|
||||
metadata=self.delivery_message.metadata,
|
||||
)
|
||||
)
|
||||
self._stream_segment += 1
|
||||
@@ -41,6 +41,26 @@ __all__ = (
|
||||
|
||||
API_SESSION_KEY = "api:default"
|
||||
API_CHAT_ID = "default"
|
||||
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
|
||||
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
|
||||
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
|
||||
_SESSION_LOCKS_KEY = web.AppKey[dict]("session_locks")
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _app_value(
|
||||
app: Any,
|
||||
key: web.AppKey[Any],
|
||||
legacy_key: str,
|
||||
default: Any = _MISSING,
|
||||
) -> Any:
|
||||
"""Read typed aiohttp state while accepting lightweight dict test doubles."""
|
||||
try:
|
||||
return app[key]
|
||||
except KeyError:
|
||||
if default is _MISSING:
|
||||
return app[legacy_key]
|
||||
return app.get(legacy_key, default)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -209,9 +229,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
if not isinstance(content_type, str):
|
||||
content_type = ""
|
||||
|
||||
agent_loop = request.app["agent_loop"]
|
||||
timeout_s: float = request.app.get("request_timeout", 120.0)
|
||||
model_name: str = request.app.get("model_name", "nanobot")
|
||||
agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
|
||||
timeout_s: float = _app_value(
|
||||
request.app,
|
||||
_REQUEST_TIMEOUT_KEY,
|
||||
"request_timeout",
|
||||
120.0,
|
||||
)
|
||||
model_name: str = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
|
||||
|
||||
stream = False
|
||||
try:
|
||||
@@ -238,7 +263,11 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
return _error_json(400, f"Only configured model '{model_name}' is available")
|
||||
|
||||
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
|
||||
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
|
||||
session_locks: dict[str, asyncio.Lock] = _app_value(
|
||||
request.app,
|
||||
_SESSION_LOCKS_KEY,
|
||||
"session_locks",
|
||||
)
|
||||
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
|
||||
|
||||
logger.info(
|
||||
@@ -315,8 +344,6 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
return resp
|
||||
|
||||
# -- non-streaming path (original logic) --
|
||||
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
try:
|
||||
async with session_lock:
|
||||
try:
|
||||
@@ -331,24 +358,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
timeout=timeout_s,
|
||||
)
|
||||
response_text = _response_text(response)
|
||||
|
||||
if not response_text or not response_text.strip():
|
||||
logger.warning("Empty response for session {}, retrying", session_key)
|
||||
retry_response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=text,
|
||||
media=media_paths if media_paths else None,
|
||||
session_key=session_key,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
persist_user_message=False,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
response_text = _response_text(retry_response)
|
||||
if not response_text or not response_text.strip():
|
||||
logger.warning("Empty response after retry, using fallback")
|
||||
response_text = fallback
|
||||
logger.warning("Empty response for session {}, using fallback", session_key)
|
||||
response_text = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return _error_json(504, f"Request timed out after {timeout_s}s")
|
||||
@@ -366,7 +378,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
|
||||
async def handle_models(request: web.Request) -> web.Response:
|
||||
"""GET /v1/models"""
|
||||
model_name = request.app.get("model_name", "nanobot")
|
||||
model_name = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
|
||||
return web.json_response(
|
||||
{
|
||||
"object": "list",
|
||||
@@ -407,10 +419,10 @@ def create_app(
|
||||
api_key: Optional API key for Bearer-token authentication on API routes.
|
||||
"""
|
||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||
app["agent_loop"] = agent_loop
|
||||
app["model_name"] = model_name
|
||||
app["request_timeout"] = request_timeout
|
||||
app["session_locks"] = {} # per-user locks, keyed by session_key
|
||||
app[_AGENT_LOOP_KEY] = agent_loop
|
||||
app[_MODEL_NAME_KEY] = model_name
|
||||
app[_REQUEST_TIMEOUT_KEY] = request_timeout
|
||||
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
||||
|
||||
@web.middleware
|
||||
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
||||
|
||||
@@ -188,6 +188,8 @@ _BRAND_ALIASES: dict[str, str] = {
|
||||
"lark-cli": "feishu",
|
||||
"minimax-cli": "minimax",
|
||||
"obsidian-cli": "obsidian",
|
||||
"obsidian-agent": "obsidian",
|
||||
"obsidian-agent-cli": "obsidian",
|
||||
"slay-the-spire-2": "slay-the-spire-ii",
|
||||
"slay-the-spire-ii": "slay-the-spire-ii",
|
||||
"unimol-tools": "unimol-tools",
|
||||
@@ -761,19 +763,30 @@ class CliAppManager:
|
||||
|
||||
def installed_payload(self) -> dict[str, Any]:
|
||||
installed = self._load_installed()
|
||||
cached_apps, _ = self.catalog(cache_only=True)
|
||||
cached_by_name = {
|
||||
str(app.get("name") or "").lower(): app
|
||||
for app in cached_apps
|
||||
if app.get("name")
|
||||
}
|
||||
rows = []
|
||||
for name, raw_entry in sorted(installed.items()):
|
||||
entry = raw_entry if isinstance(raw_entry, dict) else {}
|
||||
strategy = str(entry.get("strategy") or "bundled")
|
||||
cached_app = cached_by_name.get(str(name).lower(), {})
|
||||
app = {
|
||||
"name": str(name),
|
||||
"display_name": str(entry.get("display_name") or name),
|
||||
"category": str(entry.get("category") or "installed"),
|
||||
"description": str(entry.get("description") or ""),
|
||||
"requires": str(entry.get("requires") or ""),
|
||||
"display_name": str(
|
||||
cached_app.get("display_name") or entry.get("display_name") or name
|
||||
),
|
||||
"category": str(cached_app.get("category") or entry.get("category") or "installed"),
|
||||
"description": str(cached_app.get("description") or entry.get("description") or ""),
|
||||
"requires": str(cached_app.get("requires") or entry.get("requires") or ""),
|
||||
"_source": str(entry.get("source") or "local"),
|
||||
"entry_point": str(entry.get("entry_point") or ""),
|
||||
"package_manager": strategy,
|
||||
"logo_url": cached_app.get("logo_url") or entry.get("logo_url"),
|
||||
"brand_color": cached_app.get("brand_color") or entry.get("brand_color"),
|
||||
}
|
||||
rows.append(self._app_payload(app, installed))
|
||||
return {
|
||||
@@ -948,6 +961,8 @@ class CliAppManager:
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
|
||||
@@ -966,6 +981,17 @@ class CliAppManager:
|
||||
"strategy": strategy,
|
||||
"installed_at": int(_now()),
|
||||
}
|
||||
for field in (
|
||||
"display_name",
|
||||
"category",
|
||||
"description",
|
||||
"requires",
|
||||
"logo_url",
|
||||
"brand_color",
|
||||
):
|
||||
value = app.get(field)
|
||||
if value not in (None, ""):
|
||||
entry[field] = value
|
||||
resolved = shutil.which(entry_point) if entry_point else None
|
||||
if resolved:
|
||||
entry["entry_point_path"] = resolved
|
||||
@@ -1340,6 +1366,8 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=effective_timeout,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from nanobot.audio.transcription_registry import (
|
||||
get_transcription_provider,
|
||||
resolve_transcription_provider,
|
||||
)
|
||||
from nanobot.config.loader import resolve_env_refs
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
|
||||
@@ -82,7 +83,7 @@ def _provider_default_api_base(provider: str) -> str | None:
|
||||
|
||||
|
||||
def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
|
||||
api_key = getattr(provider_cfg, "api_key", None) if provider_cfg else None
|
||||
api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else ""
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
@@ -97,7 +98,7 @@ def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
|
||||
|
||||
|
||||
def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str:
|
||||
api_base = getattr(provider_cfg, "api_base", None) if provider_cfg else None
|
||||
api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else ""
|
||||
if api_base:
|
||||
return api_base
|
||||
return _provider_default_api_base(provider) or ""
|
||||
|
||||
@@ -17,6 +17,7 @@ OUTBOUND_META_AGENT_UI = "_agent_ui"
|
||||
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
||||
RUNTIME_CONTROL_ACK = "_ack"
|
||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -81,6 +81,13 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
|
||||
model_preset: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnModelUpdatedEvent(OutboundEvent):
|
||||
"""The fallback model currently handling one chat turn."""
|
||||
|
||||
model: str
|
||||
|
||||
|
||||
def outbound_message_for_event(
|
||||
*,
|
||||
channel: str,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Chat channels module with plugin architecture."""
|
||||
"""Shared contracts for chat channels."""
|
||||
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
|
||||
__all__ = ["BaseChannel", "ChannelManager"]
|
||||
__all__ = ["BaseChannel"]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Small constructors shared by declarative channel manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, FieldKind, SetupRequirement
|
||||
|
||||
GROUP_POLICIES = frozenset({"mention", "open", "allowlist"})
|
||||
DIRECT_GROUP_POLICIES = frozenset({"mention", "open"})
|
||||
|
||||
|
||||
def field(
|
||||
kind: FieldKind = "string",
|
||||
*,
|
||||
choices: Iterable[str] = (),
|
||||
default: Any = None,
|
||||
writable: bool = True,
|
||||
snapshot: bool = True,
|
||||
) -> ChannelFieldSpec:
|
||||
return ChannelFieldSpec(
|
||||
kind=kind,
|
||||
choices=frozenset(choices),
|
||||
default=default,
|
||||
writable=writable,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
|
||||
def required(name: str) -> SetupRequirement:
|
||||
return SetupRequirement.field(name)
|
||||
|
||||
|
||||
def required_fields(*names: str) -> tuple[SetupRequirement, ...]:
|
||||
return tuple(required(name) for name in names)
|
||||
|
||||
|
||||
def one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||
return SetupRequirement.one_of(*alternatives)
|
||||
@@ -1,343 +1,23 @@
|
||||
"""Shared channel setup contract for configuration, display, and validation."""
|
||||
"""Resolve channel-owned setup contracts for settings consumers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||
RouteFieldType = str | tuple[str, set[str]]
|
||||
from nanobot.channels.contracts import ChannelSetupSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelFieldSpec:
|
||||
"""One channel field exposed through the settings contract."""
|
||||
|
||||
kind: FieldKind = "string"
|
||||
choices: frozenset[str] = frozenset()
|
||||
writable: bool = True
|
||||
snapshot: bool = True
|
||||
|
||||
@property
|
||||
def route_type(self) -> RouteFieldType:
|
||||
if self.kind == "enum":
|
||||
return ("enum", set(self.choices))
|
||||
return self.kind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetupRequirement:
|
||||
"""A requirement satisfied by any one complete field group."""
|
||||
|
||||
alternatives: tuple[tuple[str, ...], ...]
|
||||
|
||||
def is_satisfied(self, values: Any) -> bool:
|
||||
return any(
|
||||
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||
for group in self.alternatives
|
||||
)
|
||||
|
||||
@property
|
||||
def simple_field(self) -> str | None:
|
||||
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||
return self.alternatives[0][0]
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelSetupSpec:
|
||||
"""Save, display, and validation contract for one channel."""
|
||||
|
||||
fields: dict[str, ChannelFieldSpec]
|
||||
required: tuple[SetupRequirement, ...] = ()
|
||||
official_url: str | None = None
|
||||
|
||||
@property
|
||||
def secrets(self) -> frozenset[str]:
|
||||
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||
|
||||
@property
|
||||
def snapshot_fields(self) -> tuple[str, ...]:
|
||||
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||
|
||||
@property
|
||||
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||
return {
|
||||
name: field.route_type
|
||||
for name, field in self.fields.items()
|
||||
if field.writable
|
||||
}
|
||||
|
||||
@property
|
||||
def simple_required_fields(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
field
|
||||
for requirement in self.required
|
||||
if (field := requirement.simple_field) is not None
|
||||
)
|
||||
|
||||
def is_configured(self, values: Any) -> bool:
|
||||
return bool(self.required) and all(
|
||||
requirement.is_satisfied(values) for requirement in self.required
|
||||
)
|
||||
|
||||
|
||||
def _field(
|
||||
kind: FieldKind = "string",
|
||||
def channel_setup_spec(
|
||||
name: str,
|
||||
*,
|
||||
choices: set[str] | None = None,
|
||||
writable: bool = True,
|
||||
snapshot: bool = True,
|
||||
) -> ChannelFieldSpec:
|
||||
return ChannelFieldSpec(
|
||||
kind=kind,
|
||||
choices=frozenset(choices or ()),
|
||||
writable=writable,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
plugin: ChannelPlugin | None = None,
|
||||
) -> ChannelSetupSpec | None:
|
||||
"""Return the setup contract declared by one channel descriptor."""
|
||||
if plugin is None:
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
|
||||
|
||||
def _required(field: str) -> SetupRequirement:
|
||||
return SetupRequirement(((field,),))
|
||||
|
||||
|
||||
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||
return SetupRequirement(alternatives)
|
||||
|
||||
|
||||
_GROUP_POLICIES = {"mention", "open", "allowlist"}
|
||||
_DIRECT_GROUP_POLICIES = {"mention", "open"}
|
||||
|
||||
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
|
||||
"websocket": ChannelSetupSpec(
|
||||
fields={},
|
||||
official_url="http://127.0.0.1:8765",
|
||||
),
|
||||
"telegram": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://t.me/BotFather",
|
||||
),
|
||||
"slack": ChannelSetupSpec(
|
||||
fields={
|
||||
"appToken": _field("secret"),
|
||||
"botToken": _field("secret"),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("appToken"), _required("botToken")),
|
||||
official_url="https://api.slack.com/apps",
|
||||
),
|
||||
"discord": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"allowChannels": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://discord.com/developers/applications",
|
||||
),
|
||||
"email": ChannelSetupSpec(
|
||||
fields={
|
||||
"consentGranted": _field("bool"),
|
||||
"imapHost": _field(),
|
||||
"imapPort": _field("int"),
|
||||
"imapUsername": _field(),
|
||||
"imapPassword": _field("secret"),
|
||||
"smtpHost": _field(),
|
||||
"smtpPort": _field("int"),
|
||||
"smtpUsername": _field(),
|
||||
"smtpPassword": _field("secret"),
|
||||
"fromAddress": _field(),
|
||||
"pollIntervalSeconds": _field("int"),
|
||||
"allowFrom": _field("list"),
|
||||
"verifyDkim": _field("bool"),
|
||||
"verifySpf": _field("bool"),
|
||||
},
|
||||
required=tuple(
|
||||
_required(field)
|
||||
for field in (
|
||||
"consentGranted",
|
||||
"imapHost",
|
||||
"imapUsername",
|
||||
"imapPassword",
|
||||
"smtpHost",
|
||||
"smtpUsername",
|
||||
"smtpPassword",
|
||||
)
|
||||
),
|
||||
official_url="https://support.google.com/accounts/answer/185833",
|
||||
),
|
||||
"matrix": ChannelSetupSpec(
|
||||
fields={
|
||||
"homeserver": _field(),
|
||||
"userId": _field(),
|
||||
"password": _field("secret"),
|
||||
"accessToken": _field("secret"),
|
||||
"deviceId": _field(),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
"allowFrom": _field("list", writable=False),
|
||||
},
|
||||
required=(
|
||||
_required("homeserver"),
|
||||
_required("userId"),
|
||||
_one_of(("password",), ("accessToken", "deviceId")),
|
||||
),
|
||||
official_url="https://matrix.org/ecosystem/clients/",
|
||||
),
|
||||
"mattermost": ChannelSetupSpec(
|
||||
fields={
|
||||
"serverUrl": _field(),
|
||||
"token": _field("secret"),
|
||||
"teamId": _field(),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("serverUrl"), _required("token")),
|
||||
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
|
||||
),
|
||||
"whatsapp": ChannelSetupSpec(
|
||||
fields={
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
|
||||
"databasePath": _field(writable=False, snapshot=False),
|
||||
},
|
||||
official_url="https://faq.whatsapp.com/",
|
||||
),
|
||||
"dingtalk": ChannelSetupSpec(
|
||||
fields={
|
||||
"clientId": _field(),
|
||||
"clientSecret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("clientId"), _required("clientSecret")),
|
||||
official_url="https://open.dingtalk.com/",
|
||||
),
|
||||
"wecom": ChannelSetupSpec(
|
||||
fields={
|
||||
"botId": _field(),
|
||||
"secret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("botId"), _required("secret")),
|
||||
official_url="https://developer.work.weixin.qq.com/",
|
||||
),
|
||||
"weixin": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://weixin.qq.com/",
|
||||
),
|
||||
"qq": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(),
|
||||
"secret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"msgFormat": _field("enum", choices={"plain", "markdown"}),
|
||||
},
|
||||
required=(_required("appId"), _required("secret")),
|
||||
official_url="https://q.qq.com/",
|
||||
),
|
||||
"signal": ChannelSetupSpec(
|
||||
fields={
|
||||
"phoneNumber": _field(),
|
||||
"daemonHost": _field(),
|
||||
"daemonPort": _field("int"),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"dm.allowFrom": _field("list"),
|
||||
"group.allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("phoneNumber"),),
|
||||
official_url="https://github.com/bbernhard/signal-cli-rest-api",
|
||||
),
|
||||
"msteams": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(),
|
||||
"appPassword": _field("secret"),
|
||||
"tenantId": _field(),
|
||||
"path": _field(),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("appId"), _required("appPassword")),
|
||||
official_url="https://dev.teams.microsoft.com/apps",
|
||||
),
|
||||
"napcat": ChannelSetupSpec(
|
||||
fields={
|
||||
"wsUrl": _field(),
|
||||
"accessToken": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("wsUrl"),),
|
||||
official_url="https://napneko.github.io/",
|
||||
),
|
||||
"feishu": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(snapshot=False),
|
||||
"appSecret": _field("secret", snapshot=False),
|
||||
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
|
||||
"groupPolicy": _field(
|
||||
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
|
||||
),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"topicIsolation": _field("bool", snapshot=False),
|
||||
},
|
||||
required=(_required("appId"), _required("appSecret")),
|
||||
official_url="https://open.feishu.cn/app",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
|
||||
return CHANNEL_SETUP_SPECS.get(name)
|
||||
|
||||
|
||||
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||
current = values
|
||||
for part in field_path.split("."):
|
||||
candidates = (part, _camel_to_snake(part))
|
||||
if isinstance(current, dict):
|
||||
for candidate in candidates:
|
||||
if candidate in current:
|
||||
current = current[candidate]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
continue
|
||||
for candidate in candidates:
|
||||
if hasattr(current, candidate):
|
||||
current = getattr(current, candidate)
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def channel_value_present(value: Any) -> bool:
|
||||
return value not in (None, "", [], {})
|
||||
|
||||
|
||||
def stringify_channel_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _camel_to_snake(value: str) -> str:
|
||||
chars: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper():
|
||||
if chars:
|
||||
chars.append("_")
|
||||
chars.append(char.lower())
|
||||
else:
|
||||
chars.append(char)
|
||||
return "".join(chars)
|
||||
plugin = load_channel_plugin(name)
|
||||
return plugin.setup
|
||||
|
||||
@@ -52,9 +52,9 @@ class BaseChannel(ABC):
|
||||
resolve_transcription_config,
|
||||
transcribe_audio_file,
|
||||
)
|
||||
from nanobot.config.loader import load_raw_config
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
return await transcribe_audio_file(file_path, resolve_transcription_config(load_raw_config()))
|
||||
return await transcribe_audio_file(file_path, resolve_transcription_config(load_config()))
|
||||
except Exception:
|
||||
self.logger.exception("Audio transcription failed")
|
||||
return ""
|
||||
@@ -224,9 +224,17 @@ class BaseChannel(ABC):
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
is_dm: bool = False,
|
||||
authorization_id: str | None = None,
|
||||
) -> None:
|
||||
"""Handle an incoming message: check permissions, issue pairing codes in DMs, or forward to bus."""
|
||||
if not self.is_allowed(sender_id):
|
||||
"""Handle a message after checking its authorization subject.
|
||||
|
||||
``sender_id`` is the identity recorded on the inbound message. Channels
|
||||
where access is scoped to another entity (for example, a group or room)
|
||||
can pass that entity as ``authorization_id`` without changing the
|
||||
sender's identity. When omitted, authorization remains sender-based.
|
||||
"""
|
||||
permission_id = authorization_id if authorization_id is not None else sender_id
|
||||
if not self.is_allowed(permission_id):
|
||||
if is_dm:
|
||||
code = generate_code(self.name, str(sender_id))
|
||||
await self.send(
|
||||
@@ -270,6 +278,16 @@ class BaseChannel(ABC):
|
||||
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
|
||||
return {"enabled": False}
|
||||
|
||||
@classmethod
|
||||
def refresh_feature_metadata(
|
||||
cls,
|
||||
config_path: Path,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> bool:
|
||||
"""Refresh persisted display metadata after an explicit settings action."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the channel is running."""
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Small contract shared by channel-owned interactive connection flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
QueryParams = Mapping[str, list[str]]
|
||||
|
||||
|
||||
class ChannelConnectError(Exception):
|
||||
"""User-facing channel connection failure."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def query_first(query: QueryParams, key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
__all__ = ["ChannelConnectError", "QueryParams", "query_first"]
|
||||
@@ -0,0 +1,602 @@
|
||||
"""Stable contracts shared by channel runtimes and management surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||
RouteFieldType = str | tuple[str, set[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChannelValidationContext:
|
||||
"""Host policy passed to package-owned setup validators."""
|
||||
|
||||
allow_local_service_access: bool = False
|
||||
|
||||
|
||||
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
|
||||
DefaultConfigFactory = Callable[[], dict[str, Any]]
|
||||
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
|
||||
InstanceConfigUpdater = Callable[..., dict[str, Any]]
|
||||
RuntimeNameFactory = Callable[[str, str], str]
|
||||
FeatureInstancesFactory = Callable[..., list[dict[str, Any]] | None]
|
||||
LocalStatePresent = Callable[[Any], bool]
|
||||
|
||||
__all__ = [
|
||||
"ChannelActivation",
|
||||
"ChannelFieldSpec",
|
||||
"ChannelInstanceSpec",
|
||||
"ChannelManagementSpec",
|
||||
"ChannelSetupSpec",
|
||||
"ChannelValidationContext",
|
||||
"SetupRequirement",
|
||||
"channel_feature_instances",
|
||||
"channel_default_config",
|
||||
"channel_field_value",
|
||||
"channel_instance_config",
|
||||
"channel_instance_specs",
|
||||
"channel_local_state_present",
|
||||
"channel_runtime_name",
|
||||
"resolve_channel_action_target",
|
||||
"channel_set_config_enabled",
|
||||
"channel_update_instance_config",
|
||||
"channel_value_present",
|
||||
"refresh_channel_feature_metadata",
|
||||
"stringify_channel_value",
|
||||
]
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelActivation:
|
||||
"""Normalized enablement state used before a channel runtime is imported.
|
||||
|
||||
Channel configuration may be a Pydantic model or persisted JSON, and a
|
||||
channel may expose independently enabled instances. Instance envelopes are
|
||||
opt-in so a channel can keep using an ``instances``
|
||||
field as ordinary channel-owned configuration.
|
||||
"""
|
||||
|
||||
enabled: bool | None = None
|
||||
instances: tuple["ChannelActivation", ...] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
section: Any,
|
||||
*,
|
||||
include_instances: bool = False,
|
||||
) -> "ChannelActivation":
|
||||
values = _config_mapping(section)
|
||||
if values is None:
|
||||
raw_enabled = getattr(section, "enabled", _MISSING)
|
||||
return cls(enabled=None if raw_enabled is _MISSING else bool(raw_enabled))
|
||||
|
||||
raw_enabled = values.get("enabled", _MISSING)
|
||||
raw_instances = values.get("instances", _MISSING) if include_instances else _MISSING
|
||||
instances = (
|
||||
tuple(
|
||||
cls.from_config(item, include_instances=True)
|
||||
for item in raw_instances
|
||||
if _config_mapping(item) is not None
|
||||
)
|
||||
if isinstance(raw_instances, list)
|
||||
else None
|
||||
)
|
||||
return cls(
|
||||
enabled=None if raw_enabled is _MISSING else bool(raw_enabled),
|
||||
instances=instances,
|
||||
)
|
||||
|
||||
def resolve(self, *, default: bool = False) -> bool:
|
||||
"""Return whether the section contains at least one enabled runtime."""
|
||||
inherited = default if self.enabled is None else self.enabled
|
||||
if self.instances is None:
|
||||
return inherited
|
||||
return any(instance.resolve(default=inherited) for instance in self.instances)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelFieldSpec:
|
||||
"""One channel field exposed through the settings contract."""
|
||||
|
||||
kind: FieldKind = "string"
|
||||
choices: frozenset[str] = frozenset()
|
||||
default: Any = None
|
||||
writable: bool = True
|
||||
snapshot: bool = True
|
||||
|
||||
@property
|
||||
def route_type(self) -> RouteFieldType:
|
||||
if self.kind == "enum":
|
||||
return ("enum", set(self.choices))
|
||||
return self.kind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetupRequirement:
|
||||
"""A requirement satisfied by any one complete field group."""
|
||||
|
||||
alternatives: tuple[tuple[str, ...], ...]
|
||||
|
||||
@classmethod
|
||||
def field(cls, name: str) -> "SetupRequirement":
|
||||
"""Require one field."""
|
||||
return cls(((name,),))
|
||||
|
||||
@classmethod
|
||||
def one_of(cls, *alternatives: tuple[str, ...]) -> "SetupRequirement":
|
||||
"""Require one complete alternative field group."""
|
||||
return cls(alternatives)
|
||||
|
||||
def is_satisfied(self, values: Any) -> bool:
|
||||
return any(
|
||||
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||
for group in self.alternatives
|
||||
)
|
||||
|
||||
@property
|
||||
def simple_field(self) -> str | None:
|
||||
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||
return self.alternatives[0][0]
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelSetupSpec:
|
||||
"""Writable setup fields, requirements, and optional validation."""
|
||||
|
||||
fields: dict[str, ChannelFieldSpec]
|
||||
required: tuple[SetupRequirement, ...] = ()
|
||||
official_url: str | None = None
|
||||
validator: SetupValidator | None = None
|
||||
|
||||
@property
|
||||
def secrets(self) -> frozenset[str]:
|
||||
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||
|
||||
@property
|
||||
def snapshot_fields(self) -> tuple[str, ...]:
|
||||
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||
|
||||
@property
|
||||
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||
return {
|
||||
name: field.route_type
|
||||
for name, field in self.fields.items()
|
||||
if field.writable
|
||||
}
|
||||
|
||||
@property
|
||||
def simple_required_fields(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
field
|
||||
for requirement in self.required
|
||||
if (field := requirement.simple_field) is not None
|
||||
)
|
||||
|
||||
def is_configured(self, values: Any) -> bool:
|
||||
return bool(self.required) and all(
|
||||
requirement.is_satisfied(values) for requirement in self.required
|
||||
)
|
||||
|
||||
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
|
||||
"""Serialize the writable setup contract for generic WebUI consumers."""
|
||||
simple_required = set(self.simple_required_fields)
|
||||
fields = []
|
||||
for name, field in self.fields.items():
|
||||
if not field.writable:
|
||||
continue
|
||||
public_field = {
|
||||
"key": f"channels.{channel_name}.{name}",
|
||||
"field": name,
|
||||
"kind": field.kind,
|
||||
"choices": sorted(field.choices),
|
||||
"required": name in simple_required,
|
||||
}
|
||||
if field.default is not None:
|
||||
public_field["default_value"] = stringify_channel_value(field.default)
|
||||
fields.append(public_field)
|
||||
payload: dict[str, Any] = {
|
||||
"fields": fields,
|
||||
}
|
||||
if self.official_url:
|
||||
payload["official_url"] = self.official_url
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelInstanceSpec:
|
||||
"""One independently managed runtime instance."""
|
||||
|
||||
instance_id: str
|
||||
config: Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelManagementSpec:
|
||||
"""Dependency-free adapter for persisted channel state.
|
||||
|
||||
Runtime classes own network and message lifecycle only. A multi-instance
|
||||
channel supplies these callbacks from a module that can be imported without
|
||||
its optional platform SDK.
|
||||
"""
|
||||
|
||||
multi_instance: bool = False
|
||||
default_config: DefaultConfigFactory | None = None
|
||||
instance_specs: InstanceSpecsFactory | None = None
|
||||
update_instance_config: InstanceConfigUpdater | None = None
|
||||
runtime_name: RuntimeNameFactory | None = None
|
||||
feature_instances: FeatureInstancesFactory | None = None
|
||||
local_state_present: LocalStatePresent | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
multi_instance_callbacks = {
|
||||
"instance_specs": self.instance_specs,
|
||||
"update_instance_config": self.update_instance_config,
|
||||
"runtime_name": self.runtime_name,
|
||||
"feature_instances": self.feature_instances,
|
||||
}
|
||||
if not self.multi_instance:
|
||||
unexpected = [
|
||||
name for name, callback in multi_instance_callbacks.items() if callback is not None
|
||||
]
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
"single-instance channel management cannot define "
|
||||
+ ", ".join(unexpected)
|
||||
)
|
||||
if self.multi_instance and self.instance_specs is None:
|
||||
raise ValueError("multi-instance channel management requires instance_specs")
|
||||
if self.multi_instance and self.update_instance_config is None:
|
||||
raise ValueError("multi-instance channel management requires update_instance_config")
|
||||
|
||||
|
||||
def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
|
||||
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
|
||||
if plugin.setup is not None:
|
||||
for name, field in plugin.setup.fields.items():
|
||||
value = field.default
|
||||
if value is None:
|
||||
value = {
|
||||
"string": "",
|
||||
"secret": "",
|
||||
"list": [],
|
||||
"bool": False,
|
||||
}.get(field.kind, _MISSING)
|
||||
if value is not _MISSING:
|
||||
_assign_channel_field(defaults, name, deepcopy(value))
|
||||
|
||||
factory = plugin.management.default_config
|
||||
if factory is None:
|
||||
return defaults
|
||||
values = factory()
|
||||
if not isinstance(values, dict):
|
||||
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
|
||||
return merge_missing_defaults(values, defaults)
|
||||
|
||||
|
||||
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
|
||||
target = values
|
||||
parts = field.split(".")
|
||||
for part in parts[:-1]:
|
||||
nested = target.get(part)
|
||||
if not isinstance(nested, dict):
|
||||
nested = {}
|
||||
target[part] = nested
|
||||
target = nested
|
||||
target[parts[-1]] = value
|
||||
|
||||
|
||||
def channel_local_state_present(plugin: ChannelPlugin, section: Any) -> bool:
|
||||
checker = plugin.management.local_state_present
|
||||
return bool(checker and checker(section))
|
||||
|
||||
|
||||
def channel_runtime_name(plugin: ChannelPlugin, instance_id: str = "default") -> str:
|
||||
factory = plugin.management.runtime_name
|
||||
if factory is None:
|
||||
if instance_id not in {"", "default"}:
|
||||
raise ValueError(f"{plugin.name} does not support multiple instances")
|
||||
runtime_name = plugin.name
|
||||
else:
|
||||
runtime_name = str(factory(plugin.name, instance_id))
|
||||
_validate_runtime_name(plugin, runtime_name)
|
||||
return runtime_name
|
||||
|
||||
|
||||
def channel_instance_specs(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
*,
|
||||
enabled_only: bool = True,
|
||||
) -> list[ChannelInstanceSpec]:
|
||||
"""Expand persisted config through the dependency-free management adapter."""
|
||||
factory = plugin.management.instance_specs
|
||||
if factory is None:
|
||||
activation = ChannelActivation.from_config(section)
|
||||
raw_specs: Iterable[ChannelInstanceSpec] = (
|
||||
[]
|
||||
if enabled_only and not activation.resolve(default=plugin.default_enabled)
|
||||
else [ChannelInstanceSpec(instance_id="default", config=section)]
|
||||
)
|
||||
else:
|
||||
raw_specs = factory(section, enabled_only=enabled_only)
|
||||
if not isinstance(raw_specs, Iterable):
|
||||
raise TypeError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
|
||||
)
|
||||
specs = list(raw_specs)
|
||||
|
||||
instance_ids: set[str] = set()
|
||||
runtime_names: set[str] = set()
|
||||
for spec in specs:
|
||||
if not isinstance(spec, ChannelInstanceSpec):
|
||||
raise TypeError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
|
||||
)
|
||||
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
|
||||
)
|
||||
if spec.instance_id in instance_ids:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate instance id "
|
||||
f"'{spec.instance_id}'"
|
||||
)
|
||||
runtime_name = channel_runtime_name(plugin, spec.instance_id)
|
||||
if runtime_name in runtime_names:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate runtime name "
|
||||
f"'{runtime_name}'"
|
||||
)
|
||||
instance_ids.add(spec.instance_id)
|
||||
runtime_names.add(runtime_name)
|
||||
return specs
|
||||
|
||||
|
||||
def resolve_channel_action_target(
|
||||
requested_instance_id: str | None,
|
||||
) -> str:
|
||||
"""Resolve a feature action to an explicit or default instance."""
|
||||
return (requested_instance_id or "").strip() or "default"
|
||||
|
||||
|
||||
def channel_instance_config(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
"""Return editable config for one instance."""
|
||||
selected = next(
|
||||
(
|
||||
spec
|
||||
for spec in channel_instance_specs(plugin, section, enabled_only=False)
|
||||
if spec.instance_id == instance_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
return {}
|
||||
config = selected.config
|
||||
if hasattr(config, "model_dump"):
|
||||
return dict(config.model_dump(mode="json", by_alias=True))
|
||||
return dict(config) if isinstance(config, dict) else {}
|
||||
|
||||
|
||||
def channel_update_instance_config(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
updater = plugin.management.update_instance_config
|
||||
if updater is None:
|
||||
if instance_id not in {"", "default"}:
|
||||
raise ValueError(f"{plugin.name} does not support multiple instances")
|
||||
return values
|
||||
return updater(section, values, instance_id=instance_id)
|
||||
|
||||
|
||||
def channel_set_config_enabled(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
enabled: bool,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle one instance while preserving channel-owned config shape."""
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
|
||||
values = channel_instance_config(plugin, section, instance_id=instance_id)
|
||||
values = merge_missing_defaults(values, channel_default_config(plugin))
|
||||
values["enabled"] = enabled
|
||||
return channel_update_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
values,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
|
||||
def channel_feature_instances(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
*,
|
||||
setup_spec: ChannelSetupSpec | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
factory = plugin.management.feature_instances
|
||||
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None
|
||||
if overrides is None and not plugin.management.multi_instance:
|
||||
return None
|
||||
if overrides is not None and (
|
||||
not isinstance(overrides, list)
|
||||
or any(not isinstance(instance, dict) for instance in overrides)
|
||||
):
|
||||
raise TypeError(
|
||||
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||
"must return a list of dicts or None"
|
||||
)
|
||||
|
||||
enabled_ids = {
|
||||
spec.instance_id for spec in channel_instance_specs(plugin, section, enabled_only=True)
|
||||
}
|
||||
|
||||
instances = [
|
||||
_channel_feature_instance(
|
||||
plugin.name,
|
||||
spec,
|
||||
setup_spec,
|
||||
enabled=spec.instance_id in enabled_ids,
|
||||
)
|
||||
for spec in channel_instance_specs(plugin, section, enabled_only=False)
|
||||
]
|
||||
if overrides is None:
|
||||
return instances
|
||||
|
||||
by_id = {instance["id"]: instance for instance in instances}
|
||||
seen: set[str] = set()
|
||||
for override in overrides:
|
||||
instance_id = override.get("id")
|
||||
if not isinstance(instance_id, str) or instance_id not in by_id:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||
"returned unknown instance id "
|
||||
f"'{instance_id}'"
|
||||
)
|
||||
if instance_id in seen:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||
"returned duplicate instance id "
|
||||
f"'{instance_id}'"
|
||||
)
|
||||
seen.add(instance_id)
|
||||
for field in ("name", "display_name", "avatar_url"):
|
||||
if field in override:
|
||||
by_id[instance_id][field] = str(override[field] or "")
|
||||
return instances
|
||||
|
||||
|
||||
def refresh_channel_feature_metadata(
|
||||
channel_cls: type[Any],
|
||||
config_path: Path,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> bool:
|
||||
return bool(channel_cls.refresh_feature_metadata(config_path, instance_id=instance_id))
|
||||
|
||||
|
||||
def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
|
||||
channel_name = str(plugin.name).strip()
|
||||
if not channel_name:
|
||||
raise ValueError("ChannelPlugin.name must not be empty")
|
||||
if not isinstance(runtime_name, str) or not runtime_name.strip():
|
||||
raise ValueError(f"ChannelPlugin.management for '{plugin.name}' returned an empty runtime name")
|
||||
if runtime_name != channel_name and not runtime_name.startswith(f"{channel_name}."):
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management runtime name '{runtime_name}' must be scoped under "
|
||||
f"'{channel_name}'"
|
||||
)
|
||||
|
||||
|
||||
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||
current = values
|
||||
for part in field_path.split("."):
|
||||
candidates = (part, _camel_to_snake(part))
|
||||
if isinstance(current, dict):
|
||||
for candidate in candidates:
|
||||
if candidate in current:
|
||||
current = current[candidate]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
continue
|
||||
for candidate in candidates:
|
||||
if hasattr(current, candidate):
|
||||
current = getattr(current, candidate)
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def channel_value_present(value: Any) -> bool:
|
||||
return value not in (None, "", [], {})
|
||||
|
||||
|
||||
def stringify_channel_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _channel_feature_instance(
|
||||
channel_name: str,
|
||||
instance: ChannelInstanceSpec,
|
||||
setup_spec: ChannelSetupSpec | None,
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> dict[str, Any]:
|
||||
config = instance.config
|
||||
name = str(channel_field_value(config, "name") or instance.instance_id).strip()
|
||||
display_name = str(channel_field_value(config, "displayName") or name).strip()
|
||||
avatar_url = str(channel_field_value(config, "avatarUrl") or "").strip()
|
||||
config_values: dict[str, str] = {}
|
||||
configured_fields: list[str] = []
|
||||
setup_fields = setup_spec.fields.items() if setup_spec else ()
|
||||
for field_name, field_spec in setup_fields:
|
||||
if not field_spec.writable:
|
||||
continue
|
||||
value = channel_field_value(config, field_name)
|
||||
if not channel_value_present(value):
|
||||
continue
|
||||
key = f"channels.{channel_name}.{field_name}"
|
||||
configured_fields.append(key)
|
||||
if field_spec.kind != "secret":
|
||||
config_values[key] = stringify_channel_value(value)
|
||||
|
||||
return {
|
||||
"id": instance.instance_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"avatar_url": avatar_url,
|
||||
"enabled": enabled,
|
||||
"configured": bool(setup_spec and setup_spec.is_configured(config)),
|
||||
"config_values": config_values,
|
||||
"configured_fields": configured_fields,
|
||||
}
|
||||
|
||||
|
||||
def _config_mapping(value: Any) -> dict[str, Any] | None:
|
||||
if hasattr(value, "model_dump"):
|
||||
dumped = value.model_dump(mode="json", by_alias=True)
|
||||
return dumped if isinstance(dumped, dict) else None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _camel_to_snake(value: str) -> str:
|
||||
chars: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper():
|
||||
if chars:
|
||||
chars.append("_")
|
||||
chars.append(char.lower())
|
||||
else:
|
||||
chars.append(char)
|
||||
return "".join(chars)
|
||||
@@ -0,0 +1 @@
|
||||
"""DingTalk channel package."""
|
||||
@@ -0,0 +1,24 @@
|
||||
"""DingTalk management contract."""
|
||||
|
||||
from nanobot.channels._manifest import field, required_fields
|
||||
from nanobot.channels.contracts import ChannelSetupSpec
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"clientId": field(),
|
||||
"clientSecret": field("secret"),
|
||||
"allowFrom": field("list"),
|
||||
},
|
||||
required=required_fields("clientId", "clientSecret"),
|
||||
official_url="https://open.dingtalk.com/",
|
||||
)
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="dingtalk",
|
||||
display_name="DingTalk",
|
||||
runtime=f"{__package__}.runtime:DingTalkChannel",
|
||||
setup=SETUP_SPEC,
|
||||
dependencies=("dingtalk-stream>=0.24.0,<1.0.0",),
|
||||
webui="webui/index.ts",
|
||||
)
|
||||
@@ -710,10 +710,11 @@ class DingTalkChannel(BaseChannel):
|
||||
"""Send a message through DingTalk."""
|
||||
token = await self._get_access_token()
|
||||
if not token:
|
||||
return
|
||||
raise RuntimeError("DingTalk access token unavailable")
|
||||
|
||||
if msg.content and msg.content.strip():
|
||||
await self._send_markdown_text(token, msg.chat_id, msg.content.strip())
|
||||
if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
|
||||
raise RuntimeError("DingTalk text message was not delivered")
|
||||
|
||||
for media_ref in msg.media or []:
|
||||
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
|
||||
@@ -722,11 +723,12 @@ class DingTalkChannel(BaseChannel):
|
||||
self.logger.error("media send failed for {}", media_ref)
|
||||
# Send visible fallback so failures are observable by the user.
|
||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||
await self._send_markdown_text(
|
||||
if not await self._send_markdown_text(
|
||||
token,
|
||||
msg.chat_id,
|
||||
f"[Attachment send failed: {filename}]",
|
||||
)
|
||||
):
|
||||
raise RuntimeError("DingTalk attachment fallback was not delivered")
|
||||
|
||||
async def _on_message(
|
||||
self,
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the DingTalk channel package."""
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -16,9 +17,14 @@ except ImportError:
|
||||
if not DINGTALK_AVAILABLE:
|
||||
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
|
||||
|
||||
import nanobot.channels.dingtalk as dingtalk_module
|
||||
import nanobot.channels.dingtalk.runtime as dingtalk_module
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.dingtalk import DingTalkChannel, DingTalkConfig, NanobotDingTalkHandler
|
||||
from nanobot.channels.dingtalk.runtime import (
|
||||
DingTalkChannel,
|
||||
DingTalkConfig,
|
||||
NanobotDingTalkHandler,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -864,6 +870,35 @@ async def test_send_batch_message_returns_false_on_api_error() -> None:
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_raises_when_access_token_is_unavailable(monkeypatch) -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value=None))
|
||||
|
||||
with pytest.raises(RuntimeError, match="access token unavailable"):
|
||||
await channel.send(
|
||||
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_raises_when_text_is_not_delivered(monkeypatch) -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value="token"))
|
||||
monkeypatch.setattr(channel, "_send_markdown_text", AsyncMock(return_value=False))
|
||||
|
||||
with pytest.raises(RuntimeError, match="text message was not delivered"):
|
||||
await channel.send(
|
||||
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
|
||||
"""When the first send fails with a transport error, _send_media_ref must
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config.loader import save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate(
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"clientId": "ding-client",
|
||||
"clientSecret": "ding-secret",
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
result = validate_channel_config("dingtalk", {})
|
||||
|
||||
assert result["status"] == "configured"
|
||||
assert result["can_enable"] is True
|
||||
assert any(check["status"] == "skipped" for check in result["checks"])
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||
|
||||
export default {
|
||||
presentation: {
|
||||
displayName: "DingTalk",
|
||||
initials: "DT",
|
||||
color: "#1677FF",
|
||||
logoUrl:
|
||||
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
|
||||
setup: {
|
||||
mode: "credentials",
|
||||
docsUrl: chatAppGuideUrl("dingtalk"),
|
||||
fields: [
|
||||
{ key: "channels.dingtalk.clientId" },
|
||||
{ key: "channels.dingtalk.clientSecret" },
|
||||
{ key: "channels.dingtalk.allowFrom" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Use nanobot from DingTalk groups.",
|
||||
"requirements": "DingTalk app credentials and gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Open DingTalk setup",
|
||||
"officialLabel": "Open DingTalk console",
|
||||
"tryIt": "Send a test message from the DingTalk group where the app is installed.",
|
||||
"summary": "DingTalk needs app credentials from Stream mode.",
|
||||
"steps": [
|
||||
"Create or choose a DingTalk app with Stream mode enabled.",
|
||||
"Add Client ID and Client Secret.",
|
||||
"Save and enable DingTalk, then send a test message."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "DingTalk client ID",
|
||||
"help": "Copy it from DingTalk app credentials."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Copy it from the same DingTalk app credentials page."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Allowed users",
|
||||
"placeholder": "User IDs, comma separated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Usa nanobot desde grupos de DingTalk.",
|
||||
"requirements": "Credenciales de la app de DingTalk y gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guía de DingTalk",
|
||||
"officialLabel": "Abrir consola de DingTalk",
|
||||
"tryIt": "Envía un mensaje de prueba desde el grupo de DingTalk donde está instalada la app.",
|
||||
"summary": "DingTalk necesita credenciales de una app en modo Stream.",
|
||||
"steps": [
|
||||
"Crea o elige una app de DingTalk con el modo Stream activado.",
|
||||
"Añade el Client ID y el Client Secret.",
|
||||
"Guarda y activa DingTalk; después envía un mensaje de prueba."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID de DingTalk",
|
||||
"help": "Cópialo de las credenciales de la app de DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Cópialo de la misma página de credenciales."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Usuarios permitidos",
|
||||
"placeholder": "ID de usuario separados por comas"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Utilisez nanobot depuis les groupes DingTalk.",
|
||||
"requirements": "Identifiants d’application DingTalk et passerelle",
|
||||
"setup": {
|
||||
"docsLabel": "Ouvrir le guide DingTalk",
|
||||
"officialLabel": "Ouvrir la console DingTalk",
|
||||
"tryIt": "Envoyez un message test dans le groupe DingTalk où l’application est installée.",
|
||||
"summary": "DingTalk nécessite les identifiants d’une application en mode Stream.",
|
||||
"steps": [
|
||||
"Créez ou choisissez une application DingTalk avec le mode Stream activé.",
|
||||
"Ajoutez le Client ID et le Client Secret.",
|
||||
"Enregistrez et activez DingTalk, puis envoyez un message test."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID DingTalk",
|
||||
"help": "Copiez-le depuis les identifiants de l’application DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Copiez-le depuis la même page d’identifiants DingTalk."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Utilisateurs autorisés",
|
||||
"placeholder": "ID utilisateur séparés par des virgules"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Gunakan nanobot dari grup DingTalk.",
|
||||
"requirements": "Kredensial aplikasi DingTalk dan gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Buka panduan DingTalk",
|
||||
"officialLabel": "Buka konsol DingTalk",
|
||||
"tryIt": "Kirim pesan uji dari grup DingTalk tempat aplikasi dipasang.",
|
||||
"summary": "DingTalk memerlukan kredensial aplikasi dari mode Stream.",
|
||||
"steps": [
|
||||
"Buat atau pilih aplikasi DingTalk dengan mode Stream aktif.",
|
||||
"Tambahkan Client ID dan Client Secret.",
|
||||
"Simpan dan aktifkan DingTalk, lalu kirim pesan uji."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID DingTalk",
|
||||
"help": "Salin dari kredensial aplikasi DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Salin dari halaman kredensial yang sama."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Pengguna yang diizinkan",
|
||||
"placeholder": "ID pengguna, dipisahkan koma"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "DingTalk グループから nanobot を利用します。",
|
||||
"requirements": "DingTalk アプリの認証情報とゲートウェイ",
|
||||
"setup": {
|
||||
"docsLabel": "DingTalk 設定ガイドを開く",
|
||||
"officialLabel": "DingTalk コンソールを開く",
|
||||
"tryIt": "アプリをインストールした DingTalk グループからテストメッセージを送信します。",
|
||||
"summary": "DingTalk には Stream モードのアプリ認証情報が必要です。",
|
||||
"steps": [
|
||||
"Stream モードを有効にした DingTalk アプリを作成または選択します。",
|
||||
"Client ID と Client Secret を追加します。",
|
||||
"保存して DingTalk を有効にし、テストメッセージを送信します。"
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "DingTalk Client ID",
|
||||
"help": "DingTalk アプリの認証情報からコピーします。"
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "同じ認証情報ページからコピーします。"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "許可するユーザー",
|
||||
"placeholder": "ユーザー ID(カンマ区切り)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "DingTalk 그룹에서 nanobot을 사용합니다.",
|
||||
"requirements": "DingTalk 앱 자격 증명 및 게이트웨이",
|
||||
"setup": {
|
||||
"docsLabel": "DingTalk 설정 가이드 열기",
|
||||
"officialLabel": "DingTalk 콘솔 열기",
|
||||
"tryIt": "앱이 설치된 DingTalk 그룹에서 테스트 메시지를 보내세요.",
|
||||
"summary": "DingTalk에는 Stream 모드 앱 자격 증명이 필요합니다.",
|
||||
"steps": [
|
||||
"Stream 모드가 활성화된 DingTalk 앱을 만들거나 선택하세요.",
|
||||
"Client ID와 Client Secret을 추가하세요.",
|
||||
"저장하고 DingTalk을 활성화한 다음 테스트 메시지를 보내세요."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "DingTalk Client ID",
|
||||
"help": "DingTalk 앱 자격 증명에서 복사하세요."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "같은 자격 증명 페이지에서 복사하세요."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "허용된 사용자",
|
||||
"placeholder": "사용자 ID, 쉼표로 구분"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Use o nanobot em grupos do DingTalk.",
|
||||
"requirements": "Credenciais do app DingTalk e gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guia do DingTalk",
|
||||
"officialLabel": "Abrir console do DingTalk",
|
||||
"tryIt": "Envie uma mensagem de teste no grupo do DingTalk onde o app está instalado.",
|
||||
"summary": "O DingTalk precisa das credenciais de um app no modo Stream.",
|
||||
"steps": [
|
||||
"Crie ou escolha um app do DingTalk com o modo Stream ativado.",
|
||||
"Adicione o Client ID e o Client Secret.",
|
||||
"Salve e ative o DingTalk; depois, envie uma mensagem de teste."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID do DingTalk",
|
||||
"help": "Copie das credenciais do app DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Copie da mesma página de credenciais."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Usuários permitidos",
|
||||
"placeholder": "IDs de usuário separados por vírgulas"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Sử dụng nanobot trong các nhóm DingTalk.",
|
||||
"requirements": "Thông tin xác thực ứng dụng DingTalk và gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Mở hướng dẫn DingTalk",
|
||||
"officialLabel": "Mở bảng điều khiển DingTalk",
|
||||
"tryIt": "Gửi tin nhắn thử từ nhóm DingTalk đã cài ứng dụng.",
|
||||
"summary": "DingTalk cần thông tin xác thực ứng dụng ở chế độ Stream.",
|
||||
"steps": [
|
||||
"Tạo hoặc chọn ứng dụng DingTalk đã bật chế độ Stream.",
|
||||
"Thêm Client ID và Client Secret.",
|
||||
"Lưu và bật DingTalk, sau đó gửi tin nhắn thử."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID DingTalk",
|
||||
"help": "Sao chép từ thông tin xác thực ứng dụng DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Sao chép từ cùng trang thông tin xác thực."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Người dùng được phép",
|
||||
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||