Compare commits

..
Author SHA1 Message Date
Xubin Ren f94e1d73e2 feat(webui): redesign apps discovery 2026-08-12 09:34:13 +09:00
358 changed files with 5046 additions and 35738 deletions
+2 -119
View File
@@ -96,9 +96,8 @@ jobs:
os: windows-latest
python-version: "3.14"
coverage: false
# Real PowerShell/process-tree tests run serially below. Keep
# them out of xdist so workers never share a Windows console.
pytest_args: "-n 2 --dist loadfile --ignore=tests/tools/test_exec_platform.py"
# Keep each test file in one worker while using both hosted-runner cores.
pytest_args: "-n 2 --dist loadfile"
steps:
- uses: actions/checkout@v4
@@ -148,13 +147,6 @@ jobs:
${{ matrix.pytest_args }}
--durations=25 --durations-min=1.0
- name: Run Windows process compatibility tests
if: runner.os == 'Windows'
run: >-
uv run --no-sync python -m pytest
tests/tools/test_exec_platform.py
--durations=25 --durations-min=1.0
webui:
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -187,77 +179,6 @@ jobs:
working-directory: webui
run: bun run build
tui:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- name: Terminal UI
os: ubuntu-latest
- name: Terminal UI (Windows)
os: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Install TUI dependencies
working-directory: tui
run: bun install --frozen-lockfile
- name: Check TUI
working-directory: tui
run: bun run check
- name: Test TUI
working-directory: tui
run: bun run test
- name: Test TUI in a real pseudo-terminal
if: runner.os != 'Windows'
working-directory: tui
run: python3 scripts/pty_smoke.py
- name: Set up Python for ConPTY smoke test
if: runner.os == 'Windows'
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Test TUI in a real ConPTY terminal
if: runner.os == 'Windows'
working-directory: tui
shell: pwsh
run: |
python -m pip install --disable-pip-version-check pywinpty==3.0.5
python scripts/conpty_smoke.py
- name: Build TUI
working-directory: tui
run: bun run build
- name: Verify licensed Linux release archive
if: runner.os == 'Linux'
working-directory: tui
run: |
bun scripts/release-notices.ts linux-x64
python3 scripts/package-release.py linux-x64
- name: Verify licensed Windows release archive
if: runner.os == 'Windows'
working-directory: tui
shell: pwsh
run: |
bun scripts/release-notices.ts win32-x64
python scripts/package-release.py win32-x64
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -268,44 +189,6 @@ jobs:
- name: Build image with default channel dependencies
run: docker build -t nanobot:test .
- name: Verify Docker Compose startup and privilege boundary
env:
HOME: ${{ runner.temp }}
run: |
docker compose run --rm --no-deps --build -T nanobot-cli status
docker compose run --rm --no-deps -T --entrypoint sh nanobot-cli -s <<'OUTER'
set -eu
field() {
awk -v key="$1:" '$1 == key { print $2 }' /proc/self/status
}
test "$(id -u)" = "0"
test "$(field NoNewPrivs)" = "1"
setpriv --reuid=nanobot --regid=nanobot --init-groups sh -s <<'INNER'
set -eu
field() {
awk -v key="$1:" '$1 == key { print $2 }' /proc/self/status
}
test "$(id -u)" = "1000"
test "$(field NoNewPrivs)" = "1"
for capability_set in CapInh CapPrm CapEff CapAmb; do
test "$(field "$capability_set")" = "0000000000000000"
done
INNER
OUTER
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml --profile cli \
config --format json > "${RUNNER_TEMP}/bwrap-compose.json"
python - <<'PY'
import json
import os
from pathlib import Path
config = json.loads(Path(os.environ["RUNNER_TEMP"], "bwrap-compose.json").read_text())
for service_name in ("nanobot-gateway", "nanobot-api", "nanobot-cli"):
service = config["services"][service_name]
assert {"CHOWN", "SETGID", "SETUID", "SYS_ADMIN"} <= set(service["cap_add"])
assert "no-new-privileges:true" in service["security_opt"]
PY
- name: Verify default WhatsApp dependencies
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
-96
View File
@@ -1,96 +0,0 @@
name: Publish Terminal UI
on:
workflow_dispatch:
inputs:
tag:
description: Existing release tag (for example, v0.3.1)
required: true
type: string
compliance_reviewed:
description: Confirm notices, source offer, source archive, and relinking were reviewed
required: true
type: boolean
default: false
permissions:
contents: write
jobs:
build:
if: ${{ inputs.compliance_reviewed }}
name: ${{ matrix.target }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
target:
- darwin-arm64
- darwin-x64
- linux-arm64
- linux-x64
- win32-x64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
- name: Verify release tag
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
shell: bash
run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]
gh release view "$TAG" >/dev/null
test "$(git rev-parse HEAD)" = "$(git rev-list -n 1 "refs/tags/$TAG")"
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Install dependencies
working-directory: tui
run: bun install --frozen-lockfile
- name: Install ${{ matrix.target }} native dependencies
working-directory: tui
run: bun scripts/prepare-target.ts ${{ matrix.target }}
- name: Build ${{ matrix.target }}
working-directory: tui
run: bun run build -- ${{ matrix.target }}
- name: Ad-hoc sign macOS executable
if: startsWith(matrix.target, 'darwin-')
uses: indygreg/apple-code-sign-action@44d0985b7f4363198e80b6fea63ac3e9dd3e9957 # v1
with:
input_path: tui/dist/nanobot-tui-${{ matrix.target }}
rcodesign_version: 0.29.0
- name: Build notices and release archive
working-directory: tui
env:
TARGET: ${{ matrix.target }}
run: |
bun scripts/release-notices.ts "$TARGET"
python3 scripts/package-release.py "$TARGET"
- name: Upload release assets
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
TARGET: ${{ matrix.target }}
shell: bash
run: |
gh release view "$TAG" >/dev/null
asset="nanobot-tui-${TARGET}"
if [[ "$TARGET" == win32-* ]]; then asset="${asset}.exe"; fi
gh release upload "$TAG" \
"tui/dist/${asset}.zip" \
"tui/dist/${asset}.zip.sha256" \
--clobber
-2
View File
@@ -16,8 +16,6 @@ webui/node_modules/
webui/dist/
webui/coverage/
webui/.vite/
tui/node_modules/
tui/dist/
*.tsbuildinfo
# Python bytecode & caches
-23
View File
@@ -136,29 +136,6 @@ GitHub Actions' free tier:
If your change genuinely needs to step outside this, please call it out
explicitly in the PR description so it can be discussed before merge.
## Release Packaging Contract
A stable install must never combine Python from one version with a TUI from another. Publish in
this order:
1. Set the package version and publish the matching GitHub release tag (`vX.Y.Z`).
2. Review the pinned Bun/OpenTUI licenses, source offer, and relinking materials for that tag.
3. Manually run **Publish Terminal UI** for the exact tag and confirm the compliance review input.
4. Wait for every platform archive and checksum to appear on the release, then publish the same
`X.Y.Z` package to PyPI.
The wheel contains the built WebUI. The native TUI stays a platform-specific release sidecar so
users download only the archive for their machine. Each archive must contain the executable,
target-specific third-party notices, project and runtime licenses, corresponding application
source, a written source offer, relinking instructions, and a checksum manifest. Never upload a
naked TUI executable. Source checkouts use an editable Python install, run `tui/` with Bun, and
rebuild stale `webui/` assets locally.
The confirmation is an operational commitment, not a cosmetic checkbox. Before accepting it,
verify that the exact Bun/WebKit revisions remain retrievable and that the project can honor the
archive's corresponding-source offer for its full stated period. Preserve published archives and
their source materials.
## Questions?
If you have questions, ideas, or half-formed insights, you are warmly welcome here.
+19 -26
View File
@@ -77,12 +77,7 @@ nanobot is a self-hosted personal AI agent runtime. It can:
Pick **one** install method:
| Track | Install with | Update with | What runs |
|---|---|---|---|
| Stable | installer, `uv`, or pip | the same package tool | one released Python/WebUI/TUI version |
| Current source | editable Git checkout | `git pull --ff-only` + editable dependency sync | Python, WebUI, and TUI from that checkout |
Prerequisites: Python 3.11 or newer. Git and [Bun](https://bun.sh/) are only needed for a source install. Published packages include the WebUI and fetch a checksummed, version-matched TUI archive—with its licenses, notices, corresponding application source, source offer, and relinking instructions—on first use.
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.
@@ -102,7 +97,7 @@ irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | i
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`.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
@@ -112,6 +107,16 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
& ([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 you prefer to inspect the script first, open [`scripts/install.sh`](./scripts/install.sh) or [`scripts/install.ps1`](./scripts/install.ps1).
**Install with `uv`**
@@ -130,27 +135,15 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c
**Install from source**
Clone the repository and install it in editable mode. Bun is required because the source
checkout runs the matching TUI directly instead of downloading an older release binary.
`bun` or `npm` must be available. From an activated virtual environment:
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m venv .venv
python -m pip install .
```
Activate it with `source .venv/bin/activate` on macOS/Linux or
`.venv\Scripts\Activate.ps1` in Windows PowerShell, then run:
```bash
python -m pip install -e .
```
After that, the normal commands are identical to a stable install. `nanobot agent` runs the TUI
from this checkout, and `nanobot webui` rebuilds stale frontend assets automatically. A later
`git pull --ff-only` updates the Python, TUI, and WebUI source together; rerun
`python -m pip install -e .` when Python dependencies change. Contributors should also read
[`CONTRIBUTING.md`](./CONTRIBUTING.md).
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:
@@ -168,7 +161,7 @@ If `nanobot` is not on `PATH`, invoke it through the method that installed it: r
nanobot webui
```
This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts or joins the shared local 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.
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.
**Your first three steps**
@@ -181,10 +174,10 @@ Any normal reply means the provider, model, workspace, and browser gateway are w
**Keep nanobot running after you close the terminal**
```bash
nanobot gateway --background
nanobot webui --background
```
This is the only command that promotes the shared gateway to persistent background mode. It leaves channels and automations running after every local TUI and WebUI launcher exits. Complete first-time model setup with `nanobot webui` before switching to background mode; open the same localhost WebUI again afterward.
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.
```bash
nanobot gateway status
@@ -209,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
nanobot agent
```
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another WebSocket session; use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `nanobot gateway --background` when the gateway must stay alive with no local clients. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` only when you need the compatibility Python prompt.
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:
+1 -5
View File
@@ -1,11 +1,7 @@
# Third-Party Notices
The following third-party components are redistributed as part of the packaged
nanobot Python distribution (`pip install nanobot-ai`). Native TUI executables are distributed
separately in per-platform release archives. Each TUI archive carries its generated
`THIRD_PARTY_NOTICES.txt`, project and runtime licenses, corresponding application source,
written source offer, and relinking instructions; those target-specific notices are generated by
`tui/scripts/release-notices.ts` and are not duplicated below.
nanobot Python distribution (`pip install nanobot-ai`).
---
-27
View File
@@ -6,7 +6,6 @@ import os
import ssl
import sys
from collections.abc import Iterator
from pathlib import Path
import certifi
import pytest
@@ -23,32 +22,6 @@ def _isolate_nanobot_log_activation() -> Iterator[None]:
logger.enable("nanobot")
@pytest.fixture(autouse=True)
def _isolate_sessions_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Redirect session storage away from the real active config data directory.
Session storage lives under the active runtime data root (outside the workspace,
per ADR-0001), so without redirection tests would write into the real home.
"""
runtime_root = tmp_path.parent / f"{tmp_path.name}-runtime-root"
legacy_root = tmp_path.parent / f"{tmp_path.name}-legacy-sessions-root"
def runtime_subdir(name: str) -> Path:
path = runtime_root / name
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr(
"nanobot.session.manager.get_runtime_subdir",
runtime_subdir,
)
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: legacy_root,
)
yield
@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.
-9
View File
@@ -8,15 +8,6 @@ x-common-config: &common-config
- ~/.nanobot:/home/nanobot/.nanobot
cap_drop:
- ALL
# Entrypoint uses these to fix bind-mount ownership and drop to the nanobot user.
cap_add:
- CHOWN
- SETGID
- SETUID
# Prevent the non-root process from regaining capabilities through setuid
# binaries or file capabilities left inside the container image.
security_opt:
- no-new-privileges:true
services:
nanobot-gateway:
+3 -10
View File
@@ -51,13 +51,6 @@ Main files:
- feeds tool results back into the model;
- stops when a final answer is produced or runtime limits are hit.
MCP connections are application-owned infrastructure. Composition roots create
an `MCPProvider`, share its `ToolRegistry` with `AgentLoop`, await `connect()`
before use, and guarantee `aclose()` during shutdown; the loop does not manage
that lifecycle. `AgentLoop.from_config()` therefore requires a caller-owned
`ToolRegistry`; callers using MCP share it with their application-owned
`MCPProvider`.
Keep this split in mind when debugging. If a problem is about channel routing, session keys, workspace selection, or outbound delivery, start in `agent/loop.py`. If it is about provider calls, tool calls, streaming, or iteration limits, start in `agent/runner.py`.
## Providers
@@ -149,7 +142,7 @@ Defaults:
|---|---|
| Config | `~/.nanobot/config.json` |
| Workspace | `~/.nanobot/workspace/` |
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` (default: `~/.nanobot/sessions/...`) |
| Sessions | `<workspace>/sessions/*.jsonl` |
| Memory | `<workspace>/memory/` |
| Cron store | `<workspace>/cron/jobs.json` |
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
@@ -164,7 +157,7 @@ a WebUI chat may select a separate project:
| Concern | Path owner |
|---|---|
| Session namespace, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace |
| 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 |
@@ -180,7 +173,7 @@ Session history is the near-term conversation replay. Memory is the longer-term
| Store | File area |
|---|---|
| Session JSONL files | `<config-dir>/sessions/<workspace-id>/` |
| Session JSONL files | `<workspace>/sessions/` |
| Long-term memory | `<workspace>/memory/MEMORY.md` |
| Consolidation source history | `<workspace>/memory/history.jsonl` |
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
+1 -1
View File
@@ -47,7 +47,7 @@ Use `/model` to inspect the current runtime model:
/model
```
The response shows the current session's model and preset, plus the available preset names. Each key under the top-level `modelPresets` config is the preset's canonical name everywhere nanobot displays or references it. `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:
+8 -46
View File
@@ -88,48 +88,13 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|---|---|
| `nanobot agent -m "Hello!"` | Send one message and exit |
| `nanobot agent` | Start interactive terminal chat |
| `nanobot agent --session <id>` | Use a WebSocket session key; add `--classic` for another channel |
| `nanobot agent --session <id>` | Use a specific session key |
| `nanobot agent --workspace <path>` | Override workspace |
| `nanobot agent --config <path>` | Use a specific config file |
| `nanobot agent --classic` | Use the compatibility Python prompt instead of the native terminal UI |
| `nanobot agent --theme auto\|dark\|light` | Auto-detect the terminal appearance or force a TUI palette |
| `nanobot agent --no-markdown` | Use the classic prompt and print plain text instead of Markdown |
| `nanobot agent --logs` | Use the classic prompt and show runtime logs while chatting |
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
| `nanobot agent --logs` | Show runtime logs while chatting |
Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` starts another saved
conversation, and `/context` explains the compacted summary and raw session suffix available to
the next agent turn. `/branch` forks a saved conversation from a completed reply, and `/diff`
opens the latest turn's file changes as a full-screen unified diff.
`PageUp` loads older transcript pages when you reach the top. The default
launch returns to the last attached TUI session; `--session` selects a specific session instead.
## Session Storage and Rollback
Session JSONL files live under `<config-dir>/sessions/<workspace-id>/`, outside the
agent-readable workspace. On the first upgraded start, nanobot safely migrates existing
`<workspace>/sessions/*.jsonl` files after verifying an atomic copy. Stop every old nanobot
process that uses the workspace before upgrading; old and new binaries must not write the
same session concurrently.
To prepare a downgrade, stop nanobot and copy the current sessions back to the path understood
by older releases:
```bash
nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bot-a/workspace
```
The command never deletes the external store and refuses to overwrite a different existing
workspace file. Back up both the config directory and workspace before changing versions.
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. A small gateway watchdog also reclaims an on-demand process if its last client crashes. Only an explicit `nanobot gateway --background` promotes it to persistent mode. `nanobot gateway restart` restarts a detached gateway without changing that lifetime; restart an attached foreground gateway in its owning terminal. `nanobot gateway stop` ends either mode.
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
`Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message to the composer. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback when a terminal cannot distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable. Use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
Packaged releases fetch a version-matched, checksummed terminal archive for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use. The cache keeps the executable together with its licenses, third-party notices, source offer, relinking instructions, and corresponding TUI source. Windows ARM64 must currently use `--classic` because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A local source install requires Bun and runs its own `tui/` source while the original checkout remains available; it never silently falls back to a release binary.
Non-interactive input/output, `--logs`, and `--no-markdown` automatically retain the classic prompt so existing scripts and diagnostic workflows do not acquire terminal control sequences or silently ignore their options.
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
@@ -138,7 +103,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
| Command | Description |
|---|---|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
@@ -147,12 +112,9 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
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.
`--dev` is a foreground source-checkout workflow. Persistent gateway lifecycle is deliberately
owned only by `nanobot gateway --background`; `nanobot webui --background` prints migration
guidance instead of silently changing process ownership.
`--dev` is a foreground source-checkout workflow and cannot be combined with `--background`.
It installs frontend dependencies when `webui/node_modules` is missing, proxies to the configured
WebSocket channel port, and stops Vite when the launcher exits. The shared on-demand gateway stops
only when no other interactive client still holds it.
WebSocket channel port, and stops Vite together with the foreground gateway.
## Gateway
@@ -166,7 +128,7 @@ only when no other interactive client still holds it.
| `nanobot gateway --workspace <path>` | Override workspace |
| `nanobot gateway --config <path>` | Use a specific config file |
| `nanobot gateway --background` | Start the gateway as a background process |
| `nanobot gateway status` | Show PID, foreground/background launch mode, explicit/on-demand lifetime, live client count, state, and logs |
| `nanobot gateway status` | Show the recorded background gateway PID, state file, and log file |
| `nanobot gateway logs --no-follow` | Print recent background gateway logs and exit |
| `nanobot gateway logs` | Follow background gateway logs |
| `nanobot gateway restart` | Restart the recorded background gateway with the current config |
+2 -9
View File
@@ -26,8 +26,7 @@ The default instance lives under `~/.nanobot/`:
| Path | Meaning |
|---|---|
| `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options |
| `~/.nanobot/workspace/` | Agent workspace: memory, heartbeat tasks, cron jobs, skills, and generated artifacts |
| `~/.nanobot/sessions/<workspace-id>/` | Session history stored outside the agent-accessible workspace; the opaque ID follows workspace moves |
| `~/.nanobot/workspace/` | Agent workspace: memory, sessions, heartbeat tasks, cron jobs, skills, and generated artifacts |
You can override both with command flags:
@@ -126,17 +125,11 @@ nanobot uses two related stores:
| Store | Location | Purpose |
|---|---|---|
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` | Recent conversation turns replayed into context |
| Sessions | `<workspace>/sessions/*.jsonl` | Recent conversation turns replayed into context |
| Memory | `<workspace>/memory/MEMORY.md` and `<workspace>/memory/history.jsonl` | Long-term facts and consolidated history |
Dream is a periodic consolidation job. It reads accumulated history and updates workspace memory so useful context can survive beyond short session replay.
The configured workspace contains a `.nanobot/workspace-id` file. It contains only an
opaque random identifier—never conversation content or credentials. Keep it with workspace
backups: it lets nanobot find the same external session namespace after the workspace is
renamed, moved, or restored. A live copy opened alongside the original receives a new ID so
the two workspaces do not share conversations accidentally.
See [`memory.md`](./memory.md) for the detailed design.
## Apps and Agent Plugins
+25 -23
View File
@@ -360,7 +360,7 @@ request, while other tools such as `web_fetch` remain available.
<details>
<summary><b>DeepSeek native web search</b></summary>
DeepSeek V4 Flash and Pro use DeepSeek's native Responses API. Their provider-hosted web search is
DeepSeek V4 Flash uses DeepSeek's native Responses API. Its provider-hosted web search is
enabled by default because it does not require a separate paid add-on. Turn it off from the
WebUI provider settings, or with:
@@ -377,9 +377,9 @@ WebUI provider settings, or with:
}
```
The switch applies to `deepseek-v4-flash` and `deepseek-v4-pro`; DeepSeek models that remain on
Chat Completions cannot use this Responses tool. Native search calls appear in the WebUI activity
stream, and their opaque output items are preserved for multi-turn Responses state replay.
The switch applies to `deepseek-v4-flash`; DeepSeek models that remain on Chat Completions
cannot use this Responses tool. Native search calls appear in the WebUI activity stream, and
their opaque output items are preserved for multi-turn Responses state replay.
</details>
@@ -391,7 +391,7 @@ Providers that use the Responses API can keep reasoning context across a
conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4, and compatible GitHub Copilot models.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models.
Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required.
@@ -1404,6 +1404,21 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
{
"modelPresets": {
"fast": {
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
"fallbackModels": ["deep", "localSmall"]
}
},
"modelPresets": {
"fast": {
"label": "Fast",
"model": "gpt-4.1-mini",
"provider": "openai",
"maxTokens": 4096,
@@ -1412,6 +1427,7 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
"reasoningEffort": "low"
},
"deep": {
"label": "Deep",
"model": "claude-opus-4-5",
"provider": "anthropic",
"maxTokens": 8192,
@@ -1419,28 +1435,22 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
"reasoningEffort": "high"
},
"localSmall": {
"label": "Local Small",
"model": "llama3.2",
"provider": "ollama",
"maxTokens": 4096,
"contextWindowTokens": 32768,
"temperature": 0.2
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
"fallbackModels": ["deep", "localSmall"]
}
}
}
```
`modelPresets` is a top-level object. Each key (`fast`, `deep`, `coding`, etc.) is the preset's one canonical name: it is shown in the interface, passed to `/model <name>`, and referenced by defaults, fallbacks, sessions, and Dream. New and renamed presets must be unique ignoring case. Existing keys accepted by earlier releases remain loadable so upgrades do not break startup. Each preset supports:
Older configs may still contain a `label` inside a preset. It is accepted when loading for compatibility but ignored; the object key remains the canonical name.
`modelPresets` is a top-level object. The keys under it (`fast`, `deep`, `coding`, etc.) are user-defined preset names. Each preset supports:
| Field | Description |
|-------|-------------|
| `label` | Optional display name shown in model lists. |
| `model` | Model name to use for this preset. |
| `provider` | Provider name, or `"auto"` to use provider auto-detection. |
| `maxTokens` | Maximum completion/output tokens. |
@@ -1911,14 +1921,6 @@ Create a key at [serper.dev](https://serper.dev). You can also set `SERPER_API_K
nanobot by default uses [Jina Reader](https://jina.ai/reader/), a third-party API, to convert arbitrary pages into Markdown format for easy digestion by the LLM, with a local fallback based on [readability-lxml](https://github.com/buriy/python-readability) if the former fails.
> [!NOTE]
> Using the remote reader means the fetched URL itself is disclosed to the
> third-party service. URLs that visibly carry credentials (userinfo, signed-URL
> or token-style query parameters) are detected and fetched locally instead, but
> secrets embedded in a URL's *path* (for example bot-token or webhook-style
> URLs) cannot be reliably detected. Set `useJinaReader: false` if fetched URLs
> must never leave the machine.
If you want to always use the local conversion, you can force it using:
```json
@@ -2093,7 +2095,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. The default `docker-compose.yml` drops all Linux capabilities except the `CHOWN`, `SETGID`, and `SETUID` capabilities required by the root entrypoint to initialize bind-mount ownership and become UID 1000. It enables `no-new-privileges` so the final non-root process cannot regain those bootstrap 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. The host must also allow unprivileged user namespaces; the override cannot bypass a host-level namespace restriction.
**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
+6 -15
View File
@@ -11,7 +11,7 @@ Check these once before Render, Docker, systemd, or LaunchAgent:
| `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run |
| `nanobot agent -m "Hello!"` works | Proves install, config, provider, model, and workspace writes before adding a service layer |
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
| The active config directory (including `sessions/`) and workspace are persistent | Sessions follow `--config`; memory, generated artifacts, and the workspace identity marker follow the workspace |
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
@@ -160,11 +160,8 @@ docker compose logs -f nanobot-gateway # view logs
docker compose down # stop
```
The default Compose file drops all Linux capabilities except `CHOWN`, `SETUID`, and
`SETGID`, which the root entrypoint needs to fix bind-mount ownership and become UID
1000. It also enables `no-new-privileges`, so the non-root process cannot regain those
bootstrap capabilities through setuid binaries or file capabilities. Docker's default
AppArmor/seccomp profiles remain enabled. If you explicitly set
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:
@@ -173,10 +170,8 @@ docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanobot-g
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanobot-cli agent -m "Hello!"
```
The override adds `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for the
container so bubblewrap can create its nested namespaces. It preserves
`no-new-privileges`. The host must also allow unprivileged user namespaces; the
override cannot bypass a host-level namespace restriction. Use it only when the
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
@@ -202,8 +197,6 @@ vim ~/.nanobot/config.json
# health endpoint on 18790.
docker run \
--cap-drop ALL \
--cap-add CHOWN --cap-add SETGID --cap-add SETUID \
--security-opt no-new-privileges:true \
-v ~/.nanobot:/home/nanobot/.nanobot \
-p 18790:18790 -p 8765:8765 \
nanobot gateway
@@ -212,9 +205,7 @@ docker run \
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
# `clone3: Operation not permitted`.
docker run \
--cap-drop ALL \
--cap-add CHOWN --cap-add SETGID --cap-add SETUID --cap-add SYS_ADMIN \
--security-opt no-new-privileges:true \
--cap-drop ALL --cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \
-v ~/.nanobot:/home/nanobot/.nanobot \
+1 -2
View File
@@ -45,8 +45,7 @@ outside the workspace when the gateway allows it.
## Production notes
- Use `nanobot gateway --background` when you do not want to keep a terminal open, then open the
configured WebUI URL in a browser.
- Use `nanobot webui --background` when you do not want to keep a terminal open.
- Use `nanobot gateway status`, `logs`, `restart`, and `stop` to manage a
background gateway.
- If you expose the WebUI beyond localhost, set a token issue secret and review
+7 -5
View File
@@ -32,14 +32,16 @@ with ones you control:
```json
{
"modelPresets": {
"Fast": {
"fast": {
"label": "Fast",
"provider": "primary-provider",
"model": "primary-model-id",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
},
"Deep": {
"deep": {
"label": "Deep",
"provider": "fallback-provider",
"model": "fallback-model-id",
"maxTokens": 4096,
@@ -49,8 +51,8 @@ with ones you control:
},
"agents": {
"defaults": {
"modelPreset": "Fast",
"fallbackModels": ["Deep"]
"modelPreset": "fast",
"fallbackModels": ["deep"]
}
}
}
@@ -67,7 +69,7 @@ for common providers.
how much context can fit.
- Put cheaper or faster fallbacks before expensive ones when acceptable.
- Use `/model <preset>` for runtime switching without editing config.
- Keep preset names human-readable; the same name appears in the WebUI and `/model`.
- Keep labels human-readable for WebUI model lists.
## Security notes
+3 -2
View File
@@ -179,7 +179,8 @@ Merge this preset into `~/.nanobot/config.json` and select it:
}
},
"modelPresets": {
"Ollama Llama 3.1 prefix-stable": {
"ollamaPrefixStable": {
"label": "Ollama Llama 3.1 prefix-stable",
"provider": "ollama",
"model": "llama3.1:8b-prefix-stable-v1",
"maxTokens": 2048,
@@ -189,7 +190,7 @@ Merge this preset into `~/.nanobot/config.json` and select it:
},
"agents": {
"defaults": {
"modelPreset": "Ollama Llama 3.1 prefix-stable"
"modelPreset": "ollamaPrefixStable"
}
}
}
@@ -41,7 +41,8 @@ Merge this into `~/.nanobot/config.json`:
}
},
"modelPresets": {
"Custom": {
"primary": {
"label": "Custom",
"provider": "custom",
"model": "provider-model-name",
"maxTokens": 4096,
@@ -51,7 +52,7 @@ Merge this into `~/.nanobot/config.json`:
},
"agents": {
"defaults": {
"modelPreset": "Custom"
"modelPreset": "primary"
}
}
}
-4
View File
@@ -81,10 +81,6 @@ in the WebUI or logs.
- Web fetch and HTTP MCP share an SSRF guard.
- Private, loopback, link-local, and cloud metadata addresses are blocked by
default.
- With `useJinaReader` enabled (the default), fetched URLs are disclosed to the
remote reader service. Credential-bearing URLs (userinfo or token/signature
query parameters) are fetched locally instead; path-embedded secrets cannot
be detected, so disable the remote reader when URLs must stay local.
- Add `tools.ssrfWhitelist` only for narrow trusted CIDRs.
- Do not give public chat users unrestricted web and shell access without
review.
+3 -5
View File
@@ -37,20 +37,18 @@ nanobot gateway
For WebUI background usage:
```bash
nanobot gateway --background
nanobot webui --background
nanobot gateway status
nanobot gateway logs
```
Open the configured WebUI URL in a browser, or run `nanobot webui` as a foreground client.
## Production notes
- Docker Compose is the most repeatable Linux container path.
- systemd user services are useful for Linux user-level gateway deployments.
- macOS LaunchAgent keeps the gateway alive after login.
- Persist the active config directory's `sessions/` folder together with the workspace
(including `.nanobot/workspace-id`), memory files, channel login state, and generated artifacts.
- Persist config, workspace, sessions, memory files, channel login state, and
generated artifacts.
- Restart the gateway after editing `config.json`.
## Security notes
+2 -3
View File
@@ -52,13 +52,12 @@ nanobot webui -c ~/.nanobot-telegram/config.json
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
```
> Interactive `nanobot agent` and `nanobot webui` commands with the same `--config` and explicit `--workspace` selectors share one gateway instance. Different selectors produce isolated runtime state and processes. The one-shot and `--classic` agent paths remain direct local executions.
> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
| Component | Resolved From | Example |
|-----------|---------------|---------|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
| **Sessions** | config directory + workspace ID | `~/.nanobot-A/sessions/<workspace-id>/` |
| **Cron Jobs** | workspace directory | `~/.nanobot-A/workspace/cron/` |
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
@@ -127,6 +126,6 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
## Notes
- Each instance must use a different port if they run at the same time
- Session data follows the active config directory; use a different workspace per instance to isolate memory, skills, and the stable session namespace ID
- Use a different workspace per instance if you want isolated memory, sessions, and skills
- `--workspace` overrides the workspace defined in the config file
- Cron jobs are stored in the active workspace; runtime media/state is derived from the config directory
+18
View File
@@ -71,6 +71,7 @@ This recipe applies when one API key routes many hosted model families.
},
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
@@ -112,6 +113,7 @@ OpenCode Zen:
},
"modelPresets": {
"primary": {
"label": "OpenCode Zen",
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
"maxTokens": 4096,
@@ -138,6 +140,7 @@ OpenCode Go:
},
"modelPresets": {
"primary": {
"label": "OpenCode Go",
"provider": "opencode_go",
"model": "opencode-go/deepseek-v4-flash",
"maxTokens": 4096,
@@ -179,6 +182,7 @@ This recipe applies when you have an OpenAI API key and want to call OpenAI dire
},
"modelPresets": {
"primary": {
"label": "OpenAI",
"provider": "openai",
"model": "gpt-5",
"maxTokens": 4096,
@@ -215,6 +219,7 @@ This recipe applies when your key comes from Anthropic and your model name is an
},
"modelPresets": {
"primary": {
"label": "Anthropic",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"maxTokens": 4096,
@@ -250,6 +255,7 @@ If you use an Anthropic-compatible proxy, keep the preset provider as `anthropic
},
"modelPresets": {
"primary": {
"label": "Anthropic proxy",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"maxTokens": 4096,
@@ -280,6 +286,7 @@ This recipe applies when your key comes from Kimi's Coding Plan endpoint. Nanobo
},
"modelPresets": {
"kimiCoding": {
"label": "Kimi Coding",
"provider": "kimi_coding",
"model": "kimi-for-coding",
"maxTokens": 4096,
@@ -317,6 +324,7 @@ This recipe applies to an OpenAI-compatible service that is not a named nanobot
},
"modelPresets": {
"primary": {
"label": "Custom",
"provider": "custom",
"model": "provider-model-name",
"maxTokens": 4096,
@@ -356,6 +364,7 @@ For multiple custom endpoints, do not overload the single `custom` block. Name e
},
"modelPresets": {
"work": {
"label": "Work proxy",
"provider": "workProxy",
"model": "gpt-4o-mini",
"maxTokens": 4096,
@@ -363,6 +372,7 @@ For multiple custom endpoints, do not overload the single `custom` block. Name e
"temperature": 0.1
},
"lab": {
"label": "Lab local",
"provider": "lab-local",
"model": "served-model-name",
"maxTokens": 4096,
@@ -398,6 +408,7 @@ ollama pull llama3.2
},
"modelPresets": {
"local": {
"label": "Local",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 2048,
@@ -442,6 +453,7 @@ This recipe applies when a local server exposes an OpenAI-compatible `/v1` API.
},
"modelPresets": {
"local": {
"label": "Local",
"provider": "vllm",
"model": "served-model-name",
"maxTokens": 4096,
@@ -468,6 +480,7 @@ For LM Studio, use its local base URL and provider name:
},
"modelPresets": {
"local": {
"label": "LM Studio",
"provider": "lm_studio",
"model": "local-model",
"maxTokens": 2048,
@@ -492,6 +505,7 @@ This recipe applies when one provider sometimes rate-limits, one model is expens
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
@@ -499,6 +513,7 @@ This recipe applies when one provider sometimes rate-limits, one model is expens
"temperature": 0.1
},
"deep": {
"label": "Deep",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"maxTokens": 4096,
@@ -506,6 +521,7 @@ This recipe applies when one provider sometimes rate-limits, one model is expens
"temperature": 0.1
},
"local": {
"label": "Local",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 2048,
@@ -564,12 +580,14 @@ Use this after you have more than one preset and are chatting through a supporte
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
"contextWindowTokens": 65536
},
"local": {
"label": "Local",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 2048,
+6 -35
View File
@@ -123,40 +123,6 @@ appended to nanobot's generated functions. This keeps unrelated local tools such
available in the same request. Responses-only server tools require an API surface that the
OpenRouter provider does not currently enable.
### OrcaRouter Gateway
[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible model routing gateway. Configure
the built-in `orcarouter` provider and use a model ID from OrcaRouter's catalog:
```json
{
"providers": {
"orcarouter": {
"apiKey": "${ORCAROUTER_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "orcarouter",
"model": "orcarouter/auto",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Use the model ID exactly as OrcaRouter lists it. `orcarouter/auto` routes to a
suitable upstream automatically; explicit IDs such as
`anthropic/claude-sonnet-4.6` or `openai/gpt-5` are also accepted. OrcaRouter API keys start with
`sk-orca-`. The WebUI can load the account's model catalog after the API key is saved under
**Settings → Models**.
### Eden AI Gateway
Eden AI exposes an OpenAI-compatible chat-completions endpoint at
@@ -321,7 +287,7 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. The WebUI exposes provider-native switches for OpenAI web search, Codex Fast mode, DeepSeek web search, and Grok X Search. These switches write the corresponding raw provider request fields under `extraBody`.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` and `deepseek-v4-pro` automatically use DeepSeek's native Responses API. Its native `web_search` tool is enabled by default and shows its lifecycle in WebUI chat activity; set `providers.deepseek.extraBody.tools` to `[]` to disable it.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions. Its native `web_search` tool is enabled by default and shows its lifecycle in WebUI chat activity; set `providers.deepseek.extraBody.tools` to `[]` to disable it.
### Custom OpenAI-Compatible Endpoint
@@ -633,6 +599,7 @@ Model presets are the recommended model configuration surface. Use them when you
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
@@ -640,6 +607,7 @@ Model presets are the recommended model configuration surface. Use them when you
"temperature": 0.1
},
"deep": {
"label": "Deep",
"provider": "anthropic",
"model": "claude-opus-4-5",
"maxTokens": 8192,
@@ -665,6 +633,7 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.5",
"maxTokens": 4096,
@@ -672,6 +641,7 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai
"temperature": 0.1
},
"deep": {
"label": "Deep",
"provider": "anthropic",
"model": "claude-opus-4-5",
"maxTokens": 8192,
@@ -679,6 +649,7 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai
"temperature": 0.1
},
"localSmall": {
"label": "Local Small",
"provider": "ollama",
"model": "llama3.2",
"maxTokens": 4096,
+14 -33
View File
@@ -12,7 +12,7 @@ These repository docs follow current `main`. The recommended installer uses the
- 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.
Git and [Bun](https://bun.sh/) are only needed for a source install. The published package already contains the WebUI and fetches a checksummed, version-matched TUI archive with its licenses, notices, corresponding application source, source offer, and relinking instructions on first use.
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 nanobot
@@ -30,7 +30,7 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release. 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.
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.
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
@@ -48,8 +48,7 @@ The WebUI launcher creates or updates:
| Path | Purpose |
|---|---|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
| `~/.nanobot/workspace/` | Memory, skills, automations, and generated files |
| `~/.nanobot/sessions/<workspace-id>/` | Recent session history stored outside the workspace; the ID remains stable across workspace moves |
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
If the installer did not open the browser, run:
@@ -79,7 +78,7 @@ Most other providers can say `not set`. This command validates local setup but d
## 4. Get the First Reply
If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that launcher open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
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:
@@ -89,7 +88,7 @@ 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.
Interactive WebUI and TUI launchers share one on-demand gateway. Closing one launcher leaves it running for the others; closing the last launcher stops it. If you prefer a persistent background process, press `Ctrl+C`, then run:
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
@@ -112,11 +111,7 @@ Then start an interactive terminal chat with:
nanobot agent
```
In interactive mode, `Enter` sends and `Shift+Enter` inserts a newline (`Ctrl+J` is the
universal fallback). While a turn is running,
`Enter` steers it, `Tab` queues a follow-up, and `Option+Up` on macOS (`Alt+Up` on
Windows/Linux) edits the latest queued message. Exit
with `exit`, `/exit`, `:q`, or `Ctrl+D`.
In interactive mode, `Enter` sends and `Alt+Enter` inserts a newline. Exit with `exit`, `/exit`, `:q`, or `Ctrl+D`.
## Choose One Next Step
@@ -155,28 +150,18 @@ If pip reports `externally-managed-environment`, use the recommended installer,
**Current source**
Clone the repository and install it in editable mode. Bun is required so the checkout can run
its matching native TUI instead of mixing current Python with an older release binary.
`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 venv .venv
```
Activate it with `source .venv/bin/activate` on macOS/Linux or
`.venv\Scripts\Activate.ps1` in Windows PowerShell, then run:
```bash
python -m pip install -e .
python -m pip install .
nanobot webui
```
The source path follows current `main` and can be newer than the published package. The editable
install keeps Python pointed at the checkout; `nanobot agent` runs `tui/` with Bun, and
`nanobot webui` automatically rebuilds `webui/` when its bundled assets are stale. All normal
commands remain the same as a stable install. For development details, follow
[`../CONTRIBUTING.md`](../CONTRIBUTING.md).
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:
@@ -235,15 +220,11 @@ python -m pip install -U nanobot-ai
For a source checkout:
```bash
git pull --ff-only
python -m pip install -e .
git pull
python -m pip install .
```
Because the install is editable, normal source changes are visible immediately. Re-running the
install synchronizes any changed Python dependencies; the TUI and WebUI refresh their own
dependencies/assets when launched. Then check `nanobot --version`. Run
`nanobot onboard --refresh` when you want to add newly introduced default fields while preserving
existing settings.
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
+1 -1
View File
@@ -160,4 +160,4 @@ Run:
nanobot webui
```
Leave that launcher open while you use nanobot. Pressing `Ctrl+C` disconnects it; the shared gateway stops when it was the last local WebUI or TUI client. After the normal foreground start and model setup work, use `nanobot gateway --background` when you want the gateway to stay online with no clients; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
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`.
+1 -2
View File
@@ -319,8 +319,7 @@ See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|---|---|
| Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. |
| Memory does not update immediately | Dream consolidation is periodic; recent turns still live in session history. |
| Sessions disappear after changing `--config` | Sessions follow the config directory at `<config-dir>/sessions/<workspace-id>/`; use the original config path or copy that `sessions/` directory into the new config directory while nanobot is stopped. |
| Sessions disappear after moving a workspace | Keep the workspace's `.nanobot/workspace-id` file with the move or backup. If it was lost, restore that marker from backup before starting nanobot. |
| Old sessions appear after moving config | Session files are stored under `<workspace>/sessions/`; verify the workspace path. |
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
## Collect Useful Evidence
+7 -11
View File
@@ -19,24 +19,21 @@ nanobot webui
`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 or joins the same on-demand gateway used by the native
TUI, and opens the browser. With a fresh config,
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.
After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
Run it in the background when you do not want to keep a terminal open:
```bash
nanobot gateway --background
nanobot webui --background
```
`nanobot webui --background` is retained only to print migration guidance. This keeps one
unambiguous owner for persistent process lifecycle.
Complete first-time model setup in a foreground `nanobot webui` session before using
`--background`.
Each foreground WebUI or TUI launcher releases only its own client. The last
interactive launcher stops an on-demand gateway. `nanobot gateway --background` makes the
gateway persistent; manage it with `nanobot gateway status`, `nanobot gateway
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
Manual config still works. Same-machine localhost WebUI access can run without
@@ -136,8 +133,7 @@ or a result you must retain.
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. A locally hosted WebUI opens the operating system's folder chooser
when one is available; remote deployments keep the manual absolute path entry.
metadata.
Selecting a project does not replace the configured agent workspace. The two
paths have different responsibilities:
+19 -1
View File
@@ -42,11 +42,29 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
)
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools)
def mcp_runtime_status(state: Any) -> dict[str, mcp_tools.MCPRuntimeStatus]:
return mcp_tools.runtime_status(state)
async def close_mcp(state: Any) -> None:
await mcp_tools.close_mcp_servers(state)
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
await state.discard_session(msg.session_key)
return True
return await image_generation_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:
+39 -105
View File
@@ -44,7 +44,7 @@ from nanobot.agent.turn_delivery import (
)
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 INBOUND_META_USER_SHELL, InboundMessage, OutboundMessage
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import StreamedResponseEvent
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
@@ -76,7 +76,6 @@ from nanobot.session.goal_state import (
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.keys import UNIFIED_SESSION_KEY, remember_last_channel
from nanobot.session.manager import (
SESSION_CACHE_MAX_SIZE,
Session,
SessionManager,
replay_max_messages_for_context,
@@ -96,9 +95,11 @@ from nanobot.utils.runtime import (
)
if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
from nanobot.config.schema import (
ChannelsConfig,
Config,
MCPServerConfig,
ProviderConfig,
ToolsConfig,
)
@@ -163,7 +164,6 @@ class TurnContext:
turn_wall_started_at: float = field(default_factory=time.time)
visible_run_started_at: float | None = None
turn_latency_ms: int | None = None
usage: dict[str, int] = field(default_factory=dict)
def require_runtime(self) -> LLMRuntime:
"""Return the runtime established by the BUILD stage."""
@@ -271,7 +271,7 @@ class AgentLoop:
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
tool_registry: ToolRegistry | None = None,
mcp_servers: dict[str, MCPServerConfig] | None = None,
channels_config: ChannelsConfig | None = None,
timezone: str | None = None,
session_ttl_minutes: int = 0,
@@ -378,15 +378,11 @@ 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(max_sessions=SESSION_CACHE_MAX_SIZE)
# SessionManager owns every durable deletion entrypoint, including the
# WebUI and fork rollback paths. Observe that boundary once instead of
# duplicating cleanup in each consumer.
self.sessions.set_delete_observer(self._file_state_store.discard)
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
self.tools = tool_registry if tool_registry is not None else ToolRegistry()
self._file_state_store = FileStateStore()
self._exec_session_manager = ExecSessionManager()
self.runner = AgentRunner()
self.subagents = SubagentManager(
@@ -403,11 +399,15 @@ class AgentLoop:
)
self._unified_session = unified_session
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
self._discarding_sessions: set[str] = set()
self._background_tasks: set[asyncio.Task[Any]] = set()
self._close_lock = asyncio.Lock()
self._close_mcp_lock = asyncio.Lock()
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
)
@@ -464,15 +464,10 @@ class AgentLoop:
cls,
config: Config,
bus: MessageBus | None = None,
*,
tool_registry: ToolRegistry,
**extra: Any,
) -> AgentLoop:
"""Create an AgentLoop from config with the common parameter set.
The tool registry is caller-owned so application composition can share
it with infrastructure such as an ``MCPProvider``.
Extra keyword arguments are forwarded to ``AgentLoop.__init__``,
allowing callers to override or extend the standard config-derived
parameters (e.g. ``cron_service``, ``session_manager``).
@@ -482,12 +477,6 @@ class AgentLoop:
if bus is None:
bus = MessageBus()
defaults = config.agents.defaults
if "session_manager" not in extra:
data_dir = config.runtime_data_dir
extra["session_manager"] = SessionManager(
config.workspace_path,
sessions_root=data_dir / "sessions" if data_dir is not None else None,
)
provider = extra.pop("provider", None) or make_provider(config)
resolved = config.resolve_preset()
model = extra.pop("model", None) or resolved.model
@@ -497,6 +486,8 @@ class AgentLoop:
config,
provider_snapshot_loader,
)
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls(
bus=bus,
provider=provider,
@@ -511,6 +502,7 @@ class AgentLoop:
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
channels_config=config.channels,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
@@ -525,7 +517,6 @@ class AgentLoop:
restart_mode=config.gateway.restart_mode,
provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader,
tool_registry=tool_registry,
**extra,
)
@@ -534,15 +525,9 @@ class AgentLoop:
self.subagents.max_iterations = self.max_iterations
def invalidate_runtime_config(self) -> None:
"""Invalidate runtime config for lazy refresh at the next admission."""
"""Invalidate runtime config and notify clients to refresh its catalog."""
self.runtime_resolver.invalidate()
def refresh_runtime_config(self) -> LLMRuntime:
"""Refresh runtime config now and publish the canonical selection."""
self.runtime_resolver.invalidate()
runtime = self.runtime_resolver.admit()
self._publish_runtime_selection(runtime)
return runtime
self._publish_runtime_selection(self.runtime_resolver.runtime)
def runtime_for_session(
self,
@@ -658,6 +643,14 @@ class AgentLoop:
logger.info("Registered {} tools: {}", len(registered), registered)
async def _connect_mcp(self) -> None:
"""Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools)
def mcp_runtime_status(self) -> dict[str, MCPRuntimeStatus]:
"""Return connection state learned from real MCP runtime attempts."""
return agent_context.mcp_runtime_status(self)
def register_runtime_context_provider(
self,
provider: RuntimeContextProvider,
@@ -802,7 +795,6 @@ class AgentLoop:
dispatch_fn: Callable[[CommandContext], Awaitable[OutboundMessage | None]],
) -> None:
"""Dispatch a command directly from the run() loop and publish the result."""
async def dispatch_and_publish() -> None:
ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self)
result = await dispatch_fn(ctx)
if result:
@@ -810,59 +802,6 @@ class AgentLoop:
else:
logger.warning("Command '{}' matched but dispatch returned None", raw)
# A shell command may run for up to the configured exec timeout. Keep
# the inbound consumer responsive when it runs beside an active turn.
if (msg.metadata or {}).get(INBOUND_META_USER_SHELL) is True:
self.schedule_background(dispatch_and_publish())
return
await dispatch_and_publish()
async def execute_user_shell_command(self, ctx: CommandContext) -> OutboundMessage:
"""Execute one trusted user command with the active workspace policy."""
metadata = dict(ctx.msg.metadata or {})
tool = self.tools.get("exec")
if tool is None:
content = "Shell execution is disabled in this nanobot configuration."
else:
session = ctx.session or self.sessions.get_or_create(ctx.key)
scope = self.workspace_scopes.for_turn(
channel=ctx.msg.channel,
message_metadata=metadata,
session_metadata=session.metadata,
)
request_token = bind_request_context(RequestContext(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
message_id=metadata.get("message_id"),
session_key=ctx.key,
original_user_text=f"!{ctx.args.strip()}",
runtime=ctx.runtime,
metadata=metadata,
sender_id=ctx.msg.sender_id,
turn_id=metadata.get("webui_turn_id"),
workspace=scope.project_path,
))
workspace_token = bind_workspace_scope(scope)
turn_scope_stack = ExitStack()
try:
for turn_scope in ctx.turn_scopes:
turn_scope_stack.enter_context(turn_scope)
result = await tool.execute(
command=ctx.args.strip(),
working_dir=str(scope.project_path),
)
content = str(result)
finally:
turn_scope_stack.close()
reset_workspace_scope(workspace_token)
reset_request_context(request_token)
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=content,
metadata={**metadata, "render_as": "text"},
)
async def _cancel_active_tasks(self, key: str) -> int:
"""Cancel and await all active work for *key*.
@@ -884,13 +823,8 @@ class AgentLoop:
self.sessions.invalidate(key)
await self._cancel_active_tasks(key)
finally:
self.discard_session_file_state(key)
self._discarding_sessions.discard(key)
def discard_session_file_state(self, key: str) -> None:
"""Forget ephemeral file-read state for a reset or removed session."""
self._file_state_store.discard(key)
def _effective_session_key(self, msg: InboundMessage) -> str:
"""Return the session key used for task routing and mid-turn injections."""
if self._unified_session and not msg.session_key_override:
@@ -1228,6 +1162,7 @@ class AgentLoop:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
try:
await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
@@ -1318,7 +1253,8 @@ class AgentLoop:
active_tasks.add(task)
task.add_done_callback(active_tasks.discard)
finally:
await self.aclose()
# MCP stdio transports use AnyIO cancel scopes; close them from the task that opened them.
await self.close_mcp()
async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message: per-session serial, cross-session concurrent."""
@@ -1436,24 +1372,24 @@ class AgentLoop:
await delivery.idle()
await self._publish_next_deferred_automation_turn(session_key)
async def aclose(self) -> None:
"""Stop active work, then close resources owned by the agent loop.
async def close_mcp(self) -> None:
"""Stop active work, then close exec, subagent, and MCP resources.
Resource teardown must still run if cancellation interrupts task draining.
Gateway shutdown deliberately bounds this coroutine, so keeping the cleanup
phase in ``finally`` prevents a timed-out background task from leaving
subprocess transports alive after the event loop closes.
"""
# The loop closes itself from ``run()`` while application shutdown also
# The agent loop closes itself from ``run()`` while gateway shutdown also
# performs a guaranteed final close. Serialize those owners so they cannot
# tear down the same resources concurrently.
close_lock = getattr(self, "_close_lock", None)
# tear down the same subprocess transports concurrently.
close_lock = getattr(self, "_close_mcp_lock", None)
if close_lock is None:
close_lock = self._close_lock = asyncio.Lock()
close_lock = self._close_mcp_lock = asyncio.Lock()
async with close_lock:
await self._aclose_unlocked()
await self._close_mcp_unlocked()
async def _aclose_unlocked(self) -> None:
async def _close_mcp_unlocked(self) -> None:
errors: list[BaseException] = []
active_task_groups = getattr(self, "_active_tasks", {})
active_tasks = tuple({task for tasks in active_task_groups.values() for task in tasks})
@@ -1476,6 +1412,7 @@ class AgentLoop:
cleanup_steps = (
self.subagents.close,
self._exec_session_manager.close_all,
lambda: agent_context.close_mcp(self),
)
for cleanup in cleanup_steps:
try:
@@ -1848,7 +1785,7 @@ class AgentLoop:
session.provider_state = None
self.sessions.save(session)
ctx.input_persisted_early = True
await ctx.delivery.runtime_admitted(runtime)
ctx.delivery.record_runtime(runtime)
ctx.request_context = self._request_context_for_turn(ctx)
if ctx.kind is TurnKind.USER:
@@ -1952,8 +1889,6 @@ class AgentLoop:
ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason
ctx.had_injections = had_injections
ctx.usage = dict(self._last_usage)
ctx.delivery.record_usage(ctx.usage)
if ctx.kind is TurnKind.USER:
await turn_continuation.maybe_continue_turn(ctx)
@@ -1979,8 +1914,6 @@ class AgentLoop:
else ctx.turn_wall_started_at
)
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
if ctx.usage and not ctx.ephemeral:
session.metadata["_last_usage"] = dict(ctx.usage)
self._save_turn(
session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms,
@@ -2368,6 +2301,7 @@ class AgentLoop:
"""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:
metadata[turn_continuation.SKIP_USER_PERSIST_META] = True
+11 -8
View File
@@ -769,24 +769,27 @@ class MemoryStore:
return f"{prefix}\n\n{diff_body}"
@staticmethod
def prune_dream_sessions(sessions: SessionManager, *, keep: int = 10) -> None:
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
"""Remove the oldest Dream session files, keeping only the N most recent.
Only current base64url-encoded Dream session keys are considered.
Non-dream session files are never touched.
"""
with sessions.locked_session_files() as sessions_dir:
dream_files: list[tuple[Path, str]] = []
dream_files: list[Path] = []
for path in sessions_dir.glob("*.jsonl"):
decoded_key = SessionManager.decode_storage_key(path.stem)
if decoded_key is not None and decoded_key.startswith("dream:"):
dream_files.append((path, decoded_key))
dream_files.sort(key=lambda item: item[0].stat().st_mtime)
dream_files.append(path)
dream_files.sort(key=lambda p: p.stat().st_mtime)
if len(dream_files) <= keep:
return
for path, key in dream_files[: max(0, len(dream_files) - keep)]:
if sessions.delete_session(key):
to_remove = dream_files[: len(dream_files) - keep]
for path in to_remove:
try:
path.unlink()
logger.debug("Pruned old dream session: {}", path.stem)
else:
except OSError:
logger.warning("Failed to prune dream session {}", path)
+3 -5
View File
@@ -79,9 +79,7 @@ def normalize_preset_name(name: str | None, presets: dict[str, ModelPresetConfig
if not isinstance(name, str) or not name.strip():
raise ValueError("model_preset must be a non-empty string")
name = name.strip()
if name in presets:
return name
matches = [candidate for candidate in presets if candidate.casefold() == name.casefold()]
if len(matches) == 1:
return matches[0]
if name not in presets:
raise KeyError(f"model_preset {name!r} not found. Available: {', '.join(presets) or '(none)'}")
return name
+40 -117
View File
@@ -23,22 +23,7 @@ AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.js
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
_MAX_LOGO_BYTES = 256 * 1024
@dataclass(frozen=True, slots=True)
class _PackageSnapshot:
root: Path
fingerprint: str
skill_dirs: tuple[Path, ...]
@dataclass(frozen=True, slots=True)
class _SkillCacheEntry:
skills: tuple[tuple[str, Path], ...]
packages: tuple[_PackageSnapshot, ...]
_SKILL_CACHE: dict[tuple[Path, Path], _SkillCacheEntry] = {}
_SKILL_CACHE: dict[tuple[Path, Path], tuple[tuple[str, Path], ...]] = {}
@dataclass(frozen=True)
@@ -81,68 +66,23 @@ def _installed_plugins(workspace: Path) -> list[AgentPlugin]:
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
"""Verify and return skills from plugins the user has explicitly enabled."""
skills: list[tuple[str, Path]] = []
packages: list[_PackageSnapshot] = []
for plugin in _installed_plugins(workspace):
plugin_skills = _discover_plugin_skills(plugin.name, plugin.root)
fingerprint = _enabled_package_fingerprint(workspace, plugin)
if fingerprint is None:
continue
skills.extend(plugin_skills)
if plugin_skills:
packages.append(
_PackageSnapshot(
root=plugin.root,
fingerprint=fingerprint,
skill_dirs=tuple(path.parent for _name, path in plugin_skills),
)
)
key = _skill_cache_key(workspace)
_SKILL_CACHE[key] = _SkillCacheEntry(tuple(skills), tuple(packages))
skills = [
skill
for plugin in _installed_plugins(workspace)
if _enabled(workspace, plugin)
for skill in _discover_plugin_skills(plugin.name, plugin.root)
]
_SKILL_CACHE[_skill_cache_key(workspace)] = tuple(skills)
return skills
def enabled_agent_plugin_skill_dirs(
workspace: Path,
*,
requested_path: str | Path | None = None,
) -> tuple[Path, ...]:
"""Return skill roots authorized for one read, revalidating their package."""
def enabled_agent_plugin_skill_dirs(workspace: Path) -> tuple[Path, ...]:
"""Return the last verified skill roots, verifying once on a cache miss."""
key = _skill_cache_key(workspace)
cached = _SKILL_CACHE.get(key)
if cached is None:
enabled_agent_plugin_skills(workspace)
cached = _SKILL_CACHE.get(key)
if cached is None:
return ()
target = (
Path(requested_path).expanduser().resolve(strict=False)
if requested_path is not None
else None
)
packages = tuple(
package
for package in cached.packages
if target is None
or any(target == root or target.is_relative_to(root) for root in package.skill_dirs)
)
if any(_package_fingerprint(package.root) != package.fingerprint for package in packages):
# Re-run the full activation check so a changed package loses its
# marker and cannot become readable again through this cache.
_invalidate_skill_cache(workspace)
enabled_agent_plugin_skills(workspace)
return ()
if target is None:
return tuple(root for package in packages for root in package.skill_dirs)
return tuple(
root
for package in packages
for root in package.skill_dirs
if target == root or target.is_relative_to(root)
)
skills = _SKILL_CACHE.get(key)
if skills is None:
skills = tuple(enabled_agent_plugin_skills(workspace))
return tuple(path.parent for _name, path in skills)
def _skill_cache_key(workspace: Path) -> tuple[Path, Path]:
@@ -156,29 +96,6 @@ def _invalidate_skill_cache(workspace: Path) -> None:
_SKILL_CACHE.pop(_skill_cache_key(workspace), None)
def _package_fingerprint(root: Path) -> str | None:
"""Hash package paths, link targets, and file contents."""
digest = sha256()
try:
for candidate in sorted(root.rglob("*")):
relative = candidate.relative_to(root).as_posix()
digest.update(relative.encode())
if candidate.is_symlink():
digest.update(b"\0link\0")
digest.update(candidate.readlink().as_posix().encode())
elif candidate.is_file():
digest.update(b"\0file\0")
digest.update(candidate.read_bytes())
elif candidate.is_dir():
digest.update(b"\0dir\0")
else:
return None
digest.update(b"\0")
except OSError:
return None
return digest.hexdigest()
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
payload = _read_object(plugin_root / "plugin.json", plugin_root)
if payload is None:
@@ -413,47 +330,53 @@ def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
return current
def _enabled_package_fingerprint(workspace: Path, plugin: AgentPlugin) -> str | None:
"""Return the content fingerprint when this exact package is enabled."""
def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
try:
if not marker.is_file():
return None
return False
current = marker.read_text(encoding="utf-8")
activation = _activation_marker(plugin)
if activation is None:
marker.unlink(missing_ok=True)
_invalidate_skill_cache(workspace)
return None
payload = cast(dict[str, object], json.loads(activation))
fingerprint = payload.get("fingerprint")
if not isinstance(fingerprint, str):
return None
return False
if current == activation:
return fingerprint
return True
if current == str(plugin.root):
marker.write_text(activation, encoding="utf-8")
marker.chmod(0o600)
return fingerprint
return True
marker.unlink(missing_ok=True)
_invalidate_skill_cache(workspace)
return None
except (OSError, json.JSONDecodeError):
return False
except OSError:
_invalidate_skill_cache(workspace)
return None
def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
return _enabled_package_fingerprint(workspace, plugin) is not None
return False
def _activation_marker(plugin: AgentPlugin) -> str | None:
"""Bind activation to one immutable package snapshot."""
fingerprint = _package_fingerprint(plugin.root)
if fingerprint is None:
digest = sha256()
try:
for candidate in sorted(plugin.root.rglob("*")):
relative = candidate.relative_to(plugin.root).as_posix()
digest.update(relative.encode())
if candidate.is_symlink():
digest.update(b"\0link\0")
digest.update(candidate.readlink().as_posix().encode())
elif candidate.is_file():
digest.update(b"\0file\0")
digest.update(candidate.read_bytes())
elif candidate.is_dir():
digest.update(b"\0dir\0")
else:
return None
digest.update(b"\0")
except OSError:
return None
return json.dumps(
{"fingerprint": fingerprint, "root": str(plugin.root)},
{"fingerprint": digest.hexdigest(), "root": str(plugin.root)},
separators=(",", ":"),
sort_keys=True,
)
+3 -43
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import inspect
import os
import time
from collections.abc import Awaitable, Callable, Iterable
from copy import deepcopy
from dataclasses import dataclass, field
@@ -933,27 +932,6 @@ class AgentRunner:
progress_state: dict[str, bool] | None = None
active_hosted_tools: dict[str, dict[str, Any]] = {}
request_started_at = 0.0
first_output_at: float | None = None
generation_started_at: float | None = None
generation_elapsed_s = 0.0
def _generation_delta(delta: str) -> None:
nonlocal first_output_at, generation_started_at
if not delta:
return
now = time.perf_counter()
if first_output_at is None:
first_output_at = now
if generation_started_at is None:
generation_started_at = now
def _pause_generation() -> None:
nonlocal generation_elapsed_s, generation_started_at
if generation_started_at is None:
return
generation_elapsed_s += max(0.0, time.perf_counter() - generation_started_at)
generation_started_at = None
async def _provider_tool_event(event: dict[str, Any]) -> None:
if event.get("kind") != "hosted_tool":
@@ -972,7 +950,6 @@ class AgentRunner:
thinking_buf = ""
async def _stream(delta: str) -> None:
_generation_delta(delta)
if delta:
context.streamed_content = True
await hook.on_stream(context, delta)
@@ -981,7 +958,6 @@ class AgentRunner:
nonlocal thinking_buf
if not delta:
return
_generation_delta(delta)
prev_clean = strip_reasoning_tags(thinking_buf)
thinking_buf += delta
new_clean = strip_reasoning_tags(thinking_buf)
@@ -991,7 +967,6 @@ class AgentRunner:
await hook.emit_reasoning(incremental)
async def _stream_recover() -> None:
_pause_generation()
await hook.on_stream_end(context, resuming=True)
coro = spec.runtime.provider.chat_stream_with_retry(
@@ -1011,7 +986,6 @@ class AgentRunner:
nonlocal stream_buf
if not delta:
return
_generation_delta(delta)
prev_clean = strip_think(stream_buf)
stream_buf += delta
new_clean = strip_think(stream_buf)
@@ -1053,7 +1027,6 @@ class AgentRunner:
if is_streaming_request and timeout_s is not None
else timeout_s
)
request_started_at = time.perf_counter()
try:
response = (
await coro if outer_timeout_s is None
@@ -1072,11 +1045,6 @@ class AgentRunner:
finish_reason="error",
error_kind="timeout",
)
_pause_generation()
if first_output_at is not None:
response.ttft_ms = max(0, round((first_output_at - request_started_at) * 1000))
if generation_elapsed_s > 0:
response.generation_ms = max(1, round(generation_elapsed_s * 1000))
# 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":
@@ -1320,18 +1288,10 @@ class AgentRunner:
if total > 0:
usage["total_tokens"] = total
usage.setdefault("provider_tokens", total)
elif response.finish_reason == "error":
return {}
else:
usage = self._estimate_response_usage(spec, messages, response)
completion = usage.get("completion_tokens", 0)
if response.generation_ms is not None and completion > 0:
usage["generation_ms"] = response.generation_ms
usage["measured_completion_tokens"] = completion
if response.ttft_ms is not None:
usage["ttft_ms"] = response.ttft_ms
usage["timed_requests"] = 1
return usage
if response.finish_reason == "error":
return {}
return self._estimate_response_usage(spec, messages, response)
def _estimate_response_usage(
self,
-218
View File
@@ -1,218 +0,0 @@
"""Windows Job Object ownership for subprocess trees."""
from __future__ import annotations
import ctypes
from ctypes import wintypes
_CREATE_SUSPENDED = 0x00000004
_PROCESS_SET_QUOTA = 0x0100
_PROCESS_TERMINATE = 0x0001
_TH32CS_SNAPTHREAD = 0x00000004
_THREAD_SUSPEND_RESUME = 0x0002
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
class _IoCounters(ctypes.Structure):
_fields_ = [
("ReadOperationCount", ctypes.c_ulonglong),
("WriteOperationCount", ctypes.c_ulonglong),
("OtherOperationCount", ctypes.c_ulonglong),
("ReadTransferCount", ctypes.c_ulonglong),
("WriteTransferCount", ctypes.c_ulonglong),
("OtherTransferCount", ctypes.c_ulonglong),
]
class _BasicLimitInformation(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", ctypes.c_longlong),
("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD),
]
class _ExtendedLimitInformation(ctypes.Structure):
_fields_ = [
("BasicLimitInformation", _BasicLimitInformation),
("IoInfo", _IoCounters),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t),
]
class _ThreadEntry32(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ThreadID", wintypes.DWORD),
("th32OwnerProcessID", wintypes.DWORD),
("tpBasePri", wintypes.LONG),
("tpDeltaPri", wintypes.LONG),
("dwFlags", wintypes.DWORD),
]
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
_kernel32.SetInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
ctypes.c_void_p,
wintypes.DWORD,
]
_kernel32.SetInformationJobObject.restype = wintypes.BOOL
_kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
_kernel32.OpenProcess.restype = wintypes.HANDLE
_kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
_kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
_kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
_kernel32.TerminateProcess.restype = wintypes.BOOL
_kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
_kernel32.TerminateJobObject.restype = wintypes.BOOL
_kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
_kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
_kernel32.Thread32First.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
_kernel32.Thread32First.restype = wintypes.BOOL
_kernel32.Thread32Next.argtypes = [wintypes.HANDLE, ctypes.POINTER(_ThreadEntry32)]
_kernel32.Thread32Next.restype = wintypes.BOOL
_kernel32.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
_kernel32.OpenThread.restype = wintypes.HANDLE
_kernel32.ResumeThread.argtypes = [wintypes.HANDLE]
_kernel32.ResumeThread.restype = wintypes.DWORD
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
_kernel32.CloseHandle.restype = wintypes.BOOL
def _win_error(operation: str) -> OSError:
code = ctypes.get_last_error()
return OSError(code, f"{operation} failed (Windows error {code})")
def _close_handle(handle: int | None) -> None:
if handle:
_kernel32.CloseHandle(handle)
def _set_kill_on_close(handle: int, enabled: bool) -> None:
info = _ExtendedLimitInformation()
if enabled:
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if not _kernel32.SetInformationJobObject(
handle,
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
ctypes.byref(info),
ctypes.sizeof(info),
):
raise _win_error("SetInformationJobObject")
def _resume_primary_thread(pid: int) -> None:
snapshot = _kernel32.CreateToolhelp32Snapshot(_TH32CS_SNAPTHREAD, 0)
if snapshot == _INVALID_HANDLE_VALUE:
raise _win_error("CreateToolhelp32Snapshot")
try:
entry = _ThreadEntry32()
entry.dwSize = ctypes.sizeof(entry)
found = _kernel32.Thread32First(snapshot, ctypes.byref(entry))
while found:
if entry.th32OwnerProcessID == pid:
thread = _kernel32.OpenThread(
_THREAD_SUSPEND_RESUME,
False,
entry.th32ThreadID,
)
if not thread:
raise _win_error("OpenThread")
try:
if _kernel32.ResumeThread(thread) == 0xFFFFFFFF:
raise _win_error("ResumeThread")
return
finally:
_close_handle(thread)
found = _kernel32.Thread32Next(snapshot, ctypes.byref(entry))
raise RuntimeError(f"suspended process {pid} has no resumable thread")
finally:
_close_handle(snapshot)
class WindowsJob:
"""Own a process tree even after its root process exits."""
creation_flags = _CREATE_SUSPENDED
def __init__(self, handle: int) -> None:
self._handle: int | None = handle
@classmethod
def create(cls) -> WindowsJob:
handle = _kernel32.CreateJobObjectW(None, None)
if not handle:
raise _win_error("CreateJobObjectW")
try:
_set_kill_on_close(handle, True)
except Exception:
_close_handle(handle)
raise
return cls(handle)
def assign_and_resume(self, pid: int) -> None:
"""Atomically establish tree ownership before the root can spawn."""
if self._handle is None:
raise RuntimeError("Windows job is already closed")
process = _kernel32.OpenProcess(
_PROCESS_SET_QUOTA | _PROCESS_TERMINATE,
False,
pid,
)
if not process:
error = _win_error("OpenProcess")
self.close()
raise error
if not _kernel32.AssignProcessToJobObject(self._handle, process):
error = _win_error("AssignProcessToJobObject")
_kernel32.TerminateProcess(process, 1)
_close_handle(process)
self.close()
raise error
try:
_resume_primary_thread(pid)
except Exception:
self.terminate()
raise
finally:
_close_handle(process)
def release(self) -> None:
"""Release ownership after successful output collection."""
if self._handle is None:
return
_set_kill_on_close(self._handle, False)
self.close()
def terminate(self) -> None:
"""Terminate every process in the job and close its handle."""
if self._handle is None:
return
try:
_kernel32.TerminateJobObject(self._handle, 1)
finally:
self.close()
def close(self) -> None:
handle = self._handle
self._handle = None
_close_handle(handle)
+1 -5
View File
@@ -209,11 +209,7 @@ class _ExecSession:
timeout=2.0,
)
# Safety-net reap after normal exit.
from nanobot.agent.tools.shell import ( # pyright: ignore[reportPrivateUsage]
ExecTool,
_reap_pid, # pyright: ignore[reportPrivateUsage]
)
ExecTool._release_process_tree(self.process) # pyright: ignore[reportPrivateUsage]
from nanobot.agent.tools.shell import _reap_pid # pyright: ignore[reportPrivateUsage]
_reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
elif yield_time_ms > 0:
await self._wait_for_buffered_output()
+5 -15
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import hashlib
import os
from collections import OrderedDict
from contextvars import ContextVar, Token
from dataclasses import dataclass
from pathlib import Path
@@ -136,30 +135,21 @@ class FileStates:
class FileStateStore:
"""Bounded lookup table for per-session file read/write state."""
"""Lookup table for per-session file read/write state."""
__slots__ = ("_max_sessions", "_states_by_key")
__slots__ = ("_states_by_key",)
def __init__(self, *, max_sessions: int = 128) -> None:
if max_sessions <= 0:
raise ValueError("max_sessions must be positive")
self._max_sessions = max_sessions
self._states_by_key: OrderedDict[str, FileStates] = OrderedDict()
def __init__(self) -> None:
self._states_by_key: dict[str, FileStates] = {}
def for_session(self, session_key: str | None) -> FileStates:
key = session_key or "__default__"
states = self._states_by_key.pop(key, None)
states = self._states_by_key.get(key)
if states is None:
states = FileStates()
self._states_by_key[key] = states
while len(self._states_by_key) > self._max_sessions:
self._states_by_key.popitem(last=False)
return states
def discard(self, session_key: str | None) -> None:
"""Forget file state when a session is reset or removed."""
self._states_by_key.pop(session_key or "__default__", None)
def clear(self) -> None:
self._states_by_key.clear()
+1 -13
View File
@@ -153,20 +153,8 @@ class _FsTool(Tool):
from nanobot.agent.plugins import enabled_agent_plugin_skill_dirs
try:
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
)
if self._effective_allowed_root(access.allowed_root) is not None:
candidate = Path(path).expanduser()
if not candidate.is_absolute() and access.project_path is not None:
candidate = access.project_path / candidate
plugin_skill_dirs = list(
enabled_agent_plugin_skill_dirs(
Path(self._workspace),
requested_path=candidate.resolve(strict=False),
)
enabled_agent_plugin_skill_dirs(Path(self._workspace))
)
except (OSError, RuntimeError):
pass
+262 -243
View File
@@ -1,6 +1,4 @@
"""MCP client and dynamic tool-provider lifecycle."""
from __future__ import annotations
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio
import hashlib
@@ -9,15 +7,23 @@ import os
import re
import shutil
import urllib.parse
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, suppress
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast
from typing import TYPE_CHECKING, Any, Literal, Mapping, Protocol, cast
from weakref import WeakKeyDictionary
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_ACK,
RUNTIME_CONTROL_MCP_RELOAD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.security.network import (
PinnedDNSAsyncTransport,
env_proxy_applies_to_url,
@@ -33,7 +39,7 @@ if TYPE_CHECKING:
from mcp.types import Tool as MCPToolDefinition
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
from nanobot.config.schema import Config, MCPServerConfig
from nanobot.config.schema import MCPServerConfig
# Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network
@@ -54,37 +60,18 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
_SANITIZE_RE = re.compile(r"_+")
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
MCPServerLoader = Callable[[], Mapping[str, "MCPServerConfig"]]
MCPRuntimeStatus = Literal["connecting", "connected", "failed"]
_MCP_RUNTIME_STATUSES: frozenset[MCPRuntimeStatus] = frozenset(
("connecting", "connected", "failed")
)
class MCPConnection(Protocol):
async def aclose(self) -> None: ...
async def _close_mcp_connection(name: str, connection: MCPConnection) -> None:
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)
async def _close_mcp_connections(connections: Mapping[str, MCPConnection]) -> None:
cancellation: asyncio.CancelledError | None = None
for name, connection in connections.items():
try:
await _close_mcp_connection(name, connection)
except asyncio.CancelledError as exc:
cancellation = cancellation or exc
if cancellation is not None:
raise cancellation
class _OwnedMCPConnection:
"""Close an MCP transport from the task that originally opened it."""
@@ -505,11 +492,11 @@ class _MCPWrapperBase(Tool):
"""Common reconnect handling for wrappers bound to one MCP server session."""
_plugin_discoverable = False
_session: ClientSession
_session: "ClientSession"
_server_name: str
_name: str
def _set_mcp_connection(self, session: ClientSession, server_name: str) -> None:
def _set_mcp_connection(self, session: "ClientSession", server_name: str) -> None:
self._session = session
self._server_name = server_name
self._reconnect: _ReconnectCallback | None = None
@@ -599,9 +586,9 @@ class MCPToolWrapper(_MCPWrapperBase):
def __init__(
self,
session: ClientSession,
session: "ClientSession",
server_name: str,
tool_def: MCPToolDefinition,
tool_def: "MCPToolDefinition",
tool_timeout: int = 30,
):
self._set_mcp_connection(session, server_name)
@@ -761,9 +748,9 @@ class MCPResourceWrapper(_MCPWrapperBase):
def __init__(
self,
session: ClientSession,
session: "ClientSession",
server_name: str,
resource_def: Resource,
resource_def: "Resource",
resource_timeout: int = 30,
):
self._set_mcp_connection(session, server_name)
@@ -865,9 +852,9 @@ class MCPPromptWrapper(_MCPWrapperBase):
def __init__(
self,
session: ClientSession,
session: "ClientSession",
server_name: str,
prompt_def: Prompt,
prompt_def: "Prompt",
prompt_timeout: int = 30,
):
self._set_mcp_connection(session, server_name)
@@ -998,10 +985,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
async def connect_mcp_servers(
mcp_servers: dict[str, MCPServerConfig],
mcp_servers: "dict[str, MCPServerConfig]",
registry: ToolRegistry,
*,
oauth_handlers: Mapping[str, MCPOAuthHandlers] | None = None,
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts.
@@ -1015,7 +1002,7 @@ async def connect_mcp_servers(
from mcp.client.streamable_http import streamable_http_client
async def open_single_server(
name: str, cfg: MCPServerConfig, server_stack: AsyncExitStack
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
) -> bool:
try:
transport_type = cfg.type
@@ -1257,7 +1244,7 @@ async def connect_mcp_servers(
return False
async def connect_single_server(
name: str, cfg: MCPServerConfig
name: str, cfg: "MCPServerConfig"
) -> tuple[str, MCPConnection | None]:
loop = asyncio.get_running_loop()
ready: asyncio.Future[bool] = loop.create_future()
@@ -1295,11 +1282,8 @@ async def connect_mcp_servers(
return name, connection
server_stacks: dict[str, MCPConnection] = {}
attempted_names: list[str] = []
try:
for name, cfg in mcp_servers.items():
attempted_names.append(name)
try:
result = await connect_single_server(name, cfg)
except Exception as e:
@@ -1307,17 +1291,6 @@ async def connect_mcp_servers(
continue
if result[1] is not None:
server_stacks[result[0]] = result[1]
except BaseException:
# Callers can bound readiness/reload with a timeout. If cancellation
# interrupts a later server, ownership of earlier connections has not
# transferred yet, so roll the whole batch back before propagating it.
for name in attempted_names:
_unregister_server_tools(registry, name)
try:
await _close_mcp_connections(server_stacks)
except BaseException as cleanup_exc:
logger.debug("MCP batch rollback cleanup error (can be ignored): {}", cleanup_exc)
raise
return server_stacks
@@ -1328,101 +1301,69 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
def _configured_servers(config: Config) -> dict[str, MCPServerConfig]:
from nanobot.agent.plugins import agent_plugin_mcp_servers
return agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
def _load_current_servers() -> dict[str, MCPServerConfig]:
from nanobot.config.loader import load_config, resolve_config_env_vars
return _configured_servers(resolve_config_env_vars(load_config()))
class MCPProvider:
"""Own configured MCP connections and their dynamic tool registrations."""
def __init__(
self,
servers: Mapping[str, MCPServerConfig],
registry: ToolRegistry,
def _runtime_status_store(
state: Any,
*,
server_loader: MCPServerLoader | None = None,
) -> None:
self._servers = dict(servers)
self._registry = registry
self._server_loader = server_loader or _load_current_servers
self._connections: dict[str, MCPConnection] = {}
self._runtime_statuses: dict[str, MCPRuntimeStatus] = {}
self._lock = asyncio.Lock()
self._closing = False
create: bool = False,
) -> dict[str, MCPRuntimeStatus] | None:
raw_statuses: object = getattr(state, "_mcp_runtime_statuses", None)
if isinstance(raw_statuses, dict):
return cast(dict[str, MCPRuntimeStatus], raw_statuses)
if not create:
return None
statuses: dict[str, MCPRuntimeStatus] = {}
state._mcp_runtime_statuses = statuses
return statuses
@classmethod
def from_config(
cls,
config: Config,
registry: ToolRegistry,
*,
server_loader: MCPServerLoader | None = None,
) -> MCPProvider:
return cls(
_configured_servers(config),
registry,
server_loader=server_loader,
)
@property
def configured_server_names(self) -> set[str]:
return set(self._servers)
@property
def connected_server_names(self) -> set[str]:
return set(self._connections)
def runtime_status(self) -> dict[str, MCPRuntimeStatus]:
"""Return the latest connection-attempt result for configured servers."""
def runtime_status(state: Any) -> dict[str, MCPRuntimeStatus]:
"""Return the latest connection-attempt result for configured MCP servers."""
statuses = _runtime_status_store(state)
raw_configured: object = getattr(state, "_mcp_servers", None)
if statuses is None or not isinstance(raw_configured, dict):
return {}
configured = cast(dict[str, Any], raw_configured)
return {
name: status
for name, status in self._runtime_statuses.items()
if name in self._servers
for name, status in statuses.items()
if name in configured and status in _MCP_RUNTIME_STATUSES
}
def _set_runtime_status(
self,
server_names: Iterable[str],
state: Any,
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
status: MCPRuntimeStatus,
) -> None:
statuses = _runtime_status_store(state, create=True)
assert statuses is not None
for name in server_names:
self._runtime_statuses[name] = status
statuses[name] = status
def _record_connection_result(
self,
attempted: Iterable[str],
connected: Iterable[str],
state: Any,
attempted: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
connected: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
) -> None:
attempted_names = set(attempted)
connected_names = set(connected)
self._set_runtime_status(connected_names, "connected")
self._set_runtime_status(attempted_names - connected_names, "failed")
_set_runtime_status(state, connected_names, "connected")
_set_runtime_status(state, attempted_names - connected_names, "failed")
async def connect(self) -> None:
"""Connect configured servers that are not currently live."""
async with self._lock:
if self._closing:
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
"""Connect configured MCP servers that are not currently live."""
async with _reload_lock(state):
if getattr(state, "_mcp_closing", False):
return
configured_missing = {
name: cfg
for name, cfg in self._servers.items()
if name not in self._connections
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
}
oauth_servers = {
name: cfg
for name, cfg in configured_missing.items()
if cfg.auth == "oauth"
if getattr(cfg, "auth", None) == "oauth"
}
authorization_pending: set[str] = set()
if oauth_servers:
@@ -1433,53 +1374,62 @@ class MCPProvider:
for name, cfg in oauth_servers.items()
if not mcp_oauth_has_credentials(name, cfg.url)
}
statuses = _runtime_status_store(state)
if statuses is not None:
for name in authorization_pending:
self._runtime_statuses.pop(name, None)
statuses.pop(name, None)
missing_servers = {
name: cfg
for name, cfg in configured_missing.items()
if name not in authorization_pending
}
if not missing_servers:
if state._mcp_connecting or not missing_servers:
return
self._set_runtime_status(missing_servers, "connecting")
state._mcp_connecting = True
_set_runtime_status(state, missing_servers, "connecting")
try:
connected = await connect_mcp_servers(missing_servers, self._registry)
if self._closing:
await _close_mcp_connections(connected)
connected = await connect_mcp_servers(missing_servers, registry)
if getattr(state, "_mcp_closing", False):
for connection in connected.values():
await connection.aclose()
return
self._connections.update(connected)
self._record_connection_result(missing_servers, connected)
self._attach_reconnect_handlers(connected)
state._mcp_stacks.update(connected)
_record_connection_result(state, missing_servers, connected)
_attach_reconnect_handlers(state, registry, connected)
if connected:
logger.info("MCP connected servers: {}", sorted(connected))
else:
logger.warning(
"No MCP servers connected successfully "
"(will retry on the next readiness check)"
)
logger.warning("No MCP servers connected successfully (will retry next message)")
except asyncio.CancelledError:
self._set_runtime_status(missing_servers, "failed")
if task_is_cancelling():
raise
logger.warning(
"MCP connection cancelled (will retry on the next readiness check)"
)
except BaseException as exc:
self._set_runtime_status(missing_servers, "failed")
logger.warning(
"Failed to connect MCP servers "
"(will retry on the next readiness check): {}",
exc,
)
_set_runtime_status(state, missing_servers, "failed")
logger.warning("MCP connection cancelled (will retry next message)")
except BaseException as e:
_set_runtime_status(state, missing_servers, "failed")
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
finally:
state._mcp_connecting = False
async def reload(self) -> dict[str, Any]:
"""Reconcile live MCP connections with the current configuration."""
async with self._lock:
if self._closing:
return self._closing_result()
async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"""Reconcile live MCP connections with the current config file."""
async with _reload_lock(state):
if getattr(state, "_mcp_closing", False):
return {
"ok": False,
"message": "MCP connections are shutting down.",
"requires_restart": True,
}
try:
next_servers = dict(self._server_loader())
from nanobot.agent.plugins import agent_plugin_mcp_servers
from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config())
next_servers = agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc)
return {
@@ -1489,7 +1439,7 @@ class MCPProvider:
"error": str(exc),
}
current_servers = dict(self._servers)
current_servers = dict(state._mcp_servers)
current_names = set(current_servers)
next_names = set(next_servers)
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
@@ -1504,54 +1454,52 @@ class MCPProvider:
changed = sorted(
name
for name in current_names & next_names
if _server_signature(current_servers[name])
!= _server_signature(next_servers[name])
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
)
tools_removed = 0
for name in [*removed, *changed]:
tools_removed += _unregister_server_tools(self._registry, name)
await self._close_server(name)
tools_removed += _unregister_server_tools(registry, name)
await _close_server(state, name)
runtime_statuses = _runtime_status_store(state)
if runtime_statuses is not None:
for name in [*removed, *authorization_pending]:
self._runtime_statuses.pop(name, None)
runtime_statuses.pop(name, None)
self._servers = next_servers
state._mcp_servers = next_servers
retry_missing = sorted(
name
for name in next_names
if name not in self._connections
if name not in state._mcp_stacks
and name not in set(added) | set(changed)
and name not in authorization_pending
)
to_connect_names = sorted(
(set(added) | set(changed) | set(retry_missing))
- authorization_pending
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
)
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {}
if to_connect:
self._set_runtime_status(to_connect, "connecting")
try:
connected = await connect_mcp_servers(to_connect, self._registry)
except BaseException:
self._set_runtime_status(to_connect, "failed")
raise
if self._closing:
await _close_mcp_connections(connected)
return self._closing_result()
self._connections.update(connected)
self._record_connection_result(to_connect, connected)
self._attach_reconnect_handlers(connected)
_set_runtime_status(state, to_connect, "connecting")
connected = await connect_mcp_servers(to_connect, registry)
if getattr(state, "_mcp_closing", False):
for connection in connected.values():
await connection.aclose()
return {
"ok": False,
"message": "MCP connections are shutting down.",
"requires_restart": True,
}
state._mcp_stacks.update(connected)
_record_connection_result(state, to_connect, connected)
_attach_reconnect_handlers(state, registry, connected)
failed = sorted(set(to_connect) - set(connected))
unchanged = not removed and not added and not changed and not retry_missing
ok = not failed
if failed:
message = (
"MCP config reloaded, but some servers did not connect: "
+ ", ".join(failed)
)
message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed)
elif unchanged:
message = "MCP config is already live."
elif retry_missing and not added and not changed and not removed:
@@ -1560,8 +1508,7 @@ class MCPProvider:
message = "MCP config reloaded without restarting nanobot."
logger.info(
"MCP hot reload: added={} changed={} removed={} retried={} "
"connected={} failed={} tools_removed={}",
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
added,
changed,
removed,
@@ -1577,51 +1524,114 @@ class MCPProvider:
"changed": changed,
"removed": removed,
"retried": retry_missing,
"connected": sorted(self._connections),
"configured": sorted(self._servers),
"connected": sorted(state._mcp_stacks),
"configured": sorted(state._mcp_servers),
"failed": failed,
"tools_removed": tools_removed,
"requires_restart": False,
}
@staticmethod
def _closing_result() -> dict[str, Any]:
async def request_mcp_reload(
bus: MessageBus,
*,
timeout: float = 15.0,
) -> dict[str, Any]:
"""Ask the running agent loop to reconcile live MCP connections."""
loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
await bus.publish_inbound(
InboundMessage(
channel="system",
sender_id="webui-settings",
chat_id="runtime",
content=RUNTIME_CONTROL_MCP_RELOAD,
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD,
RUNTIME_CONTROL_ACK: ack,
},
)
)
try:
result = await asyncio.wait_for(ack, timeout=timeout)
except asyncio.TimeoutError:
return {
"ok": False,
"message": "MCP connections are shutting down.",
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
return result if isinstance(cast(object, result), dict) else {
"ok": False,
"message": "MCP hot reload returned an unexpected response.",
"requires_restart": True,
}
def _attach_reconnect_handlers(self, server_names: Iterable[str]) -> None:
async def reconnect(
server_name: str,
tool_name: str,
stale_tool: Tool,
) -> Tool | None:
return await self._refresh_terminated_server(
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
if control != RUNTIME_CONTROL_MCP_RELOAD:
return False
ack = metadata.get(RUNTIME_CONTROL_ACK)
try:
result = await reload_servers(state, registry)
except Exception as exc:
logger.exception("MCP hot reload failed")
result = {
"ok": False,
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
return True
def _reload_lock(state: Any) -> asyncio.Lock:
try:
return _RELOAD_LOCKS[state]
except KeyError:
lock = asyncio.Lock()
_RELOAD_LOCKS[state] = lock
return lock
def _attach_reconnect_handlers(
state: Any,
registry: ToolRegistry,
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
) -> None:
async def reconnect(server_name: str, tool_name: str, stale_tool: Tool) -> Tool | None:
return await _refresh_terminated_server(
state,
registry,
server_name,
tool_name,
stale_tool,
)
for server_name in server_names:
for tool_name in list(self._registry.tool_names):
tool = self._registry.get(tool_name)
for tool_name in list(registry.tool_names):
tool = registry.get(tool_name)
if not _tool_belongs_to_server(tool, tool_name, server_name):
continue
if isinstance(tool, _MCPWrapperBase):
tool.set_reconnect_handler(reconnect)
async def _refresh_terminated_server(
self,
state: Any,
registry: ToolRegistry,
server_name: str,
tool_name: str,
stale_tool: Tool,
) -> Tool | None:
async with self._lock:
if self._closing:
async with _reload_lock(state):
if getattr(state, "_mcp_closing", False):
return None
cfg = self._servers.get(server_name)
cfg = state._mcp_servers.get(server_name)
if cfg is None:
logger.warning(
"MCP server '{}' session terminated but is no longer configured",
@@ -1629,56 +1639,31 @@ class MCPProvider:
)
return None
current_tool = self._registry.get(tool_name)
current_tool = registry.get(tool_name)
if (
current_tool is not None
and current_tool is not stale_tool
and server_name in self._connections
and server_name in state._mcp_stacks
):
return current_tool
logger.warning(
"MCP server '{}' session terminated; refreshing connection",
server_name,
)
_unregister_server_tools(self._registry, server_name)
await self._close_server(server_name)
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
_unregister_server_tools(registry, server_name)
await _close_server(state, server_name)
self._set_runtime_status({server_name}, "connecting")
connected = await connect_mcp_servers(
{server_name: cfg},
self._registry,
)
if self._closing:
await _close_mcp_connections(connected)
_set_runtime_status(state, {server_name}, "connecting")
connected = await connect_mcp_servers({server_name: cfg}, registry)
if getattr(state, "_mcp_closing", False):
for connection in connected.values():
await connection.aclose()
return None
self._connections.update(connected)
self._record_connection_result({server_name}, connected)
self._attach_reconnect_handlers(connected)
state._mcp_stacks.update(connected)
_record_connection_result(state, {server_name}, connected)
_attach_reconnect_handlers(state, registry, connected)
if server_name not in connected:
logger.warning(
"MCP server '{}' reconnect failed after session termination",
server_name,
)
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
return None
return self._registry.get(tool_name)
async def _close_server(self, server_name: str) -> None:
connection = self._connections.pop(server_name, None)
if connection is None:
return
await _close_mcp_connection(server_name, connection)
async def aclose(self) -> None:
"""Close every connection while excluding reconnect and hot reload."""
self._closing = True
async with self._lock:
connections = dict(self._connections)
self._connections.clear()
self._runtime_statuses.clear()
for name in self._servers:
_unregister_server_tools(self._registry, name)
await _close_mcp_connections(connections)
return registry.get(tool_name)
def _server_signature(cfg: Any) -> Any:
@@ -1705,3 +1690,37 @@ def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
registry.unregister(tool_name)
removed += 1
return removed
async def _close_server(state: Any, server_name: str) -> None:
stack = state._mcp_stacks.pop(server_name, None)
if stack is 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)
async def close_mcp_servers(state: Any) -> None:
"""Close every MCP connection while excluding reconnect and hot reload."""
state._mcp_closing = True
async with _reload_lock(state):
connections = list(state._mcp_stacks.items())
state._mcp_stacks.clear()
statuses = _runtime_status_store(state)
if statuses is not None:
statuses.clear()
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)
+1
View File
@@ -235,6 +235,7 @@ def _snapshot_model_presets(
) -> dict[str, dict[str, object]]:
return {
name: {
"label": preset.label,
"model": preset.model,
"provider": preset.provider,
"max_tokens": preset.max_tokens,
+1 -1
View File
@@ -78,7 +78,7 @@ class MyTool(Tool):
"runner", "sessions", "consolidator",
"dream", "auto_compact", "context", "commands",
# Sensitive runtime state (credentials, message routing, task tracking)
"_pending_queues",
"_mcp_servers", "_mcp_stacks", "_pending_queues",
"_session_locks", "_active_tasks", "_background_tasks",
# Security boundaries (inspect + modify both blocked)
"restrict_to_workspace", "channels_config",
+15 -221
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import os
import re
import shlex
import shutil
import signal
import subprocess
@@ -13,8 +12,7 @@ import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path, PureWindowsPath
from typing import Any, Protocol, cast
from urllib.parse import unquote
from typing import Any
from loguru import logger
from pydantic import Field
@@ -44,17 +42,6 @@ from nanobot.security.workspace_access import current_scope_allows_loopback, cur
from nanobot.security.workspace_policy import is_path_within
_IS_WINDOWS = sys.platform == "win32"
_PROCESS_TREE_OWNER_ATTR = "_nanobot_process_tree_owner"
class _ProcessTreeOwner(Protocol):
creation_flags: int
def assign_and_resume(self, pid: int) -> None: ...
def release(self) -> None: ...
def terminate(self) -> None: ...
def _reap_pid(pid: int) -> None:
@@ -339,7 +326,6 @@ class ExecTool(Tool):
prepared.env,
prepared.shell_program,
prepared.login,
process_tree=True,
)
try:
@@ -348,10 +334,10 @@ class ExecTool(Tool):
timeout=prepared.timeout,
)
except asyncio.TimeoutError:
await self._kill_process_tree(process)
await self._kill_process(process)
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
except asyncio.CancelledError:
await self._kill_process_tree(process)
await self._kill_process(process)
raise
# Safety-net reap: asyncio *should* have reaped the child via
@@ -382,14 +368,13 @@ class ExecTool(Tool):
+ result[-half:]
)
self._release_process_tree(process)
return result
except Exception as e:
# Kill and reap the child if it was spawned but an unexpected
# error prevented communicate() from completing.
if process is not None:
await self._kill_process_tree(process)
await self._kill_process(process)
return ToolResult.error(f"Error executing command: {str(e)}")
async def _execute_session(
@@ -552,31 +537,22 @@ class ExecTool(Tool):
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
windows_job = None
process = None
creation_flags = 0
if process_tree and sys.platform == "win32":
windows_job = ExecTool._create_windows_job()
creation_flags = windows_job.creation_flags
# Default to PowerShell so single-line and multi-line commands
# share the same shell semantics. cmd.exe is reachable via the
# explicit shell="cmd" parameter (see _resolve_shell).
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
program = shell_program or default_program
program_name = PureWindowsPath(program).name.lower()
try:
if program_name in ("cmd", "cmd.exe"):
cmd_env = {**env, "COMSPEC": program}
process = await asyncio.create_subprocess_shell(
return await asyncio.create_subprocess_shell(
command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=cmd_env,
creationflags=creation_flags,
)
else:
command = ExecTool._normalize_powershell_command(command)
command = (
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
@@ -585,25 +561,14 @@ class ExecTool(Tool):
f"{command}\n"
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
)
process = await asyncio.create_subprocess_exec(
return await asyncio.create_subprocess_exec(
program, "-NoProfile", "-NonInteractive", "-Command", command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
creationflags=creation_flags,
)
if windows_job is not None:
windows_job.assign_and_resume(process.pid)
setattr(process, _PROCESS_TREE_OWNER_ATTR, windows_job)
return process
except BaseException:
if windows_job is not None:
windows_job.terminate()
if process is not None:
await ExecTool._kill_process(process)
raise
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args: list[str] = [shell_program]
shell_name = Path(shell_program).name.lower()
@@ -722,12 +687,11 @@ class ExecTool(Tool):
@staticmethod
async def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
"""Kill a session process and descendants, then reap the root process."""
owner = ExecTool._process_tree_owner(process)
if process.returncode is not None:
_reap_pid(process.pid)
return
try:
if owner is not None:
owner.terminate()
elif _IS_WINDOWS:
if process.returncode is None:
if _IS_WINDOWS:
with suppress(OSError, asyncio.TimeoutError):
await asyncio.wait_for(
asyncio.to_thread(
@@ -751,36 +715,8 @@ class ExecTool(Tool):
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(process.wait(), timeout=5.0)
finally:
if owner is not None:
ExecTool._drop_process_tree_owner(process)
_reap_pid(process.pid)
@staticmethod
def _process_tree_owner(
process: asyncio.subprocess.Process,
) -> _ProcessTreeOwner | None:
# _spawn is the only writer for this private ownership marker.
return cast(_ProcessTreeOwner | None, vars(process).get(_PROCESS_TREE_OWNER_ATTR))
@staticmethod
def _create_windows_job() -> _ProcessTreeOwner:
from nanobot.agent.tools._windows_job import WindowsJob
return WindowsJob.create()
@staticmethod
def _drop_process_tree_owner(process: asyncio.subprocess.Process) -> None:
with suppress(AttributeError):
delattr(process, _PROCESS_TREE_OWNER_ATTR)
@staticmethod
def _release_process_tree(process: asyncio.subprocess.Process) -> None:
owner = ExecTool._process_tree_owner(process)
if owner is None:
return
owner.release()
ExecTool._drop_process_tree_owner(process)
def _build_env(self) -> dict[str, str]:
"""Build a minimal environment for subprocess execution.
@@ -890,27 +826,12 @@ class ExecTool(Tool):
for raw in self._extract_absolute_paths(cmd):
try:
expanded = os.path.expandvars(raw.strip())
# Python's expanduser() intentionally does not implement
# shell directory-stack forms. ``~+`` is the active cwd,
# while ``~-`` and indexed forms can resolve outside it;
# normalize the former and fail closed on the latter.
if expanded == "~+":
p = cwd_path
elif expanded.startswith("~+/"):
p = (cwd_path / expanded[3:]).resolve()
elif re.match(r"^~(?:-|[+-]\d+)(?:/|$)", expanded):
return ToolResult.error(
"Error: Command blocked by safety guard "
"(path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
else:
p = Path(expanded).expanduser().resolve()
# Match against the un-resolved path first. On Linux,
# /dev/stderr is a symlink to /proc/self/fd/2 and
# ``Path.resolve()`` would mask the device-file intent.
if self._is_benign_device_path(expanded):
continue
p = Path(expanded).expanduser().resolve()
except Exception:
continue
@@ -993,9 +914,7 @@ class ExecTool(Tool):
):
current.append(ch)
operator_len = 1
# A newline separates commands just like ";" does, so a payload
# smuggled onto its own line must be checked on its own too.
elif ch in {";", "|", "\n", "\r"}:
elif ch in {";", "|"}:
operator_len = 1
if operator_len:
@@ -1029,134 +948,9 @@ class ExecTool(Tool):
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command
)
try:
lexer = shlex.shlex(command, posix=True, punctuation_chars="();<>|&")
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except ValueError:
# Keep malformed quoting fail-closed. The shell will normally reject
# it too, but a conservative raw scan must not turn it into a bypass.
tokens = [command]
paths = [*win_paths]
seen = set(win_paths)
for index, token in enumerate(tokens):
for path in ExecTool._extract_posix_paths_from_token(token):
if path not in seen:
paths.append(path)
seen.add(path)
if index > 0 and tokens[index - 1] in {"-c", "-lc", "--command"}:
for path in ExecTool._extract_absolute_paths(token):
if path not in seen:
paths.append(path)
seen.add(path)
return paths
@staticmethod
def _extract_posix_paths_from_token(token: str) -> list[str]:
"""Extract local POSIX/home paths from one shell-decoded token.
``shlex`` separates real grouping/redirection operators while preserving
parentheses and spaces that were quoted or escaped as part of a path.
Embedded scripts (for example ``sh -c \"cat /tmp/x\"``) still need a
small boundary scan. Colons are not general boundaries: treating them
as such misclassifies URLs, ``host:/remote`` and ``C:/Windows``. They
are considered only inside a syntactically valid assignment, where
shells expand each colon-delimited tilde component.
"""
paths: list[str] = []
for match in re.finditer(
r"file://(?:[^/\s\"']+)?(/[^\s\"'<>|;&]*)",
token,
flags=re.IGNORECASE,
):
uri_prefix = token[: match.start()]
raw_path = match.group(1)
if uri_prefix.count("(") > uri_prefix.count(")"):
raw_path = raw_path.split(")", 1)[0]
if uri_prefix.count("{") > uri_prefix.count("}"):
raw_path = raw_path.split(",", 1)[0].split("}", 1)[0]
raw_path = raw_path.split("?", 1)[0].split("#", 1)[0]
if raw_path:
paths.append(unquote(raw_path))
boundary_chars = frozenset(" \t\r\n=({,<>|;&\"'")
i = 0
while i < len(token):
is_posix = token[i] == "/"
home_match = re.match(
r"~(?:[+-](?:\d+)?|[A-Za-z0-9_.@-]+)?(?=/|:|$)",
token[i:],
)
is_home = home_match is not None
if not is_posix and not is_home:
i += 1
continue
prefix = token[:i]
parameter_default = (
i >= 2 and token[i - 2] == ":" and token[i - 1] in "-+?="
)
word_start = max(
(prefix.rfind(char) for char in " \t\r\n<>|;&"),
default=-1,
) + 1
word_prefix = prefix[word_start:]
assignment_component = bool(
re.fullmatch(
r"(?:[A-Za-z_][A-Za-z0-9_]*|--?[A-Za-z0-9_.-]+)="
r"(?:[^:=\s]*:)*",
word_prefix,
)
)
at_boundary = i == 0 or token[i - 1] in boundary_chars
if is_home:
# A shell word beginning with ``~`` is a separate shlex token.
# Mid-token expansion is valid only after ``=`` or a colon in
# an assignment. This avoids PromQL/Loki ``=~`` and ``|~``
# match operators while covering PATH-like values.
at_boundary = i == 0 or assignment_component
if not at_boundary and not parameter_default:
i += 1
continue
if re.search(r"[A-Za-z][A-Za-z0-9+.-]*://", word_prefix) or re.match(
r"(?:[^/:=\s]+@)?[^/:=\s]+:$",
word_prefix,
):
# HTTP-style URL path/query fragments and scp-style remote paths
# are not local filesystem references. ``file://`` paths were
# decoded above. Windows drive paths are already captured by the
# platform-specific expression above.
i += 1
continue
assignment_value = assignment_component
if i == 0 or assignment_value:
end = len(token)
if assignment_value:
separator = token.find(":", i)
if separator >= 0:
end = separator
elif token[i - 1] in {"'", '"'}:
quote = token[i - 1]
closing = token.find(quote, i)
end = len(token) if closing < 0 else closing
else:
end_chars = set(" \t\r\n\"'<>|;&")
if prefix.count("(") > prefix.count(")"):
end_chars.add(")")
if prefix.count("{") > prefix.count("}"):
end_chars.update({",", "}"})
end = i
while end < len(token) and token[end] not in end_chars:
end += 1
candidate = token[i:end]
if candidate:
paths.append(candidate)
i = max(end, i + 1)
return paths
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
@staticmethod
def _normalize_bind_roots(paths: list[str] | None) -> list[Path]:
+17 -120
View File
@@ -11,7 +11,7 @@ import os
import re
from collections.abc import Callable
from typing import Any, cast
from urllib.parse import parse_qsl, quote, urljoin, urlparse
from urllib.parse import quote, urljoin, urlparse
import httpx
from loguru import logger
@@ -148,59 +148,6 @@ def _unsafe_url_request_error(exc: BaseException) -> str | None:
return str(exc) if isinstance(exc, UnsafeURLRequestError) else None
# Forwarding a URL to the remote Jina reader discloses it to a third party, so
# URLs that embed credential material (userinfo, signed-URL parameters, token
# or key query values) must never leave the machine. Matching is by parameter
# name: over-matching only costs the local readability fallback, while
# under-matching leaks a secret.
_CREDENTIAL_QUERY_PARAMS = frozenset({
"access_token", "api-key", "api-token", "apikey", "api_key", "api_token",
"auth", "authorization", "client_assertion", "client_secret", "code",
"credential", "credentials", "id_token", "jwt", "key", "password",
"passwd", "private_key", "pwd", "refresh_token", "samlresponse", "secret",
"session_id", "session_token", "sessionid", "sig", "signature", "sso_token",
"ticket", "token",
})
_CREDENTIAL_QUERY_PREFIXES = ("x-amz-", "x-goog-")
def _url_carries_credentials(url: str) -> bool:
try:
parsed = urlparse(url)
except ValueError:
return True
if parsed.username is not None or parsed.password is not None:
return True
# Some frameworks still accept semicolons as query separators. Treating
# them as separators here may over-match a value, but the safe consequence
# is only using the local extractor instead of disclosing a credential.
query = parsed.query.replace(";", "&")
for name, _value in parse_qsl(query, keep_blank_values=True):
lowered = name.strip().lower()
if lowered in _CREDENTIAL_QUERY_PARAMS or lowered.startswith(_CREDENTIAL_QUERY_PREFIXES):
return True
return False
def _redact_url_for_log(url: str) -> str:
"""Return only a URL's origin, excluding userinfo, path, query, and fragment."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
if not parsed.scheme or hostname is None:
return "<redacted URL>"
if ":" in hostname:
hostname = f"[{hostname}]"
try:
port = parsed.port
except ValueError:
port = None
authority = f"{hostname}:{port}" if port is not None else hostname
return f"{parsed.scheme}://{authority}"
except ValueError:
return "<redacted URL>"
async def _get_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
@@ -244,14 +191,13 @@ async def _stream_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, Any | None, str | None, bool]:
) -> tuple[httpx.Response | None, Any | None, str | None]:
"""Open a streamed response while validating every redirect target first."""
current_url = url
chain_carries_credentials = _url_carries_credentials(url)
for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg, _ = _resolve_url_safe(current_url)
if not is_valid:
return None, None, f"Redirect blocked: {error_msg}", chain_carries_credentials
return None, None, f"Redirect blocked: {error_msg}"
stream = client.stream(
"GET",
@@ -264,39 +210,26 @@ async def _stream_with_safe_redirects(
except httpx.RequestError as exc:
unsafe_error = _unsafe_url_request_error(exc)
if unsafe_error is not None:
return (
None,
None,
f"Redirect blocked: {unsafe_error}",
chain_carries_credentials,
)
return None, None, f"Redirect blocked: {unsafe_error}"
raise
is_redirect = 300 <= response.status_code < 400
if not is_redirect:
return response, stream, None, chain_carries_credentials
return response, stream, None
location = response.headers.get("location")
if not location:
return response, stream, None, chain_carries_credentials
return response, stream, None
next_url = urljoin(str(response.url), location)
chain_carries_credentials = (
chain_carries_credentials or _url_carries_credentials(next_url)
)
is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid:
await stream.__aexit__(None, None, None)
return None, None, f"Redirect blocked: {error_msg}", chain_carries_credentials
return None, None, f"Redirect blocked: {error_msg}"
await stream.__aexit__(None, None, None)
current_url = next_url
return (
None,
None,
f"Too many redirects: exceeded limit of {MAX_REDIRECTS}",
chain_carries_credentials,
)
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
@@ -1110,26 +1043,20 @@ class WebFetchTool(Tool):
if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
# Detect and fetch images directly to avoid Jina's textual image captioning.
# This local preflight also proves that no credential-bearing URL occurs
# in the redirect chain before the original URL may be sent to Jina.
jina_remote_safe = False
# Detect and fetch images directly to avoid Jina's textual image captioning
try:
async with httpx.AsyncClient(
**_fetch_client_kwargs(self.proxy, 15.0),
) as client:
r, stream, redirect_error, chain_carries_credentials = (
await _stream_with_safe_redirects(
r, stream, redirect_error = await _stream_with_safe_redirects(
client,
url,
headers={"User-Agent": self.user_agent},
)
)
if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
jina_remote_safe = not chain_carries_credentials
try:
ctype = r.headers.get("content-type", "")
@@ -1144,14 +1071,10 @@ class WebFetchTool(Tool):
unsafe_error = _unsafe_url_request_error(e)
if unsafe_error is not None:
return json.dumps({"error": f"URL validation failed: {unsafe_error}", "url": url}, ensure_ascii=False)
logger.debug(
"Pre-fetch image detection failed for {} ({})",
_redact_url_for_log(url),
type(e).__name__,
)
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
result = None
if self.config.use_jina_reader and jina_remote_safe:
if self.config.use_jina_reader:
result = await self._fetch_jina(url, max_chars)
if result is None:
result = await self._fetch_readability(url, extract_mode, max_chars)
@@ -1159,23 +1082,13 @@ class WebFetchTool(Tool):
async def _fetch_jina(self, url: str, max_chars: int) -> str | None:
"""Try fetching via Jina Reader API. Returns None on failure."""
if _url_carries_credentials(url):
logger.debug(
"Skipping Jina Reader for {}: URL carries credential material",
_redact_url_for_log(url),
)
return None
# httpx already drops the fragment when building the request; strip it
# explicitly so client-side-only data (OAuth implicit flows put tokens
# there) stays out of this path even if the transport changes.
forwarded_url = url.split("#", 1)[0]
try:
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
jina_key = os.environ.get("JINA_API_KEY", "")
if jina_key:
headers["Authorization"] = f"Bearer {jina_key}"
async with httpx.AsyncClient(proxy=self.proxy, timeout=20.0) as client:
r = await client.get(f"https://r.jina.ai/{forwarded_url}", headers=headers)
r = await client.get(f"https://r.jina.ai/{url}", headers=headers)
if r.status_code == 429:
logger.debug("Jina Reader rate limited, falling back to readability")
return None
@@ -1200,11 +1113,7 @@ class WebFetchTool(Tool):
"untrusted": True, "text": text,
}, ensure_ascii=False)
except Exception as e:
logger.debug(
"Jina Reader failed for {}, falling back to readability ({})",
_redact_url_for_log(url),
type(e).__name__,
)
logger.debug("Jina Reader failed for {}, falling back to readability: {}", url, e)
return None
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
@@ -1235,11 +1144,7 @@ class WebFetchTool(Tool):
text = self._extract_readable_html(r.text, extract_mode)
extractor = "readability"
except Exception as e:
logger.warning(
"Readability failed for {}, using raw HTML fallback ({})",
_redact_url_for_log(url),
type(e).__name__,
)
logger.warning("Readability failed for {}, using raw HTML fallback: {}", url, e)
text, extractor = _normalize(_strip_tags(r.text)), "html"
else:
text, extractor = r.text, "raw"
@@ -1255,18 +1160,10 @@ class WebFetchTool(Tool):
"untrusted": True, "text": text,
}, ensure_ascii=False)
except httpx.ProxyError as e:
logger.warning(
"WebFetch proxy error for {} ({})",
_redact_url_for_log(url),
type(e).__name__,
)
logger.exception("WebFetch proxy error for {}", url)
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
except Exception as e:
logger.warning(
"WebFetch error for {} ({})",
_redact_url_for_log(url),
type(e).__name__,
)
logger.exception("WebFetch error for {}", url)
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
+2 -13
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import dataclasses
import time
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
@@ -189,23 +189,12 @@ class TurnDelivery:
started_at=started_at,
)
async def runtime_admitted(self, runtime: LLMRuntime) -> None:
"""Record the immutable runtime and expose it at the lifecycle seam."""
if self.route.publish_lifecycle:
await self.runtime_event_publisher.turn_runtime_admitted(
self.delivery_message,
self.session_key,
runtime,
)
return
def record_runtime(self, runtime: LLMRuntime) -> None:
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
def record_latency(self, latency_ms: int | None) -> None:
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
def record_usage(self, usage: Mapping[str, int]) -> None:
self.runtime_event_publisher.record_turn_usage(self.session_key, usage)
def background_response(
self,
content: str | None,
+8 -21
View File
@@ -48,7 +48,6 @@ _AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
_SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
_PREPARE_AGENT_KEY = web.AppKey[Callable[[], Awaitable[None]] | None]("prepare_agent")
_MISSING = object()
@@ -67,17 +66,6 @@ def _app_value(
return app.get(legacy_key, default)
async def _prepare_agent(app: Any) -> None:
prepare: Callable[[], Awaitable[None]] | None = _app_value(
app,
_PREPARE_AGENT_KEY,
"prepare_agent",
None,
)
if prepare is not None:
await prepare()
# ---------------------------------------------------------------------------
# Response helpers
# ---------------------------------------------------------------------------
@@ -358,9 +346,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
nonlocal stream_failed
try:
async with session_lock:
async with asyncio.timeout(timeout_s):
await _prepare_agent(request.app)
response = await agent_loop.process_direct(
response = await asyncio.wait_for(
agent_loop.process_direct(
content=text,
media=media_paths if media_paths else None,
session_key=session_key,
@@ -368,6 +355,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
chat_id=API_CHAT_ID,
on_stream=_on_stream,
on_stream_end=_on_stream_end,
),
timeout=timeout_s,
)
if not emitted_content:
response_text = _response_text(response)
@@ -401,14 +390,15 @@ async def handle_chat_completions(request: web.Request) -> web.Response | web.St
try:
async with session_lock:
try:
async with asyncio.timeout(timeout_s):
await _prepare_agent(request.app)
response = await agent_loop.process_direct(
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,
),
timeout=timeout_s,
)
response_text = _response_text(response)
if not response_text or not response_text.strip():
@@ -462,7 +452,6 @@ def create_app(
model_name: str = "nanobot",
request_timeout: float = 120.0,
api_key: str = "",
prepare_agent: Callable[[], Awaitable[None]] | None = None,
) -> web.Application:
"""Create the aiohttp application.
@@ -471,14 +460,12 @@ def create_app(
model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds.
api_key: Optional API key for Bearer-token authentication on API routes.
prepare_agent: Optional application-owned readiness callback run before each turn.
"""
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
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
app[_PREPARE_AGENT_KEY] = prepare_agent
@web.middleware
async def auth_middleware(
+33 -5
View File
@@ -104,7 +104,6 @@ _BRANDS: dict[str, tuple[str, str]] = {
"audacity": ("audacity", "#0000CC"),
"blender": ("blender", "#E87D0D"),
"browser": ("googlechrome", "#4285F4"),
"calibre": ("calibre", "#45B29D"),
"chromadb": ("chroma", "#FFDE2D"),
"comfyui": ("comfyui", "#111827"),
"contentful": ("contentful", "#2478CC"),
@@ -158,6 +157,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
"3mf": ("3mf.io", "#00A1DE"),
"anygen": ("anygen.io", "#111827"),
"calibre": ("calibre-ebook.com", "#45B29D"),
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
@@ -201,6 +201,13 @@ _BRAND_ALIASES: dict[str, str] = {
}
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
_GENERIC_HOMEPAGE_HOSTS = frozenset({
"bitbucket.org",
"github.com",
"gitlab.com",
"npmjs.com",
"pypi.org",
})
def _now() -> float:
@@ -333,11 +340,25 @@ def _brand_candidates(app: dict[str, Any]) -> list[str]:
return candidates
def _homepage_domain(app: dict[str, Any]) -> str | None:
value = str(app.get("homepage") or "").strip()
try:
parsed = urlparse(value)
except ValueError:
return None
host = (parsed.hostname or "").lower().removeprefix("www.")
if parsed.scheme not in {"http", "https"} or host in _GENERIC_HOMEPAGE_HOSTS:
return None
if not host or "." not in host or any(not label for label in host.split(".")):
return None
return host
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
declared_logo = str(app.get("logo_url") or "").strip()
declared_color = str(app.get("brand_color") or "").strip() or None
if declared_logo.startswith(("https://", "/")):
declared_color = str(app.get("brand_color") or "").strip()
return declared_logo, declared_color or None
return declared_logo, declared_color
brand = None
domain_brand = None
@@ -349,13 +370,21 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
domain_brand = _BRAND_DOMAINS.get(key)
if domain_brand:
break
brand_color = declared_color or (brand or domain_brand or (None, None))[1]
homepage_domain = _homepage_domain(app)
if homepage_domain:
return (
f"https://www.google.com/s2/favicons?domain={homepage_domain}&sz=64",
brand_color,
)
if not brand:
if not domain_brand:
return None, None
domain, color = domain_brand
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
slug, color = brand
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", brand_color
def _read_json(path: Path) -> dict[str, Any] | None:
@@ -1029,7 +1058,6 @@ class CliAppManager:
encoding="utf-8",
errors="replace",
timeout=timeout,
env=self._subprocess_env(),
)
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
output = (result.stderr or result.stdout or "").strip()
+129
View File
@@ -0,0 +1,129 @@
"""Small, fail-safe registry for the Apps page Featured section."""
from __future__ import annotations
import asyncio
import json
import os
import re
import time
import urllib.request
from pathlib import Path
from typing import Any, cast
REGISTRY_URL = "https://nanobot.wiki/registry/v1/discovery.json"
CACHE_TTL_S = 60 * 60
_MAX_RESPONSE_BYTES = 64 * 1024
_APP_ID_RE = re.compile(r"^(?:cli|mcp):[a-z0-9][a-z0-9._-]*$")
_FALLBACK = {
"schema_version": 1,
"updated_at": "2026-08-12T00:00:00Z",
"featured": [
"mcp:github",
"mcp:playwright",
"mcp:notion",
"mcp:figma",
"mcp:context7",
"cli:obsidian",
"mcp:linear",
"cli:browser",
"cli:1password-cli",
"cli:blender",
"cli:libreoffice",
"cli:zotero",
],
}
_refresh_tasks: dict[Path, asyncio.Task[None]] = {}
def _validated_payload(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
payload = cast(dict[str, object], value)
if payload.get("schema_version") != 1:
return None
updated_at = payload.get("updated_at")
raw_featured = payload.get("featured")
if not isinstance(updated_at, str) or not updated_at.strip():
return None
if not isinstance(raw_featured, list):
return None
featured_values = cast(list[object], raw_featured)
if not 1 <= len(featured_values) <= 12:
return None
featured: list[str] = []
for item in featured_values:
if not isinstance(item, str) or _APP_ID_RE.fullmatch(item) is None:
return None
featured.append(item)
if len(featured) != len(set(featured)):
return None
return {
"schema_version": 1,
"updated_at": updated_at,
"featured": featured,
}
def _read_cache(path: Path) -> dict[str, Any] | None:
try:
return _validated_payload(json.loads(path.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
return None
def _fetch_remote() -> dict[str, Any]:
request = urllib.request.Request(
REGISTRY_URL,
headers={"Accept": "application/json", "User-Agent": "nanobot-apps/1"},
)
with urllib.request.urlopen(request, timeout=3) as response:
raw = response.read(_MAX_RESPONSE_BYTES + 1)
if len(raw) > _MAX_RESPONSE_BYTES:
raise ValueError("Apps discovery response is too large")
payload = _validated_payload(json.loads(raw))
if payload is None:
raise ValueError("Invalid Apps discovery response")
return payload
def _write_cache(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
async def _refresh(path: Path) -> None:
try:
payload = await asyncio.to_thread(_fetch_remote)
await asyncio.to_thread(_write_cache, path, payload)
except Exception:
# Discovery is optional: the bundled list remains usable offline.
pass
def _schedule_refresh(path: Path) -> None:
task = _refresh_tasks.get(path)
if task is not None and not task.done():
return
task = asyncio.create_task(_refresh(path))
_refresh_tasks[path] = task
task.add_done_callback(lambda completed: _refresh_tasks.pop(path, None))
async def discovery_payload(*, data_dir: Path) -> dict[str, Any]:
"""Return cached Featured IDs immediately and refresh stale data in the background."""
cache_path = data_dir / "apps-discovery.json"
cached = _read_cache(cache_path)
try:
fresh = cached is not None and time.time() - cache_path.stat().st_mtime < CACHE_TTL_S
except OSError:
fresh = False
if fresh and cached is not None:
return cached
_schedule_refresh(cache_path)
return {**(cached or _FALLBACK), "refresh_pending": True}
+3 -3
View File
@@ -12,11 +12,11 @@ if TYPE_CHECKING:
# render it and other channels may ignore unknown keys.
OUTBOUND_META_AGENT_UI = "_agent_ui"
# Internal-only inbound metadata minted by trusted transports and runtime
# services. Never accept these keys verbatim from an untrusted client.
# Internal-only inbound metadata used by in-process channels to ask the agent
# loop to update runtime state without going through a user session.
INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
INBOUND_META_USER_SHELL = "_user_shell"
RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
+1 -11
View File
@@ -58,8 +58,6 @@ class StreamedResponseEvent(OutboundEvent):
class TurnEndEvent(OutboundEvent):
latency_ms: int | None = None
goal_state: dict[str, Any] | None = None
usage: dict[str, int] | None = None
context_window_tokens: int | None = None
@dataclass(frozen=True)
@@ -86,11 +84,9 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
@dataclass(frozen=True)
class TurnModelUpdatedEvent(OutboundEvent):
"""The canonical preset and concrete model handling one chat turn."""
"""The fallback model currently handling one chat turn."""
model: str
model_preset: str | None = None
context_window_tokens: int | None = None
def outbound_message_for_event(
@@ -175,12 +171,6 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
return TurnEndEvent(
latency_ms=_metadata_int(meta, "latency_ms"),
goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
usage=(
cast(dict[str, int], meta.get("usage"))
if isinstance(meta.get("usage"), dict)
else None
),
context_window_tokens=_metadata_int(meta, "context_window_tokens"),
)
if meta.get("_session_updated"):
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
+1 -42
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import asyncio
import contextlib
import inspect
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -40,14 +40,6 @@ class SessionTurnStarted:
context: RuntimeEventContext
@dataclass(frozen=True)
class TurnRuntimeAdmitted:
"""The immutable model runtime selected for one admitted turn."""
context: RuntimeEventContext
runtime: LLMRuntime
@dataclass(frozen=True)
class TurnRunStatusChanged:
"""Visible run status changed for a turn."""
@@ -64,7 +56,6 @@ class TurnCompleted:
context: RuntimeEventContext
latency_ms: int | None = None
runtime: LLMRuntime | None = None
usage: dict[str, int] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -94,7 +85,6 @@ class RuntimeModelChanged:
RuntimeEvent = (
SessionTurnStarted
| TurnRuntimeAdmitted
| SessionTurnPersisted
| TurnRunStatusChanged
| TurnCompleted
@@ -103,7 +93,6 @@ RuntimeEvent = (
)
RuntimeEventType = (
type[SessionTurnStarted]
| type[TurnRuntimeAdmitted]
| type[SessionTurnPersisted]
| type[TurnRunStatusChanged]
| type[TurnCompleted]
@@ -170,7 +159,6 @@ class RuntimeEventPublisher:
self.bus = bus or RuntimeEventBus()
self._turn_latency_ms: dict[str, int] = {}
self._turn_runtime: dict[str, LLMRuntime] = {}
self._turn_usage: dict[str, dict[str, int]] = {}
@staticmethod
def _context(
@@ -196,17 +184,9 @@ class RuntimeEventPublisher:
if latency_ms is not None:
self._turn_latency_ms[session_key] = int(latency_ms)
def record_turn_usage(self, session_key: str, usage: Mapping[str, int]) -> None:
self._turn_usage[session_key] = {
key: int(value)
for key, value in usage.items()
if type(value) is int and value >= 0
}
def clear_turn(self, session_key: str) -> None:
self._turn_latency_ms.pop(session_key, None)
self._turn_runtime.pop(session_key, None)
self._turn_usage.pop(session_key, None)
async def session_turn_started(
self,
@@ -224,26 +204,6 @@ class RuntimeEventPublisher:
)
)
async def turn_runtime_admitted(
self,
msg: InboundMessage,
session_key: str,
runtime: LLMRuntime,
) -> None:
"""Record and publish the runtime selected for one turn."""
self.record_turn_runtime(session_key, runtime)
await self.bus.publish(
TurnRuntimeAdmitted(
context=self._context(
channel=msg.channel,
chat_id=msg.chat_id,
session_key=session_key,
metadata=msg.metadata,
),
runtime=runtime,
)
)
async def run_status_changed(
self,
msg: InboundMessage,
@@ -305,7 +265,6 @@ class RuntimeEventPublisher:
),
latency_ms=self._turn_latency_ms.pop(session_key, None),
runtime=self._turn_runtime.pop(session_key, None),
usage=self._turn_usage.pop(session_key, {}),
)
)
@@ -59,7 +59,7 @@ export function FeishuAssistantsPanel({
/>
),
footer: (
<div className="mt-4 overflow-hidden rounded-floating border border-border/70 bg-background px-4 py-4">
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
<div className="text-[13px] font-semibold text-foreground">
{tx("custom.createAnother", "Create another assistant")}
</div>
@@ -144,7 +144,7 @@ function FeishuInstanceAction({
</Button>
</div>
{error ? (
<div className="mt-3 rounded-control border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
) : null}
+1 -7
View File
@@ -1,13 +1,7 @@
import { lazy } from "react";
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
const FeishuAssistantsPanel = lazy(() =>
import("./FeishuAssistantsPanel").then(({ FeishuAssistantsPanel: component }) => ({
default: component,
})),
);
import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel";
export default {
Panel: FeishuAssistantsPanel,
+1 -7
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
from collections.abc import Awaitable, Callable, Iterable, Mapping
from collections.abc import Callable, Iterable, Mapping
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -95,14 +95,12 @@ class ChannelManager:
cron_service: CronService | None = None,
local_trigger_store: LocalTriggerStore | None = None,
webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_refresh_runtime_config: Callable[[], None] | None = None,
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None,
config_path: Path | None = None,
):
@@ -117,14 +115,12 @@ class ChannelManager:
self._cron_service = cron_service
self._local_trigger_store = local_trigger_store
self._webui_runtime_model_name = webui_runtime_model_name
self._webui_refresh_runtime_config = webui_refresh_runtime_config
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self._webui_mcp_runtime_status = webui_mcp_runtime_status
self._webui_mcp_reload = webui_mcp_reload
self._webui_skill_state_action = webui_skill_state_action
self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {}
@@ -185,7 +181,6 @@ class ChannelManager:
config_path=self._config_path,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
refresh_runtime_config=self._webui_refresh_runtime_config,
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
@@ -195,7 +190,6 @@ class ChannelManager:
channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status,
mcp_runtime_status=self._webui_mcp_runtime_status,
mcp_reload=self._webui_mcp_reload,
skill_state_action=self._webui_skill_state_action,
logger=logger,
)
+25 -180
View File
@@ -25,9 +25,9 @@ from telegram import (
Update,
User,
)
from telegram.error import BadRequest, InvalidToken, NetworkError, TimedOut
from telegram.error import BadRequest, NetworkError, TimedOut
from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters
from telegram.request import BaseRequest, HTTPXRequest
from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
@@ -38,7 +38,6 @@ from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.security.network import validate_url_target
from nanobot.utils.helpers import split_message
from nanobot.utils.logging_bridge import redirect_lib_logging
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
@@ -54,42 +53,6 @@ TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for repl
TelegramApplication: TypeAlias = Application[Any, Any, Any, Any, Any, Any]
_T = TypeVar("_T")
# A healthy getUpdates long poll completes every ~10s even with no traffic;
# PTB retries timeouts silently, so stalls must be detected here.
POLL_STALE_SECONDS = 120.0
POLL_WATCH_INTERVAL = 1.0
RESTART_BACKOFF_INITIAL_SECONDS = 5.0
RESTART_BACKOFF_MAX_SECONDS = 300.0
# How long a send waits out a rebuild; short because ChannelManager dispatches
# every channel from one serial loop.
APP_RESTART_SEND_WAIT_SECONDS = 2.0
class _LivenessTrackedRequest(BaseRequest):
"""Wrap the getUpdates request pool, reporting each completed round trip."""
__slots__ = ("inner", "_on_round_trip")
def __init__(self, inner: BaseRequest, on_round_trip: Callable[[], None]) -> None:
super().__init__()
self.inner = inner
self._on_round_trip = on_round_trip
@property
def read_timeout(self) -> float | None:
return self.inner.read_timeout
async def initialize(self) -> None:
await self.inner.initialize()
async def shutdown(self) -> None:
await self.inner.shutdown()
async def do_request(self, *args: Any, **kwargs: Any) -> tuple[int, bytes]:
result = await self.inner.do_request(*args, **kwargs)
self._on_round_trip()
return result
def _split_telegram_markdown(content: str, max_len: int) -> list[str]:
"""Split raw Telegram Markdown without leaving fenced code blocks unbalanced."""
@@ -514,9 +477,6 @@ class TelegramChannel(BaseChannel):
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
self._inbound_workers: dict[str, asyncio.Task[None]] = {}
self._rich_send_disabled: bool = False # Latch off if Bot API < 10.1
self._last_poll_ok: float = 0.0 # monotonic time of last getUpdates round trip
self._app_ready = asyncio.Event() # cleared while the app is being rebuilt
self._teardown_lock = asyncio.Lock()
def _require_app(self) -> TelegramApplication:
if self._app is None:
@@ -556,63 +516,13 @@ class TelegramChannel(BaseChannel):
return content
async def start(self) -> None:
"""Start the Telegram bot, rebuilding the app whenever polling stalls."""
"""Start the Telegram bot."""
if not self.config.token:
self.logger.error("bot token not configured")
return
redirect_lib_logging("telegram")
redirect_lib_logging("httpx", level="WARNING")
self._running = True
backoff = RESTART_BACKOFF_INITIAL_SECONDS
while self._running:
try:
await self._start_app()
except InvalidToken:
# A config error, not a blip: fail the channel. The scrubbed
# re-raise keeps PTB's token-bearing message out of the log.
await self._teardown_app()
self._running = False
self.logger.error("bot token rejected by Telegram")
raise RuntimeError("Telegram bot token was rejected by the server") from None
except Exception as e:
await self._teardown_app()
if not self._running:
break
if not self._is_transient_startup_error(e):
# Never heals on its own: fail instead of retrying forever
# while ChannelManager keeps reporting the channel running.
self._running = False
self.logger.error("startup failed: {}", self._format_telegram_error(e))
raise
self.logger.error(
"startup failed: {}; retrying in {:.0f}s",
self._format_telegram_error(e),
backoff,
)
await self._idle(backoff)
backoff = min(backoff * 2, RESTART_BACKOFF_MAX_SECONDS)
continue
backoff = RESTART_BACKOFF_INITIAL_SECONDS
if not self._running:
# stop() ran while _start_app() was mid-flight and tore down the
# previous (possibly None) app; this one would leak otherwise.
await self._teardown_app()
break
stalled = await self._watch_polling()
if not stalled or not self._running:
break
self.logger.warning(
"polling stalled: no getUpdates round trip for {:.0f}s; "
"rebuilding connection pools and restarting",
time.monotonic() - self._last_poll_ok,
)
await self._teardown_app()
async def _start_app(self) -> None:
"""Build, initialize and start the Telegram application."""
proxy = self.config.proxy or None
# Separate pools so long-polling (getUpdates) never starves outbound sends.
@@ -634,7 +544,7 @@ class TelegramChannel(BaseChannel):
Application.builder()
.token(self.config.token)
.request(api_request)
.get_updates_request(_LivenessTrackedRequest(poll_request, self._note_poll_ok))
.get_updates_request(poll_request)
)
self._app = builder.build()
self._app.add_error_handler(self._on_error)
@@ -711,80 +621,16 @@ class TelegramChannel(BaseChannel):
max_connections=self.config.webhook_max_connections,
)
else:
self._last_poll_ok = time.monotonic()
# Start polling (this runs until stopped)
await cast(Any, self._app.updater).start_polling(
allowed_updates=allowed_updates,
drop_pending_updates=False, # Process pending messages on startup
error_callback=self._on_polling_error,
)
self._app_ready.set()
@staticmethod
def _is_transient_startup_error(exc: Exception) -> bool:
"""Report whether a startup failure is worth retrying.
HTTPXRequest wraps every httpx failure into NetworkError/TimedOut, so
anything else is terminal: a bad proxy raises ValueError, an already
bound webhook port raises OSError.
"""
return isinstance(exc, NetworkError | TimedOut | asyncio.TimeoutError)
async def _wait_for_app(self) -> TelegramApplication | None:
"""Return the live app, briefly waiting out an in-flight rebuild.
Returning quietly while ``start()`` rebuilds would let the manager count
the message as delivered, so raise once the wait runs out. None means the
channel is stopped: nothing left to deliver.
"""
if self._app_ready.is_set() and self._app is not None:
return self._app
if not self._running:
return None
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._app_ready.wait(), APP_RESTART_SEND_WAIT_SECONDS)
if not self._app_ready.is_set() or self._app is None:
raise RuntimeError("Telegram application is restarting; message not delivered")
return self._app
def _note_poll_ok(self) -> None:
# HTTP error statuses count too: the watchdog detects transport stalls,
# not logical failures.
self._last_poll_ok = time.monotonic()
async def _watch_polling(self) -> bool:
"""Idle until stop(); in polling mode, return True when getUpdates goes stale."""
watch = self.config.mode != "webhook"
# Keep running until stopped
while self._running:
await asyncio.sleep(POLL_WATCH_INTERVAL)
if watch and time.monotonic() - self._last_poll_ok > POLL_STALE_SECONDS:
return True
return False
async def _idle(self, seconds: float) -> None:
"""Sleep in short steps so stop() stays responsive."""
deadline = time.monotonic() + seconds
while self._running and time.monotonic() < deadline:
await asyncio.sleep(POLL_WATCH_INTERVAL)
async def _teardown_app(self) -> None:
"""Shut down the application, tolerating partially started state."""
async with self._teardown_lock:
app, self._app = self._app, None
self._app_ready.clear()
if not app:
return
for step in (cast(Any, app.updater).stop, app.stop, app.shutdown):
try:
await step()
except Exception as e:
self.logger.debug("teardown step failed: {}", e)
# Application.shutdown() skips the HTTPX pools unless initialize()
# finished, so a failed startup leaks one per retry. This is idempotent.
try:
await app.bot.shutdown()
except Exception as e:
self.logger.debug("bot shutdown failed: {}", e)
await asyncio.sleep(1)
async def stop(self) -> None:
"""Stop the Telegram bot."""
@@ -806,9 +652,10 @@ class TelegramChannel(BaseChannel):
if self._app:
self.logger.info("Stopping bot...")
# Join an in-flight supervisor teardown before ChannelManager cancels
# start(), otherwise cancellation can strand the old HTTPX pools.
await self._teardown_app()
await cast(Any, self._app.updater).stop()
await self._app.stop()
await self._app.shutdown()
self._app = None
@staticmethod
def _get_media_type(path: str) -> str:
@@ -899,8 +746,7 @@ class TelegramChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Telegram."""
app = await self._wait_for_app()
if app is None:
if not self._app:
self.logger.warning("bot not running")
return
@@ -939,11 +785,11 @@ class TelegramChannel(BaseChannel):
try:
media_type = self._get_media_type(media_path)
sender = {
"photo": app.bot.send_photo,
"video": app.bot.send_video,
"voice": app.bot.send_voice,
"audio": app.bot.send_audio,
}.get(media_type, app.bot.send_document)
"photo": self._app.bot.send_photo,
"video": self._app.bot.send_video,
"voice": self._app.bot.send_voice,
"audio": self._app.bot.send_audio,
}.get(media_type, self._app.bot.send_document)
param = {
"photo": "photo",
"video": "video",
@@ -983,7 +829,7 @@ class TelegramChannel(BaseChannel):
except Exception:
filename = media_path.rsplit("/", 1)[-1]
self.logger.exception("Failed to send media {}", media_path)
await app.bot.send_message(
await self._app.bot.send_message(
chat_id=chat_id,
text=f"[Failed to send: {filename}]",
reply_parameters=reply_params,
@@ -1111,8 +957,7 @@ class TelegramChannel(BaseChannel):
merge_next: bool = False,
) -> None:
"""Progressive message editing: send on first delta, edit on subsequent ones."""
app = await self._wait_for_app()
if app is None:
if not self._app:
return
meta = metadata or {}
int_chat_id = int(chat_id)
@@ -1151,7 +996,7 @@ class TelegramChannel(BaseChannel):
# Delete the streaming preview message
try:
await self._call_with_retry(
app.bot.delete_message,
self._app.bot.delete_message,
chat_id=int_chat_id, message_id=buf.message_id,
)
except Exception:
@@ -1165,7 +1010,7 @@ class TelegramChannel(BaseChannel):
extra_html_chunks = html_chunks[1:]
try:
await self._call_with_retry(
app.bot.edit_message_text,
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
text=primary_html, parse_mode="HTML",
)
@@ -1182,7 +1027,7 @@ class TelegramChannel(BaseChannel):
primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text
try:
await self._call_with_retry(
app.bot.edit_message_text,
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
text=primary_plain,
)
@@ -1195,7 +1040,7 @@ class TelegramChannel(BaseChannel):
for extra_html_chunk in extra_html_chunks:
try:
await self._call_with_retry(
app.bot.send_message,
self._app.bot.send_message,
chat_id=int_chat_id, text=extra_html_chunk,
parse_mode="HTML",
**thread_kwargs,
@@ -1225,7 +1070,7 @@ class TelegramChannel(BaseChannel):
preview = _strip_md_block(buf.text)
try:
sent = await self._call_with_retry(
app.bot.send_message,
self._app.bot.send_message,
chat_id=int_chat_id, text=preview,
**stream_thread_kwargs,
)
@@ -1242,7 +1087,7 @@ class TelegramChannel(BaseChannel):
preview = _strip_md_block(buf.text)
try:
await self._call_with_retry(
app.bot.edit_message_text,
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
text=preview,
)
@@ -61,10 +61,6 @@ class _FakeBot:
self.sent_messages: list[dict] = []
self.sent_media: list[dict] = []
self.get_me_calls = 0
self.shutdown_calls = 0
async def shutdown(self) -> None:
self.shutdown_calls += 1
async def get_me(self):
self.get_me_calls += 1
@@ -157,14 +153,6 @@ class _FakeBuilder:
return self.app
def _install_ready_app(channel: TelegramChannel) -> _FakeApp:
"""Install the ready app state expected by ordinary send tests."""
app = _FakeApp(lambda: None)
channel._app = app
channel._app_ready.set()
return app
def _make_telegram_update(
*,
chat_type: str = "group",
@@ -349,7 +337,7 @@ async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None:
assert api_req.kwargs["connection_pool_size"] == 32
assert poll_req.kwargs["connection_pool_size"] == 4
assert builder.request_value is api_req
assert builder.get_updates_request_value.inner is poll_req
assert builder.get_updates_request_value is poll_req
assert callable(app.updater.start_polling_kwargs["error_callback"])
assert any(cmd.command == "status" for cmd in app.bot.commands)
assert any(cmd.command == "history" for cmd in app.bot.commands)
@@ -390,311 +378,6 @@ async def test_start_respects_custom_pool_config(monkeypatch) -> None:
assert poll_req.kwargs["pool_timeout"] == 10.0
@pytest.mark.asyncio
async def test_stalled_polling_triggers_pool_rebuild(monkeypatch) -> None:
"""When no getUpdates round trip completes for too long, the app is rebuilt."""
_FakeHTTPXRequest.clear()
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
bus = MessageBus()
channel = TelegramChannel(config, bus)
apps: list[_FakeApp] = []
def on_start_polling() -> None:
if len(apps) >= 2:
channel._running = False
def make_builder():
app = _FakeApp(on_start_polling)
apps.append(app)
return _FakeBuilder(app)
monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest)
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.Application",
SimpleNamespace(builder=make_builder),
)
monkeypatch.setattr("nanobot.channels.telegram.runtime.POLL_STALE_SECONDS", -1.0)
monkeypatch.setattr("nanobot.channels.telegram.runtime.POLL_WATCH_INTERVAL", 0.0)
await channel.start()
assert len(apps) == 2
assert apps[0].updater.start_polling_kwargs is not None
assert apps[1].updater.start_polling_kwargs is not None
# 2 fresh pools per app
assert len(_FakeHTTPXRequest.instances) == 4
@pytest.mark.asyncio
async def test_startup_failure_retries_with_backoff(monkeypatch) -> None:
"""Transient startup failures back off and retry until the app comes up."""
from telegram.error import NetworkError
_FakeHTTPXRequest.clear()
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
bus = MessageBus()
channel = TelegramChannel(config, bus)
apps: list[_FakeApp] = []
def make_builder():
app = _FakeApp(lambda: setattr(channel, "_running", False))
if len(apps) < 2:
async def _fail() -> None:
raise NetworkError("connect failed")
app.initialize = _fail
apps.append(app)
return _FakeBuilder(app)
monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest)
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.Application",
SimpleNamespace(builder=make_builder),
)
monkeypatch.setattr("nanobot.channels.telegram.runtime.POLL_WATCH_INTERVAL", 0.0)
monkeypatch.setattr("nanobot.channels.telegram.runtime.RESTART_BACKOFF_INITIAL_SECONDS", 0.0)
await channel.start()
assert len(apps) == 3
assert apps[0].updater.start_polling_kwargs is None
assert apps[1].updater.start_polling_kwargs is None
assert apps[2].updater.start_polling_kwargs is not None
# Pools must be closed via the bot: app.shutdown() skips them here.
assert apps[0].bot.shutdown_calls == 1
assert apps[1].bot.shutdown_calls == 1
@pytest.mark.asyncio
async def test_terminal_startup_error_is_not_retried(monkeypatch) -> None:
"""Config errors (bad proxy, bound webhook port) must fail the channel."""
_FakeHTTPXRequest.clear()
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
bus = MessageBus()
channel = TelegramChannel(config, bus)
apps: list[_FakeApp] = []
def make_builder():
app = _FakeApp(lambda: None)
async def _fail() -> None:
raise ValueError("Unknown scheme for proxy URL")
app.initialize = _fail
apps.append(app)
return _FakeBuilder(app)
monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest)
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.Application",
SimpleNamespace(builder=make_builder),
)
monkeypatch.setattr("nanobot.channels.telegram.runtime.RESTART_BACKOFF_INITIAL_SECONDS", 0.0)
with pytest.raises(ValueError, match="proxy URL"):
await channel.start()
assert len(apps) == 1 # no retry loop
assert channel._app is None
assert channel.is_running is False
@pytest.mark.asyncio
async def test_invalid_token_stops_without_retry(monkeypatch) -> None:
"""A rejected token is a config error: fail the channel instead of retrying."""
from telegram.error import InvalidToken
_FakeHTTPXRequest.clear()
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
bus = MessageBus()
channel = TelegramChannel(config, bus)
apps: list[_FakeApp] = []
def make_builder():
app = _FakeApp(lambda: None)
async def _reject() -> None:
raise InvalidToken("token rejected by Telegram")
app.initialize = _reject
apps.append(app)
return _FakeBuilder(app)
monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest)
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.Application",
SimpleNamespace(builder=make_builder),
)
monkeypatch.setattr("nanobot.channels.telegram.runtime.RESTART_BACKOFF_INITIAL_SECONDS", 0.0)
with pytest.raises(RuntimeError) as excinfo:
await channel.start()
assert len(apps) == 1
assert channel._app is None
assert channel.is_running is False
assert "123:abc" not in str(excinfo.value) # token must not reach the log
@pytest.mark.asyncio
async def test_stop_during_startup_does_not_leak_app(monkeypatch) -> None:
"""stop() landing while _start_app() is mid-flight must not leave the app running."""
_FakeHTTPXRequest.clear()
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
bus = MessageBus()
channel = TelegramChannel(config, bus)
# Simulate stop() winning the race just before start_polling returns.
app = _FakeApp(lambda: setattr(channel, "_running", False))
builder = _FakeBuilder(app)
monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest)
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.Application",
SimpleNamespace(builder=lambda: builder),
)
await channel.start()
assert channel._app is None # torn down, not leaked
@pytest.mark.asyncio
async def test_stop_waits_for_inflight_watchdog_teardown(monkeypatch) -> None:
"""Manager cancellation after stop() must not interrupt an active teardown."""
_FakeHTTPXRequest.clear()
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
teardown_started = asyncio.Event()
finish_teardown = asyncio.Event()
app = _FakeApp(lambda: None)
async def slow_updater_stop() -> None:
teardown_started.set()
await finish_teardown.wait()
app.updater.stop = slow_updater_stop
monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest)
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.Application",
SimpleNamespace(builder=lambda: _FakeBuilder(app)),
)
monkeypatch.setattr("nanobot.channels.telegram.runtime.POLL_STALE_SECONDS", -1.0)
monkeypatch.setattr("nanobot.channels.telegram.runtime.POLL_WATCH_INTERVAL", 0.0)
start_task = asyncio.create_task(channel.start())
await teardown_started.wait()
assert channel._app is None
stop_task = asyncio.create_task(channel.stop())
await asyncio.sleep(0)
assert not stop_task.done()
finish_teardown.set()
await stop_task
await start_task
assert app.bot.shutdown_calls == 1
@pytest.mark.asyncio
async def test_send_during_rebuild_fails_instead_of_dropping(monkeypatch) -> None:
"""A send that cannot reach Telegram must raise so the manager can retry."""
monkeypatch.setattr("nanobot.channels.telegram.runtime.APP_RESTART_SEND_WAIT_SECONDS", 0.0)
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
channel = TelegramChannel(config, MessageBus())
# Mid-rebuild: still running, but no app to send through.
channel._running = True
channel._app = None
msg = OutboundMessage(channel="telegram", chat_id="123", content="hello")
with pytest.raises(RuntimeError, match="restarting"):
await channel.send(msg)
with pytest.raises(RuntimeError, match="restarting"):
await channel.send_delta("123", "hello", stream_id="s1")
# Stopped: nothing to deliver, so stay quiet.
channel._running = False
await channel.send(msg)
await channel.send_delta("123", "hello", stream_id="s1")
@pytest.mark.asyncio
async def test_send_waits_for_rebuild_to_finish(monkeypatch) -> None:
"""A fast rebuild is waited out rather than surfaced as a delivery failure."""
monkeypatch.setattr("nanobot.channels.telegram.runtime.APP_RESTART_SEND_WAIT_SECONDS", 5.0)
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
channel = TelegramChannel(config, MessageBus())
app = _FakeApp(lambda: None)
channel._running = True
channel._app = None
async def _finish_rebuild() -> None:
await asyncio.sleep(0)
channel._app = app
channel._app_ready.set()
rebuild = asyncio.create_task(_finish_rebuild())
await channel.send(OutboundMessage(channel="telegram", chat_id="123", content="hello"))
await rebuild
assert [m["text"] for m in app.bot.sent_messages] == ["hello"]
@pytest.mark.asyncio
async def test_send_waits_for_partially_initialized_app(monkeypatch) -> None:
"""A built app is not available for sends until startup marks it ready."""
monkeypatch.setattr("nanobot.channels.telegram.runtime.APP_RESTART_SEND_WAIT_SECONDS", 5.0)
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
channel = TelegramChannel(config, MessageBus())
app = _FakeApp(lambda: None)
channel._running = True
channel._app = app
send_task = asyncio.create_task(
channel.send(OutboundMessage(channel="telegram", chat_id="123", content="hello"))
)
await asyncio.sleep(0)
assert not send_task.done()
channel._app_ready.set()
await send_task
assert [m["text"] for m in app.bot.sent_messages] == ["hello"]
@pytest.mark.asyncio
async def test_liveness_tracked_request_stamps_on_round_trip() -> None:
from nanobot.channels.telegram.runtime import _LivenessTrackedRequest
stamps: list[int] = []
class _Inner:
read_timeout = 5.0
async def initialize(self) -> None:
pass
async def shutdown(self) -> None:
pass
async def do_request(self, *args, **kwargs):
return 200, b"{}"
wrapped = _LivenessTrackedRequest(_Inner(), lambda: stamps.append(1))
assert await wrapped.do_request(url="https://example.org", method="POST") == (200, b"{}")
assert stamps == [1]
def test_webhook_config_requires_https_url_and_secret() -> None:
with pytest.raises(ValueError, match="webhook_url is required"):
TelegramConfig(enabled=True, token="123:abc", mode="webhook")
@@ -796,7 +479,7 @@ async def test_send_text_retries_on_timeout() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
call_count = 0
original_send = channel._app.bot.send_message
@@ -831,7 +514,7 @@ async def test_send_text_gives_up_after_max_retries() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
async def always_timeout(**kwargs):
raise TimedOut()
@@ -858,7 +541,7 @@ async def test_send_rich_capability_error_latches_and_falls_back() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.do_api_request = AsyncMock(side_effect=BadRequest("Method not found"))
await channel.send(OutboundMessage(channel="telegram", chat_id="123", content="**hello**"))
@@ -877,7 +560,7 @@ async def test_send_rich_bad_request_does_not_latch_capability() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.do_api_request = AsyncMock(
side_effect=BadRequest("Bad Request: message to reply not found")
)
@@ -896,7 +579,7 @@ async def test_rich_messages_default_skips_send_rich_message() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.do_api_request = AsyncMock()
await channel.send(OutboundMessage(channel="telegram", chat_id="123", content="**hello**"))
@@ -983,7 +666,7 @@ async def test_send_delta_stream_end_raises_and_keeps_buffer_on_failure() -> Non
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock(side_effect=RuntimeError("boom"))
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
@@ -999,7 +682,7 @@ async def test_send_delta_merge_next_preserves_buffer() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._stream_bufs["123"] = _StreamBuf(
text="first-",
@@ -1028,7 +711,7 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("Message is not modified"))
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0, stream_id="s:0")
@@ -1048,7 +731,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
monkeypatch.setattr("nanobot.channels.telegram.runtime._SEND_RETRY_BASE_DELAY", 0)
# _call_with_retry retries TimedOut up to 3 times, so the mock will be called
# multiple times but all calls must be with parse_mode="HTML" (no plain fallback).
@@ -1076,7 +759,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_error() -> Non
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock(side_effect=NetworkError("connection reset"))
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
@@ -1100,7 +783,7 @@ async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
# First call (HTML) raises BadRequest, second call (plain) succeeds
channel._app.bot.edit_message_text = AsyncMock(
@@ -1132,7 +815,7 @@ async def test_send_delta_stream_end_splits_oversized_reply() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
@@ -1166,7 +849,7 @@ async def test_send_delta_stream_end_html_expansion_does_not_overflow() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
@@ -1197,7 +880,7 @@ async def test_send_delta_stream_end_splits_long_code_block_before_html_renderin
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
@@ -1226,7 +909,7 @@ async def test_send_delta_new_stream_id_replaces_stale_buffer() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._stream_bufs["123"] = _StreamBuf(
text="hello",
message_id=7,
@@ -1250,7 +933,7 @@ async def test_send_delta_incremental_edit_treats_not_modified_as_success() -> N
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0, stream_id="s:0")
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("Message is not modified"))
@@ -1268,7 +951,7 @@ async def test_send_delta_incremental_edit_splits_oversized_buffer() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
@@ -1305,7 +988,7 @@ async def test_send_delta_incremental_html_expansion_does_not_overflow() -> None
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
@@ -1339,7 +1022,7 @@ async def test_send_delta_incremental_html_parse_failure_falls_back_to_plain() -
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock(
side_effect=[BadRequest("Can't parse entities"), None]
)
@@ -1373,7 +1056,7 @@ async def test_send_delta_initial_send_keeps_message_in_thread() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
await channel.send_delta(
"123",
@@ -1446,7 +1129,7 @@ def test_is_allowed_rejects_invalid_legacy_telegram_sender_shapes() -> None:
async def test_send_progress_keeps_message_in_topic() -> None:
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
channel = TelegramChannel(config, MessageBus())
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
await channel.send(
OutboundMessage(
@@ -1465,7 +1148,7 @@ async def test_send_progress_keeps_message_in_topic() -> None:
async def test_send_reply_infers_topic_from_message_id_cache() -> None:
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], reply_to_message=True)
channel = TelegramChannel(config, MessageBus())
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._message_threads[("123", 10)] = 42
await channel.send(
@@ -1487,7 +1170,7 @@ async def test_send_remote_media_url_after_security_validation(monkeypatch) -> N
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
monkeypatch.setattr("nanobot.channels.telegram.runtime.validate_url_target", lambda url: (True, ""))
await channel.send(
@@ -1515,7 +1198,7 @@ async def test_send_local_media_preserves_filename(tmp_path: Path) -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
attachment = tmp_path / "report.final.md"
attachment.write_bytes(b"# Report\n")
@@ -1545,7 +1228,7 @@ async def test_send_blocks_unsafe_remote_media_url(monkeypatch) -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.validate_url_target",
lambda url: (False, "Blocked: example.com resolves to private/internal address 127.0.0.1"),
@@ -1576,7 +1259,7 @@ async def test_group_policy_mention_ignores_unmentioned_group_message() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
@@ -1598,7 +1281,7 @@ async def test_group_policy_mention_accepts_text_mention_and_caches_bot_identity
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
@@ -1622,7 +1305,7 @@ async def test_group_policy_mention_accepts_caption_mention() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
@@ -1648,7 +1331,7 @@ async def test_group_policy_mention_accepts_reply_to_bot() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
@@ -1670,7 +1353,7 @@ async def test_group_policy_open_accepts_plain_group_message() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
@@ -1698,7 +1381,7 @@ async def test_extract_reply_context_no_reply() -> None:
async def test_extract_reply_context_with_text() -> None:
"""When reply has text, return prefixed string."""
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
reply = SimpleNamespace(text="Hello world", caption=None, from_user=SimpleNamespace(id=2, username="testuser", first_name="Test"))
message = SimpleNamespace(reply_to_message=reply)
assert await channel._extract_reply_context(message) == "[Reply to @testuser: Hello world]"
@@ -1708,7 +1391,7 @@ async def test_extract_reply_context_with_text() -> None:
async def test_extract_reply_context_with_caption_only() -> None:
"""When reply has only caption (no text), caption is used."""
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
reply = SimpleNamespace(text=None, caption="Photo caption", from_user=SimpleNamespace(id=2, username=None, first_name="Test"))
message = SimpleNamespace(reply_to_message=reply)
assert await channel._extract_reply_context(message) == "[Reply to Test: Photo caption]"
@@ -1718,7 +1401,7 @@ async def test_extract_reply_context_with_caption_only() -> None:
async def test_extract_reply_context_truncation() -> None:
"""Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN."""
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100)
reply = SimpleNamespace(text=long_text, caption=None, from_user=SimpleNamespace(id=2, username=None, first_name=None))
message = SimpleNamespace(reply_to_message=reply)
@@ -1745,7 +1428,7 @@ async def test_on_message_includes_reply_context() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
@@ -1777,7 +1460,7 @@ async def test_download_message_media_returns_path_when_download_succeeds(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.get_file = AsyncMock(
return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None))
)
@@ -1904,7 +1587,7 @@ async def test_on_message_reply_to_media_fallback_when_download_fails() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.get_file = None
handled = []
async def capture_handle(**kwargs) -> None:
@@ -1987,7 +1670,7 @@ async def test_forward_command_does_not_inject_reply_context() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
@@ -2007,7 +1690,7 @@ async def test_forward_command_pairs_unauthorized_private_user(monkeypatch) -> N
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
monkeypatch.setattr(
"nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH"
)
@@ -2024,7 +1707,7 @@ async def test_forward_command_preserves_dream_log_args_and_strips_bot_suffix()
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
@@ -2045,7 +1728,7 @@ async def test_forward_command_normalizes_telegram_safe_dream_aliases() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
@@ -2120,7 +1803,7 @@ async def test_on_start_sends_pairing_code_to_unauthorized_private_user(monkeypa
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
update = _make_telegram_update(text="/start", chat_type="private")
update.message.reply_text = AsyncMock()
monkeypatch.setattr(
@@ -2140,7 +1823,7 @@ async def test_on_help_sends_pairing_code_to_unauthorized_private_user(monkeypat
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
update = _make_telegram_update(text="/help", chat_type="private")
update.message.reply_text = AsyncMock()
monkeypatch.setattr(
@@ -2162,7 +1845,7 @@ async def test_on_message_pairs_unauthorized_private_user_before_side_effects(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
started_typing: list[str] = []
channel._start_typing = lambda chat_id: started_typing.append(chat_id)
channel._add_reaction = AsyncMock(return_value=None)
@@ -2187,7 +1870,7 @@ async def test_on_message_location_content() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
@@ -2209,7 +1892,7 @@ async def test_on_message_location_with_text() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
@@ -2273,7 +1956,7 @@ async def test_send_text_does_not_fallback_on_network_timeout() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
call_count = 0
@@ -2310,7 +1993,7 @@ async def test_send_text_does_not_fallback_on_network_error() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
call_count = 0
@@ -2347,7 +2030,7 @@ async def test_send_text_falls_back_on_bad_request() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
original_send = channel._app.bot.send_message
html_call_count = 0
@@ -2385,7 +2068,7 @@ async def test_send_text_bad_request_plain_fallback_exhausted() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
call_count = 0
@@ -2508,7 +2191,7 @@ async def test_send_delta_mid_stream_strips_markdown() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42))
channel._app.bot.edit_message_text = AsyncMock()
@@ -2609,7 +2292,7 @@ async def test_send_falls_back_buttons_to_inline_text_when_flag_off() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=False),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
await channel.send(
OutboundMessage(
@@ -2637,7 +2320,7 @@ async def test_send_uses_native_keyboard_when_flag_on() -> None:
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=True),
MessageBus(),
)
_install_ready_app(channel)
channel._app = _FakeApp(lambda: None)
await channel.send(
OutboundMessage(
+46 -356
View File
@@ -3,17 +3,14 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import ipaddress
import json
import re
import ssl
import time
import uuid
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Self, TypeGuard, cast
from urllib.parse import urlsplit, urlunsplit
@@ -24,7 +21,6 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from nanobot.bus.events import (
INBOUND_META_USER_SHELL,
OUTBOUND_META_AGENT_UI,
OutboundMessage,
)
@@ -40,7 +36,7 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import USER_SHELL_COMMAND, builtin_command_starts_agent_turn
from nanobot.command.builtin import builtin_command_starts_agent_turn
from nanobot.config.schema import Base
from nanobot.runtime_context import (
RUNTIME_CONTEXT_INPUT_META,
@@ -53,7 +49,6 @@ from nanobot.security.workspace_access import (
WorkspaceScopeError,
)
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.model_selection import model_preset_from_metadata
from nanobot.session.webui_turns import (
clear_websocket_turn_if_current,
clear_websocket_turns,
@@ -63,7 +58,6 @@ from nanobot.session.webui_turns import (
websocket_turn_transcript_persistence_failed,
websocket_turn_wall_started_at,
)
from nanobot.utils.helpers import safe_filename
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.forking import handle_webui_fork_chat
from nanobot.webui.gateway_services import GatewayServices
@@ -98,8 +92,6 @@ from nanobot.webui.websocket_logging import websockets_server_logger
# Plain HTTP WebUI routes also run through websockets.process_request.
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
_WEBUI_REQUEST_CACHE_TTL_S = 5 * 60.0
_WEBUI_REQUEST_CACHE_MAX = 256
_ROUTING_ASSERTION_HEADERS = frozenset(
@@ -356,21 +348,6 @@ def _is_websocket_upgrade(request: WsRequest) -> bool:
return True
@dataclass(frozen=True)
class _WebUIRequestResult:
result: Any = None
status: int | None = None
message: str | None = None
@dataclass
class _WebUIRequestOperation:
action: str
payload_digest: bytes
task: asyncio.Task[_WebUIRequestResult]
completed_at: float | None = None
class WebSocketChannel(BaseChannel):
"""Run a local WebSocket server; forward text/JSON messages to the message bus."""
@@ -396,17 +373,13 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[ServerConnection, str] = {}
# Connections authenticated with a one-time token from /webui/bootstrap.
self._webui_connections: set[ServerConnection] = set()
# Delivery tasks are connection-bound, while operations are keyed only
# by request_id so reconnect retries join or replay the original work.
# Request/reply mutations aren't replayed across reconnects. Tasks may
# finish after a client-side deadline so an already-started mutation
# isn't ambiguously cancelled halfway through.
self._webui_request_tasks: dict[
tuple[ServerConnection, str],
asyncio.Task[None],
] = {}
self._webui_request_operations: dict[str, _WebUIRequestOperation] = {}
# Preserve request/response order for mutations from one
# UI. Without this, an earlier slow settings response can overwrite a
# newer settings snapshot in the client.
self._webui_request_locks: dict[ServerConnection, asyncio.Lock] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
@@ -436,35 +409,6 @@ class WebSocketChannel(BaseChannel):
self._subs.setdefault(chat_id, set()).add(connection)
self._conn_chats.setdefault(connection, set()).add(chat_id)
def _attached_model_fields(self, chat_id: str) -> dict[str, Any]:
"""Expose small session runtime facts on the attach handshake."""
sessions = self.gateway.session_manager
if sessions is None:
return {}
snapshot = sessions.read_session_metadata(f"websocket:{chat_id}")
raw_metadata = snapshot.get("metadata") if snapshot is not None else None
metadata = cast(dict[str, object], raw_metadata) if isinstance(raw_metadata, dict) else None
fields: dict[str, Any] = {}
try:
fields["model_preset"] = model_preset_from_metadata(metadata)
except ValueError:
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
fields["model_preset"] = None
if isinstance(metadata, dict):
usage = metadata.get("_last_usage")
if isinstance(usage, dict):
sanitized_usage: dict[str, int | float] = {}
for key, value in cast(dict[object, object], usage).items():
if (
isinstance(key, str)
and isinstance(value, (int, float))
and not isinstance(value, bool)
and value >= 0
):
sanitized_usage[key] = value
fields["usage"] = sanitized_usage
return fields
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
chats = self._conn_chats.get(connection)
if chats is not None:
@@ -510,12 +454,7 @@ class WebSocketChannel(BaseChannel):
"""Attach and hydrate a newly created WebUI chat fork."""
scope = self._workspaces.scope_for_session_key(fork_key)
self._attach(connection, fork_id)
await self._send_event(
connection,
"attached",
chat_id=fork_id,
**self._attached_model_fields(fork_id),
)
await self._send_event(connection, "attached", chat_id=fork_id)
await self._send_event(
connection,
"session_updated",
@@ -537,10 +476,9 @@ class WebSocketChannel(BaseChannel):
await self._discard_connection_owned_chat(connection, cid)
self._conn_default.pop(connection, None)
self._webui_connections.discard(connection)
self._discard_webui_request_lock_if_idle(connection)
async def _maybe_push_persisted_goal_state(self, chat_id: str) -> None:
"""Replay actionable goal state after *chat_id* is subscribed.
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
Goal metadata lives on the session JSONL and survives gateway restarts, but
connected clients normally see it via ``goal_state`` / ``turn_end`` frames.
@@ -554,7 +492,7 @@ class WebSocketChannel(BaseChannel):
if not isinstance(meta, dict):
meta = {}
blob = goal_state_ws_blob(cast(dict[str, Any], meta))
if not blob.get("active") and blob.get("status") != "blocked":
if not blob.get("active"):
return
await self.send_goal_state(chat_id, blob)
@@ -572,7 +510,7 @@ class WebSocketChannel(BaseChannel):
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay persisted or actively running per-chat state after subscribe."""
await self._maybe_push_persisted_goal_state(chat_id)
await self._maybe_push_active_goal_state(chat_id)
await self._maybe_push_turn_run_wall_clock(chat_id)
async def _send_event(
@@ -592,61 +530,6 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.warning("failed to send {} event: {}", event, e)
async def _broadcast_webui_event(self, event: str, **fields: Any) -> None:
for connection in tuple(self._webui_connections):
await self._send_event(connection, event, **fields)
async def _broadcast_user_message(
self,
origin: ServerConnection,
chat_id: str,
text: str,
*,
turn_id: str | None,
starts_turn: bool,
media_paths: list[str],
media_names: list[str | None],
cli_apps: list[dict[str, Any]],
mcp_presets: list[dict[str, Any]],
session_mentions: list[SessionMention],
) -> None:
"""Project one accepted user message to the other clients on the chat.
The origin already has an optimistic row and receives canonical turn
ownership in ``message_accepted``. Peers need the ingress projection.
"""
body: dict[str, Any] = {
"event": "user_message",
"chat_id": chat_id,
"text": text,
"starts_turn": starts_turn,
}
if turn_id is not None:
body["turn_id"] = turn_id
media = self._media.augment_transcript_user_media(media_paths)
for attachment, name in zip(media, media_names, strict=False):
if name:
attachment["name"] = name
if media:
body["media_urls"] = media
if cli_apps:
body["cli_apps"] = cli_apps
if mcp_presets:
body["mcp_presets"] = mcp_presets
if session_mentions:
body["session_mentions"] = session_mentions
active_turn_id = websocket_turn_id(chat_id)
if active_turn_id is not None:
body["active_turn_id"] = active_turn_id
started_at = websocket_turn_wall_started_at(chat_id)
if active_turn_id is not None and started_at is not None:
body["started_at"] = started_at
raw = json.dumps(body, ensure_ascii=False)
for connection in tuple(self._subs.get(chat_id, ())):
if connection is origin:
continue
await self._safe_send_to(connection, raw, label=" user_message ")
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebSocketConfig().model_dump(by_alias=True)
@@ -898,12 +781,7 @@ class WebSocketChannel(BaseChannel):
return
self._workspaces.persist_scope(new_id, scope)
self._attach(connection, new_id)
await self._send_event(
connection,
"attached",
chat_id=new_id,
**self._attached_model_fields(new_id),
)
await self._send_event(connection, "attached", chat_id=new_id)
await self._send_event(
connection,
"session_updated",
@@ -954,12 +832,7 @@ class WebSocketChannel(BaseChannel):
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
return
self._attach(connection, cid)
await self._send_event(
connection,
"attached",
chat_id=cid,
**self._attached_model_fields(cid),
)
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
return
if t == "set_sidebar_state":
@@ -975,7 +848,7 @@ class WebSocketChannel(BaseChannel):
)
return
try:
saved_state = await asyncio.to_thread(
await asyncio.to_thread(
write_webui_sidebar_state,
cast(dict[str, Any], state),
)
@@ -986,11 +859,6 @@ class WebSocketChannel(BaseChannel):
detail="invalid_sidebar_state",
)
return
await self._broadcast_webui_event(
"sidebar_state_updated",
state=saved_state,
)
return
if t == "set_workspace_scope":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
@@ -1014,10 +882,6 @@ class WebSocketChannel(BaseChannel):
if scope is None:
return
self._workspaces.persist_scope(cid, scope)
# Other clients on the same gateway only need an invalidation; they
# can reload the authoritative session row without receiving a
# local project path that belongs to another connection.
await self.send_session_updated(cid, scope="metadata")
await self._send_event(
connection,
"session_updated",
@@ -1027,10 +891,7 @@ class WebSocketChannel(BaseChannel):
)
return
if t == "transcribe_audio":
event, payload = await webui_transcription_event(
envelope,
config_path=self.gateway.settings.config.path,
)
event, payload = await webui_transcription_event(envelope)
await self._send_event(connection, event, **payload)
return
if t == "message":
@@ -1093,7 +954,6 @@ class WebSocketChannel(BaseChannel):
raw_media = envelope.get("media")
media_paths: list[str] = []
media_names: list[str | None] = []
if raw_media is not None:
if not isinstance(raw_media, list):
await self._send_event(
@@ -1114,12 +974,6 @@ class WebSocketChannel(BaseChannel):
**rejection_fields,
)
return
for item in cast(list[Any], raw_media):
attachment = cast(dict[str, Any], item) if isinstance(item, dict) else {}
name = attachment.get("name")
media_names.append(
(safe_filename(name) or None) if isinstance(name, str) else None
)
if temporary_policy is not None:
self._temporary_chats.register_media(connection, cid, media_paths)
@@ -1173,25 +1027,10 @@ class WebSocketChannel(BaseChannel):
metadata["webui"] = True
metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id")))
trusted_webui = metadata.get("webui") is True and connection in self._webui_connections
is_user_shell = (
trusted_webui
and envelope.get("user_shell") is True
and content.startswith("!")
)
if is_user_shell:
metadata[INBOUND_META_USER_SHELL] = True
dispatch_content = (
f"{USER_SHELL_COMMAND} {content[1:].lstrip()}"
if is_user_shell
else content
)
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
mcp_presets = normalize_mcp_preset_mentions(
envelope.get("mcp_presets"),
config_path=self.gateway.settings.config.path,
)
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
if mcp_presets:
metadata["mcp_presets"] = mcp_presets
session_mentions: list[SessionMention] = []
@@ -1210,7 +1049,7 @@ class WebSocketChannel(BaseChannel):
self._workspaces.persist_scope(cid, scope)
is_webui = metadata.get("webui") is True
queued_owner = None
if is_webui and not is_user_shell and builtin_command_starts_agent_turn(content):
if is_webui and builtin_command_starts_agent_turn(content):
queued_owner = register_queued_websocket_turn_if_idle(cid, turn_id)
if queued_owner is not None:
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
@@ -1247,7 +1086,7 @@ class WebSocketChannel(BaseChannel):
await self._handle_message(
sender_id=client_id,
chat_id=cid,
content=dispatch_content,
content=content,
media=media_paths or None,
metadata=metadata,
is_dm=False,
@@ -1266,38 +1105,12 @@ class WebSocketChannel(BaseChannel):
finally:
if not accepted and queued_owner is not None:
clear_websocket_turn_if_current(cid, queued_owner)
if is_webui:
await self._broadcast_user_message(
connection,
cid,
content,
turn_id=turn_id,
starts_turn=queued_owner is not None,
media_paths=media_paths,
media_names=media_names,
cli_apps=cli_apps,
mcp_presets=mcp_presets,
session_mentions=session_mentions,
)
if is_webui and turn_id:
active_turn_id = websocket_turn_id(cid)
started_at = websocket_turn_wall_started_at(cid)
await self._send_event(
connection,
"message_accepted",
chat_id=cid,
turn_id=turn_id,
starts_turn=queued_owner is not None,
**(
{"active_turn_id": active_turn_id}
if active_turn_id is not None
else {}
),
**(
{"started_at": started_at}
if active_turn_id is not None and started_at is not None
else {}
),
)
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
@@ -1349,136 +1162,33 @@ class WebSocketChannel(BaseChannel):
)
return
payload_digest = hashlib.sha256(
json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).digest()
self._prune_webui_request_operations()
operation = self._webui_request_operations.get(request_id)
is_replay = operation is not None
if operation is not None and (
operation.action != action or operation.payload_digest != payload_digest
):
key = (connection, request_id)
if key in self._webui_request_tasks:
await self._send_webui_response(
connection,
request_id,
status=409,
message="request_id was already used for a different WebUI mutation",
message="duplicate WebUI request_id",
)
return
if operation is None:
operation_task = asyncio.create_task(
self._execute_webui_request(
task = asyncio.create_task(
self._complete_webui_request(
connection,
request_id,
action,
cast(dict[str, Any], payload),
)
)
new_operation = _WebUIRequestOperation(
action=action,
payload_digest=payload_digest,
task=operation_task,
)
operation = new_operation
self._webui_request_operations[request_id] = new_operation
self._webui_request_tasks[key] = task
def mark_complete(_task: asyncio.Task[_WebUIRequestResult]) -> None:
current = self._webui_request_operations.get(request_id)
if current is not new_operation:
return
new_operation.completed_at = time.monotonic()
self._prune_webui_request_operations()
operation_task.add_done_callback(mark_complete)
key = (connection, request_id)
if key in self._webui_request_tasks:
return
delivery_task = asyncio.create_task(
self._deliver_webui_request(
connection,
request_id,
operation.task,
sequence=is_replay,
)
)
self._webui_request_tasks[key] = delivery_task
def _prune_webui_request_operations(self) -> None:
now = time.monotonic()
for request_id, operation in tuple(self._webui_request_operations.items()):
if (
operation.completed_at is not None
and now - operation.completed_at >= _WEBUI_REQUEST_CACHE_TTL_S
):
self._webui_request_operations.pop(request_id, None)
completed = sorted(
(
(operation.completed_at, request_id)
for request_id, operation in self._webui_request_operations.items()
if operation.completed_at is not None
),
key=lambda item: item[0],
)
for _, request_id in completed[:-_WEBUI_REQUEST_CACHE_MAX]:
self._webui_request_operations.pop(request_id, None)
def _discard_webui_request_lock_if_idle(self, connection: ServerConnection) -> None:
if connection in self._webui_connections:
return
if any(task_connection is connection for task_connection, _ in self._webui_request_tasks):
return
self._webui_request_locks.pop(connection, None)
async def _deliver_webui_request(
async def _complete_webui_request(
self,
connection: ServerConnection,
request_id: str,
operation_task: asyncio.Task[_WebUIRequestResult],
*,
sequence: bool = False,
) -> None:
try:
if sequence:
# Make replayed work the predecessor for subsequent mutations on
# this connection without blocking its receive loop.
lock = self._webui_request_locks.setdefault(connection, asyncio.Lock())
async with lock:
result = await asyncio.shield(operation_task)
await self._send_webui_response(
connection,
request_id,
result=result.result,
status=result.status,
message=result.message,
)
return
result = await asyncio.shield(operation_task)
await self._send_webui_response(
connection,
request_id,
result=result.result,
status=result.status,
message=result.message,
)
finally:
self._webui_request_tasks.pop((connection, request_id), None)
self._discard_webui_request_lock_if_idle(connection)
async def _execute_webui_request(
self,
connection: ServerConnection,
action: str,
payload: dict[str, Any],
) -> _WebUIRequestResult:
) -> None:
try:
lock = self._webui_request_locks.setdefault(connection, asyncio.Lock())
async with lock:
response = await self._http_router.dispatch_webui_mutation(
connection,
action,
@@ -1490,17 +1200,22 @@ class WebSocketChannel(BaseChannel):
try:
result = json.loads(body)
except json.JSONDecodeError:
return _WebUIRequestResult(
await self._send_webui_response(
connection,
request_id,
status=502,
message="WebUI mutation returned an invalid response",
)
if action == "sidebar.update" and isinstance(result, dict):
await self._broadcast_webui_event(
"sidebar_state_updated",
state=result,
return
await self._send_webui_response(
connection,
request_id,
result=result,
)
return _WebUIRequestResult(result=result)
return _WebUIRequestResult(
return
await self._send_webui_response(
connection,
request_id,
status=status,
message=body or response.reason_phrase,
)
@@ -1508,10 +1223,14 @@ class WebSocketChannel(BaseChannel):
raise
except Exception:
self.logger.exception("WebUI mutation '{}' failed", action)
return _WebUIRequestResult(
await self._send_webui_response(
connection,
request_id,
status=500,
message="WebUI mutation failed",
)
finally:
self._webui_request_tasks.pop((connection, request_id), None)
async def _send_webui_response(
self,
@@ -1582,19 +1301,12 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None
delivery_tasks = tuple(self._webui_request_tasks.values())
operation_tasks = tuple(
operation.task for operation in self._webui_request_operations.values()
)
for task in (*delivery_tasks, *operation_tasks):
mutation_tasks = tuple(self._webui_request_tasks.values())
for task in mutation_tasks:
task.cancel()
if delivery_tasks:
await asyncio.gather(*delivery_tasks, return_exceptions=True)
if operation_tasks:
await asyncio.gather(*operation_tasks, return_exceptions=True)
if mutation_tasks:
await asyncio.gather(*mutation_tasks, return_exceptions=True)
self._webui_request_tasks.clear()
self._webui_request_locks.clear()
self._webui_request_operations.clear()
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
@@ -1681,8 +1393,6 @@ class WebSocketChannel(BaseChannel):
await self.send_turn_model_updated(
msg.chat_id,
model_name=event.model,
model_preset=event.model_preset,
context_window_tokens=event.context_window_tokens,
)
return
if isinstance(event, GoalStateSyncEvent):
@@ -1727,8 +1437,6 @@ class WebSocketChannel(BaseChannel):
msg.chat_id,
latency_ms=event.latency_ms,
goal_state=event.goal_state,
usage=event.usage,
context_window_tokens=event.context_window_tokens,
metadata=msg.metadata,
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
)
@@ -1755,9 +1463,6 @@ class WebSocketChannel(BaseChannel):
"chat_id": msg.chat_id,
"text": wire_text,
}
turn_id = msg.metadata.get(WEBUI_TURN_METADATA_KEY)
if isinstance(turn_id, str) and turn_id:
payload["turn_id"] = turn_id
if msg.media:
payload["media"] = msg.media
urls: list[dict[str, str]] = []
@@ -1946,25 +1651,16 @@ class WebSocketChannel(BaseChannel):
latency_ms: int | None = None,
*,
goal_state: dict[str, Any] | None = None,
usage: dict[str, int] | None = None,
context_window_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
turn_owner: str | None = None,
) -> None:
"""Signal that the agent has fully finished processing the current turn."""
conns = list(self._subs.get(chat_id, ()))
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
if isinstance(turn_id, str) and turn_id:
body["turn_id"] = turn_id
if latency_ms is not None:
body["latency_ms"] = int(latency_ms)
if goal_state is not None:
body["goal_state"] = goal_state
if usage:
body["usage"] = usage
if context_window_tokens is not None:
body["context_window_tokens"] = int(context_window_tokens)
canonical_webui_turn = (metadata or {}).get("webui") is True
prior_persistence_failure = (
canonical_webui_turn
@@ -2064,8 +1760,6 @@ class WebSocketChannel(BaseChannel):
chat_id: str,
*,
model_name: Any,
model_preset: Any = None,
context_window_tokens: Any = None,
) -> None:
"""Notify one chat's subscribers which model is handling its current request."""
conns = list(self._subs.get(chat_id, ()))
@@ -2080,10 +1774,6 @@ class WebSocketChannel(BaseChannel):
"chat_id": chat_id,
"model_name": model_name.strip(),
}
if isinstance(model_preset, str) and model_preset.strip():
body["model_preset"] = model_preset.strip()
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
body["context_window_tokens"] = context_window_tokens
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
@@ -1,14 +0,0 @@
"""Shared isolation for WebSocket tests that persist runtime state."""
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def isolate_websocket_runtime_data(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Keep transcripts and other runtime files out of the active user data directory."""
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
@@ -6,7 +6,7 @@ import time
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
@@ -18,7 +18,6 @@ from websockets.frames import Close
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
INBOUND_META_USER_SHELL,
OUTBOUND_META_AGENT_UI,
RUNTIME_CONTROL_SESSION_DISCARD,
OutboundMessage,
@@ -46,7 +45,6 @@ from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOUR
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
from nanobot.webui.http_utils import (
http_error as _http_error,
@@ -264,33 +262,6 @@ async def _new_temporary_chat(
return payload["chat_id"]
@pytest.mark.asyncio
async def test_attach_exposes_the_session_canonical_model_preset(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:pinned-model")
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "Deep Research"
sessions.save(session)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
connection = AsyncMock()
await channel._dispatch_envelope(
connection,
"tui-client",
{"type": "attach", "chat_id": "pinned-model"},
)
payload = json.loads(connection.send.await_args_list[0].args[0])
assert payload == {
"event": "attached",
"chat_id": "pinned-model",
"model_preset": "Deep Research",
}
@pytest.mark.asyncio
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
@@ -746,7 +717,7 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
bus,
port=port,
token="static-token",
tokenIssuePath="/custom-token",
tokenIssuePath="/auth/token",
websocketRequiresToken=True,
)
@@ -754,16 +725,15 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
await asyncio.sleep(0.3)
try:
denied = await _http_get(f"http://127.0.0.1:{port}/custom-token")
denied = await _http_get(f"http://127.0.0.1:{port}/auth/token")
assert denied.status_code == 401
allowed = await _http_get(
f"http://127.0.0.1:{port}/custom-token",
f"http://127.0.0.1:{port}/auth/token",
headers={"Authorization": "Bearer static-token"},
)
assert allowed.status_code == 200
assert allowed.json()["token"].startswith("nbwt_")
assert allowed.headers["Cache-Control"] == "no-store"
finally:
await channel.stop()
await server_task
@@ -815,61 +785,6 @@ async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) ->
assert isinstance(lines[0].get("created_at_ms"), int)
@pytest.mark.asyncio
async def test_trusted_webui_shell_preserves_display_text_and_hides_dispatch_command(
bus: MagicMock,
) -> None:
from nanobot.webui.transcript import read_transcript_lines
channel = _ch(bus)
conn = MagicMock()
conn.remote_address = ("127.0.0.1", 50123)
channel._webui_connections.add(conn)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "message",
"chat_id": "shell-chat",
"content": "!printf ok",
"webui": True,
"user_shell": True,
"turn_id": "shell-turn",
},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.content == "/__shell printf ok"
assert msg.metadata[INBOUND_META_USER_SHELL] is True
assert msg.metadata["webui_turn_id"] == "shell-turn"
assert read_transcript_lines("websocket:shell-chat")[0]["text"] == "!printf ok"
@pytest.mark.asyncio
async def test_untrusted_websocket_cannot_enable_user_shell(bus: MagicMock) -> None:
channel = _ch(bus)
conn = MagicMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"plain-client",
{
"type": "message",
"chat_id": "plain-chat",
"content": "!printf nope",
"webui": True,
"user_shell": True,
"turn_id": "plain-turn",
},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.content == "!printf nope"
assert INBOUND_META_USER_SHELL not in msg.metadata
@pytest.mark.asyncio
async def test_webui_message_envelope_persists_user_transcript_for_refresh(
bus: MagicMock,
@@ -1027,259 +942,6 @@ async def test_authenticated_webui_request_returns_correlated_success(bus: Magic
}
@pytest.mark.asyncio
async def test_webui_mutations_preserve_request_and_response_order(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
first_started = asyncio.Event()
release_first = asyncio.Event()
dispatch_order: list[str] = []
async def dispatch(
_connection: object,
action: str,
_payload: dict[str, object],
) -> Any:
dispatch_order.append(action)
if action == "settings.provider.update":
first_started.set()
await release_first.wait()
return _http_json_response({"action": action})
channel.gateway.http.dispatch_webui_mutation = dispatch
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-first",
"action": "settings.provider.update",
"payload": {},
},
)
await first_started.wait()
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-second",
"action": "settings.agent.update",
"payload": {},
},
)
await asyncio.sleep(0)
assert dispatch_order == ["settings.provider.update"]
release_first.set()
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
assert dispatch_order == [
"settings.provider.update",
"settings.agent.update",
]
responses = [json.loads(call.args[0]) for call in conn.send.await_args_list]
assert [response["request_id"] for response in responses] == [
"request-first",
"request-second",
]
@pytest.mark.asyncio
async def test_webui_request_survives_disconnect_and_preserves_reconnect_order(
bus: MagicMock,
) -> None:
channel = _ch(bus)
first_conn = AsyncMock()
retry_conn = AsyncMock()
first_conn.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._webui_connections.update({first_conn, retry_conn})
started = asyncio.Event()
release = asyncio.Event()
dispatch_order: list[str] = []
async def mutate(_connection: object, action: str, _payload: dict[str, Any]) -> Any:
dispatch_order.append(action)
if action == "automation.run":
started.set()
await release.wait()
return _http_json_response({"action": action})
channel.gateway.http.dispatch_webui_mutation = AsyncMock(side_effect=mutate)
envelope = {
"type": "webui_request",
"request_id": "request-retry",
"action": "automation.run",
"payload": {"id": "daily-summary"},
}
queued_envelope = {
"type": "webui_request",
"request_id": "request-queued",
"action": "automation.update",
"payload": {"id": "daily-summary"},
}
await channel._dispatch_envelope(first_conn, "webui-client", envelope)
await started.wait()
await channel._dispatch_envelope(first_conn, "webui-client", queued_envelope)
assert dispatch_order == ["automation.run"]
await channel._cleanup_connection(first_conn)
assert first_conn not in channel._webui_connections
await channel._dispatch_envelope(retry_conn, "webui-client", envelope)
await channel._dispatch_envelope(retry_conn, "webui-client", queued_envelope)
await channel._dispatch_envelope(
retry_conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-next",
"action": "automation.delete",
"payload": {"id": "daily-summary"},
},
)
await asyncio.sleep(0)
assert dispatch_order == ["automation.run"]
pending = tuple(channel._webui_request_tasks.values())
release.set()
await asyncio.gather(*pending)
assert dispatch_order == ["automation.run", "automation.update", "automation.delete"]
responses = [json.loads(call.args[0]) for call in retry_conn.send.await_args_list]
assert [response["request_id"] for response in responses] == [
"request-retry",
"request-queued",
"request-next",
]
assert first_conn not in channel._webui_request_locks
@pytest.mark.asyncio
async def test_webui_request_replays_completed_result_after_reconnect(bus: MagicMock) -> None:
channel = _ch(bus)
first_conn = AsyncMock()
retry_conn = AsyncMock()
channel._webui_connections.update({first_conn, retry_conn})
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_json_response({"installed": True})
)
envelope = {
"type": "webui_request",
"request_id": "request-completed",
"action": "skill.install",
"payload": {"skill": "demo"},
}
await channel._dispatch_envelope(first_conn, "webui-client", envelope)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
await channel._dispatch_envelope(retry_conn, "webui-client", envelope)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once()
assert json.loads(retry_conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-completed",
"ok": True,
"result": {"installed": True},
}
@pytest.mark.asyncio
async def test_webui_request_rejects_request_id_reuse_with_different_payload(
bus: MagicMock,
) -> None:
channel = _ch(bus)
first_conn = AsyncMock()
retry_conn = AsyncMock()
channel._webui_connections.update({first_conn, retry_conn})
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_json_response({"ran": True})
)
await channel._dispatch_envelope(
first_conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-conflict",
"action": "automation.run",
"payload": {"id": "job-a"},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
await channel._dispatch_envelope(
retry_conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-conflict",
"action": "automation.run",
"payload": {"id": "job-b"},
},
)
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once()
assert json.loads(retry_conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-conflict",
"ok": False,
"error": {
"status": 409,
"message": "request_id was already used for a different WebUI mutation",
},
}
def test_webui_request_cache_prunes_expired_completed_but_keeps_pending(
bus: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import nanobot.channels.websocket.runtime as websocket_module
channel = _ch(bus)
now = 1_000.0
monkeypatch.setattr(websocket_module, "time", SimpleNamespace(monotonic=lambda: now))
operations = cast(dict[str, Any], channel._webui_request_operations)
operations["pending"] = SimpleNamespace(completed_at=None)
operations["expired"] = SimpleNamespace(
completed_at=now - websocket_module._WEBUI_REQUEST_CACHE_TTL_S
)
operations["fresh"] = SimpleNamespace(completed_at=now - 1)
channel._prune_webui_request_operations()
assert "pending" in operations
assert "expired" not in operations
assert "fresh" in operations
def test_webui_request_cache_prunes_oldest_completed_at_capacity(
bus: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import nanobot.channels.websocket.runtime as websocket_module
channel = _ch(bus)
now = 1_000.0
monkeypatch.setattr(websocket_module, "time", SimpleNamespace(monotonic=lambda: now))
operations = cast(dict[str, Any], channel._webui_request_operations)
operations["pending"] = SimpleNamespace(completed_at=None)
for index in range(websocket_module._WEBUI_REQUEST_CACHE_MAX + 1):
operations[f"completed-{index}"] = SimpleNamespace(
completed_at=now - 1 + index / 1_000
)
channel._prune_webui_request_operations()
assert "pending" in operations
assert "completed-0" not in operations
assert "completed-1" in operations
completed = [operation for operation in operations.values() if operation.completed_at is not None]
assert len(completed) == websocket_module._WEBUI_REQUEST_CACHE_MAX
@pytest.mark.asyncio
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
channel = _ch(bus)
@@ -1375,63 +1037,6 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
}
@pytest.mark.asyncio
async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
source = AsyncMock()
source.request = SimpleNamespace(headers=Headers())
other_device = AsyncMock()
channel._webui_connections.update({source, other_device})
request_id = "sidebar-workbench-state"
await channel._dispatch_envelope(
source,
"webui-client",
{
"type": "webui_request",
"request_id": request_id,
"action": "sidebar.update",
"payload": {
"state": {
"workbench": {
"version": 1,
"tabs": {
"tab:websocket:a": {
"explicit": True,
"title": "Research",
"paneKeys": ["websocket:a", "websocket:b"],
"layoutPaneKeys": ["websocket:b", "websocket:a"],
"layout": "columns",
"splitRatios": [0.35],
}
},
}
}
},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
event = json.loads(other_device.send.await_args.args[0])
assert event["event"] == "sidebar_state_updated"
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [
"websocket:a",
"websocket:b",
]
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [
"websocket:b",
"websocket:a",
]
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [
0.35
]
@pytest.mark.asyncio
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
channel = _ch(bus)
@@ -1521,47 +1126,6 @@ async def test_webui_message_scope_inherits_persisted_session_scope(
}
@pytest.mark.asyncio
async def test_workspace_scope_change_invalidates_other_attached_clients(
bus: MagicMock,
tmp_path,
) -> None:
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
origin = AsyncMock()
origin.remote_address = ("127.0.0.1", 50123)
peer = AsyncMock()
peer.remote_address = ("127.0.0.1", 50124)
channel._attach(origin, "shared")
channel._attach(peer, "shared")
await channel._dispatch_envelope(
origin,
"terminal-a",
{
"type": "set_workspace_scope",
"chat_id": "shared",
"workspace_scope": {
"project_path": str(tmp_path),
"access_mode": "full",
},
},
)
peer_event = json.loads(peer.send.await_args.args[0])
assert peer_event == {
"event": "session_updated",
"chat_id": "shared",
"scope": "metadata",
}
origin_event = json.loads(origin.send.await_args.args[0])
assert origin_event["workspace_scope"]["access_mode"] == "full"
@pytest.mark.asyncio
async def test_webui_scope_expands_home_project_path(
bus: MagicMock,
@@ -2020,11 +1584,7 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=TurnModelUpdatedEvent(
model="deepseek/deepseek-chat",
model_preset="Deep Research",
context_window_tokens=128_000,
),
event=TurnModelUpdatedEvent(model="deepseek/deepseek-chat"),
)
)
@@ -2033,38 +1593,10 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
"event": "turn_model_updated",
"chat_id": "chat-1",
"model_name": "deepseek/deepseek-chat",
"model_preset": "Deep Research",
"context_window_tokens": 128_000,
}
chat_two.send.assert_not_awaited()
def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
manager = MagicMock()
manager.read_session_metadata.return_value = {
"metadata": {
SESSION_MODEL_PRESET_METADATA_KEY: "Deep Research",
"_last_usage": {
"prompt_tokens": 120,
"completion_tokens": 8,
"negative": -1,
"boolean": True,
},
}
}
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=manager),
)
assert channel._attached_model_fields("chat-1") == {
"model_preset": "Deep Research",
"usage": {"prompt_tokens": 120, "completion_tokens": 8},
}
@pytest.mark.asyncio
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
bus = MagicMock()
@@ -3063,21 +2595,11 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
channel="websocket",
chat_id="chat-1",
content="",
event=TurnEndEvent(
latency_ms=1500,
usage={"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
context_window_tokens=128_000,
),
event=TurnEndEvent(latency_ms=1500),
))
assert _sent_ws_payloads(mock_ws) == [
{
"event": "turn_end",
"chat_id": "chat-1",
"latency_ms": 1500,
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
"context_window_tokens": 128_000,
},
{"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500},
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
]
@@ -3186,7 +2708,7 @@ async def test_maybe_push_active_goal_state_noop_without_session_manager() -> No
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
await channel._maybe_push_active_goal_state("chat-1")
mock_ws.send.assert_not_called()
@@ -3202,7 +2724,7 @@ async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
await channel._maybe_push_active_goal_state("chat-1")
mock_ws.send.assert_not_called()
@@ -3227,7 +2749,7 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
await channel._maybe_push_active_goal_state("chat-1")
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body["event"] == "goal_state"
@@ -3237,39 +2759,6 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
assert body["goal_state"]["ui_summary"] == "Docs"
@pytest.mark.asyncio
async def test_maybe_push_goal_state_restores_blocked_attention_on_disk() -> None:
bus = MagicMock()
sm = MagicMock()
sm.read_session_file.return_value = {
"metadata": {
"goal_state": {
"status": "blocked",
"objective": "deploy safely",
"ui_summary": "Approval required",
},
},
"messages": [],
}
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sm),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
body = json.loads(mock_ws.send.await_args.args[0])
assert body["goal_state"] == {
"active": False,
"status": "blocked",
"ui_summary": "Approval required",
"objective": "deploy safely",
}
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None:
bus = MagicMock()
@@ -3585,10 +3074,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert providers["azure_openai"]["api_key_required"] is False # AAD auth supported; no static key required
assert providers["openrouter"]["configured"] is False
assert providers["openrouter"]["api_key_required"] is True
assert providers["orcarouter"]["label"] == "OrcaRouter"
assert providers["orcarouter"]["configured"] is False
assert providers["orcarouter"]["api_key_required"] is True
assert providers["orcarouter"]["default_api_base"] == "https://api.orcarouter.ai/v1"
assert providers["skywork"]["label"] == "Skywork"
assert providers["skywork"]["default_api_base"] == "https://api.apifree.ai/agent/v1"
assert providers["ant_ling"]["label"] == "Ant Ling"
@@ -3753,7 +3238,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
created_presets = {
preset["name"]: preset for preset in created_body["model_presets"]
}
assert created_presets["fast-writing"]["label"] == "fast-writing"
assert created_presets["fast-writing"]["label"] == "Fast writing"
assert created_presets["fast-writing"]["provider"] == "openai"
updated_preset = await _webui_mutate(
@@ -3761,7 +3246,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
"settings.model_configuration.update",
{
"name": "fast-writing",
"new_name": "Codex",
"label": "Codex",
"provider": "openai",
"model": "openai/gpt-5.5",
},
@@ -3773,24 +3258,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
updated_presets = {
preset["name"]: preset for preset in updated_preset_body["model_presets"]
}
assert updated_presets["Codex"]["label"] == "Codex"
assert updated_presets["fast-writing"]["label"] == "Codex"
call_order_updated = await _webui_mutate(
webui_client,
"settings.model_call_order.update",
{"order": ["Codex", "deep"]},
{"order": ["fast-writing", "deep"]},
)
assert call_order_updated.status_code == 200
call_order_body = call_order_updated.json()
assert call_order_body["agent"]["model_preset"] == "Codex"
assert call_order_body["agent"]["model_preset"] == "fast-writing"
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
assert call_order_body["model_call_order"] == ["Codex", "deep"]
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
duplicate_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"name": "codex",
"label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
@@ -3888,10 +3373,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
assert saved.agents.defaults.model_preset == "Codex"
assert saved.agents.defaults.model_preset == "fast-writing"
assert saved.agents.defaults.fallback_models == ["deep"]
assert saved.model_presets["Codex"].model == "openai/gpt-5.5"
assert saved.model_presets["Codex"].provider == "openai"
assert saved.model_presets["fast-writing"].label == "Codex"
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
assert saved.model_presets["fast-writing"].provider == "openai"
assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "nanobot"
assert saved.agents.defaults.bot_icon == "🐈"
@@ -4260,7 +3746,6 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None:
headers={"Authorization": "Bearer s"},
)
assert resp.status_code == 429
assert resp.headers["Cache-Control"] == "no-store"
data = resp.json()
assert "error" in data
finally:
@@ -4393,89 +3878,9 @@ async def test_authorized_webui_turn_is_acked_after_bus_acceptance(
"event": "message_accepted",
"chat_id": "chat-accepted",
"turn_id": "turn-accepted",
"starts_turn": True,
"active_turn_id": "turn-accepted",
"started_at": wth.websocket_turn_wall_started_at("chat-accepted"),
}
@pytest.mark.asyncio
async def test_user_messages_fan_out_to_other_clients_on_the_same_chat(
bus: MagicMock,
) -> None:
channel = _ch(bus)
origin = AsyncMock()
origin.remote_address = ("127.0.0.1", 50123)
peer = AsyncMock()
outsider = AsyncMock()
channel._attach(peer, "shared-chat")
channel._attach(outsider, "other-chat")
await channel._dispatch_envelope(
origin,
"terminal-a",
{
"type": "message",
"chat_id": "shared-chat",
"content": "hello from A",
"webui": True,
"turn_id": "turn-a",
},
)
await channel._dispatch_envelope(
origin,
"terminal-a",
{
"type": "message",
"chat_id": "shared-chat",
"content": "one more detail",
"webui": True,
"turn_id": "steer-a",
},
)
peer_payloads = [
payload
for payload in _sent_ws_payloads(peer)
if payload["event"] == "user_message"
]
assert len(peer_payloads) == 2
assert peer_payloads[0] == {
"event": "user_message",
"chat_id": "shared-chat",
"text": "hello from A",
"starts_turn": True,
"turn_id": "turn-a",
"active_turn_id": "turn-a",
"started_at": pytest.approx(wth.websocket_turn_wall_started_at("shared-chat")),
}
assert peer_payloads[1] == {
"event": "user_message",
"chat_id": "shared-chat",
"text": "one more detail",
"starts_turn": False,
"turn_id": "steer-a",
"active_turn_id": "turn-a",
"started_at": pytest.approx(wth.websocket_turn_wall_started_at("shared-chat")),
}
origin_payloads = _sent_ws_payloads(origin)
assert [
(
payload["event"],
payload["turn_id"],
payload["starts_turn"],
payload["active_turn_id"],
)
for payload in origin_payloads
if payload["event"] == "message_accepted"
] == [
("message_accepted", "turn-a", True, "turn-a"),
("message_accepted", "steer-a", False, "turn-a"),
]
outsider.send.assert_not_awaited()
assert bus.publish_inbound.await_count == 2
@pytest.mark.asyncio
async def test_side_channel_command_does_not_register_queued_turn(
bus: MagicMock,
@@ -4504,7 +3909,6 @@ async def test_side_channel_command_does_not_register_queued_turn(
"event": "message_accepted",
"chat_id": "chat-status",
"turn_id": "turn-status",
"starts_turn": False,
}
@@ -5024,39 +4428,6 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
assert body["has_pending_tool_calls"] is False
@pytest.mark.asyncio
async def test_handle_session_context_get_reads_detached_session() -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.session import Session
session = Session(
key="websocket:context-route",
messages=[{"role": "user", "content": "hello"}],
metadata={"_last_usage": {"prompt_tokens": 12, "completion_tokens": 3}},
)
manager = MagicMock()
manager.read_session_snapshot.return_value = session
gateway = _basic_handler(MagicMock(), session_manager=manager)
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
encoded = quote(session.key, safe="")
request = Request(
f"/api/sessions/{encoded}/context",
Headers([("Authorization", "Bearer tok")]),
)
response = await gateway.http._handle_session_context_get(request, encoded)
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["replay_messages"] == 1
assert body["last_usage"] == {"prompt_tokens": 12, "completion_tokens": 3}
manager.read_session_snapshot.assert_called_once_with(session.key)
def test_handle_webui_thread_get_reports_registered_turn_as_pending(
tmp_path,
monkeypatch,
@@ -129,47 +129,9 @@ async def test_webui_message_acceptance_echoes_turn_id() -> None:
"event": "message_accepted",
"chat_id": "abc123",
"turn_id": "turn-accepted",
"starts_turn": True,
"active_turn_id": "turn-accepted",
"started_at": wth.websocket_turn_wall_started_at("abc123"),
}
@pytest.mark.asyncio
async def test_webui_message_projects_attachments_to_other_clients(tmp_path: Path) -> None:
channel = _make_channel()
origin = AsyncMock()
peer = AsyncMock()
channel._attach(origin, "abc123")
channel._attach(peer, "abc123")
channel._webui_connections.add(origin)
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "please inspect @drawio",
"webui": True,
"turn_id": "turn-shared",
"media": [{"data_url": _tiny_png_data_url(), "name": "shot.png"}],
"cli_apps": [{"name": "DrawIO", "entry_point": "cli-anything-drawio"}],
}
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path):
await channel._dispatch_envelope(origin, "client-1", envelope)
event = json.loads(peer.send.await_args.args[0])
assert event["event"] == "user_message"
assert event["turn_id"] == "turn-shared"
assert event["text"] == "please inspect @drawio"
assert event["cli_apps"] == [{
"name": "drawio",
"entry_point": "cli-anything-drawio",
}]
assert event["media_urls"][0]["kind"] == "image"
assert event["media_urls"][0]["name"] == "shot.png"
assert event["media_urls"][0]["url"].startswith("/api/media/")
assert json.loads(origin.send.await_args.args[0])["event"] == "message_accepted"
@pytest.mark.asyncio
async def test_message_text_policy_is_independent_from_transport_limit() -> None:
channel = _make_channel()
@@ -16,7 +16,6 @@ import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
from nanobot.config.loader import load_config, save_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.optional_features import InstallResult
@@ -32,11 +31,6 @@ from .ws_test_client import http_get as _http_get
_PORT = 29900
@pytest.fixture(autouse=True)
def _isolate_runtime_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
class _MatrixChannel(BaseChannel):
name = "matrix"
display_name = "Matrix"
@@ -81,7 +75,6 @@ def _make_handler(
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -101,7 +94,6 @@ def _make_handler(
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload,
)
@@ -119,7 +111,6 @@ def _ch(
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -143,7 +134,6 @@ def _ch(
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload,
)
return InProcessHttpChannel(cfg, bus, gateway=gateway)
@@ -233,7 +223,6 @@ async def test_bootstrap_returns_token_for_localhost(
try:
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
assert resp.status_code == 200
assert resp.headers["Cache-Control"] == "no-store"
body = resp.json()
assert body["token"].startswith("nbwt_")
assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui"
@@ -289,53 +278,6 @@ async def test_sessions_list_requires_bearer_token(
await server_task
@pytest.mark.asyncio
async def test_sessions_list_and_thread_restore_transcript_without_canonical_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = SessionManager(tmp_path / "workspace")
from nanobot.webui.transcript import append_transcript_object
key = "websocket:restored-history"
append_transcript_object(
key,
{"event": "user", "chat_id": "restored-history", "text": "original question"},
)
append_transcript_object(
key,
{"event": "message", "chat_id": "restored-history", "text": "original answer"},
)
assert not sm._get_session_path(key).exists()
port = _free_port()
channel = _ch(bus, session_manager=sm, port=port)
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
listing = await _http_get(f"http://127.0.0.1:{port}/api/sessions", headers=auth)
thread = await _http_get(
f"http://127.0.0.1:{port}/api/sessions/"
"websocket%3Arestored-history/webui-thread",
headers=auth,
)
assert listing.status_code == 200
assert [row["key"] for row in listing.json()["sessions"]] == [key]
assert listing.json()["sessions"][0]["preview"] == "original question"
assert thread.status_code == 200
assert [message["content"] for message in thread.json()["messages"]] == [
"original question",
"original answer",
]
assert not sm._get_session_path(key).exists()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_legacy_session_messages_route_is_not_exposed(
bus: MagicMock, tmp_path: Path
@@ -637,7 +579,6 @@ async def test_webui_skill_management_routes(
*,
enabled: bool,
disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]:
assert workspace == tmp_path
assert name == "custom-skill"
@@ -650,7 +591,6 @@ async def test_webui_skill_management_routes(
name: str,
*,
disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]:
assert workspace == tmp_path
assert name == "custom-skill"
@@ -929,6 +869,10 @@ async def test_webui_skill_install_honors_remote_install_opt_in(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
policy = MagicMock()
policy.tools.webui_allow_remote_package_install = True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
async def install(
source: str,
skill_id: str,
@@ -955,9 +899,6 @@ async def test_webui_skill_install_honors_remote_install_opt_in(
workspace_path=tmp_path,
port=_free_port(),
)
policy = load_config(channel.gateway.settings.config.path)
policy.tools.webui_allow_remote_package_install = True
save_config(policy, channel.gateway.settings.config.path)
response = await _webui_mutate(
channel,
"skill.install",
@@ -2113,15 +2054,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
_custom_action,
)
async def _hot_reload():
async def _hot_reload(_bus):
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=29913,
mcp_reload=_hot_reload,
monkeypatch.setattr(
"nanobot.webui.settings_routes.request_mcp_reload",
_hot_reload,
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
server_task = asyncio.create_task(channel.start())
try:
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
@@ -2321,40 +2261,6 @@ async def test_session_delete_removes_file(
await server_task
@pytest.mark.asyncio
async def test_session_delete_removes_transcript_without_canonical_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = SessionManager(tmp_path / "workspace")
from nanobot.webui.transcript import append_transcript_object
key = "websocket:transcript-only"
append_transcript_object(
key,
{"event": "user", "chat_id": "transcript-only", "text": "recover me"},
)
assert not sm._get_session_path(key).exists()
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
assert webui_path.is_file()
channel = _ch(bus, session_manager=sm, port=_free_port())
server_task = asyncio.create_task(channel.start())
try:
response = await _webui_mutate(
channel,
"session.delete",
{"key": key},
)
assert response.status_code == 200
assert response.json()["deleted"] is True
assert not webui_path.exists()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
bus: MagicMock, tmp_path: Path
@@ -3268,85 +3174,6 @@ async def _webui_mutate(
)
@pytest.mark.asyncio
async def test_workspace_folder_picker_is_local_authenticated_mutation(
bus: MagicMock,
tmp_path: Path,
monkeypatch,
) -> None:
selected = tmp_path / "project"
selected.mkdir()
pick_folder = AsyncMock(return_value=str(selected))
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await _webui_mutate(channel, "workspace.pick_folder")
assert response.status_code == 200
assert response.json() == {"path": str(selected)}
pick_folder.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_workspace_folder_picker_rejects_direct_http(
bus: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await channel.gateway.http.dispatch(
_LOCAL,
_FakeReq(
{"Host": "127.0.0.1:8765"},
path="/api/workspaces/pick-folder",
),
)
assert response is not None
assert response.status_code == 405
assert b"authenticated WebSocket" in response.body
pick_folder.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("connection", "host"),
[(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")],
)
async def test_workspace_folder_picker_rejects_nonlocal_surfaces(
bus: MagicMock,
monkeypatch,
connection: _FakeConn,
host: str,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "")
response = await _webui_mutate(
channel,
"workspace.pick_folder",
connection=connection,
)
assert response.status_code == 403
pick_folder.assert_not_awaited()
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
from nanobot.webui.http_utils import is_local_browser_request
@@ -3701,7 +3528,7 @@ def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> N
def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config",
lambda _config_path=None: "from-disk",
lambda: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ")
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
@@ -3713,7 +3540,7 @@ def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytes
def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config",
lambda _config_path=None: "from-disk",
lambda: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ")
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
@@ -3725,7 +3552,7 @@ def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeyp
def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config",
lambda _config_path=None: "from-disk",
lambda: "from-disk",
)
def boom():
@@ -334,56 +334,6 @@ async def test_independent_sessions(bus: MagicMock) -> None:
await t
@pytest.mark.asyncio
async def test_same_session_projects_one_turn_to_both_clients(bus: MagicMock) -> None:
ch = _ch(bus, 29925)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29925/", client_id="terminal-a") as a:
async with WsTestClient("ws://127.0.0.1:29925/", client_id="terminal-b") as b:
chat_id = (await a.recv_ready()).chat_id
await b.recv_ready()
await b.send_json({"type": "attach", "chat_id": chat_id})
attached = await b.recv()
assert attached.event == "attached"
assert attached.chat_id == chat_id
await a.send_json(
{
"type": "message",
"chat_id": chat_id,
"content": "hello from terminal A",
"webui": True,
"turn_id": "turn-a",
}
)
accepted = await a.recv()
projected = await b.recv()
assert accepted.event == "message_accepted"
assert accepted.raw["turn_id"] == "turn-a"
assert accepted.raw["starts_turn"] is True
assert accepted.raw["active_turn_id"] == "turn-a"
assert projected.raw == {
"event": "user_message",
"chat_id": chat_id,
"text": "hello from terminal A",
"starts_turn": True,
"turn_id": "turn-a",
"active_turn_id": "turn-a",
"started_at": projected.raw["started_at"],
}
await ch.send_delta(chat_id, "shared reply", stream_id="stream-a")
assert (await a.recv_delta()).text == "shared reply"
assert (await b.recv_delta()).text == "shared reply"
assert bus.publish_inbound.await_count == 1
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
ch = _ch(bus, 29914)
-29
View File
@@ -486,35 +486,6 @@ class WeixinChannel(BaseChannel):
if base_url:
self.config.base_url = base_url
self._save_state(force=True)
self._persist_connect_credentials(token=token, base_url=base_url)
def _persist_connect_credentials(self, *, token: str, base_url: str) -> None:
"""Write the QR-login token and base_url back to config.json.
The connect flow saves account state to ``account.json`` (via
``_save_state``), but the WebUI's post-connect ``enable`` step calls
``set_channel_config_enabled`` which reads config.json. Without
persisting the token here, that step would overwrite it with the
default empty value, losing the freshly obtained credential.
"""
from nanobot.config.loader import get_config_path, load_config, save_config
try:
full_config = load_config()
section = getattr(full_config.channels, "weixin", None)
if section is not None and hasattr(section, "model_dump"):
values = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
values = dict(cast(dict[str, Any], section))
else:
values = {}
values["token"] = token
if base_url:
values["baseUrl"] = base_url
setattr(full_config.channels, "weixin", values)
save_config(full_config, get_config_path())
except Exception:
self.logger.exception("Failed to persist WeChat credentials to config.json")
# ------------------------------------------------------------------
# HTTP helpers (matches api.ts buildHeaders / apiFetch)
@@ -66,63 +66,6 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
assert saved["token"] == "wx-token"
assert saved["base_url"] == "https://weixin.example"
# Token and base_url must also be persisted to config.json so the
# post-connect enable step does not overwrite them with empty defaults.
config_data = json.loads(config_path.read_text(encoding="utf-8"))
weixin_cfg = config_data.get("channels", {}).get("weixin", {})
assert weixin_cfg.get("token") == "wx-token"
assert weixin_cfg.get("baseUrl") == "https://weixin.example"
assert weixin_cfg.get("stateDir") == str(state_dir)
@pytest.mark.asyncio
async def test_weixin_connect_persists_credentials_without_channels_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When config.json has no channels key at all, connect must still write
the obtained token and base_url back to config.json."""
config_path = tmp_path / "config.json"
# config.json with NO channels key — the bug scenario
config_path.write_text(
json.dumps({"agents": {"defaults": {"model": "test"}}}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
self: WeixinChannel,
*,
base_url: str,
endpoint: str,
params: dict[str, Any],
auth: bool,
) -> dict[str, str]:
return {
"status": "confirmed",
"bot_token": "wx-token",
"baseurl": "https://weixin.example",
"ilink_user_id": "wx-user",
}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start()
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
config_data = json.loads(config_path.read_text(encoding="utf-8"))
weixin_cfg = config_data.get("channels", {}).get("weixin", {})
assert weixin_cfg.get("token") == "wx-token"
assert weixin_cfg.get("baseUrl") == "https://weixin.example"
@pytest.mark.asyncio
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
+27 -9
View File
@@ -33,10 +33,28 @@ import {
WEIXIN_AUTH_EXPIRED_MESSAGE,
WeixinConnectFlow,
} from "./WeixinConnectFlow";
import {
WEIXIN_ADVANCED_FIELD_KEYS,
WEIXIN_PRIMARY_FIELD_KEYS,
} from "./presentation";
export const WEIXIN_PRIMARY_FIELD_KEYS = [
"channels.weixin.sendProgress",
"channels.weixin.sendToolHints",
"channels.weixin.streaming",
] as const;
export const WEIXIN_ADVANCED_FIELD_KEYS = [
"channels.weixin.allowFrom",
"channels.weixin.token",
"channels.weixin.replyProgressMessages",
"channels.weixin.replyProgressMaxMessages",
"channels.weixin.contextMessageBudget",
"channels.weixin.blockStreaming",
"channels.weixin.blockStreamingMinChars",
"channels.weixin.blockStreamingMaxMessages",
"channels.weixin.baseUrl",
"channels.weixin.cdnBaseUrl",
"channels.weixin.routeTag",
"channels.weixin.stateDir",
"channels.weixin.pollTimeout",
] as const;
export function WeixinPanel({
token,
@@ -197,7 +215,7 @@ export function WeixinPanel({
});
return (
<aside className="min-h-full rounded-panel bg-settings-surface p-5">
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<WeixinLogo showBrandLogos={showBrandLogos} />
@@ -251,7 +269,7 @@ export function WeixinPanel({
</div>
{runtimeError ? (
<div className="mt-4 rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
<div className="mt-4 rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
{runtimeError}
</div>
) : null}
@@ -304,7 +322,7 @@ export function WeixinPanel({
{saveError ? (
<div
role="alert"
className="rounded-control border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
className="rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
>
{saveError}
</div>
@@ -413,7 +431,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (showBrandLogos && logoUrl) {
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-control bg-background">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background">
<img
src={logoUrl}
alt=""
@@ -428,7 +446,7 @@ function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
}
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-control bg-background text-[11px] font-bold"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
style={{ color: "#07C160" }}
aria-hidden
>
+3 -12
View File
@@ -1,21 +1,12 @@
import { lazy } from "react";
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
import { WeixinConnectFlow } from "./WeixinConnectFlow";
import {
WEIXIN_ADVANCED_FIELD_KEYS,
WEIXIN_PRIMARY_FIELD_KEYS,
} from "./presentation";
const WeixinPanel = lazy(() =>
import("./WeixinPanel").then(({ WeixinPanel: component }) => ({ default: component })),
);
const WeixinConnectFlow = lazy(() =>
import("./WeixinConnectFlow").then(({ WeixinConnectFlow: component }) => ({
default: component,
})),
);
WeixinPanel,
} from "./WeixinPanel";
export default {
Panel: WeixinPanel,
@@ -1,21 +0,0 @@
export const WEIXIN_PRIMARY_FIELD_KEYS = [
"channels.weixin.sendProgress",
"channels.weixin.sendToolHints",
"channels.weixin.streaming",
] as const;
export const WEIXIN_ADVANCED_FIELD_KEYS = [
"channels.weixin.allowFrom",
"channels.weixin.token",
"channels.weixin.replyProgressMessages",
"channels.weixin.replyProgressMaxMessages",
"channels.weixin.contextMessageBudget",
"channels.weixin.blockStreaming",
"channels.weixin.blockStreamingMinChars",
"channels.weixin.blockStreamingMaxMessages",
"channels.weixin.baseUrl",
"channels.weixin.cdnBaseUrl",
"channels.weixin.routeTag",
"channels.weixin.stateDir",
"channels.weixin.pollTimeout",
] as const;
+322 -57
View File
@@ -1,18 +1,52 @@
"""Agent CLI command."""
"""Direct and interactive agent CLI command."""
import asyncio
import signal
import sys
from collections.abc import Awaitable, Callable
from types import FrameType
from typing import Any
import typer
from rich.console import Console
from nanobot.cli.runtime_config import _load_runtime_config
from nanobot import __logo__
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.log_control import _set_nanobot_logs
from nanobot.cli.runtime_config import (
_load_runtime_config,
_migrate_cron_store,
_model_display,
_print_agent_start_error,
)
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
from nanobot.config.paths import is_default_workspace
from nanobot.utils.helpers import (
sanitize_surrogates as _sanitize_surrogates,
)
from nanobot.utils.helpers import (
sync_workspace_templates,
)
from nanobot.utils.restart import (
consume_restart_notice_from_env,
format_restart_completed_message,
should_show_cli_restart_notice,
)
console = Console()
def agent(
message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str | None = typer.Option(None, "--session", "-s", help="Session ID"),
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
markdown: bool = typer.Option(
@@ -25,63 +59,294 @@ def agent(
"--logs/--no-logs",
help="Show nanobot runtime logs during chat",
),
classic: bool = typer.Option(
False,
"--classic",
help="Use the compatibility Python prompt instead of the terminal UI",
),
theme: str = typer.Option(
"auto",
"--theme",
help="Native terminal UI appearance: auto, dark, or light",
),
) -> None:
"""Chat in the terminal or send one message non-interactively."""
):
"""Interact with the agent directly."""
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.providers.factory import make_provider
from nanobot.providers.image_generation import image_gen_provider_configs
runtime_config = _load_runtime_config(config, workspace)
theme = theme.strip().lower()
if theme not in {"auto", "dark", "light"}:
raise typer.BadParameter("must be auto, dark, or light", param_hint="--theme")
if message is None and not classic:
from nanobot.cli.tui_launcher import TuiSessionError, TuiUnavailableError, launch_tui
from nanobot.config.loader import get_config_path
if not sys.stdin.isatty() or not sys.stdout.isatty():
raise typer.BadParameter(
"the native TUI requires an interactive terminal; use --message for "
"one-shot input or --classic for the compatibility prompt",
param_hint="terminal",
)
if not markdown:
raise typer.BadParameter("--no-markdown requires --classic", param_hint="--no-markdown")
if logs:
raise typer.BadParameter("--logs requires --classic", param_hint="--logs")
try:
exit_code = launch_tui(
runtime_config,
config_path=get_config_path().resolve(strict=False),
workspace_override=workspace,
session_id=session_id,
theme=theme,
)
except TuiSessionError as exc:
raise typer.BadParameter(str(exc), param_hint="--session") from exc
except TuiUnavailableError as exc:
console.print(f"[red]Native TUI unavailable: {exc}[/red]")
console.print(
"[dim]Use `nanobot agent --classic` only if you want the compatibility prompt.[/dim]"
)
provider = make_provider(runtime_config)
except ValueError as exc:
_print_agent_start_error(exc)
raise typer.Exit(1) from exc
if exit_code:
raise typer.Exit(exit_code)
sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus()
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(runtime_config.workspace_path):
_migrate_cron_store(runtime_config)
# Create cron service with workspace-scoped store
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path)
_set_nanobot_logs(logs)
try:
agent_loop = AgentLoop.from_config(
runtime_config,
bus,
provider=provider,
cron_service=cron,
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook],
)
except ValueError as exc:
_print_agent_start_error(exc)
raise typer.Exit(1) from exc
restart_notice = consume_restart_notice_from_env()
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
cli_terminal._print_agent_response(
format_restart_completed_message(restart_notice.started_at_raw),
render_markdown=False,
)
# Shared reference for progress callbacks
_thinking: ThinkingSpinner | None = None
def _make_progress(
renderer: StreamRenderer | None = None,
) -> Callable[..., Awaitable[None]]:
reasoning_buffer = cli_terminal._ReasoningBuffer()
async def _cli_progress(
content: str,
*,
tool_hint: bool = False,
reasoning: bool = False,
**_kwargs: Any,
) -> None:
ch = agent_loop.channels_config
if _kwargs.get("reasoning_end"):
if ch and not ch.show_reasoning:
reasoning_buffer.clear()
else:
cli_terminal._flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
return
from nanobot.cli.agent_runtime import run_local_agent
if reasoning:
if ch and not ch.show_reasoning:
reasoning_buffer.clear()
return
text = reasoning_buffer.add(content)
if text:
cli_terminal._print_cli_reasoning(text, _thinking, renderer)
return
if ch and tool_hint and not ch.send_tool_hints:
return
if ch and not tool_hint and not ch.send_progress:
return
cli_terminal._print_cli_progress_line(content, _thinking, renderer)
run_local_agent(
runtime_config,
message=message,
session_id=session_id or "cli:direct",
markdown=markdown,
logs=logs,
return _cli_progress
if message:
# Single message mode — direct call, no bus needed
async def run_once() -> None:
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
)
response = await agent_loop.process_direct(
message,
session_id,
on_progress=_make_progress(renderer),
on_stream=renderer.on_delta,
on_stream_end=renderer.on_end,
)
if not renderer.streamed:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer.header_printed:
print_kwargs["show_header"] = False
cli_terminal._print_agent_response(
response.content if response else "",
render_markdown=markdown,
metadata=response.metadata if response else None,
**print_kwargs,
)
await agent_loop.close_mcp()
asyncio.run(run_once())
else:
# Interactive mode — route through bus like other channels
from nanobot.bus.events import InboundMessage
cli_terminal._init_prompt_session()
_model, _preset_tag = _model_display(runtime_config)
_icon = runtime_config.agents.defaults.bot_icon or __logo__
console.print(
f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} "
"— type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n"
)
if ":" in session_id:
cli_channel, cli_chat_id = session_id.split(":", 1)
else:
cli_channel, cli_chat_id = "cli", session_id
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
sig_name = signal.Signals(signum).name
cli_terminal._restore_terminal()
console.print(f"\nReceived {sig_name}, goodbye!")
sys.exit(0)
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
# SIGHUP is not available on Windows
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, _handle_signal)
# Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
# SIGPIPE is not available on Windows
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def run_interactive() -> None:
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[Any] = []
renderer: StreamRenderer | None = None
reasoning_buffer = cli_terminal._ReasoningBuffer()
async def _consume_outbound() -> None:
while True:
try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
event = outbound_event_from_message(msg)
if isinstance(event, StreamDeltaEvent):
if renderer:
await renderer.on_delta(msg.content)
continue
if isinstance(event, StreamEndEvent):
if renderer:
await renderer.on_end(
resuming=event.resuming,
)
continue
if isinstance(event, StreamedResponseEvent):
if msg.content and renderer and not renderer.streamed:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer.header_printed:
print_kwargs["show_header"] = False
cli_terminal._print_agent_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
**print_kwargs,
)
turn_done.set()
continue
if await cli_terminal._maybe_print_interactive_progress(
msg,
None,
agent_loop.channels_config,
renderer,
reasoning_buffer,
):
continue
if not turn_done.is_set():
if msg.content:
turn_response.append(msg)
turn_done.set()
elif msg.content:
await cli_terminal._print_interactive_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
outbound_task = asyncio.create_task(_consume_outbound())
try:
while True:
try:
cli_terminal._flush_pending_tty_input()
# Stop spinner before user input to avoid prompt_toolkit conflicts
if renderer:
renderer.stop_for_input()
user_input = _sanitize_surrogates(
await cli_terminal._read_interactive_input_async()
)
command = user_input.strip()
if not command:
continue
if cli_terminal._is_exit_command(command):
cli_terminal._restore_terminal()
console.print("\nGoodbye!")
break
turn_done.clear()
turn_response.clear()
reasoning_buffer.clear()
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
)
await bus.publish_inbound(
InboundMessage(
channel=cli_channel,
sender_id="user",
chat_id=cli_chat_id,
content=user_input,
metadata={"_wants_stream": True},
)
)
await turn_done.wait()
if turn_response:
response_msg = turn_response[0]
content = response_msg.content
meta = response_msg.metadata
if content and not isinstance(
response_msg.event,
StreamedResponseEvent,
):
if renderer:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer and renderer.header_printed:
print_kwargs["show_header"] = False
cli_terminal._print_agent_response(
content,
render_markdown=markdown,
metadata=meta,
**print_kwargs,
)
elif renderer and not renderer.streamed:
await renderer.close()
except KeyboardInterrupt:
cli_terminal._restore_terminal()
console.print("\nGoodbye!")
break
except EOFError:
cli_terminal._restore_terminal()
console.print("\nGoodbye!")
break
finally:
agent_loop.stop()
outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
await agent_loop.close_mcp()
asyncio.run(run_interactive())
-308
View File
@@ -1,308 +0,0 @@
"""Python runtime for one-shot agent calls and the compatibility prompt."""
import asyncio
import signal
import sys
from types import FrameType
from typing import Any
import typer
from nanobot import __logo__
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.bus.queue import MessageBus
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.log_control import _set_nanobot_logs
from nanobot.cli.runtime_config import (
_migrate_cron_store,
_model_display,
_print_agent_start_error,
)
from nanobot.cli.stream import StreamRenderer
from nanobot.config.paths import is_default_workspace
from nanobot.config.schema import Config
from nanobot.cron.service import CronService
from nanobot.providers.factory import make_provider
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.utils.helpers import sanitize_surrogates, sync_workspace_templates
from nanobot.utils.restart import (
consume_restart_notice_from_env,
format_restart_completed_message,
should_show_cli_restart_notice,
)
def run_local_agent(
config: Config,
*,
message: str | None,
session_id: str,
markdown: bool,
logs: bool,
) -> None:
"""Run without the gateway: once for a message, otherwise as the classic prompt."""
runtime = _LocalAgent(config, logs=logs, session_id=session_id)
if message is not None:
asyncio.run(runtime.run_once(message, session_id=session_id, markdown=markdown))
else:
runtime.run_classic(session_id=session_id, markdown=markdown)
class _LocalAgent:
def __init__(self, config: Config, *, logs: bool, session_id: str) -> None:
self.config = config
try:
provider = make_provider(config)
except ValueError as exc:
_print_agent_start_error(exc)
raise typer.Exit(1) from exc
sync_workspace_templates(config.workspace_path)
if is_default_workspace(config.workspace_path):
_migrate_cron_store(config)
self.bus = MessageBus()
tools = ToolRegistry()
self.mcp = MCPProvider.from_config(config, tools)
_set_nanobot_logs(logs)
try:
self.loop = AgentLoop.from_config(
config,
self.bus,
provider=provider,
cron_service=CronService(config.workspace_path / "cron" / "jobs.json"),
image_generation_provider_configs=image_gen_provider_configs(config),
hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
)
except ValueError as exc:
_print_agent_start_error(exc)
raise typer.Exit(1) from exc
notice = consume_restart_notice_from_env()
if notice and should_show_cli_restart_notice(notice, session_id):
cli_terminal._print_agent_response(
format_restart_completed_message(notice.started_at_raw),
render_markdown=False,
)
async def close(self) -> None:
try:
await self.loop.aclose()
finally:
await self.mcp.aclose()
def renderer(self, markdown: bool) -> StreamRenderer:
return StreamRenderer(
render_markdown=markdown,
bot_name=self.config.agents.defaults.bot_name,
bot_icon=self.config.agents.defaults.bot_icon,
)
async def run_once(self, message: str, *, session_id: str, markdown: bool) -> None:
try:
await self.mcp.connect()
renderer = self.renderer(markdown)
reasoning_buffer = cli_terminal._ReasoningBuffer()
async def report(
content: str,
*,
tool_hint: bool = False,
reasoning: bool = False,
**kwargs: Any,
) -> None:
channel_config = self.loop.channels_config
if kwargs.get("reasoning_end"):
if channel_config and not channel_config.show_reasoning:
reasoning_buffer.clear()
else:
cli_terminal._flush_cli_reasoning(reasoning_buffer, None, renderer)
return
if reasoning:
if channel_config and not channel_config.show_reasoning:
reasoning_buffer.clear()
return
text = reasoning_buffer.add(content)
if text:
cli_terminal._print_cli_reasoning(text, None, renderer)
return
if channel_config and tool_hint and not channel_config.send_tool_hints:
return
if channel_config and not tool_hint and not channel_config.send_progress:
return
cli_terminal._print_cli_progress_line(content, None, renderer)
response = await self.loop.process_direct(
message,
session_id,
on_progress=report,
on_stream=renderer.on_delta,
on_stream_end=renderer.on_end,
)
if renderer.streamed:
return
await renderer.close()
cli_terminal._print_agent_response(
response.content if response else "",
render_markdown=markdown,
metadata=response.metadata if response else None,
**({"show_header": False} if renderer.header_printed else {}),
)
finally:
await self.close()
def run_classic(self, *, session_id: str, markdown: bool) -> None:
cli_terminal._init_prompt_session()
model, preset_tag = _model_display(self.config)
icon = self.config.agents.defaults.bot_icon or __logo__
cli_terminal.console.print(
f"{icon} Interactive mode [bold blue]({model})[/bold blue]{preset_tag} "
"— type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n"
)
channel, chat_id = (
session_id.split(":", 1) if ":" in session_id else ("cli", session_id)
)
self._install_signal_handlers()
asyncio.run(self._run_classic_loop(channel, chat_id, markdown=markdown))
@staticmethod
def _install_signal_handlers() -> None:
def exit_on_signal(signum: int, _frame: FrameType | None) -> None:
cli_terminal._restore_terminal()
cli_terminal.console.print(f"\nReceived {signal.Signals(signum).name}, goodbye!")
sys.exit(0)
signal.signal(signal.SIGINT, exit_on_signal)
signal.signal(signal.SIGTERM, exit_on_signal)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, exit_on_signal)
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def _run_classic_loop(self, channel: str, chat_id: str, *, markdown: bool) -> None:
await self.mcp.connect()
bus_task = asyncio.create_task(self.loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[OutboundMessage] = []
renderer: StreamRenderer | None = None
reasoning_buffer = cli_terminal._ReasoningBuffer()
async def consume_outbound() -> None:
while True:
try:
msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0)
event = outbound_event_from_message(msg)
if isinstance(event, StreamDeltaEvent):
if renderer:
await renderer.on_delta(msg.content)
continue
if isinstance(event, StreamEndEvent):
if renderer:
await renderer.on_end(resuming=event.resuming)
continue
if isinstance(event, StreamedResponseEvent):
if msg.content and renderer and not renderer.streamed:
await renderer.close()
cli_terminal._print_agent_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
**({"show_header": False} if renderer.header_printed else {}),
)
turn_done.set()
continue
if await cli_terminal._maybe_print_interactive_progress(
msg,
None,
self.loop.channels_config,
renderer,
reasoning_buffer,
):
continue
if not turn_done.is_set():
if msg.content:
turn_response.append(msg)
turn_done.set()
elif msg.content:
await cli_terminal._print_interactive_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
outbound_task = asyncio.create_task(consume_outbound())
try:
while True:
try:
cli_terminal._flush_pending_tty_input()
if renderer:
renderer.stop_for_input()
user_input = sanitize_surrogates(
await cli_terminal._read_interactive_input_async()
)
command = user_input.strip()
if not command:
continue
if cli_terminal._is_exit_command(command):
cli_terminal._restore_terminal()
cli_terminal.console.print("\nGoodbye!")
break
turn_done.clear()
turn_response.clear()
reasoning_buffer.clear()
renderer = self.renderer(markdown)
await self.bus.publish_inbound(
InboundMessage(
channel=channel,
sender_id="user",
chat_id=chat_id,
content=user_input,
metadata={"_wants_stream": True},
)
)
await turn_done.wait()
if turn_response:
response = turn_response[0]
if response.content and not isinstance(
response.event, StreamedResponseEvent
):
if renderer:
await renderer.close()
cli_terminal._print_agent_response(
response.content,
render_markdown=markdown,
metadata=response.metadata,
**(
{"show_header": False}
if renderer and renderer.header_printed
else {}
),
)
elif renderer and not renderer.streamed:
await renderer.close()
except (KeyboardInterrupt, EOFError):
cli_terminal._restore_terminal()
cli_terminal.console.print("\nGoodbye!")
break
finally:
self.loop.stop()
outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
await self.close()
+2 -49
View File
@@ -49,8 +49,6 @@ from nanobot import __logo__, __version__ # noqa: E402
from nanobot import optional_features as feature_support # noqa: E402
from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.agent.tools.mcp import MCPProvider # noqa: E402
from nanobot.agent.tools.registry import ToolRegistry # noqa: E402
from nanobot.cli import terminal as cli_terminal # noqa: E402
from nanobot.cli.agent import agent # noqa: E402
from nanobot.cli.gateway import create_gateway_app # noqa: E402
@@ -353,15 +351,12 @@ def serve(
sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus()
session_manager = SessionManager(runtime_config.workspace_path)
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(runtime_config, tools)
try:
agent_loop = AgentLoop.from_config(
runtime_config, bus,
session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
@@ -383,17 +378,13 @@ def serve(
api_app = create_app(
agent_loop, model_name=model_name, request_timeout=timeout,
api_key=api_key,
prepare_agent=mcp_provider.connect,
)
async def on_startup(_app: Any) -> None:
await mcp_provider.connect()
await agent_loop._connect_mcp()
async def on_cleanup(_app: Any) -> None:
try:
await agent_loop.aclose()
finally:
await mcp_provider.aclose()
await agent_loop.close_mcp()
api_app.on_startup.append(on_startup)
api_app.on_cleanup.append(on_cleanup)
@@ -440,44 +431,6 @@ app.add_typer(
app.command(name="agent")(agent)
# ============================================================================
# Session Commands
# ============================================================================
sessions_app = typer.Typer(help="Manage persisted session history")
app.add_typer(sessions_app, name="sessions")
@sessions_app.command("restore-workspace")
def sessions_restore_workspace(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
) -> None:
"""Copy sessions back into the workspace before downgrading nanobot."""
from nanobot.session.manager import SessionManager
runtime_config = _load_runtime_config(config, workspace)
data_dir = runtime_config.runtime_data_dir
manager = SessionManager(
runtime_config.workspace_path,
sessions_root=data_dir / "sessions" if data_dir is not None else None,
)
result = manager.restore_sessions_to_workspace()
console.print(
f"Restored {result.restored} session file(s) to "
f"{escape(str(runtime_config.workspace_path / 'sessions'))}; "
f"{result.unchanged} already matched."
)
if result.conflicts:
console.print(
"[red]Rollback is incomplete: existing or invalid files require manual review.[/red]"
)
for path in result.conflicts:
console.print(Text(f"- {path}", style="red"))
raise typer.Exit(1)
# ============================================================================
# Channel Commands
# ============================================================================
+21 -105
View File
@@ -14,9 +14,8 @@ from rich.console import Console
from nanobot.config.schema import Config
from nanobot.gateway import (
GatewayAlreadyRunningError,
GatewayInstance,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
GatewayStatus,
)
@@ -36,15 +35,6 @@ GatewayServiceFactory = Callable[[], Any]
WebUIBundlePreparer = Callable[[Config, BuildMode], None]
def _resolved_config_selector(config: str | None) -> Path:
"""Return the one canonical config identity used by every local client."""
if config:
return Path(config).expanduser().resolve(strict=False)
from nanobot.config.loader import get_config_path
return get_config_path().resolve(strict=False)
def create_gateway_app(
*,
console: Console,
@@ -79,21 +69,19 @@ def create_gateway_app(
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
def instance_for_selectors(
*,
workspace: str | None = None,
config: str | None = None,
) -> GatewayInstance:
return GatewayInstance.resolve(
config_path=_resolved_config_selector(config),
workspace=workspace,
)
def runtime_for_instance(*, workspace: str | None = None, config: str | None = None):
if runtime_factory is not None:
return runtime_factory(workspace=workspace, config=config)
instance = instance_for_selectors(workspace=workspace, config=config)
return GatewayRuntime(paths=instance.paths)
config_path = str(Path(config).expanduser().resolve(strict=False)) if config else None
workspace_path = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
data_dir = Path(config_path).parent if config_path else None
return GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=data_dir,
workspace=workspace_path,
config_path=config_path,
)
)
def service_installer():
return service_factory() if service_factory is not None else GatewayServiceInstaller()
@@ -112,12 +100,13 @@ def create_gateway_app(
loaded_config: Config | None = None,
) -> GatewayStartOptions:
cfg = loaded_config or load_runtime_config(config, workspace)
return instance_for_selectors(
workspace=workspace,
config=config,
).start_options(
resolved_config = str(Path(config).expanduser().resolve()) if config else None
resolved_workspace = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
return GatewayStartOptions(
port=port if port is not None else cfg.gateway.port,
verbose=verbose,
workspace=resolved_workspace,
config_path=resolved_config,
)
def print_status(status: GatewayStatus) -> None:
@@ -129,10 +118,6 @@ def create_gateway_app(
console.print(f"Port: {status.port}")
if status.started_at is not None:
console.print(f"Started At: {status.started_at}")
if status.running:
console.print(f"Launch Mode: {status.launch_mode}")
console.print(f"Lifetime: {status.lifetime}")
console.print(f"Clients: {status.clients}")
console.print(f"State: {status.state_path}")
console.print(f"Logs: {status.log_path}")
@@ -181,55 +166,9 @@ def create_gateway_app(
loaded_config=cfg,
)
)
if (
result.message == "gateway_already_running"
and result.status.launch_mode == "foreground"
):
console.print(
"[yellow]Gateway is already running in the foreground; "
"an attached process cannot be detached in place.[/yellow]"
)
console.print(
"[dim]Stop it in its current terminal, then run "
"`nanobot gateway --background`.[/dim]"
)
print_status(result.status)
raise typer.Exit(1)
if (
result.message == "gateway_already_running"
and result.status.launch_mode == "unknown"
and result.status.lifetime == "explicit"
):
console.print(
"[yellow]Gateway is already running, but this older process did "
"not record whether it is attached or detached.[/yellow]"
)
console.print(
"[dim]Stop it first, then rerun `nanobot gateway --background` "
"to establish an unambiguous lifecycle.[/dim]"
)
print_status(result.status)
raise typer.Exit(1)
if result.ok:
console.print("[green]Gateway started in the background.[/green]")
print_status(runtime.status())
return
if result.message == "gateway_already_running":
if result.promoted:
console.print(
"[green]Existing on-demand gateway promoted to persistent "
"background mode.[/green]"
)
console.print(
"[dim]It will keep running after all local clients exit; "
"use `nanobot gateway stop` to stop it.[/dim]"
)
else:
console.print(
"[yellow]Gateway is already running in persistent "
"background mode.[/yellow]"
)
print_status(runtime.status())
print_status(result.status)
return
console.print(f"[yellow]Gateway was not started: {result.message}[/yellow]")
print_status(result.status)
@@ -237,22 +176,18 @@ def create_gateway_app(
configure_logging(verbose)
cfg = load_runtime_config(config, workspace)
instance = instance_for_selectors(workspace=workspace, config=config)
unconfigured_provider_error = None
if validate_startup_config is not None:
unconfigured_provider_error = validate_startup_config(cfg)
try:
if unconfigured_provider_error is None:
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
else:
run_gateway(
cfg,
port=port,
webui_bundle_mode=interactive_build_mode(),
unconfigured_provider_error=unconfigured_provider_error,
gateway_instance=instance,
)
except GatewayAlreadyRunningError as exc:
console.print("[yellow]Gateway is already running.[/yellow]")
print_status(exc.status)
raise typer.Exit(1) from None
@gateway_app.command("status")
def gateway_status( # pyright: ignore[reportUnusedFunction]
@@ -287,8 +222,7 @@ def create_gateway_app(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
) -> None:
"""Stop the background gateway."""
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.stop(timeout_s=timeout)
result = runtime_for_instance(workspace=workspace, config=config).stop(timeout_s=timeout)
if result.ok:
console.print("[green]Gateway stopped.[/green]")
else:
@@ -326,24 +260,6 @@ def create_gateway_app(
console.print("[green]Gateway restarted in the background.[/green]")
print_status(result.status)
return
if result.message == "gateway_not_running":
console.print("[yellow]Gateway is not running; there is nothing to restart.[/yellow]")
console.print(
"[dim]Start a persistent gateway with `nanobot gateway --background`.[/dim]"
)
print_status(result.status)
raise typer.Exit(1)
if result.message == "gateway_foreground_restart_required":
console.print(
"[yellow]Gateway is attached to a foreground terminal and cannot "
"be restarted as a background process.[/yellow]"
)
console.print(
"[dim]Restart it in that terminal, or stop it and run "
"`nanobot gateway --background`.[/dim]"
)
print_status(result.status)
raise typer.Exit(1)
console.print(f"[red]Gateway restart failed: {result.message}[/red]")
print_status(result.status)
raise typer.Exit(1)
+26 -68
View File
@@ -14,8 +14,6 @@ from rich.console import Console
from nanobot import __logo__, __version__
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.runtime_config import _migrate_cron_store
from nanobot.cli.webui_support import (
@@ -32,7 +30,6 @@ from nanobot.cli.webui_support import (
)
from nanobot.config.paths import is_default_workspace
from nanobot.config.schema import Config
from nanobot.gateway.runtime import GatewayInstance
from nanobot.security.network import is_loopback_host
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
@@ -236,7 +233,6 @@ def _print_gateway_health_endpoint(host: str, port: int) -> None:
async def _close_gateway_runtime(
agent: AgentLoop,
mcp_provider: MCPProvider,
channels: Any,
tasks: list[asyncio.Task[Any]],
runtime_tasks: asyncio.Future[list[Any]] | None,
@@ -244,13 +240,18 @@ async def _close_gateway_runtime(
task_wait_timeout: float = 15.0,
close_timeout: float = 15.0,
) -> None:
"""Cancel runtime tasks, then deterministically close application resources.
"""Cancel runtime tasks, then deterministically close agent resources.
Order matters: runtime tasks (including the agent loop and any in-flight
turn) are cancelled and awaited -- bounded -- before the loop-owned resources
and the application-owned MCP provider are torn down. The final close is
bounded and idempotent, so it also covers a cancelled or incomplete loop
cleanup without leaving subprocess transports alive past ``loop.close()``.
turn) are cancelled and awaited -- bounded -- before exec sessions,
subagents, and MCP servers are torn down, so no active turn is using a
shared resource when it closes. The final close is bounded and idempotent:
the agent loop's own finally also calls ``close_mcp()``, so this runs again
as a no-op when that path already completed, and as the guaranteed final
close when it was skipped or cut short (which previously left asyncio
subprocess transports alive past ``loop.close()``, producing
"RuntimeError: Event loop is closed" noise and potentially orphaned
processes at interpreter exit).
"""
# Some SDKs swallow task cancellation while attempting to reconnect.
# Close channel transports before waiting for their runners to exit.
@@ -271,14 +272,10 @@ async def _close_gateway_runtime(
task.cancel()
if runtime_tasks is not None and not runtime_tasks.done():
runtime_tasks.cancel()
for label, close in (
("agent", agent.aclose),
("MCP provider", mcp_provider.aclose),
):
try:
await asyncio.wait_for(close(), timeout=close_timeout)
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout)
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
logger.warning("Gateway shutdown: {} cleanup incomplete: {}", label, exc)
logger.warning("Gateway shutdown: agent resource cleanup incomplete: {}", exc)
# Retrieving an already-finished gather prevents noisy unhandled exceptions,
# but never wait for it here: its children were bounded individually above.
if runtime_tasks is not None and runtime_tasks.done():
@@ -299,7 +296,6 @@ def _run_gateway(
health_server_enabled: bool = True,
unconfigured_provider_error: str | None = None,
webui_dev_server: WebUIDevServer | None = None,
gateway_instance: GatewayInstance | None = None,
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.model_presets import load_model_preset_catalog
@@ -389,20 +385,19 @@ def _run_gateway(
raise typer.Exit(1) from exc
session_manager = SessionManager(config.workspace_path)
# Use the same runtime identity for foreground and managed gateway processes.
# Self-heal the gateway state file with the current PID after any restart.
from nanobot.config.loader import get_config_path
from nanobot.gateway.runtime import (
GatewayClientLease,
GatewayRuntime,
monitor_gateway_clients,
)
from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths
instance = gateway_instance or GatewayInstance.resolve(
config_path=get_config_path(),
config_path = str(get_config_path().resolve(strict=False))
GatewayRuntime.refresh_state_pid(
paths=GatewayRuntimePaths.for_instance(
workspace=str(config.workspace_path)
if not is_default_workspace(config.workspace_path)
else None,
config_path=config_path,
)
)
config_path = str(instance.config_path)
gateway_runtime = GatewayRuntime(paths=instance.paths)
gateway_start_options = instance.start_options(port=port)
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path):
@@ -419,9 +414,6 @@ def _run_gateway(
route_policy=WebuiTurnRoutePolicy(session_manager),
)
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(config, tools)
# Create agent with cron service
agent = AgentLoop.from_config(
config, bus,
@@ -439,7 +431,6 @@ def _run_gateway(
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
)
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
@@ -521,7 +512,6 @@ def _run_gateway(
prompt, last_cursor = result
key = dream_session_key()
dream_runtime = agent.dream_runtime()
await mcp_provider.connect()
resp = await agent.process_direct(
prompt,
session_key=key,
@@ -569,7 +559,7 @@ def _run_gateway(
if sha:
logger.info("Dream commit: {}", sha)
store.compact_history()
prune_dream_sessions(agent.sessions)
prune_dream_sessions(agent.sessions.sessions_dir)
return None
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
@@ -599,7 +589,6 @@ def _run_gateway(
if isinstance(message_tool, MessageTool):
suppress_token = message_tool.set_suppress_delivery(True)
try:
await mcp_provider.connect()
resp = await agent.process_direct(
prompt,
session_key="heartbeat",
@@ -660,9 +649,6 @@ def _run_gateway(
def _webui_runtime_model_name() -> str | None:
return agent.model.strip() or None
def _webui_refresh_runtime_config() -> None:
agent.refresh_runtime_config()
def _webui_skill_state_action(disabled_skills: set[str]) -> None:
config.agents.defaults.disabled_skills = sorted(disabled_skills)
agent.context.skills.disabled_skills = set(disabled_skills)
@@ -677,14 +663,12 @@ def _run_gateway(
cron_service=cron,
local_trigger_store=trigger_store,
webui_runtime_model_name=_webui_runtime_model_name,
webui_refresh_runtime_config=_webui_refresh_runtime_config,
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
webui_mcp_runtime_status=mcp_provider.runtime_status,
webui_mcp_reload=mcp_provider.reload,
webui_mcp_runtime_status=agent.mcp_runtime_status,
webui_skill_state_action=_webui_skill_state_action,
config_path=Path(config_path),
)
@@ -860,21 +844,6 @@ def _run_gateway(
await cron.start()
# Re-read once on first admission to close the watcher subscription window.
agent.runtime_resolver.invalidate()
async def _run_agent() -> None:
try:
await mcp_provider.connect()
await agent.run()
finally:
await mcp_provider.aclose()
async def _monitor_local_clients() -> None:
orphaned = await monitor_gateway_clients(
GatewayClientLease(gateway_runtime, kind="gateway-monitor"),
shutdown_event,
)
if orphaned:
logger.info("Last local client disappeared; stopping on-demand gateway")
tasks = [
asyncio.create_task(
watch_config_file(
@@ -883,7 +852,7 @@ def _run_gateway(
),
name="nanobot-config-watcher",
),
asyncio.create_task(_run_agent(), name="nanobot-agent-loop"),
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
asyncio.create_task(
run_local_trigger_queue(
@@ -893,10 +862,6 @@ def _run_gateway(
),
name="nanobot-local-triggers",
),
asyncio.create_task(
_monitor_local_clients(),
name="nanobot-gateway-client-monitor",
),
]
if health_server_enabled:
tasks.append(asyncio.create_task(
@@ -945,13 +910,7 @@ def _run_gateway(
agent.stop()
# Cancel runtime tasks first, then deterministically close
# exec/MCP resources while the event loop is still alive.
await _close_gateway_runtime(
agent,
mcp_provider,
channels,
tasks,
runtime_tasks,
)
await _close_gateway_runtime(agent, channels, tasks, runtime_tasks)
# Flush all cached sessions to durable storage before exit.
# This prevents data loss on filesystems with write-back
# caching (rclone VFS, NFS, FUSE mounts, etc.).
@@ -961,5 +920,4 @@ def _run_gateway(
finally:
restore_shutdown_handlers()
with gateway_runtime.foreground_instance(gateway_start_options):
asyncio.run(run())
+1
View File
@@ -1594,6 +1594,7 @@ def _pause(message: str = "Press Enter to continue...") -> None:
def _set_primary_quick_start_preset(config: Config, provider_name: str, model: str) -> None:
"""Store the primary preset used by Quick Start."""
config.model_presets["primary"] = ModelPresetConfig(
label="Primary",
model=model,
provider=provider_name,
)
-497
View File
@@ -1,497 +0,0 @@
"""Launch the TypeScript terminal client against the local gateway."""
from __future__ import annotations
import hashlib
import io
import json
import os
import platform
import shutil
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from nanobot import __version__
from nanobot.cli.runtime_config import _model_display
from nanobot.cli.webui_support import (
_gateway_health_ready,
_webui_browser_url,
_webui_endpoint_reachable,
webui_bootstrap_secret,
)
from nanobot.config.paths import get_data_dir
from nanobot.config.schema import Config
if TYPE_CHECKING:
from nanobot.gateway import GatewayClientLease
class TuiUnavailableError(RuntimeError):
"""Raised when the native TypeScript TUI cannot run on this installation."""
class TuiSessionError(ValueError):
"""Raised when a session selector cannot be opened by the native TUI."""
_TUI_RELEASE_FILES = (
"THIRD_PARTY_NOTICES.txt",
"RELINKING.md",
"SOURCE_OFFER.md",
"LICENSE",
"BUN-1.3.13-LICENSE.md",
"LGPL-2.0.txt",
"LGPL-2.1.txt",
"nanobot-tui-source.tar.gz",
)
_TUI_RELEASE_LIMITS = {
"THIRD_PARTY_NOTICES.txt": 4 * 1024 * 1024,
"RELINKING.md": 256 * 1024,
"SOURCE_OFFER.md": 256 * 1024,
"LICENSE": 256 * 1024,
"BUN-1.3.13-LICENSE.md": 1024 * 1024,
"LGPL-2.0.txt": 256 * 1024,
"LGPL-2.1.txt": 256 * 1024,
"nanobot-tui-source.tar.gz": 20 * 1024 * 1024,
"MANIFEST.sha256": 64 * 1024,
}
@dataclass(frozen=True)
class _GatewayHandle:
base_url: str
lease: GatewayClientLease | None = None
def launch_tui(
config: Config,
*,
config_path: Path,
workspace_override: str | None,
session_id: str | None,
theme: str,
) -> int:
"""Run the native TUI against the shared local gateway."""
state_path = config_path.parent / "tui" / "state.json"
chat_id = _initial_tui_chat_id(session_id, state_path)
command = _resolve_tui_command()
gateway = _ensure_gateway(
config,
config_path=config_path,
workspace_override=workspace_override,
)
try:
bootstrap = _fetch_bootstrap(
gateway.base_url,
secret=webui_bootstrap_secret(config),
)
env = os.environ.copy()
env.update(
{
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
"NANOBOT_TUI_API_URL": gateway.base_url,
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
"NANOBOT_TUI_MODEL": _model_display(config)[0],
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
"NANOBOT_TUI_VERSION": __version__,
"NANOBOT_TUI_ACCESS": (
"workspace access" if config.tools.restrict_to_workspace else "full access"
),
"NANOBOT_TUI_THEME": theme,
}
)
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
if chat_id:
env["NANOBOT_TUI_CHAT_ID"] = chat_id
else:
env.pop("NANOBOT_TUI_CHAT_ID", None)
return subprocess.run(command, env=env, check=False).returncode
except OSError as exc:
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
finally:
lease = getattr(gateway, "lease", None)
if lease is not None:
lease.release()
def _resolve_tui_command() -> list[str]:
override = os.environ.get("NANOBOT_TUI_BIN", "").strip()
if override:
executable = Path(override).expanduser().resolve(strict=False)
if not executable.is_file():
raise TuiUnavailableError(f"NANOBOT_TUI_BIN does not exist: {executable}")
return [str(executable)]
suffix = ".exe" if os.name == "nt" else ""
system = {"Windows": "win32", "Darwin": "darwin", "Linux": "linux"}.get(
platform.system(),
platform.system().lower(),
)
machine = {"x86_64": "x64", "AMD64": "x64", "aarch64": "arm64"}.get(
platform.machine(),
platform.machine().lower(),
)
if system == "win32" and machine == "arm64":
raise TuiUnavailableError(
"the native TUI is not available on Windows ARM64 because Bun FFI is disabled "
"on that platform; use the classic prompt until the upstream runtime supports it"
)
asset = f"nanobot-tui-{system}-{machine}{suffix}"
source_dir = _source_checkout_tui_dir()
if source_dir is not None:
bun = shutil.which("bun")
if not bun:
raise TuiUnavailableError(
"this source checkout requires Bun to run its matching TUI; "
"install Bun, then run `nanobot agent` again"
)
return _resolve_source_tui_command(source_dir, bun)
packaged = Path(__file__).resolve().parents[1] / "tui" / "bin" / asset
if packaged.is_file():
return [str(packaged)]
downloaded = _download_release_tui(asset)
if downloaded is not None:
return [str(downloaded)]
raise TuiUnavailableError(
f"no native TUI archive is published for nanobot {__version__} on this platform; "
"current source installs must be editable and keep their checkout and Bun available, "
"while released packages need a matching GitHub release archive; use "
"`nanobot agent --classic` if intentional"
)
def _source_checkout_tui_dir() -> Path | None:
"""Return this checkout's TUI source, never a neighboring unrelated directory."""
return _tui_source_dir(Path(__file__).resolve().parents[2])
def _tui_source_dir(project_root: Path) -> Path | None:
project_root = project_root.resolve(strict=False)
source_dir = project_root / "tui"
if (project_root / "pyproject.toml").is_file() and (source_dir / "package.json").is_file():
return source_dir
return None
def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
dependency = source_dir / "node_modules" / "@opentui" / "core"
try:
install = subprocess.run(
[bun, "install", "--frozen-lockfile"],
cwd=source_dir,
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
raise TuiUnavailableError(f"could not install TUI dependencies: {exc}") from exc
if install.returncode != 0 or not dependency.is_dir():
detail = (install.stderr or install.stdout).strip().splitlines()
suffix = f": {detail[-1]}" if detail else ""
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
return [bun, str(source_dir / "src" / "index.ts")]
def _download_release_tui(asset: str) -> Path | None:
"""Install the complete, version-matched TUI release bundle."""
if os.environ.get("NANOBOT_TUI_NO_DOWNLOAD") == "1":
return None
version = __version__.strip()
if not version or version.endswith((".dev0", "+dev")):
return None
target_dir = get_data_dir() / "bin" / "tui" / version
cached = _cached_release_tui(target_dir, asset)
if cached is not None:
return cached
base = f"https://github.com/HKUDS/nanobot/releases/download/v{version}"
archive_name = f"{asset}.zip"
try:
checksum = _read_release_asset(f"{base}/{archive_name}.sha256", max_bytes=1024)
expected = _release_checksum(checksum, archive_name)
if expected is None:
return None
archive = _read_release_asset(f"{base}/{archive_name}", max_bytes=200 * 1024 * 1024)
except (OSError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError):
return None
if hashlib.sha256(archive).hexdigest() != expected:
raise TuiUnavailableError("downloaded TUI archive failed checksum verification")
files = _verified_release_archive(archive, asset)
temporary: dict[str, Path] = {}
try:
target_dir.mkdir(parents=True, exist_ok=True)
for name, content in files.items():
path = target_dir / name
pending = path.with_name(f"{path.name}.tmp-{os.getpid()}")
pending.write_bytes(content)
if name == asset and os.name != "nt":
pending.chmod(0o755)
temporary[name] = pending
for name in _release_bundle_names(asset):
temporary[name].replace(target_dir / name)
except OSError:
for path in temporary.values():
path.unlink(missing_ok=True)
_clear_cached_release(target_dir, asset)
return None
return target_dir / asset
def _release_bundle_names(asset: str) -> tuple[str, ...]:
return (asset, *_TUI_RELEASE_FILES, "MANIFEST.sha256")
def _release_checksum(raw: bytes, archive_name: str) -> str | None:
try:
parts = raw.decode("utf-8").split()
except UnicodeDecodeError:
return None
if len(parts) != 2 or parts[1] != archive_name:
return None
digest = parts[0].lower()
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
return None
return digest
def _release_manifest(raw: bytes, asset: str) -> dict[str, str]:
expected_names = set(_release_bundle_names(asset)[:-1])
try:
lines = raw.decode("utf-8").splitlines()
except UnicodeDecodeError as exc:
raise TuiUnavailableError("TUI release manifest is not valid UTF-8") from exc
checksums: dict[str, str] = {}
for line in lines:
digest, separator, name = line.partition(" ")
digest = digest.lower()
if (
separator != " "
or name not in expected_names
or name in checksums
or len(digest) != 64
or any(character not in "0123456789abcdef" for character in digest)
):
raise TuiUnavailableError("TUI release manifest is malformed")
checksums[name] = digest
if set(checksums) != expected_names:
raise TuiUnavailableError("TUI release manifest is incomplete")
return checksums
def _verified_release_archive(raw: bytes, asset: str) -> dict[str, bytes]:
expected_names = set(_release_bundle_names(asset))
files: dict[str, bytes] = {}
try:
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
entries = archive.infolist()
names = [entry.filename for entry in entries if not entry.is_dir()]
if len(names) != len(entries) or len(names) != len(set(names)):
raise TuiUnavailableError("TUI release archive contains invalid entries")
if set(names) != expected_names:
raise TuiUnavailableError("TUI release archive is incomplete")
for entry in entries:
limit = 150 * 1024 * 1024 if entry.filename == asset else _TUI_RELEASE_LIMITS[
entry.filename
]
if entry.file_size == 0 or entry.file_size > limit:
raise TuiUnavailableError(
f"TUI release file has an invalid size: {entry.filename}"
)
files[entry.filename] = archive.read(entry)
except zipfile.BadZipFile as exc:
raise TuiUnavailableError("downloaded TUI archive is not a valid ZIP file") from exc
checksums = _release_manifest(files["MANIFEST.sha256"], asset)
for name, expected in checksums.items():
if hashlib.sha256(files[name]).hexdigest() != expected:
raise TuiUnavailableError(f"TUI release file failed verification: {name}")
return files
def _cached_release_tui(target_dir: Path, asset: str) -> Path | None:
target = target_dir / asset
manifest = target_dir / "MANIFEST.sha256"
if not target.is_file() and not manifest.exists():
return None
try:
checksums = _release_manifest(manifest.read_bytes(), asset)
for name, expected in checksums.items():
if hashlib.sha256((target_dir / name).read_bytes()).hexdigest() != expected:
raise OSError("cached release checksum mismatch")
if os.name != "nt":
target.chmod(0o755)
except (OSError, TuiUnavailableError):
_clear_cached_release(target_dir, asset)
return None
return target
def _clear_cached_release(target_dir: Path, asset: str) -> None:
for name in _release_bundle_names(asset):
try:
(target_dir / name).unlink(missing_ok=True)
except OSError:
pass
def _read_release_asset(url: str, *, max_bytes: int) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": f"nanobot/{__version__}"})
with urllib.request.urlopen(request, timeout=5) as response:
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > max_bytes:
raise OSError("release asset exceeds size limit")
body = response.read(max_bytes + 1)
if len(body) > max_bytes:
raise OSError("release asset exceeds size limit")
return body
def _ensure_gateway(
config: Config,
*,
config_path: Path,
workspace_override: str | None,
) -> _GatewayHandle:
from nanobot.gateway import (
GatewayClientLease,
GatewayInstance,
GatewayRuntime,
)
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
instance = GatewayInstance.resolve(
config_path=config_path,
workspace=workspace_override,
)
runtime = GatewayRuntime(paths=instance.paths)
lease = GatewayClientLease(runtime, kind="tui")
lease.acquire()
try:
status = runtime.status()
endpoint_reachable = _webui_endpoint_reachable(base_url)
if status.running:
if status.port not in {None, config.gateway.port}:
raise TuiUnavailableError(
"the matching gateway instance is running on a different port; "
"restart it or use `nanobot agent --classic`"
)
if endpoint_reachable:
return _GatewayHandle(base_url=base_url, lease=lease)
elif endpoint_reachable:
raise TuiUnavailableError(
"the configured gateway port belongs to a different nanobot instance; "
"stop that instance or use `nanobot agent --classic`"
)
result = lease.ensure_on_demand_gateway(
instance.start_options(port=config.gateway.port)
)
if not result.ok and result.message != "gateway_already_running":
raise TuiUnavailableError(
f"could not start the local gateway ({result.message}); "
f"logs: {result.status.log_path}"
)
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if _webui_endpoint_reachable(base_url):
current = runtime.status()
if current.running and current.port in {None, config.gateway.port}:
return _GatewayHandle(base_url=base_url, lease=lease)
break
if not runtime.status().running and not _gateway_health_ready(
config.gateway.host,
config.gateway.port,
):
break
time.sleep(0.1)
raise TuiUnavailableError(
f"local gateway did not become ready; logs: {result.status.log_path}"
)
except BaseException:
lease.release(timeout_s=5)
raise
def _fetch_bootstrap(base_url: str, *, secret: str) -> dict[str, Any]:
headers = {"X-Nanobot-Auth": secret} if secret else {}
request = urllib.request.Request(f"{base_url}/webui/bootstrap", headers=headers)
try:
with urllib.request.urlopen(request, timeout=5) as response:
raw_payload: Any = json.loads(response.read().decode("utf-8"))
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
raise TuiUnavailableError(
f"could not authenticate with the local gateway: {exc}"
) from exc
if not isinstance(raw_payload, dict):
raise TuiUnavailableError("gateway bootstrap response is missing ws_path")
payload = cast(dict[str, Any], raw_payload)
if not payload.get("ws_path"):
raise TuiUnavailableError("gateway bootstrap response is missing ws_path")
return payload
def _authenticated_ws_url(bootstrap: dict[str, Any]) -> str:
raw_url = str(bootstrap.get("ws_url") or "").strip()
if not raw_url:
raise TuiUnavailableError("gateway bootstrap response is missing ws_url")
parsed = urllib.parse.urlsplit(raw_url)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
token = str(bootstrap.get("token") or "").strip()
if token:
query.append(("token", token))
query.append(("client_id", f"tui-{os.getpid()}"))
return urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, urllib.parse.urlencode(query), parsed.fragment)
)
def _websocket_chat_id(session_id: str) -> str | None:
"""Map the CLI selector to the WebSocket namespace used by the native TUI."""
if session_id.startswith("websocket:"):
return session_id.split(":", 1)[1] or None
if ":" in session_id:
raise TuiSessionError(
"the native TUI can open only WebSocket sessions; use --classic to resume "
f"{session_id!r}"
)
return session_id or None
def _initial_tui_chat_id(session_id: str | None, state_path: Path) -> str | None:
"""Resume the last TUI chat, while keeping an explicit selector authoritative."""
if session_id is not None:
return _websocket_chat_id(session_id)
return _read_tui_chat_id(state_path)
def _read_tui_chat_id(path: Path) -> str | None:
"""Read the last attached chat without making launch depend on optional state."""
try:
raw_payload: Any = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(raw_payload, dict):
return None
payload = cast(dict[str, Any], raw_payload)
value = payload.get("chat_id")
if not isinstance(value, str):
return None
value = value.strip()
if not value or len(value) > 256 or any(character in value for character in "\r\n"):
return None
return value
+51 -75
View File
@@ -7,6 +7,7 @@ from pydantic import ValidationError
from rich.console import Console
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.gateway_runtime import _run_gateway
from nanobot.cli.runtime_config import (
_load_runtime_config,
_print_config_error,
@@ -26,6 +27,7 @@ from nanobot.cli.webui_support import (
_open_webui_browser,
_prepare_webui_bundle_for_gateway,
_print_foreground_port_conflict,
_print_webui_foreground_lifecycle,
_resolve_webui_config_path,
_run_quick_start_for_webui,
_tcp_endpoint_reachable,
@@ -82,7 +84,7 @@ def webui(
background: bool = typer.Option(
False,
"--background",
help="Deprecated; use `nanobot gateway --background`",
help="Keep the gateway running after this command exits",
),
dev: bool = typer.Option(
False,
@@ -99,31 +101,13 @@ def webui(
) -> None:
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
from nanobot.config.loader import resolve_config_env_vars, save_config
from nanobot.gateway import (
GatewayClientLease,
GatewayInstance,
GatewayRuntime,
)
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
cli_terminal._ensure_interactive_tty_mode()
config_path = _resolve_webui_config_path(config)
if background:
import shlex
command = ["nanobot", "gateway", "--background", "--config", str(config_path)]
if workspace:
command.extend(
["--workspace", str(Path(workspace).expanduser().resolve(strict=False))]
)
console.print(
"[red]`nanobot webui --background` no longer owns gateway lifecycle.[/red]"
)
console.print("Start the persistent gateway explicitly, then open the WebUI:")
console.print(" [cyan]" + " ".join(shlex.quote(part) for part in command) + "[/cyan]")
console.print(
" [cyan]nanobot webui --config " + shlex.quote(str(config_path)) + "[/cyan]"
)
if dev and background:
console.print("[red]Error: --dev cannot be combined with --background.[/red]")
raise typer.Exit(1)
config_path = _resolve_webui_config_path(config)
created_config = not config_path.exists()
if created_config:
console.print(f"[yellow]No config found at {config_path}.[/yellow]")
@@ -147,6 +131,12 @@ def webui(
if settings_setup_error:
console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]")
console.print("Configure a provider and model in WebUI Settings → Models.")
if background:
console.print(
"[red]First-time WebUI setup must run in the foreground. "
"Run `nanobot webui` without --background.[/red]"
)
raise typer.Exit(1)
elif provider_error:
console.print(f"[dim]Provider check: {provider_error}[/dim]")
setup_config = _run_quick_start_for_webui(
@@ -217,21 +207,25 @@ def webui(
)
webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
_prepare_webui_bundle_for_gateway(
runtime_config,
mode="skip" if dev else webui_bundle_mode,
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_arg,
config_path=config_arg,
)
)
start_options = GatewayStartOptions(
port=effective_gateway_port,
workspace=workspace_arg,
config_path=config_arg,
)
instance = GatewayInstance.resolve(
config_path=config_path,
workspace=workspace,
)
runtime = GatewayRuntime(paths=instance.paths)
start_options = instance.start_options(port=effective_gateway_port)
def ensure_shared_gateway(*, client_lease: GatewayClientLease) -> None:
"""Start or refresh the one managed gateway shared by local clients."""
result = client_lease.ensure_on_demand_gateway(start_options)
if background:
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
result = runtime.start_background(start_options)
restarted = False
restart_attempted = False
if not result.ok and result.message == "gateway_already_running" and changed_webui:
@@ -250,8 +244,6 @@ def webui(
console.print("[green]Gateway started in the background.[/green]")
else:
console.print("[yellow]Gateway is already running in the background.[/yellow]")
def print_shared_gateway_controls() -> None:
console.print(
"Manage this instance: "
f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
@@ -265,26 +257,14 @@ def webui(
"Stop nanobot: "
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url)
return
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
webui_ready = _webui_endpoint_reachable(webui_url)
if gateway_ready and webui_ready:
lease = GatewayClientLease(runtime, kind="webui")
lease.acquire()
try:
if changed_webui and runtime.status().running:
ensure_shared_gateway(client_lease=lease)
gateway_ready = _gateway_health_ready(
runtime_config.gateway.host,
effective_gateway_port,
)
webui_ready = _webui_endpoint_reachable(webui_url)
if not gateway_ready or not webui_ready:
console.print("[red]Gateway did not become ready after the config update.[/red]")
raise typer.Exit(1)
console.print(
"[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]"
)
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
if not dev:
console.print(
"Restart the gateway if you need it to pick up local source changes: "
@@ -325,11 +305,6 @@ def webui(
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
finally:
if lease.release():
console.print(
"[dim]Last local client exited; the on-demand gateway was stopped.[/dim]"
)
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
_host_for_local_browser(runtime_config.gateway.host),
@@ -344,11 +319,7 @@ def webui(
)
raise typer.Exit(1)
lease = GatewayClientLease(runtime, kind="webui")
lease.acquire()
try:
ensure_shared_gateway(client_lease=lease)
print_shared_gateway_controls()
_print_webui_foreground_lifecycle(attached=False)
if dev_browser_url:
dev_proxy_target = webui_dev_proxy_target(webui_url)
try:
@@ -357,20 +328,25 @@ def webui(
browser_url=dev_browser_url,
output=lambda message: console.print(f"[green]✓[/green] {message}"),
) as dev_server:
if not no_open:
_open_webui_browser(dev_browser_url)
_attach_to_background_gateway(
runtime,
poll_hook=dev_server.ensure_running,
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else dev_browser_url,
open_browser_ready_url=f"{dev_proxy_target}/webui/bootstrap",
webui_static_dist=False,
webui_bundle_mode="skip",
unconfigured_provider_error=settings_setup_error,
webui_dev_server=dev_server,
)
except WebUIDevError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
if not no_open:
_open_webui_browser(webui_url)
_attach_to_background_gateway(runtime)
finally:
if lease.release():
console.print("[dim]Last local client exited; the on-demand gateway was stopped.[/dim]")
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else webui_url,
webui_bundle_mode=webui_bundle_mode,
unconfigured_provider_error=settings_setup_error,
)
+15 -31
View File
@@ -24,7 +24,6 @@ from nanobot.webui.build import (
BuildMode,
WebUIBuildError,
ensure_webui_bundle,
inspect_webui_bundle,
)
if TYPE_CHECKING:
@@ -50,7 +49,6 @@ __all__ = [
"_validate_gateway_startup",
"_warn_webui_bind_scope",
"_webui_browser_url",
"webui_bootstrap_secret",
"_webui_build_mode_for_interactive",
"_webui_channel_enabled",
"_webui_display_url",
@@ -192,11 +190,6 @@ def _prepare_webui_bundle_for_gateway(
return typer.confirm(message, default=True)
try:
# A source checkout is the development product. Every gateway entrypoint
# keeps its browser client in lockstep with Python; only Vite mode skips
# the production bundle intentionally.
if mode != "skip" and inspect_webui_bundle().source_available:
mode = "auto"
ensure_webui_bundle(
mode=mode,
confirm=_confirm if mode == "prompt" else None,
@@ -231,8 +224,7 @@ def _gateway_health_bind_note(host: str) -> str:
return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]"
def webui_bootstrap_secret(config: Config) -> str:
"""Return the shared local bootstrap credential for WebUI protocol clients."""
def _webui_bootstrap_secret(config: Config) -> str:
ws_cfg = _webui_config_dict(config)
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
@@ -244,7 +236,7 @@ def _webui_browser_url(config: Config) -> str:
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
port = int(ws_cfg.get("port") or 8765)
base_url = f"http://{host}:{port}"
secret = webui_bootstrap_secret(config)
secret = _webui_bootstrap_secret(config)
if not secret:
return base_url
return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
@@ -390,25 +382,15 @@ def _print_foreground_port_conflict(
gateway_host: str,
gateway_port: int,
) -> None:
gateway_running = _gateway_health_ready(gateway_host, gateway_port)
if gateway_running:
console.print(
"[yellow]A nanobot gateway is already running for this local instance.[/yellow]"
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
)
else:
console.print(
"[red]Error: nanobot cannot start because one of its local ports "
"is already in use.[/red]"
)
console.print(f" WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
console.print(
f" Gateway health: "
f"[cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
)
console.print()
if gateway_running:
console.print("Use the existing instance, or stop it first:")
else:
console.print("If this is an existing nanobot instance, use it or stop it first:")
console.print(" [cyan]nanobot gateway status[/cyan]")
console.print(" [cyan]nanobot gateway stop[/cyan]")
@@ -436,31 +418,33 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
"""Explain how the browser and gateway lifecycles differ."""
console.print()
if attached:
console.print("[green]WebUI is attached to the shared gateway.[/green]")
console.print("[green]nanobot is attached to the existing gateway.[/green]")
else:
console.print("[green]WebUI is attached to the shared gateway.[/green]")
console.print("[green]nanobot is running in this terminal.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print(
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]"
)
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
def _attach_to_background_gateway(
runtime: "GatewayRuntime",
*,
poll_hook: Callable[[], None] | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""Keep a WebUI launcher attached without taking ownership of the gateway."""
"""Keep a foreground WebUI command attached to a managed gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
while runtime.status().running:
if poll_hook is not None:
poll_hook()
sleep(0.5)
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]WebUI launcher detached.[/yellow]")
console.print("\n[yellow]Stopping nanobot...[/yellow]")
result = runtime.stop()
if result.ok or result.message == "gateway_not_running":
console.print("[green]Gateway stopped.[/green]")
return
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
raise typer.Exit(1)
console.print("[yellow]Gateway stopped.[/yellow]")
+12 -32
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, cast
from nanobot import __version__
from nanobot.bus.events import INBOUND_META_USER_SHELL, OutboundMessage
from nanobot.bus.events import OutboundMessage
from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text
from nanobot.utils.helpers import build_status_content
from nanobot.utils.restart import set_restart_notice_to_env
@@ -37,8 +37,6 @@ CommandLifecycle = Literal[
"agent_turn_with_args",
]
USER_SHELL_COMMAND = "/__shell"
@dataclass(frozen=True)
class BuiltinCommandSpec:
@@ -304,7 +302,6 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
"""Stop active task and start a fresh session."""
loop = ctx.loop
await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
loop.discard_session_file_state(ctx.key)
session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:]
runtime = None
@@ -377,7 +374,16 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
metadata=metadata,
)
name = args
parts = args.split()
if len(parts) != 1:
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="Usage: `/model [preset]`",
metadata=metadata,
)
name = parts[0]
try:
runtime = loop.set_session_model_preset(ctx.key, name)
except (KeyError, ValueError) as exc:
@@ -484,7 +490,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
if sha:
content += f" (commit {sha})"
store.compact_history()
prune_dream_sessions(loop.sessions)
prune_dream_sessions(loop.sessions.sessions_dir)
await loop.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
@@ -1001,30 +1007,6 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
)
async def cmd_user_shell(ctx: CommandContext) -> OutboundMessage:
"""Run a trusted local ``!command`` through nanobot's exec policy."""
metadata = dict(ctx.msg.metadata or {})
if (
ctx.msg.channel != "websocket"
or metadata.get("webui") is not True
or metadata.get(INBOUND_META_USER_SHELL) is not True
):
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="Shell commands are only available from a trusted local client.",
metadata={**metadata, "render_as": "text"},
)
if not ctx.args.strip():
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="Type a command after `!`, for example `!pwd`.",
metadata={**metadata, "render_as": "text"},
)
return await ctx.loop.execute_user_shell_command(ctx)
def build_help_text() -> str:
"""Build canonical help text shared across channels."""
lines = ["🐈 nanobot commands:"]
@@ -1064,5 +1046,3 @@ def register_builtin_commands(router: CommandRouter) -> None:
router.exact("/help", cmd_help)
router.exact("/pairing", cmd_pairing)
router.prefix("/pairing ", cmd_pairing)
router.exact(USER_SHELL_COMMAND, cmd_user_shell)
router.prefix(f"{USER_SHELL_COMMAND} ", cmd_user_shell)
-2
View File
@@ -75,7 +75,6 @@ def load_config(config_path: Path | None = None) -> Config:
summary="Environment-based configuration is invalid.",
issues=validation_issues(exc),
) from exc
config.bind_source_path(path)
_apply_ssrf_whitelist(config)
return config
@@ -131,7 +130,6 @@ def load_config(config_path: Path | None = None) -> Config:
issues=issues,
) from exc
config.bind_source_path(path)
_apply_ssrf_whitelist(config)
return config
+2 -16
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
from pydantic import AliasChoices, ConfigDict, Field, PrivateAttr, field_validator, model_validator
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from nanobot.config.timezone import detect_system_timezone
@@ -97,6 +97,7 @@ FallbackCandidate = str | InlineFallbackConfig
class ModelPresetConfig(Base):
"""A named set of model + generation parameters for quick switching."""
label: str | None = None
model: str
provider: str = "auto"
max_tokens: int = 8192
@@ -261,7 +262,6 @@ class ProvidersConfig(Base):
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
openai: ProviderConfig = Field(default_factory=ProviderConfig)
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
orcarouter: ProviderConfig = Field(default_factory=ProviderConfig) # OrcaRouter API gateway
assemblyai: ProviderConfig = Field(default_factory=ProviderConfig) # AssemblyAI voice transcription
huggingface: ProviderConfig = Field(default_factory=ProviderConfig)
skywork: ProviderConfig = Field(default_factory=ProviderConfig) # Skywork / APIFree API gateway
@@ -431,8 +431,6 @@ class ToolsConfig(Base):
class Config(BaseSettings):
"""Root configuration for nanobot."""
_source_path: Path | None = PrivateAttr(default=None)
agents: AgentsConfig = Field(default_factory=AgentsConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
@@ -451,20 +449,8 @@ class Config(BaseSettings):
_resolve_tool_config_refs()
super().__init__(**values)
def bind_source_path(self, path: Path) -> None:
"""Record the config file that owns instance-level runtime data."""
self._source_path = path.expanduser().resolve(strict=False)
@property
def runtime_data_dir(self) -> Path | None:
"""Return the active instance data directory when loaded from a config path."""
return self._source_path.parent if self._source_path is not None else None
@model_validator(mode="after")
def _validate_model_preset(self) -> "Config":
# Keep persisted names accepted by previous releases loadable. New
# names are normalized and checked case-insensitively at mutation
# boundaries, where conflicts can be reported without breaking startup.
if "default" in self.model_presets:
raise ValueError("model_preset name 'default' is reserved for agents.defaults")
name = self.agents.defaults.model_preset
+4 -37
View File
@@ -170,7 +170,6 @@ class CronService:
self._timer_task: asyncio.Task[None] | None = None
self._running = False
self._active_executions = 0
self._store_dirty = False
self.max_sleep_ms = max_sleep_ms
def _should_persist_store(self) -> bool:
@@ -306,11 +305,6 @@ class CronService:
load (during ``start``) can return ``None`` to signal an unrecoverable
state to the caller.
"""
# Never replace state that a previous save failed to persist. Reloading
# the older on-disk snapshot here could make an already executed job due
# again and repeat its side effect.
if self._store_dirty and self._store:
return self._store
if self._active_executions > 0 and self._store and not reload_during_execution:
return self._store
loaded = self._load_jobs()
@@ -353,9 +347,6 @@ class CronService:
if not self._store:
return
# Set this before serialization/write so every exceptional exit keeps
# the in-memory snapshot authoritative until a later save succeeds.
self._store_dirty = True
self.store_path.parent.mkdir(parents=True, exist_ok=True)
data = {
@@ -408,7 +399,6 @@ class CronService:
}
self._atomic_write(self.store_path, json.dumps(data, indent=2, ensure_ascii=False))
self._store_dirty = False
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
@@ -524,18 +514,12 @@ class CronService:
reload_store = self._active_executions == 0
self._active_executions += 1
try:
# A prior tick may have completed external side effects but failed
# to persist their advanced schedule. Persist that exact snapshot
# before reloading or executing anything else; otherwise the older
# disk state can replay the same job.
if self._store_dirty:
self._save_store()
return
store = self._load_store(reload_during_execution=reload_store)
# If a hot reload found a corrupt store on disk, ``self._store``
# may still hold the previous, known-good in-memory snapshot.
# If a hot reload found a corrupt store on disk, ``self._store`` may
# still hold the previous, known-good in-memory snapshot. Keep using
# it rather than crashing the timer or wiping live jobs.
if store is None:
self._arm_timer()
return
now = _now_ms()
@@ -548,20 +532,8 @@ class CronService:
await self._execute_job(job)
self._save_store()
except Exception:
# A load/persist failure must not kill the scheduler: keep the
# in-memory store and retry on the next tick. This mirrors the
# read-path defense in ``_load_jobs`` (``.corrupt-<ts>`` backups);
# ``_load_store`` may also persist (agent-binding migrations).
logger.exception(
"Cron: tick failed ({}); "
"keeping in-memory state and retrying on next tick",
self.store_path,
)
finally:
self._active_executions -= 1
# Always re-arm the timer, even on unexpected failures, so a
# single bad tick cannot silently stop all future jobs.
self._arm_timer()
async def _execute_job(self, job: CronJob) -> None:
@@ -825,11 +797,6 @@ class CronService:
reload_store = self._active_executions == 0
self._active_executions += 1
try:
# A manual run is another side-effecting entrypoint. Do not start
# it while the result of a previous timer execution is still only
# in memory.
if self._store_dirty:
self._save_store()
store = self._require_store(reload_during_execution=reload_store)
for job in store.jobs:
if job.id == job_id:
-6
View File
@@ -1,9 +1,6 @@
"""Lightweight background runtime for the nanobot gateway."""
from nanobot.gateway.runtime import (
GatewayAlreadyRunningError,
GatewayClientLease,
GatewayInstance,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
@@ -13,9 +10,6 @@ from nanobot.gateway.runtime import (
)
__all__ = [
"GatewayAlreadyRunningError",
"GatewayClientLease",
"GatewayInstance",
"GatewayRuntime",
"GatewayRuntimePaths",
"GatewayStartOptions",
+3 -551
View File
@@ -1,25 +1,14 @@
"""Gateway-specific configuration for the shared background process runtime."""
# pyright: reportPrivateUsage=false
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import subprocess
import tempfile
import time
import uuid
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Generator, Literal, cast
from filelock import FileLock
from typing import Any
from nanobot.config.paths import get_data_dir
from nanobot.process_runtime import (
@@ -28,51 +17,11 @@ from nanobot.process_runtime import (
ProcessRuntimePaths,
ProcessStartOptions,
ProcessStatus,
process_identity_record,
process_is_running,
)
GatewayStartOptions = ProcessStartOptions
GatewayLaunchMode = Literal["foreground", "background", "unknown"]
GatewayLifetime = Literal["explicit", "on_demand"]
def _default_config_path() -> Path:
return (Path.home() / ".nanobot" / "config.json").resolve(strict=False)
@dataclass(frozen=True)
class GatewayStatus(ProcessStatus):
"""Observable lifecycle state for one shared local gateway."""
launch_mode: GatewayLaunchMode = "unknown"
lifetime: GatewayLifetime = "explicit"
clients: int = 0
@dataclass(frozen=True)
class GatewayLeaseSnapshot:
"""Live local clients and the gateway lifetime they imply."""
auto_stop: bool
clients: int
@dataclass(frozen=True)
class RuntimeResult(ProcessResult):
"""Result of a gateway lifecycle operation."""
status: GatewayStatus
promoted: bool = False
class GatewayAlreadyRunningError(RuntimeError):
"""Raised when a foreground gateway tries to replace a live instance."""
def __init__(self, status: GatewayStatus) -> None:
super().__init__("gateway_already_running")
self.status = status
GatewayStatus = ProcessStatus
RuntimeResult = ProcessResult
def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]:
@@ -120,56 +69,6 @@ class GatewayRuntimePaths(ProcessRuntimePaths):
)
@dataclass(frozen=True)
class GatewayInstance:
"""One stable local gateway identity and its child-process selectors."""
config_path: Path
workspace: str | None
paths: GatewayRuntimePaths
@classmethod
def resolve(
cls,
*,
config_path: str | Path,
workspace: str | None = None,
) -> "GatewayInstance":
resolved_config = Path(config_path).expanduser().resolve(strict=False)
resolved_workspace = (
str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
)
# The released default instance used gateway.json. Keep that identity stable
# across upgrades while still namespacing explicit configs and workspaces.
config_selector = (
None if resolved_config == _default_config_path() else str(resolved_config)
)
return cls(
config_path=resolved_config,
workspace=resolved_workspace,
paths=GatewayRuntimePaths.for_instance(
data_dir=resolved_config.parent,
workspace=resolved_workspace,
config_path=config_selector,
),
)
def start_options(
self,
*,
port: int,
verbose: bool = False,
) -> GatewayStartOptions:
return GatewayStartOptions(
port=port,
verbose=verbose,
workspace=self.workspace,
config_path=(
None if self.config_path == _default_config_path() else str(self.config_path)
),
)
class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
"""Manage a background ``nanobot gateway`` process."""
@@ -197,453 +96,6 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
return build_gateway_command(self.python_executable, options)
def _transition_lock(self) -> FileLock:
"""Serialize long lifecycle transitions without blocking child cleanup."""
return FileLock(f"{self.paths.state_path}.transition.lock")
def start_background(self, options: ProcessStartOptions) -> RuntimeResult:
"""Start the gateway detached from the current terminal."""
lease = GatewayClientLease(self, kind="gateway-background")
while True:
lease.wait_for_shutdown()
with self._transition_lock(), self._lifecycle_lock():
promoted = lease._try_mark_persistent_locked()
if promoted is None:
continue
result = self._start_background(options)
return RuntimeResult(result.ok, result.message, result.status, promoted)
def start_on_demand(self, options: ProcessStartOptions) -> RuntimeResult:
"""Atomically reuse a gateway or start one owned by local client leases."""
lease = GatewayClientLease(self, kind="gateway-start")
while True:
lease.wait_for_shutdown()
with self._transition_lock(), self._lifecycle_lock():
if lease._shutdown_pending_locked():
continue
status = self.status()
if status.running:
return RuntimeResult(False, "gateway_already_running", status)
lease._mark_ephemeral_locked()
return self._start_background(options)
def _start_background(self, options: ProcessStartOptions) -> RuntimeResult:
result = super()._start_background(options)
if not result.ok:
return self._result(result)
state = self._read_state()
if state and result.status.pid == state.get("pid"):
state["launch_mode"] = "background"
state["pending_pid_handoff"] = True
self._write_state(state)
return RuntimeResult(True, result.message, self.status())
def stop(self, *, timeout_s: int = 20) -> RuntimeResult:
"""Stop the gateway recorded by this runtime."""
with self._transition_lock():
result = self._stop(timeout_s=timeout_s)
with self._lifecycle_lock():
if result.ok or result.message in {
"gateway_not_running",
"gateway_state_stale",
}:
GatewayClientLease(self, kind="gateway-stop")._clear_locked()
return self._result(result)
def status(self, *, reason: str | None = None) -> GatewayStatus:
"""Return process, launch, and client lifetime state in one snapshot."""
process = super().status(reason=reason)
state = self._read_state() if process.running else None
raw_mode = state.get("launch_mode") if state else None
launch_mode: GatewayLaunchMode = (
raw_mode if raw_mode in {"foreground", "background"} else "unknown"
)
lease = GatewayClientLease(self, kind="gateway-status").snapshot()
return GatewayStatus(
running=process.running,
pid=process.pid,
state_path=process.state_path,
log_path=process.log_path,
started_at=process.started_at,
port=process.port,
command=process.command,
reason=process.reason,
launch_mode=launch_mode,
lifetime="on_demand" if lease.auto_stop else "explicit",
clients=lease.clients,
)
@contextmanager
def foreground_instance(self, options: ProcessStartOptions) -> Generator[None]:
"""Publish this foreground gateway while it is available to local clients."""
self._claim_current_process(options)
try:
yield
finally:
self._release_current_process()
def _claim_current_process(self, options: ProcessStartOptions) -> GatewayLaunchMode:
lease = GatewayClientLease(self, kind="gateway-foreground")
pid = os.getpid()
while True:
lease.wait_for_shutdown()
with self._transition_lock(), self._lifecycle_lock():
current = self.status()
state = self._read_state() or {}
pid_handoff = (
self.platform_name == "Windows"
and current.running
and current.pid != pid
and current.pid == os.getppid()
and state.get("pid") == current.pid
and state.get("launch_mode") == "background"
and state.get("pending_pid_handoff") is True
)
if current.running and current.pid != pid and not pid_handoff:
raise GatewayAlreadyRunningError(current)
if lease._shutdown_pending_locked():
continue
launch_mode: GatewayLaunchMode = (
"background"
if state.get("launch_mode") == "background"
and (state.get("pid") == pid or pid_handoff)
else "foreground"
)
state.update(
{
"pid": pid,
"started_at": datetime.now(UTC).isoformat(),
"platform": self.platform_name,
"port": options.port,
"workspace": options.workspace,
"config_path": options.config_path,
"command": self._build_child_command(options),
"log_path": str(self.paths.log_path),
"launch_mode": launch_mode,
}
)
state.pop("pending_pid_handoff", None)
state.pop("stable_identity", None)
state.update(self.process_identity_record(pid))
self._write_state(state)
if launch_mode == "foreground":
lease._try_mark_persistent_locked()
return launch_mode
def _release_current_process(self) -> None:
with self._lifecycle_lock():
state = self._read_state()
if state and self._record_matches_process(state, os.getpid()):
self._clear_state()
GatewayClientLease(
self,
kind="gateway-exit",
)._finish_shutdown_locked()
def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> RuntimeResult:
"""Restart an existing gateway without creating a new persistent instance."""
with self._transition_lock():
with self._lifecycle_lock():
status = self.status()
if not status.running:
return RuntimeResult(False, "gateway_not_running", status)
if status.launch_mode == "foreground":
return RuntimeResult(
False,
"gateway_foreground_restart_required",
status,
)
stop_result = self._stop(timeout_s=timeout_s)
if not stop_result.ok:
return self._result(stop_result)
with self._lifecycle_lock():
return self._start_background(options)
def _result(self, result: ProcessResult) -> RuntimeResult:
status = result.status
gateway_status = status if isinstance(status, GatewayStatus) else self.status()
return RuntimeResult(result.ok, result.message, gateway_status)
class GatewayClientLease:
"""Reference-count an on-demand gateway shared by local interactive clients."""
def __init__(
self,
runtime: GatewayRuntime,
*,
kind: str,
pid: int | None = None,
token: str | None = None,
) -> None:
self.runtime = runtime
self.kind = kind
self.pid = pid or os.getpid()
self.token = token or uuid.uuid4().hex
state_path = runtime.paths.state_path
self.state_path = state_path.with_name(
f"{state_path.stem}.clients{state_path.suffix}"
)
self.transition_lock = FileLock(f"{state_path}.transition.lock")
self.lifecycle_lock = FileLock(f"{state_path}.lock")
self.lock = FileLock(f"{self.state_path}.lock")
self._acquired = False
def acquire(self) -> None:
"""Register this client before it starts or attaches to the gateway."""
while True:
self.wait_for_shutdown()
with self.transition_lock, self.lifecycle_lock, self.lock:
state = self._live_state()
if state.get("stopping"):
continue
self._register(state)
return
def ensure_on_demand_gateway(self, options: GatewayStartOptions) -> RuntimeResult:
"""Atomically reuse a gateway or start one owned by local client leases."""
if not self._acquired:
raise RuntimeError("gateway client lease must be acquired before startup")
return self.runtime.start_on_demand(options)
def mark_ephemeral(self) -> None:
"""Mark a gateway started by a client for last-client shutdown."""
with self.transition_lock, self.lifecycle_lock:
self._mark_ephemeral_locked()
def _mark_ephemeral_locked(self) -> None:
with self.lock:
state = self._live_state()
state["auto_stop"] = True
self._write_state(state)
def mark_persistent(self) -> bool:
"""Keep an explicitly backgrounded gateway alive; return whether it was promoted."""
while True:
self.wait_for_shutdown()
with self.transition_lock, self.lifecycle_lock:
promoted = self._try_mark_persistent_locked()
if promoted is not None:
return promoted
def _try_mark_persistent_locked(self) -> bool | None:
with self.lock:
state = self._live_state()
if state.get("stopping"):
return None
promoted = bool(state.get("auto_stop"))
state["auto_stop"] = False
self._write_or_clear(state)
return promoted
def clear(self) -> None:
"""Forget leases after an explicit gateway stop."""
with self.transition_lock, self.lifecycle_lock:
self._clear_locked()
def _clear_locked(self) -> None:
with self.lock:
self.state_path.unlink(missing_ok=True)
def snapshot(self) -> GatewayLeaseSnapshot:
"""Prune dead clients and return current lifetime state."""
with self.lock:
state = self._live_state()
self._write_or_clear(state)
return GatewayLeaseSnapshot(
auto_stop=bool(state.get("auto_stop")),
clients=len(self._clients(state)),
)
def begin_orphan_shutdown(self) -> bool:
"""Commit shutdown only while an on-demand gateway still has no clients."""
with self.transition_lock, self.lifecycle_lock, self.lock:
state = self._live_state()
if not bool(state.get("auto_stop")) or self._clients(state):
self._write_or_clear(state)
return False
state["stopping"] = True
self._write_state(state)
return True
def release(self, *, timeout_s: int = 20) -> bool:
"""Release this client and stop an ephemeral gateway when it was the last."""
if not self._acquired:
return False
while True:
self.wait_for_shutdown()
with self.transition_lock:
with self.lifecycle_lock, self.lock:
state = self._live_state()
if state.get("stopping"):
continue
clients = self._clients(state)
clients.pop(self.token, None)
self._acquired = False
should_stop = not clients and bool(state.get("auto_stop"))
self._write_or_clear(state)
if not should_stop:
return False
result = self.runtime._stop(timeout_s=timeout_s)
stopped = result.ok or result.message in {
"gateway_not_running",
"gateway_state_stale",
}
with self.lifecycle_lock:
if stopped:
self._clear_locked()
else:
self._mark_ephemeral_locked()
return stopped
def wait_for_shutdown(self, *, timeout_s: float = 20) -> None:
"""Wait until a committed orphan shutdown can no longer accept clients."""
deadline = time.monotonic() + timeout_s
while True:
with self.lifecycle_lock:
with self.lock:
state = self._live_state()
if not state.get("stopping"):
return
if not self.runtime.status().running:
self._finish_shutdown_locked()
return
if time.monotonic() >= deadline:
raise RuntimeError("gateway is still shutting down; try again shortly")
time.sleep(0.05)
def _shutdown_pending_locked(self) -> bool:
with self.lock:
return bool(self._live_state().get("stopping"))
def _finish_shutdown_locked(self) -> None:
with self.lock:
state = self._live_state()
state.pop("stopping", None)
if not self._clients(state):
self.state_path.unlink(missing_ok=True)
else:
self._write_state(state)
def _register(self, state: dict[str, object]) -> None:
clients = self._clients(state)
record: dict[str, object] = {
"pid": self.pid,
"kind": self.kind,
}
record.update(process_identity_record(self._process_identity(self.pid), lease=True))
clients[self.token] = record
self._write_state(state)
self._acquired = True
def _live_state(self) -> dict[str, object]:
state = self._read_state()
clients = self._clients(state)
stale: list[str] = []
for token, value in clients.items():
if not isinstance(value, dict):
stale.append(token)
continue
record = cast(dict[str, object], value)
pid = record.get("pid")
identity = record.get("stable_identity")
if identity is None:
identity = record.get("identity")
if not isinstance(pid, int) or not self._process_is_running(pid):
stale.append(token)
continue
if self._process_identity_match(identity, pid) == "mismatch":
stale.append(token)
for token in stale:
clients.pop(token, None)
return state
def _process_identity(self, pid: int) -> str | int | None:
resolver = getattr(self.runtime, "process_identity", None)
value = resolver(pid) if callable(resolver) else None
return value if isinstance(value, (str, int)) else None
def _process_identity_match(
self,
recorded: object,
pid: int,
) -> Literal["match", "mismatch", "unknown"]:
matcher = getattr(self.runtime, "process_identity_match", None)
if callable(matcher):
result = matcher(recorded, pid)
if result in {"match", "mismatch", "unknown"}:
return cast(Literal["match", "mismatch", "unknown"], result)
if recorded is None:
return "match"
current = self._process_identity(pid)
if current is None:
return "unknown"
return "match" if recorded == current else "mismatch"
def _process_is_running(self, pid: int) -> bool:
checker = getattr(self.runtime, "process_is_running", None)
return bool(checker(pid)) if callable(checker) else process_is_running(pid)
@staticmethod
def _clients(state: dict[str, object]) -> dict[str, object]:
value = state.get("clients")
if isinstance(value, dict):
return cast(dict[str, object], value)
clients: dict[str, object] = {}
state["clients"] = clients
return clients
def _read_state(self) -> dict[str, object]:
try:
payload: object = json.loads(self.state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return {"auto_stop": False, "clients": {}}
if isinstance(payload, dict):
return cast(dict[str, object], payload)
return {"auto_stop": False, "clients": {}}
def _write_or_clear(self, state: dict[str, object]) -> None:
clients = state.get("clients")
if not clients and not bool(state.get("auto_stop")):
self.state_path.unlink(missing_ok=True)
return
self._write_state(state)
def _write_state(self, state: dict[str, object]) -> None:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(
prefix=f"{self.state_path.name}.",
suffix=".tmp",
dir=self.state_path.parent,
)
temporary = Path(temporary_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.replace(self.state_path)
finally:
temporary.unlink(missing_ok=True)
async def monitor_gateway_clients(
lease: GatewayClientLease,
shutdown_event: asyncio.Event,
*,
poll_interval_s: float = 1.0,
) -> bool:
"""Stop waiting when an on-demand gateway loses every live client."""
while not shutdown_event.is_set():
try:
await asyncio.wait_for(shutdown_event.wait(), timeout=poll_interval_s)
except TimeoutError:
if lease.begin_orphan_shutdown():
shutdown_event.set()
return True
return False
def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | None:
raw = "|".join(value for value in (workspace, config_path) if value)
+4 -24
View File
@@ -10,8 +10,6 @@ from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import Config
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
@@ -73,16 +71,9 @@ class Nanobot:
print(result.content)
"""
def __init__(
self,
loop: AgentLoop,
*,
config: Config | None = None,
mcp_provider: MCPProvider | None = None,
) -> None:
def __init__(self, loop: AgentLoop, *, config: Config | None = None) -> None:
self._loop = loop
self._config = config
self._mcp_provider = mcp_provider
self.sessions = SessionClient(loop)
self.memory = MemoryClient(loop)
self.runtime = RuntimeClient(loop)
@@ -129,15 +120,12 @@ class Nanobot:
elif model_preset is not None:
config.agents.defaults.model_preset = model_preset
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(config, tools)
loop = AgentLoop.from_config(
config,
image_generation_provider_configs=image_gen_provider_configs(config),
hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
)
return cls(loop, config=config, mcp_provider=mcp_provider)
return cls(loop, config=config)
async def run(
self,
@@ -190,8 +178,6 @@ class Nanobot:
)
if runtime is not None:
kwargs["runtime"] = runtime
if self._mcp_provider is not None:
await self._mcp_provider.connect()
response = await self._loop.process_direct(
message,
**kwargs,
@@ -273,8 +259,6 @@ class Nanobot:
if override_runtime is not None:
kwargs["runtime"] = override_runtime
try:
if self._mcp_provider is not None:
await self._mcp_provider.connect()
response = await self._loop.process_direct(
message,
**kwargs,
@@ -343,12 +327,8 @@ class Nanobot:
await run.aclose()
async def aclose(self) -> None:
"""Release resources held by this instance."""
try:
await self._loop.aclose()
finally:
if self._mcp_provider is not None:
await self._mcp_provider.aclose()
"""Release resources held by this instance (MCP connections, etc.)."""
await self._loop.close_mcp()
async def __aenter__(self) -> Nanobot:
return self
+40 -326
View File
@@ -5,21 +5,17 @@ from __future__ import annotations
import ctypes
import json
import os
import re
import signal
import struct
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable
from contextlib import suppress
from ctypes import wintypes
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import Any, Generic, Literal, TypeVar, cast
from typing import Any, Generic, TypeVar, cast
from filelock import FileLock
@@ -91,10 +87,6 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
self._popen = popen
self._subprocess_run = subprocess_run
self._sleep = sleep
# Keep the handle for children spawned by this runtime. On POSIX an
# exited child remains visible to kill(pid, 0) until its parent reaps
# it; poll() both reaps it and reports the real lifecycle state.
self._owned_process: Any | None = None
@classmethod
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
@@ -107,8 +99,7 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
return
state["pid"] = os.getpid()
runtime = cls(paths=paths)
state.pop("stable_identity", None)
state.update(runtime.process_identity_record(os.getpid()))
state["identity"] = runtime._process_identity(os.getpid())
state["started_at"] = _utc_now()
runtime._write_state(state)
@@ -134,15 +125,16 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
stderr=subprocess.STDOUT,
**self._popen_platform_kwargs(),
)
self._owned_process = process
pid = int(process.pid)
self._sleep(0.2)
if not self._is_pid_running(pid):
return ProcessResult(False, self._message("exited_during_startup"), self.status())
state: dict[str, object] = {
self._write_state(
{
"pid": pid,
"identity": self._process_identity(pid),
"started_at": _utc_now(),
"platform": self.platform_name,
"port": options.port,
@@ -151,8 +143,7 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
"command": command,
"log_path": str(self.paths.log_path),
}
state.update(self.process_identity_record(pid))
self._write_state(state)
)
return ProcessResult(True, self._message("started_background"), self.status())
def stop(self, *, timeout_s: int = 20) -> ProcessResult:
@@ -166,14 +157,7 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
return ProcessResult(False, self._message("not_running"), status)
state = self._read_state()
identity_match = self._process_identity_match(state, status.pid)
if identity_match == "unknown":
return ProcessResult(
False,
self._message("identity_unavailable"),
status,
)
if identity_match == "mismatch":
if not self._record_matches_process(state, status.pid):
self._clear_state()
return ProcessResult(
False,
@@ -216,8 +200,7 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
)
assert state is not None
identity_match = self._process_identity_match(state, pid)
if not self._is_pid_running(pid) or identity_match == "mismatch":
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
self._clear_state()
return ProcessStatus(
running=False,
@@ -236,9 +219,7 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
started_at=_as_str(state.get("started_at")),
port=_as_int(state.get("port")),
command=tuple(cast(list[str], command)) if isinstance(command, list) else (),
reason=reason or (
"identity_unavailable" if identity_match == "unknown" else "running"
),
reason=reason or "running",
)
def read_log_tail(self, *, tail: int = 200) -> list[str]:
@@ -269,50 +250,6 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
except KeyboardInterrupt:
return 130
def process_identity(self, pid: int) -> str | int | None:
"""Return an identity that changes when an operating-system PID is reused."""
return self._process_identity(pid)
def process_identity_record(
self,
pid: int,
*,
lease: bool = False,
) -> dict[str, str | int | None]:
"""Serialize an identity without breaking pre-upgrade macOS readers."""
return process_identity_record(self._process_identity(pid), lease=lease)
def process_identity_match(
self,
recorded: object,
pid: int,
) -> Literal["match", "mismatch", "unknown"]:
"""Compare a recorded identity with the current process safely."""
if recorded is None:
return "match"
current = self._process_identity(pid)
if current is None:
return "unknown"
if recorded == current:
return "match"
# Older POSIX state files stored only the process group id.
if (
isinstance(recorded, int)
and isinstance(current, str)
and (
current.startswith(f"{recorded}:")
or current.startswith(f"darwin:{recorded}:")
)
):
return "match"
if self.platform_name == "Darwin":
return _darwin_identity_match(recorded, current)
return "mismatch"
def process_is_running(self, pid: int) -> bool:
"""Return whether the recorded operating-system process is still live."""
return self._is_pid_running(pid)
def _message(self, event: str) -> str:
return f"{self.service_name}_{event}"
@@ -358,18 +295,26 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
return self._wait_for_exit(pid, 2)
def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool:
# ``os.kill(pid, CTRL_BREAK_EVENT)`` delegates to
# GenerateConsoleCtrlEvent. That API targets a console process group,
# not an individual process, and can interrupt the caller when a
# detached/no-window child has no addressable console group. Keep
# termination scoped to the recorded PID tree instead.
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
if ctrl_break is not None:
ctrl_break_sent = False
try:
os.kill(pid, ctrl_break)
except ProcessLookupError:
return True
except OSError:
pass
else:
ctrl_break_sent = True
if ctrl_break_sent and self._wait_for_exit(pid, timeout_s):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if self._wait_for_exit(pid, timeout_s):
if self._wait_for_exit(pid, 2):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T", "/F"],
@@ -390,65 +335,33 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
def _is_pid_running(self, pid: int) -> bool:
if pid <= 0:
return False
owned_process = self._owned_process
if owned_process is not None and getattr(owned_process, "pid", None) == pid:
poll = getattr(owned_process, "poll", None)
if callable(poll):
if self.platform_name == "Windows":
return _windows_process_identity(pid) is not None
try:
return poll() is None
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
pass
return process_is_running(pid, platform_name=self.platform_name)
return False
return True
def _process_identity(self, pid: int) -> str | int | None:
# Process inspection must follow the host API even when tests inject a
# target platform. On Windows, falling through to POSIX calls is not
# merely unsupported: ``os.kill(pid, 0)`` broadcasts CTRL_C_EVENT.
host_platform = _platform_name()
if host_platform == "Windows" or self.platform_name == "Windows":
if self.platform_name == "Windows":
return _windows_process_identity(pid)
if self.platform_name == "Darwin":
birth = _darwin_process_birth(pid)
if birth is None:
return None
process_group, started_at_seconds, started_at_microseconds = birth
return (
f"darwin:{process_group}:{started_at_seconds}:"
f"{started_at_microseconds}"
)
try:
process_group = os.getpgid(pid)
return os.getpgid(pid)
except OSError:
return None
started_at = self._posix_process_started_at(pid)
return f"{process_group}:{started_at}" if started_at else process_group
def _posix_process_started_at(self, pid: int) -> str | None:
if self.platform_name == "Linux":
try:
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
except OSError:
return None
closing_paren = stat.rfind(")")
fields = stat[closing_paren + 2 :].split() if closing_paren >= 0 else []
# /proc/<pid>/stat fields after comm begin at field 3; starttime is field 22.
return fields[19] if len(fields) > 19 else None
return None
def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool:
return self._process_identity_match(state, pid) == "match"
def _process_identity_match(
self,
state: dict[str, Any] | None,
pid: int,
) -> Literal["match", "mismatch", "unknown"]:
if not state:
return "mismatch"
recorded = state.get("stable_identity")
if recorded is None:
return False
recorded = state.get("identity")
return self.process_identity_match(recorded, pid)
if recorded is None:
return True
return recorded == self._process_identity(pid)
def _read_state(self) -> dict[str, Any] | None:
try:
@@ -488,52 +401,6 @@ def _platform_name() -> str:
return "Linux"
def process_is_running(pid: int, *, platform_name: str | None = None) -> bool:
"""Probe a PID without delivering a control event on Windows."""
if pid <= 0:
return False
host_platform = _platform_name()
if host_platform == "Windows" or platform_name == "Windows":
# On Windows ``os.kill(pid, 0)`` sends CTRL_C_EVENT (whose value is 0)
# instead of performing the harmless POSIX existence probe.
return _windows_process_identity(pid) is not None
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return _posix_process_state(pid, platform_name=host_platform) != "Z"
def _posix_process_state(pid: int, *, platform_name: str) -> str | None:
"""Return the host process state when available; zombies are not live clients."""
if platform_name == "Linux":
try:
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
except OSError:
return None
closing_paren = stat.rfind(")")
fields = stat[closing_paren + 2 :].split() if closing_paren >= 0 else []
return fields[0] if fields else None
if platform_name == "Darwin":
try:
result = subprocess.run(
["ps", "-o", "stat=", "-p", str(pid)],
check=False,
capture_output=True,
text=True,
timeout=1,
)
except (OSError, subprocess.SubprocessError):
return None
value = getattr(result, "stdout", "").strip()
return value[:1].upper() or None
return None
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
@@ -553,145 +420,6 @@ def _as_str(value: object) -> str | None:
return value if isinstance(value, str) else None
def _darwin_identity_match(
recorded: object,
current: object,
) -> Literal["match", "mismatch", "unknown"]:
"""Compare the new numeric identity with a pre-upgrade ``ps`` identity."""
if not isinstance(recorded, str) or not isinstance(current, str):
return "mismatch"
current_identity = _parse_darwin_identity(current)
if current_identity is None:
return "mismatch"
current_group, current_seconds, _ = current_identity
recorded_group, separator, recorded_started_at = recorded.partition(":")
if not separator or not recorded_group.isdigit():
return "mismatch"
if int(recorded_group) != current_group:
return "mismatch"
legacy_epoch = _legacy_darwin_started_at(recorded_started_at)
if legacy_epoch is None:
# The PID is alive and its process group still matches, but an older
# locale produced a date we cannot safely parse. Keep the record until
# the owning client exits instead of killing a live gateway.
return "unknown"
return "match" if legacy_epoch == current_seconds else "mismatch"
def _parse_darwin_identity(value: object) -> tuple[int, int, int] | None:
if not isinstance(value, str):
return None
match = re.fullmatch(r"darwin:(\d+):(\d+):(\d+)", value)
if match is None:
return None
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def process_identity_record(
identity: str | int | None,
*,
lease: bool = False,
) -> dict[str, str | int | None]:
"""Serialize an identity without breaking pre-upgrade macOS readers."""
darwin = _parse_darwin_identity(identity)
if darwin is None:
return {"identity": identity}
process_group, _, _ = darwin
# Old process-state readers understand a PGID-only integer. Old lease
# readers raw-compare identities, so ``None`` asks them to rely on the
# still-live PID while upgraded readers use the stable native value.
return {
"identity": None if lease else process_group,
"stable_identity": identity,
}
def _legacy_darwin_started_at(value: str) -> int | None:
"""Parse the English and numeric macOS ``ps lstart`` formats we released."""
english = re.fullmatch(
r"[A-Za-z]{3}\s+([A-Za-z]{3})\s+(\d{1,2})\s+"
r"(\d{2}):(\d{2}):(\d{2})\s+(\d{4})",
value.strip(),
)
months = {
"Jan": 1,
"Feb": 2,
"Mar": 3,
"Apr": 4,
"May": 5,
"Jun": 6,
"Jul": 7,
"Aug": 8,
"Sep": 9,
"Oct": 10,
"Nov": 11,
"Dec": 12,
}
if english is not None:
month = months.get(english.group(1))
if month is None:
return None
day, hour, minute, second, year = map(int, english.groups()[1:])
else:
numeric = re.fullmatch(
r"\S+\s+(\d{1,2})/(\d{1,2})\s+"
r"(\d{2}):(\d{2}):(\d{2})\s+(\d{4})",
value.strip(),
)
if numeric is None:
return None
month, day, hour, minute, second, year = map(int, numeric.groups())
try:
return int(time.mktime((year, month, day, hour, minute, second, -1, -1, -1)))
except (OSError, OverflowError, ValueError):
return None
@lru_cache(maxsize=1)
def _darwin_proc_pidinfo() -> Any | None:
if sys.platform != "darwin":
return None
try:
proc_pidinfo = ctypes.CDLL(
"/usr/lib/libproc.dylib",
use_errno=True,
).proc_pidinfo
except (AttributeError, OSError):
return None
proc_pidinfo.argtypes = [
ctypes.c_int,
ctypes.c_int,
ctypes.c_uint64,
ctypes.c_void_p,
ctypes.c_int,
]
proc_pidinfo.restype = ctypes.c_int
return proc_pidinfo
def _darwin_process_birth(pid: int) -> tuple[int, int, int] | None:
"""Read PGID and microsecond process birth time from ``proc_bsdinfo``."""
proc_pidinfo = _darwin_proc_pidinfo()
if proc_pidinfo is None:
return None
# ``proc_bsdinfo`` is 136 bytes on supported macOS versions. These stable
# field offsets come from ``sys/proc_info.h``: pid=12, pgid=100,
# start_tvsec=120, and start_tvusec=128.
buffer = ctypes.create_string_buffer(136)
try:
written = proc_pidinfo(pid, 3, 0, buffer, len(buffer))
except (OSError, ValueError):
return None
if written != len(buffer) or struct.unpack_from("=I", buffer, 12)[0] != pid:
return None
process_group = struct.unpack_from("=I", buffer, 100)[0]
started_at_seconds = struct.unpack_from("=Q", buffer, 120)[0]
started_at_microseconds = struct.unpack_from("=Q", buffer, 128)[0]
if started_at_seconds <= 0:
return None
return process_group, started_at_seconds, started_at_microseconds
def _windows_process_identity(pid: int) -> str | None:
if os.name != "nt":
return None
@@ -704,21 +432,7 @@ def _windows_process_identity(pid: int) -> str | None:
return (int(self.high) << 32) | int(self.low)
process_query_limited_information = 0x1000
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.GetProcessTimes.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(FileTime),
ctypes.POINTER(FileTime),
ctypes.POINTER(FileTime),
ctypes.POINTER(FileTime),
]
kernel32.GetProcessTimes.restype = wintypes.BOOL
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
if not handle:
return None
@@ -736,7 +450,7 @@ def _windows_process_identity(pid: int) -> str | None:
)
if not ok:
return None
exit_code = wintypes.DWORD()
exit_code = ctypes.c_uint32()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return None
if exit_code.value != 259:
+5 -6
View File
@@ -782,15 +782,11 @@ class AnthropicProvider(LLMProvider):
idle_timeout_s = resolve_stream_idle_timeout_s()
try:
async with self._client.messages.stream(**kwargs) as stream:
if on_content_delta or on_thinking_delta or on_tool_call_delta:
# Idle timeout must track *any* SSE chunk (thinking_delta,
# tool JSON deltas, etc.), not only text_stream tokens.
# Otherwise extended thinking can stall text_stream for minutes
# while the connection is healthy (e.g. MiniMax Anthropic).
# Drain the whole stream with per-chunk idle waits so the
# timeout measures inactivity, not total generation time: a
# long but continuously-active stream must never be killed.
# The SDK accumulates the final message snapshot during
# iteration, so get_final_message() below returns instantly.
tool_blocks: dict[int, dict[str, str]] = {}
while True:
try:
@@ -843,7 +839,10 @@ class AnthropicProvider(LLMProvider):
"name": state.get("name", ""),
"arguments_delta": partial,
})
response = await stream.get_final_message()
response = await asyncio.wait_for(
stream.get_final_message(),
timeout=idle_timeout_s,
)
return self._parse_response(response)
except asyncio.TimeoutError:
return LLMResponse(
-6
View File
@@ -258,12 +258,6 @@ class LLMResponse:
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
# Locally measured streaming telemetry. ``generation_ms`` excludes time to
# first token and provider retry gaps; ``ttft_ms`` measures the first
# streamed reasoning/content delta from request start. They stay separate
# from provider usage because providers do not report these consistently.
generation_ms: int | None = None
ttft_ms: int | None = None
retry_after: float | None = None # Provider supplied retry wait in seconds.
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
+1 -25
View File
@@ -49,30 +49,6 @@ def _provider_extra_headers(
return headers or None
def _provider_spec_for_config(
provider_name: str,
provider_config: ProviderConfig | None,
) -> ProviderSpec | None:
spec = find_by_name(provider_name)
if (
spec is not None
and spec.name == "orcarouter"
and provider_config is not None
and provider_config.api_base
and provider_config.api_base.rstrip("/").lower()
!= spec.default_api_base.rstrip("/").lower()
):
# Before OrcaRouter became a built-in provider, this name was valid for a
# dynamic custom provider. Preserve that provider's model-prefix behavior
# when an existing config points the name at a different endpoint.
return create_dynamic_spec(
provider_name,
display_name=provider_config.display_name or "",
thinking_style=provider_config.thinking_style or "",
)
return spec
def _resolve_provider_setup(
config: Config,
*,
@@ -85,7 +61,7 @@ def _resolve_provider_setup(
p = config.get_provider(model, preset=preset)
if not provider_name:
raise ValueError(f"No provider is configured for model '{model}'.")
spec = _provider_spec_for_config(provider_name, p)
spec = find_by_name(provider_name)
if not spec and p:
if not p.api_base:
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
+2 -82
View File
@@ -56,8 +56,6 @@ if TYPE_CHECKING:
# that ``unittest.mock.patch`` can find and replace it.
AsyncOpenAI: Any = None
_GEMINI_SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
def _is_hosted_web_search_type(value: object) -> bool:
return isinstance(value, str) and (
@@ -692,8 +690,6 @@ class OpenAICompatProvider(LLMProvider):
if strip_reasoning:
for msg in sanitized:
msg.pop("reasoning_content", None)
if self._spec and self._spec.name == "gemini":
sanitized = self._ensure_gemini_thought_signatures(sanitized)
def map_id(value: Any) -> Any:
if not isinstance(value, str):
@@ -771,81 +767,6 @@ class OpenAICompatProvider(LLMProvider):
clean["content"] = self._coerce_content_to_string(clean.get("content"))
return self._enforce_role_alternation(sanitized)
@staticmethod
def _gemini_thought_signature(tool_call: dict[str, Any]) -> str | None:
"""Return Gemini's thought signature attached to a tool call, if any.
Gemini's OpenAI-compatible endpoint returns tool calls with an
``extra_content`` field: ``{"google": {"thought_signature": "..."}}``.
nanobot preserves it through the parse -> serialize round-trip so
replayed calls stay valid. Calls produced by other providers (e.g.
after a mid-conversation model switch) carry no signature.
"""
extra = tool_call.get("extra_content")
if not isinstance(extra, dict):
return None
google = cast(dict[str, Any], extra).get("google")
if not isinstance(google, dict):
return None
signature = cast(dict[str, Any], google).get("thought_signature")
if isinstance(signature, str) and signature:
return signature
return None
def _ensure_gemini_thought_signatures(
self, messages: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Keep migrated tool history wire-valid without losing tool context.
Gemini requires the first call in each function-call step to carry a
thought signature. Native parallel calls intentionally leave later
calls unsigned, so they must remain in their original order. For a
fully unsigned step imported from another provider, Google documents
``skip_thought_signature_validator`` as a last-resort migration value.
"""
kept: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
calls = msg.get("tool_calls")
if role != "assistant" or not isinstance(calls, list) or not calls:
kept.append(msg)
continue
call_values = cast(list[object], calls)
typed_calls = [
cast(dict[str, Any], tool_call)
for tool_call in call_values
if isinstance(tool_call, dict)
]
if not typed_calls:
if msg.get("content"):
clean = dict(msg)
clean.pop("tool_calls", None)
kept.append(clean)
continue
clean_calls = typed_calls
if self._gemini_thought_signature(typed_calls[0]) is None:
first = dict(typed_calls[0])
extra_value = first.get("extra_content")
extra = dict(cast(dict[str, Any], extra_value)) if isinstance(
extra_value, dict
) else {}
google_value = extra.get("google")
google = dict(cast(dict[str, Any], google_value)) if isinstance(
google_value, dict
) else {}
google["thought_signature"] = _GEMINI_SKIP_THOUGHT_SIGNATURE
extra["google"] = google
first["extra_content"] = extra
clean_calls = [first, *typed_calls[1:]]
if clean_calls != call_values:
msg = dict(msg)
msg["tool_calls"] = clean_calls
kept.append(msg)
return kept
# ------------------------------------------------------------------
# Build kwargs
# ------------------------------------------------------------------
@@ -1237,8 +1158,7 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages)
)
)
is_deepseek = bool(self._spec and self._spec.name == "deepseek")
preserve_reasoning = is_deepseek
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
instructions, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
@@ -1274,7 +1194,7 @@ class OpenAICompatProvider(LLMProvider):
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and (reasoning_effort.lower() != "none" or is_deepseek):
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
if replayed and "gpt-5.6" in model_name.lower():
body.setdefault("reasoning", {})["context"] = "all_turns"
+3 -14
View File
@@ -112,7 +112,8 @@ class ProviderSpec:
implicit_reasoning_models: tuple[str, ...] = ()
# Models that expose the OpenAI Responses wire format. This is model-level
# because providers may add Responses support incrementally.
# because providers may add Responses support incrementally (DeepSeek V4
# Flash is supported before V4 Pro).
responses_models: tuple[str, ...] = ()
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
@@ -199,18 +200,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
supports_prompt_caching=True,
gateway_reasoning_style="reasoning_effort",
),
# OrcaRouter: global gateway, keys start with "sk-orca-"
ProviderSpec(
name="orcarouter",
keywords=("orcarouter",),
env_key="ORCAROUTER_API_KEY",
display_name="OrcaRouter",
backend="openai_compat",
is_gateway=True,
detect_by_key_prefix="sk-orca-",
detect_by_base_keyword="orcarouter",
default_api_base="https://api.orcarouter.ai/v1",
),
# Eden AI: OpenAI-compatible gateway. Models use the "provider/model"
# naming scheme (e.g. "anthropic/claude-sonnet-4-5"); the full id is sent upstream.
ProviderSpec(
@@ -493,7 +482,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.deepseek.com",
thinking_style="thinking_type",
responses_models=("deepseek-v4-flash", "deepseek-v4-pro"),
responses_models=("deepseek-v4-flash",),
responses_default_tools=("web_search",),
),
# Gemini: Google's OpenAI-compatible endpoint
-1
View File
@@ -138,7 +138,6 @@ class SessionClient:
def clear(self, session_key: str) -> SessionSnapshot:
"""Clear one session and persist the empty session."""
self._loop.discard_session_file_state(session_key)
session = self._loop.sessions.get_or_create(session_key)
session.clear()
self._loop.sessions.save(session)
+2 -6
View File
@@ -98,20 +98,16 @@ def goal_state_runtime_lines(metadata: Mapping[str, Any] | None) -> list[str]:
def goal_state_ws_blob(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""JSON-safe snapshot for WebSocket ``goal_state`` events (one chat_id per frame)."""
goal = parse_goal_state(_session_goal_raw(metadata)) if metadata else None
if isinstance(goal, dict) and goal.get("status") in {"active", "blocked"}:
status = str(goal.get("status"))
if isinstance(goal, dict) and goal.get("status") == "active":
objective = str(goal.get("objective") or "").strip()
if len(objective) > _MAX_OBJECTIVE_WS:
objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + ""
summary = str(goal.get("ui_summary") or "").strip()[:120]
blob: dict[str, Any] = {"active": status == "active", "status": status}
blob: dict[str, Any] = {"active": True}
if summary:
blob["ui_summary"] = summary
if objective:
blob["objective"] = objective
recap = str(goal.get("recap") or "").strip()[:240]
if recap:
blob["recap"] = recap
return blob
return {"active": False}
+16 -601
View File
@@ -2,31 +2,26 @@
import base64
import errno
import hashlib
import json
import os
import re
import secrets
import stat
from collections import OrderedDict
from contextlib import contextmanager, suppress
from contextlib import suppress
from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Collection, Generator, Protocol, TypedDict, cast
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
from weakref import WeakValueDictionary
from filelock import FileLock
from loguru import logger
from nanobot.config.paths import get_legacy_sessions_dir, get_runtime_subdir
from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.providers.base import ProviderConversationState
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
public_history_message,
)
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir,
@@ -62,12 +57,6 @@ _FORK_VOLATILE_METADATA_KEYS = {
"title",
"title_user_edited",
}
_WORKSPACE_STATE_DIR = ".nanobot"
_WORKSPACE_ID_FILE = "workspace-id"
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
_SESSION_FILES_LOCK_FILENAME = ".session-files.lock"
_COPY_CHUNK_SIZE = 1024 * 1024
def _json_object(value: object) -> dict[str, Any]:
@@ -474,28 +463,13 @@ class Session:
if limit <= 0 or len(self.messages) <= limit:
return
original_messages = self.messages
original_last_consolidated = self.last_consolidated
original_provider_state = self.provider_state
original_updated_at = self.updated_at
result = self.retain_recent_legal_suffix(limit)
if not result.dropped:
return
archive_chunk = result.dropped[result.already_consolidated_count:]
if archive_chunk and on_archive:
try:
on_archive(archive_chunk)
except BaseException:
# Retention runs before the archive callback so the callback can
# receive the exact dropped prefix. Restore the in-memory session
# if archival fails; otherwise a later save would persist the
# trimmed state and make that prefix impossible to retry.
self.messages = original_messages
self.last_consolidated = original_last_consolidated
self.provider_state = original_provider_state
self.updated_at = original_updated_at
raise
logger.info(
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
self.key,
@@ -529,23 +503,6 @@ class SessionInfo(TypedDict):
path: str
@dataclass(frozen=True)
class _SessionFileSnapshot:
digest: str
size: int
mtime_ns: int
updated_at: float
device: int
inode: int
@dataclass(frozen=True)
class SessionRestoreResult:
restored: int
unchanged: int
conflicts: tuple[Path, ...]
class SessionStore(Protocol):
def load(self, key: str) -> Session | None: ...
@@ -563,455 +520,9 @@ class SessionStore(Protocol):
class JsonlSessionStore:
"""JSONL implementation of session persistence."""
def __init__(self, workspace: Path, *, sessions_root: Path | None = None):
canonical_workspace = Path(workspace).expanduser().resolve(strict=False)
ensure_dir(canonical_workspace)
root = (
Path(sessions_root).expanduser().resolve(strict=False)
if sessions_root is not None
else get_runtime_subdir("sessions").resolve(strict=False)
)
if root == canonical_workspace or root.is_relative_to(canonical_workspace):
raise RuntimeError(
"session storage must be outside the agent workspace; "
"move --config outside --workspace or choose a nested workspace directory"
)
ensure_dir(root)
with suppress(OSError):
os.chmod(root, 0o700)
self.workspace = canonical_workspace
self._migration_lock = FileLock(
str(root / ".workspace-migration.lock"),
timeout=_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS,
)
with self._migration_lock:
workspace_id = self._load_or_create_workspace_id(canonical_workspace, root)
workspace_id = self._claim_workspace_namespace(
root,
canonical_workspace,
workspace_id,
)
self.sessions_dir = ensure_dir(root / workspace_id)
def __init__(self, workspace: Path):
self.sessions_dir = ensure_dir(workspace / "sessions")
self.legacy_sessions_dir = get_legacy_sessions_dir()
self._session_files_lock = FileLock(
str(self.sessions_dir / _SESSION_FILES_LOCK_FILENAME)
)
with self._session_files_lock:
self._migrate_from_workspace(canonical_workspace)
@contextmanager
def locked_session_files(self) -> Generator[Path, None, None]:
"""Guard direct access to canonical session files in this directory."""
with self._session_files_lock:
yield self.sessions_dir
@staticmethod
def _fsync_directory(path: Path) -> None:
with suppress(PermissionError, NotImplementedError):
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(fd)
@classmethod
def _write_text_atomic(cls, path: Path, content: str, *, mode: int = 0o600) -> None:
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
try:
with open(tmp, "x", encoding="utf-8") as handle:
os.chmod(tmp, mode)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
cls._fsync_directory(path.parent)
finally:
tmp.unlink(missing_ok=True)
@classmethod
def _read_workspace_id(cls, marker: Path) -> str:
if marker.is_symlink():
raise RuntimeError(f"workspace identity marker must not be a symlink: {marker}")
value = marker.read_text(encoding="utf-8").strip()
if not _WORKSPACE_ID_RE.fullmatch(value):
raise RuntimeError(
f"workspace identity marker is invalid: {marker}; "
"restore its original 32-character identifier before starting nanobot"
)
return value
@staticmethod
def _workspace_id_path(workspace: Path) -> Path:
state_dir = workspace / _WORKSPACE_STATE_DIR
if state_dir.is_symlink():
raise RuntimeError(f"workspace state directory must not be a symlink: {state_dir}")
ensure_dir(state_dir)
return state_dir / _WORKSPACE_ID_FILE
@classmethod
def _find_workspace_namespace(cls, workspace: Path, root: Path) -> str | None:
"""Recover an identity marker removed by cleanup at the same workspace path."""
matches: list[str] = []
for sessions_dir in root.iterdir():
if (
not _WORKSPACE_ID_RE.fullmatch(sessions_dir.name)
or sessions_dir.is_symlink()
or not sessions_dir.is_dir()
):
continue
marker = sessions_dir / ".workspace"
if marker.is_symlink() or not marker.is_file():
continue
try:
recorded = Path(marker.read_text(encoding="utf-8").strip()).expanduser()
recorded = recorded.resolve(strict=False)
same_workspace = recorded == workspace or (
recorded.exists() and recorded.samefile(workspace)
)
except (OSError, UnicodeError, ValueError):
continue
if same_workspace:
matches.append(sessions_dir.name)
if len(matches) > 1:
raise RuntimeError(
f"multiple session namespaces claim workspace {workspace}; "
"remove the stale namespace marker before starting nanobot"
)
return matches[0] if matches else None
@classmethod
def _load_or_create_workspace_id(cls, workspace: Path, root: Path) -> str:
marker = cls._workspace_id_path(workspace)
if marker.exists() or marker.is_symlink():
return cls._read_workspace_id(marker)
recovered = cls._find_workspace_namespace(workspace, root)
if recovered is not None:
cls._write_text_atomic(marker, f"{recovered}\n")
return recovered
workspace_id = secrets.token_hex(16)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(marker, flags, 0o600)
except FileExistsError:
return cls._read_workspace_id(marker)
try:
payload = f"{workspace_id}\n".encode("ascii")
view = memoryview(payload)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
except BaseException:
with suppress(OSError):
marker.unlink()
raise
finally:
os.close(fd)
cls._fsync_directory(marker.parent)
return workspace_id
@classmethod
def _replace_workspace_id(cls, workspace: Path, workspace_id: str) -> None:
cls._write_text_atomic(cls._workspace_id_path(workspace), f"{workspace_id}\n")
@classmethod
def _write_workspace_marker(cls, sessions_dir: Path, workspace: Path) -> None:
cls._write_text_atomic(sessions_dir / ".workspace", f"{workspace}\n")
@classmethod
def _claim_workspace_namespace(
cls,
root: Path,
workspace: Path,
workspace_id: str,
) -> str:
"""Bind a stable workspace ID, rotating copied live workspaces apart."""
for _attempt in range(3):
sessions_dir = root / workspace_id
marker = sessions_dir / ".workspace"
if sessions_dir.is_symlink():
raise RuntimeError(f"session namespace must not be a symlink: {sessions_dir}")
if not sessions_dir.exists():
ensure_dir(sessions_dir)
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
if marker.is_symlink():
raise RuntimeError(f"session workspace marker must not be a symlink: {marker}")
if not marker.exists():
if any(sessions_dir.iterdir()):
raise RuntimeError(
f"session namespace has data but no workspace marker: {sessions_dir}"
)
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
recorded_text = marker.read_text(encoding="utf-8").strip()
if not recorded_text:
raise RuntimeError(f"session workspace marker is empty: {marker}")
recorded = Path(recorded_text).expanduser().resolve(strict=False)
if recorded == workspace:
return workspace_id
try:
same_workspace = recorded.exists() and recorded.samefile(workspace)
except OSError:
same_workspace = False
if same_workspace:
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
if not recorded.exists():
# The identity marker travelled with a renamed or moved workspace.
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
# Both paths exist and are different: this is a copy, not a move.
workspace_id = secrets.token_hex(16)
cls._replace_workspace_id(workspace, workspace_id)
raise RuntimeError(f"could not allocate an isolated session namespace for {workspace}")
@staticmethod
def _session_file_snapshot(path: Path) -> _SessionFileSnapshot | None:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(path, flags)
except OSError:
return None
try:
before = os.fstat(fd)
if not stat.S_ISREG(before.st_mode):
return None
digest = hashlib.sha256()
saw_record = False
updated_at: float | None = None
with os.fdopen(fd, "rb", closefd=False) as handle:
for raw_line in handle:
digest.update(raw_line)
if not raw_line.strip():
continue
value: object = json.loads(raw_line.decode("utf-8"))
data = _json_object(value)
saw_record = True
if data.get("_type") == "metadata":
raw_updated_at = cast(object, data.get("updated_at"))
if isinstance(raw_updated_at, str) and raw_updated_at:
updated_at = datetime.fromisoformat(raw_updated_at).timestamp()
after = os.fstat(fd)
if (
not saw_record
or before.st_dev != after.st_dev
or before.st_ino != after.st_ino
or before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
):
return None
return _SessionFileSnapshot(
digest=digest.hexdigest(),
size=after.st_size,
mtime_ns=after.st_mtime_ns,
updated_at=(updated_at if updated_at is not None else after.st_mtime_ns / 1e9),
device=after.st_dev,
inode=after.st_ino,
)
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError):
return None
finally:
os.close(fd)
@classmethod
def _prepare_copy(
cls,
src: Path,
dst_dir: Path,
snapshot: _SessionFileSnapshot,
) -> Path:
tmp = dst_dir / f".{src.name}.{secrets.token_hex(8)}.tmp"
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
src_fd = os.open(src, flags)
try:
before = os.fstat(src_fd)
if (
before.st_dev != snapshot.device
or before.st_ino != snapshot.inode
or before.st_size != snapshot.size
or before.st_mtime_ns != snapshot.mtime_ns
):
raise OSError("session source changed before migration")
digest = hashlib.sha256()
size = 0
with os.fdopen(src_fd, "rb", closefd=False) as source, open(tmp, "xb") as target:
os.chmod(tmp, 0o600)
while chunk := source.read(_COPY_CHUNK_SIZE):
digest.update(chunk)
size += len(chunk)
target.write(chunk)
target.flush()
os.fsync(target.fileno())
after = os.fstat(src_fd)
if (
digest.hexdigest() != snapshot.digest
or size != snapshot.size
or after.st_dev != snapshot.device
or after.st_ino != snapshot.inode
or after.st_size != snapshot.size
or after.st_mtime_ns != snapshot.mtime_ns
):
raise OSError("session source changed during migration")
return tmp
except BaseException:
tmp.unlink(missing_ok=True)
raise
finally:
os.close(src_fd)
@classmethod
def _install_snapshot(
cls,
src: Path,
dst: Path,
snapshot: _SessionFileSnapshot,
) -> None:
tmp = cls._prepare_copy(src, dst.parent, snapshot)
try:
os.replace(tmp, dst)
cls._fsync_directory(dst.parent)
installed = cls._session_file_snapshot(dst)
if installed is None or installed.digest != snapshot.digest:
raise OSError(f"session migration verification failed: {dst}")
finally:
tmp.unlink(missing_ok=True)
def _archive_conflict(
self,
src: Path,
snapshot: _SessionFileSnapshot,
label: str,
) -> Path:
conflict_dir = ensure_dir(self.sessions_dir / ".migration-conflicts")
conflict = conflict_dir / (
f"{src.stem}.{label}.{snapshot.digest[:12]}.{secrets.token_hex(4)}.jsonl"
)
self._install_snapshot(src, conflict, snapshot)
return conflict
@classmethod
def _remove_migrated_source(
cls,
src: Path,
snapshot: _SessionFileSnapshot,
) -> bool:
try:
current = src.stat(follow_symlinks=False)
if (
current.st_dev != snapshot.device
or current.st_ino != snapshot.inode
or current.st_size != snapshot.size
or current.st_mtime_ns != snapshot.mtime_ns
):
return False
src.unlink()
cls._fsync_directory(src.parent)
return True
except OSError:
return False
def _migrate_from_workspace(self, workspace: Path) -> None:
"""Durably copy legacy sessions out of the workspace, then remove the source."""
old_dir = workspace / "sessions"
if old_dir.is_symlink() or not old_dir.is_dir():
if old_dir.is_symlink():
logger.warning("Skipping symlinked legacy sessions directory: {}", old_dir)
return
for src in old_dir.glob("*.jsonl"):
if src.is_symlink() or not src.is_file():
logger.warning("Skipping unsafe legacy session file: {}", src)
continue
dst = self.sessions_dir / src.name
source_snapshot = self._session_file_snapshot(src)
if source_snapshot is None:
logger.warning("Skipping invalid or changing legacy session file: {}", src)
continue
try:
destination_snapshot = self._session_file_snapshot(dst) if dst.exists() else None
if dst.exists() and destination_snapshot is None:
logger.warning(
"Keeping legacy session because destination is invalid: {}",
dst,
)
continue
if destination_snapshot is None:
self._install_snapshot(src, dst, source_snapshot)
elif destination_snapshot.digest == source_snapshot.digest:
pass
elif source_snapshot.updated_at > destination_snapshot.updated_at:
archived = self._archive_conflict(dst, destination_snapshot, "destination")
self._install_snapshot(src, dst, source_snapshot)
logger.warning("Archived older session migration conflict at {}", archived)
else:
archived = self._archive_conflict(src, source_snapshot, "workspace")
logger.warning("Archived older session migration conflict at {}", archived)
installed = self._session_file_snapshot(dst)
if installed is None:
raise OSError(f"session migration destination is unreadable: {dst}")
selected_digest = (
source_snapshot.digest
if destination_snapshot is None
or source_snapshot.updated_at > destination_snapshot.updated_at
else destination_snapshot.digest
)
if installed.digest != selected_digest:
raise OSError(f"session migration selected unexpected data: {dst}")
if not self._remove_migrated_source(src, source_snapshot):
logger.warning(
"Session migrated but legacy source changed or could not be removed: {}",
src,
)
except OSError as exc:
logger.warning("Failed to migrate session {}: {}", src, exc)
def restore_to_workspace(self) -> SessionRestoreResult:
"""Copy canonical sessions back for an explicit downgrade or rollback."""
restored = 0
unchanged = 0
conflicts: list[Path] = []
old_dir = self.workspace / "sessions"
if old_dir.is_symlink():
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
ensure_dir(old_dir)
with self._migration_lock, self._session_files_lock:
for src in self.sessions_dir.glob("*.jsonl"):
if self.session_key_from_path(src) is None:
continue
source_snapshot = self._session_file_snapshot(src)
if source_snapshot is None:
conflicts.append(src)
continue
dst = old_dir / src.name
if dst.exists():
destination_snapshot = self._session_file_snapshot(dst)
if (
destination_snapshot is not None
and destination_snapshot.digest == source_snapshot.digest
):
unchanged += 1
else:
conflicts.append(dst)
continue
self._install_snapshot(src, dst, source_snapshot)
restored += 1
return SessionRestoreResult(
restored=restored,
unchanged=unchanged,
conflicts=tuple(conflicts),
)
@staticmethod
def safe_key(key: str) -> str:
@@ -1048,10 +559,6 @@ class JsonlSessionStore:
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
def load(self, key: str) -> Session | None:
with self._session_files_lock:
return self._load_unlocked(key)
def _load_unlocked(self, key: str) -> Session | None:
path = self.get_session_path(key)
if not path.exists():
return None
@@ -1117,7 +624,7 @@ class JsonlSessionStore:
)
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to load session {}: {}", key, e)
repaired = self._repair_unlocked(key)
repaired = self.repair(key)
if repaired is not None:
logger.info(
"Recovered session {} from corrupt file ({} messages)",
@@ -1127,10 +634,6 @@ class JsonlSessionStore:
return repaired
def repair(self, key: str, *, path: Path | None = None) -> Session | None:
with self._session_files_lock:
return self._repair_unlocked(key, path=path)
def _repair_unlocked(self, key: str, *, path: Path | None = None) -> Session | None:
if path is None:
path = self.get_session_path(key)
if not path.exists():
@@ -1223,15 +726,11 @@ class JsonlSessionStore:
}
def save(self, session: Session, *, fsync: bool = False) -> None:
with self._session_files_lock:
self._save_unlocked(session, fsync=fsync)
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
path = self.get_session_path(session.key)
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
tmp_path = path.with_suffix(".jsonl.tmp")
try:
with open(tmp_path, "x", encoding="utf-8") as f:
with open(tmp_path, "w", encoding="utf-8") as f:
metadata_line = {
"_type": "metadata",
"key": session.key,
@@ -1265,14 +764,11 @@ class JsonlSessionStore:
raise
finally:
os.close(fd)
finally:
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
def delete(self, key: str) -> bool:
with self._session_files_lock:
return self._delete_unlocked(key)
def _delete_unlocked(self, key: str) -> bool:
paths = [
self.get_session_path(key),
self.get_legacy_lossy_path(key),
@@ -1290,10 +786,6 @@ class JsonlSessionStore:
return deleted
def read(self, key: str) -> SessionPayload | None:
with self._session_files_lock:
return self._read_unlocked(key)
def _read_unlocked(self, key: str) -> SessionPayload | None:
path = self.get_session_path(key)
if not path.exists():
return None
@@ -1343,17 +835,13 @@ class JsonlSessionStore:
}
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to read session {}: {}", key, e)
repaired = self._repair_unlocked(key, path=path)
repaired = self.repair(key, path=path)
if repaired is not None:
logger.info("Recovered read-only session view {} from corrupt file", key)
return self.session_payload(repaired)
return None
def read_metadata(self, key: str) -> SessionMetadataPayload | None:
with self._session_files_lock:
return self._read_metadata_unlocked(key)
def _read_metadata_unlocked(self, key: str) -> SessionMetadataPayload | None:
path = self.get_session_path(key)
if not path.exists():
return None
@@ -1388,7 +876,7 @@ class JsonlSessionStore:
return None
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to read session metadata {}: {}", key, e)
repaired = self._repair_unlocked(key, path=path)
repaired = self.repair(key, path=path)
if repaired is not None:
logger.info("Recovered read-only session metadata {} from corrupt file", key)
return {
@@ -1400,10 +888,6 @@ class JsonlSessionStore:
return None
def list_sessions(self) -> list[SessionInfo]:
with self._session_files_lock:
return self._list_sessions_unlocked()
def _list_sessions_unlocked(self) -> list[SessionInfo]:
sessions: list[SessionInfo] = []
for path in self.sessions_dir.glob("*.jsonl"):
@@ -1481,7 +965,7 @@ class JsonlSessionStore:
except FileNotFoundError:
continue
except _SESSION_DATA_ERRORS:
repaired = self._repair_unlocked(storage_key, path=path)
repaired = self.repair(storage_key, path=path)
if repaired is not None:
sessions.append(
{
@@ -1507,15 +991,9 @@ class JsonlSessionStore:
class SessionManager:
"""Manage session identity, caching, retention, and persistence."""
def __init__(
self,
workspace: Path,
*,
store: SessionStore | None = None,
sessions_root: Path | None = None,
):
def __init__(self, workspace: Path, *, store: SessionStore | None = None):
self.workspace = workspace
self._jsonl_store = JsonlSessionStore(workspace, sessions_root=sessions_root)
self._jsonl_store = JsonlSessionStore(workspace)
self._store: SessionStore = store if store is not None else self._jsonl_store
self.sessions_dir = self._jsonl_store.sessions_dir
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
@@ -1524,7 +1002,6 @@ class SessionManager:
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
self._file_cap_archiver: Callable[..., None] | None = None
self._delete_observer: Callable[[str], None] | None = None
def _remember(self, session: Session) -> None:
"""Keep recent sessions strongly cached without duplicating live objects."""
@@ -1554,10 +1031,6 @@ class SessionManager:
"""Archive unconsolidated overflow whenever a session is persisted."""
self._file_cap_archiver = archiver
def set_delete_observer(self, observer: Callable[[str], None]) -> None:
"""Observe explicit session deletion for process-local state cleanup."""
self._delete_observer = observer
@staticmethod
def safe_key(key: str) -> str:
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
@@ -1595,12 +1068,6 @@ class SessionManager:
"""Legacy global session path (~/.nanobot/sessions/)."""
return self._jsonl_store.get_legacy_session_path(key)
@contextmanager
def locked_session_files(self) -> Generator[Path, None, None]:
"""Guard exceptional direct access to canonical JSONL files."""
with self._jsonl_store.locked_session_files() as sessions_dir:
yield sessions_dir
def get_or_create(self, key: str) -> Session:
"""
Get an existing session or create a new one.
@@ -1664,47 +1131,6 @@ class SessionManager:
self._store.save(session, fsync=fsync)
self._remember(session)
def rename_model_preset(self, old_name: str, new_name: str) -> int:
"""Rename a session-scoped model preset across durable and live sessions."""
if old_name == new_name:
return 0
cached = dict(self._overflow_cache.items())
cached.update(self._cache)
keys = set(cached)
keys.update(item["key"] for item in self._store.list_sessions())
changed: list[Session] = []
try:
for key in sorted(keys):
session = cached.get(key) or self._load(key)
if (
session is None
or session.metadata.get(SESSION_MODEL_PRESET_METADATA_KEY) != old_name
):
continue
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = new_name
changed.append(session)
if session.policy.persist:
self.save(session, fsync=True)
else:
self._remember(session)
except BaseException:
for session in reversed(changed):
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = old_name
try:
if session.policy.persist:
self.save(session, fsync=True)
else:
self._remember(session)
except Exception:
logger.exception(
"Failed to roll back model preset rename for session {}",
session.key,
)
raise
return len(changed)
def flush_all(self) -> int:
"""Re-save every cached session with fsync for durable shutdown.
@@ -1731,14 +1157,7 @@ class SessionManager:
def delete_session(self, key: str) -> bool:
"""Delete a persisted session and invalidate its cache entry."""
self.invalidate(key)
deleted = self._store.delete(key)
if self._delete_observer is not None:
self._delete_observer(key)
return deleted
def restore_sessions_to_workspace(self) -> SessionRestoreResult:
"""Restore session files to the pre-relocation path for an explicit rollback."""
return self._jsonl_store.restore_to_workspace()
return self._store.delete(key)
def fork_session_before_user_index(
self,
@@ -1800,10 +1219,6 @@ class SessionManager:
"""Read a session without populating the cache."""
return cast(dict[str, Any] | None, self._store.read(key))
def read_session_snapshot(self, key: str) -> Session | None:
"""Load a detached session snapshot without populating the runtime cache."""
return self._store.load(key)
def read_session_metadata(self, key: str) -> dict[str, Any] | None:
"""Read session metadata without loading the transcript."""
return cast(dict[str, Any] | None, self._store.read_metadata(key))
+1 -37
View File
@@ -33,7 +33,6 @@ from nanobot.bus.runtime_events import (
SessionTurnStarted,
TurnCompleted,
TurnRunStatusChanged,
TurnRuntimeAdmitted,
)
from nanobot.providers.base import LLMProvider
from nanobot.providers.fallback_provider import FallbackModelObserver
@@ -460,14 +459,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
outbound_message_for_event(
channel=context.channel,
chat_id=chat_id,
event=TurnModelUpdatedEvent(
model=model,
model_preset=(
context.runtime.model_preset
if context.runtime is not None
else None
),
),
event=TurnModelUpdatedEvent(model=model),
metadata=context.metadata,
)
)
@@ -494,10 +486,6 @@ class WebuiTurnCoordinator:
self._handle_run_status_changed,
TurnRunStatusChanged,
),
runtime_events.subscribe(
self._handle_turn_runtime_admitted,
TurnRuntimeAdmitted,
),
runtime_events.subscribe(
self._handle_turn_completed_event,
TurnCompleted,
@@ -549,22 +537,6 @@ class WebuiTurnCoordinator:
started_at=event.started_at,
)
async def _handle_turn_runtime_admitted(self, event: TurnRuntimeAdmitted) -> None:
if not self._is_websocket_event(event.context):
return
await self.bus.publish_outbound(
outbound_message_for_event(
channel=event.context.channel,
chat_id=event.context.chat_id,
event=TurnModelUpdatedEvent(
model=event.runtime.model,
model_preset=event.runtime.model_preset,
context_window_tokens=event.runtime.context_window_tokens,
),
metadata=event.context.metadata,
)
)
async def _handle_turn_completed_event(self, event: TurnCompleted) -> None:
if not self._is_websocket_event(event.context):
return
@@ -573,10 +545,6 @@ class WebuiTurnCoordinator:
msg,
session_key=event.context.session_key,
latency_ms=event.latency_ms,
usage=event.usage,
context_window_tokens=(
event.runtime.context_window_tokens if event.runtime is not None else None
),
)
self._schedule_title_update_from_event(event)
@@ -624,8 +592,6 @@ class WebuiTurnCoordinator:
*,
session_key: str,
latency_ms: int | None,
usage: dict[str, int] | None = None,
context_window_tokens: int | None = None,
) -> None:
if msg.channel != "websocket":
return
@@ -638,8 +604,6 @@ class WebuiTurnCoordinator:
event=TurnEndEvent(
latency_ms=latency_ms,
goal_state=goal_state_ws_blob(session.metadata),
usage=usage or None,
context_window_tokens=context_window_tokens,
),
metadata=msg.metadata,
)
+2 -13
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Mapping
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
@@ -56,7 +56,6 @@ def build_gateway_services(
default_restrict_to_workspace: bool,
config_path: Path | None = None,
runtime_model_name: Callable[[], str | None] | None,
refresh_runtime_config: Callable[[], None] | None = None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
disabled_skills: set[str] | None = None,
@@ -67,19 +66,10 @@ def build_gateway_services(
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
settings = WebUISettingsServices.create(
config_path or get_config_path(),
rename_model_preset=(
session_manager.rename_model_preset
if session_manager is not None
else None
),
refresh_runtime_config=refresh_runtime_config,
)
settings = WebUISettingsServices.create(config_path or get_config_path())
tokens = GatewayTokenStore()
ingress = DEFAULT_WEBUI_INGRESS_POLICY
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
@@ -129,7 +119,6 @@ def build_gateway_services(
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status,
mcp_reload=mcp_reload,
skill_state_action=skill_state_action,
log=logger,
)
-3
View File
@@ -100,7 +100,6 @@ def http_json_response(
*,
status: int = 200,
accept_encoding: str | None = None,
extra_headers: list[tuple[str, str]] | None = None,
) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers = [
@@ -113,8 +112,6 @@ def http_json_response(
if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding):
body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0)
headers.append(("Content-Encoding", "gzip"))
if extra_headers:
headers.extend(extra_headers)
headers.append(("Content-Length", str(len(body))))
reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body)
+8 -12
View File
@@ -56,6 +56,7 @@ _MCP_ATTACHMENT_KEYS = (
"status",
"configured",
)
_MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
@@ -502,10 +503,10 @@ def _known_preset_names() -> set[str]:
return {preset.name for preset in MCP_PRESETS}
def _known_mcp_names(config_path: Path | None = None) -> set[str]:
def _known_mcp_names() -> set[str]:
names = _known_preset_names()
with suppress(Exception):
names.update(load_config(config_path).tools.mcp_servers)
names.update(load_config().tools.mcp_servers)
return names
@@ -518,15 +519,11 @@ def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
return text[:limit]
def normalize_mcp_preset_mentions(
raw: Any,
*,
config_path: Path | None = None,
) -> list[dict[str, Any]]:
def normalize_mcp_preset_mentions(raw: Any) -> list[dict[str, Any]]:
"""Sanitize structured MCP preset mentions sent by the WebUI."""
if not isinstance(raw, list):
return []
known = _known_mcp_names(config_path)
known = _known_mcp_names()
out: list[dict[str, Any]] = []
seen: set[str] = set()
for item_value in cast(list[object], raw)[:8]:
@@ -1100,7 +1097,7 @@ async def mcp_presets_test_action(
*,
config_path: Path | None = None,
) -> dict[str, Any]:
"""Connect to an enabled MCP preset and report its complete tool surface."""
"""Connect to an enabled MCP preset and report its tool surface."""
from nanobot.agent.tools.mcp import connect_mcp_servers
name = (_query_first(query, "name") or "").strip()
@@ -1160,10 +1157,9 @@ async def mcp_presets_test_action(
registry = ToolRegistry()
stacks: dict[str, Any] = {}
inspection_cfg = cfg.model_copy(update={"enabled_tools": ["*"]})
try:
stacks = await asyncio.wait_for(
connect_mcp_servers({name: inspection_cfg}, registry),
connect_mcp_servers({name: cfg}, registry),
timeout=_test_timeout(cfg),
)
tool_prefix = f"mcp_{name}_"
@@ -1182,7 +1178,7 @@ async def mcp_presets_test_action(
else f"{display_name} connected, but reported no tools."
),
"tool_count": len(tool_names),
"tool_names": tool_names,
"tool_names": tool_names[:_MAX_TEST_TOOLS],
"checked_at": _checked_at(),
}
else:
-211
View File
@@ -1,211 +0,0 @@
"""Native directory picker used by a locally hosted WebUI."""
from __future__ import annotations
import asyncio
import os
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
_PICKER_TIMEOUT_SECONDS = 300
_COMMON_ENV_KEYS = (
"HOME",
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"LOGNAME",
"PATH",
"SHELL",
"TMPDIR",
"USER",
)
_LINUX_GUI_ENV_KEYS = (
"DBUS_SESSION_BUS_ADDRESS",
"DESKTOP_SESSION",
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR",
)
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
_WINDOWS_GUI_ENV_KEYS = (
"APPDATA",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATHEXT",
"ProgramData",
"ProgramFiles",
"ProgramFiles(x86)",
"ProgramW6432",
"SESSIONNAME",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERNAME",
"USERPROFILE",
)
class NativeFolderPickerError(RuntimeError):
"""Raised when an available native folder picker cannot complete."""
@dataclass(frozen=True)
class _PickerCommand:
argv: tuple[str, ...]
cancel_codes: frozenset[int]
cancel_markers: tuple[str, ...] = ()
def _picker_command() -> _PickerCommand | None:
if sys.platform == "darwin":
executable = shutil.which("osascript")
if executable is None:
return None
return _PickerCommand(
argv=(
executable,
"-e",
'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
"-e",
"POSIX path of selectedFolder",
),
cancel_codes=frozenset({1}),
cancel_markers=("user canceled", "(-128)"),
)
if sys.platform == "win32":
executable = shutil.which("powershell.exe") or shutil.which("powershell")
if executable is None:
return None
script = (
"Add-Type -AssemblyName System.Windows.Forms;"
"$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;"
"$dialog.Description='Select Workspace Directory';"
"$dialog.ShowNewFolderButton=$true;"
"if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){"
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();"
"[Console]::Out.Write($dialog.SelectedPath)}"
)
return _PickerCommand(
argv=(
executable,
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
script,
),
cancel_codes=frozenset(),
)
if sys.platform.startswith("linux"):
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return None
zenity = shutil.which("zenity")
if zenity is not None:
return _PickerCommand(
argv=(
zenity,
"--file-selection",
"--directory",
"--title=Select Workspace Directory",
),
cancel_codes=frozenset({1}),
)
kdialog = shutil.which("kdialog")
if kdialog is not None:
return _PickerCommand(
argv=(kdialog, "--getexistingdirectory", str(Path.home())),
cancel_codes=frozenset({1}),
)
return None
def native_folder_picker_available() -> bool:
"""Return whether this host can display a native directory picker."""
return _picker_command() is not None
def _picker_environment() -> dict[str, str]:
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
keys: list[str] = list(_COMMON_ENV_KEYS)
if sys.platform == "darwin":
keys.extend(_MACOS_GUI_ENV_KEYS)
elif sys.platform == "win32":
keys.extend(_WINDOWS_GUI_ENV_KEYS)
elif sys.platform.startswith("linux"):
keys.extend(_LINUX_GUI_ENV_KEYS)
return {
key: value
for key in keys
if (value := os.environ.get(key)) is not None
}
async def _stop_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def pick_native_folder() -> str | None:
"""Open the platform directory picker and return an existing absolute path."""
command = _picker_command()
if command is None:
raise NativeFolderPickerError("native folder picker is unavailable on this host")
try:
process = await asyncio.create_subprocess_exec(
*command.argv,
env=_picker_environment(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
raise NativeFolderPickerError("native folder picker failed to start") from exc
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=_PICKER_TIMEOUT_SECONDS,
)
except asyncio.CancelledError:
await _stop_process(process)
raise
except TimeoutError as exc:
await _stop_process(process)
raise NativeFolderPickerError("native folder picker timed out") from exc
error_text = stderr.decode("utf-8", errors="replace").strip()
normalized_error = error_text.lower()
if process.returncode != 0:
if process.returncode in command.cancel_codes and (
not command.cancel_markers
or any(marker in normalized_error for marker in command.cancel_markers)
):
return None
raise NativeFolderPickerError("native folder picker failed")
selected = stdout.decode("utf-8", errors="replace").strip()
if not selected:
return None
path = Path(selected).expanduser()
if not path.is_absolute() or not path.is_dir():
raise NativeFolderPickerError("native folder picker returned an invalid directory")
return str(path)
-64
View File
@@ -1,64 +0,0 @@
"""Read-only projection of the session material available to the agent."""
from __future__ import annotations
from typing import Any, cast
from nanobot.session.manager import Session
from nanobot.utils.helpers import estimate_message_tokens, truncate_text
_SUMMARY_PREVIEW_CHARS = 4_000
def session_context_payload(session: Session) -> dict[str, Any]:
"""Return an explainable view of session replay without building a model prompt.
The final prompt also contains workspace instructions, memory, skills, and a
model-specific token budget. This projection deliberately reports only the
session-owned part: archived summary plus the replayable raw suffix.
"""
replay = session.get_history(max_messages=0, include_runtime_context=False)
raw_summary = session.metadata.get("_last_summary")
summary = ""
summary_preview = ""
summary_at: str | None = None
if isinstance(raw_summary, dict):
summary_data = cast(dict[str, object], raw_summary)
text = summary_data.get("text")
last_active = summary_data.get("last_active")
if isinstance(text, str):
summary = text.strip()
summary_preview = truncate_text(summary, _SUMMARY_PREVIEW_CHARS)
if isinstance(last_active, str):
summary_at = last_active
replay_tokens = sum(estimate_message_tokens(message) for message in replay)
summary_tokens = (
estimate_message_tokens({"role": "system", "content": summary}) if summary else 0
)
raw_usage = session.metadata.get("_last_usage")
last_usage = (
{
key: value
for key, value in cast(dict[object, object], raw_usage).items()
if isinstance(key, str)
and type(value) is int
and value >= 0
}
if isinstance(raw_usage, dict)
else None
)
return {
"schema_version": 1,
"session_key": session.key,
"total_messages": len(session.messages),
"archived_messages": min(session.last_consolidated, len(session.messages)),
"replay_messages": len(replay),
"estimated_replay_tokens": replay_tokens,
"estimated_summary_tokens": summary_tokens,
"estimated_session_tokens": replay_tokens + summary_tokens,
"archived_summary": summary_preview or None,
"archived_summary_at": summary_at,
"last_usage": last_usage,
}
+27 -298
View File
@@ -1,16 +1,14 @@
"""Cache-only WebUI session list index.
The core ``SessionManager`` owns model context while the WebUI transcript owns
durable display history. The sidebar discovers both without reconstructing one
store from the other, so core session writes stay independent from UI state.
The core ``SessionManager`` owns durable conversation history. This module owns
the WebUI sidebar optimization so core session writes stay independent from UI
presentation caches.
"""
from __future__ import annotations
import json
import os
import re
import secrets
from datetime import datetime
from pathlib import Path
from typing import Any, cast
@@ -32,12 +30,9 @@ from nanobot.session.manager import (
)
from nanobot.session.model_selection import model_preset_from_metadata
_INDEX_VERSION = 7
_INDEX_VERSION = 6
_INDEX_FILENAME = ".webui_session_index.json"
_MODEL_PRESET_FIELD = "model_preset"
_ROW_SOURCE_FIELD = "_source"
_SESSION_SOURCE = "session"
_TRANSCRIPT_SOURCE = "webui_transcript"
_WORKSPACE_SCOPE_PRESENT_FIELD = "_workspace_scope_present"
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset(
@@ -47,96 +42,52 @@ _INDEXED_WORKSPACE_SCOPE_KEYS = ("project_path", "path", "access_mode")
_MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
_WEBUI_ACTIVITY_FILES = "webui_activity_files"
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
_WEBUI_SESSION_STEM_PREFIX = SessionManager.safe_key("websocket:")
_WEBUI_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
_TRANSCRIPT_SEGMENTS_SUFFIX = ".segments"
_TRANSCRIPT_NON_ANSWER_KINDS = {"progress", "reasoning", "tool_hint"}
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
"""Return session rows for the WebUI sidebar, backed by a rebuildable cache."""
with session_manager.locked_session_files():
rows, changed = _reconcile_index(session_manager)
if changed:
try:
_write_index_rows(session_manager.sessions_dir, rows)
except Exception as e:
logger.debug("Failed to write WebUI session list index: {}", e)
sessions = [
_public_row(session_manager.sessions_dir, get_webui_dir(), row)
for row in rows
]
sessions = [_public_row(session_manager.sessions_dir, row) for row in rows]
return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True)
def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]:
existing_rows = _read_index_rows(session_manager.sessions_dir)
existing_by_source = {
(row.get(_ROW_SOURCE_FIELD), row.get("file")): row
existing_by_file = {
row.get("file"): row
for row in existing_rows or []
if isinstance(row.get(_ROW_SOURCE_FIELD), str)
and isinstance(row.get("file"), str)
if isinstance(row.get("file"), str)
}
webui_dir = get_webui_dir()
session_paths: dict[str, Path] = {}
for path in sorted(session_manager.sessions_dir.glob("*.jsonl")):
key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
if key is not None:
session_paths[key] = path
paths = sorted(
path
for path in session_manager.sessions_dir.glob("*.jsonl")
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
)
if not paths:
return [], existing_rows != []
session_keys_by_stem = {
SessionManager.safe_key(key): key
for key in session_paths
if key.startswith("websocket:")
}
webui_dir = get_webui_dir()
rows: list[dict[str, Any]] = []
changed = existing_rows is None
expected_sources: set[tuple[str, str]] = set()
for key, path in sorted(session_paths.items()):
identity = (_SESSION_SOURCE, path.name)
row = existing_by_source.get(identity)
for path in paths:
row = existing_by_file.get(path.name)
if row is not None and _indexed_row_matches_file(row, path, webui_dir):
rows.append(row)
expected_sources.add(identity)
continue
changed = True
scanned = _scan_session_row(session_manager, path, webui_dir)
if scanned is not None:
rows.append(scanned)
expected_sources.add(identity)
for stem, paths in _webui_transcript_sources(webui_dir).items():
if stem in session_keys_by_stem:
continue
identity = (_TRANSCRIPT_SOURCE, stem)
row = existing_by_source.get(identity)
cached_key = row.get("key") if row is not None else None
key = (
cached_key
if isinstance(cached_key, str) and _valid_transcript_session_key(cached_key, stem)
else None
)
if key is not None and row is not None and _indexed_transcript_row_matches(
row,
key,
webui_dir,
):
rows.append(row)
expected_sources.add(identity)
continue
changed = True
scanned = _scan_transcript_row(key, stem, paths, webui_dir)
scanned_key = scanned.get("key") if scanned is not None else None
if scanned is not None and scanned_key not in session_paths:
rows.append(scanned)
expected_sources.add(identity)
if set(existing_by_source) != expected_sources:
if set(existing_by_file) != {path.name for path in paths}:
changed = True
if existing_rows is not None and rows != existing_rows:
changed = True
@@ -171,14 +122,14 @@ def _read_index_rows(sessions_dir: Path) -> list[dict[str, Any]] | None:
def _write_index_rows(sessions_dir: Path, rows: list[dict[str, Any]]) -> None:
path = _index_path(sessions_dir)
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
tmp_path = path.with_suffix(".json.tmp")
data = {"version": _INDEX_VERSION, "sessions": rows}
try:
with open(tmp_path, "x", encoding="utf-8") as file:
file.write(json.dumps(data, ensure_ascii=False) + "\n")
tmp_path.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
os.replace(tmp_path, path)
finally:
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
def _file_signature(path: Path) -> dict[str, int]:
@@ -193,7 +144,7 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path)
return False
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
return False
if row.get(_ROW_SOURCE_FIELD) != _SESSION_SOURCE or row.get("file") != path.name:
if row.get("file") != path.name:
return False
try:
signature = _file_signature(path)
@@ -205,39 +156,10 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path)
and row.get("size") == signature["size"]
and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
and row.get(_WEBUI_ACTIVITY_FILES) == activity_signature[_WEBUI_ACTIVITY_FILES]
)
def _indexed_transcript_row_matches(
row: dict[str, Any],
session_key: str,
webui_dir: Path,
) -> bool:
if not all(isinstance(row.get(key), str) for key in ("key", "created_at", "updated_at")):
return False
if row.get(_ROW_SOURCE_FIELD) != _TRANSCRIPT_SOURCE:
return False
if row.get("key") != session_key or row.get("file") != SessionManager.safe_key(session_key):
return False
if not isinstance(row.get("title", ""), str) or not isinstance(row.get("preview", ""), str):
return False
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
return False
signature = _webui_activity_signature(session_key, webui_dir)
return (
row.get(_WEBUI_ACTIVITY_MTIME_NS) == signature[_WEBUI_ACTIVITY_MTIME_NS]
and row.get(_WEBUI_ACTIVITY_SIZE) == signature[_WEBUI_ACTIVITY_SIZE]
and row.get(_WEBUI_ACTIVITY_FILES) == signature[_WEBUI_ACTIVITY_FILES]
)
def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
file = str(row.get("file", ""))
if row.get(_ROW_SOURCE_FIELD) == _TRANSCRIPT_SOURCE:
path = webui_dir / f"{file}.jsonl"
else:
path = sessions_dir / file
def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
return {
"key": row.get("key"),
"created_at": row.get("created_at"),
@@ -247,7 +169,7 @@ def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dic
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
"path": str(path),
"path": str(sessions_dir / str(row.get("file", ""))),
}
@@ -320,90 +242,17 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
return fallback_preview
def _webui_transcript_record_paths(stem: str, webui_dir: Path) -> tuple[Path, ...]:
paths: list[Path] = []
segments_dir = webui_dir / f"{stem}{_TRANSCRIPT_SEGMENTS_SUFFIX}"
if segments_dir.is_dir() and not segments_dir.is_symlink():
try:
paths.extend(
sorted(
path
for path in segments_dir.glob("*.jsonl")
if path.is_file() and not path.is_symlink()
)
)
except OSError:
pass
active = webui_dir / f"{stem}.jsonl"
if active.is_file() and not active.is_symlink():
paths.append(active)
return tuple(paths)
def _webui_transcript_sources(webui_dir: Path) -> dict[str, tuple[Path, ...]]:
stems: set[str] = set()
try:
entries = tuple(webui_dir.iterdir())
except OSError:
return {}
for path in entries:
if path.is_symlink():
continue
if path.is_file() and path.suffix == ".jsonl":
stem = path.stem
elif path.is_dir() and path.name.endswith(_TRANSCRIPT_SEGMENTS_SUFFIX):
stem = path.name.removesuffix(_TRANSCRIPT_SEGMENTS_SUFFIX)
else:
continue
if stem.startswith(_WEBUI_SESSION_STEM_PREFIX):
stems.add(stem)
return {
stem: paths
for stem in sorted(stems)
if (paths := _webui_transcript_record_paths(stem, webui_dir))
}
def _transcript_record(line: str) -> dict[str, Any] | None:
try:
value: object = json.loads(line)
except json.JSONDecodeError:
return None
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _valid_transcript_session_key(key: str, stem: str) -> bool:
if not key.startswith("websocket:"):
return False
chat_id = key.split(":", 1)[1]
return _WEBUI_CHAT_ID_RE.fullmatch(chat_id) is not None and SessionManager.safe_key(key) == stem
def _webui_activity_paths(session_key: str, webui_dir: Path) -> list[Path]:
stem = SessionManager.safe_key(session_key)
paths = [
return [
webui_dir / f"{stem}.jsonl",
webui_dir / f"{stem}.json",
]
segments_dir = webui_dir / f"{stem}{_TRANSCRIPT_SEGMENTS_SUFFIX}"
if segments_dir.is_dir() and not segments_dir.is_symlink():
try:
paths.extend(
sorted(
path
for path in segments_dir.iterdir()
if path.is_file() and not path.is_symlink()
)
)
except OSError:
pass
return paths
def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, int]:
latest_mtime_ns = 0
total_size = 0
file_count = 0
for path in _webui_activity_paths(session_key, webui_dir):
try:
stat = path.stat()
@@ -411,13 +260,11 @@ def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, in
continue
if not path.is_file():
continue
file_count += 1
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
total_size += stat.st_size
return {
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
_WEBUI_ACTIVITY_SIZE: total_size,
_WEBUI_ACTIVITY_FILES: file_count,
}
@@ -486,7 +333,6 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
"preview": _preview_from_messages(session.messages),
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
**_indexed_workspace_scope_fields(session.metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name,
"mtime_ns": signature["mtime_ns"],
"size": signature["size"],
@@ -494,122 +340,6 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
}
def _transcript_preview(record: dict[str, Any]) -> tuple[str, str]:
text = record.get("text")
if not isinstance(text, str) or not text.strip():
return "", ""
preview = _message_preview_text({"content": text})
if not preview:
return "", ""
event = record.get("event")
if event == "user" or record.get("role") == "user":
return preview, ""
if (
event == "message"
and record.get("kind") not in _TRANSCRIPT_NON_ANSWER_KINDS
) or record.get("role") == "assistant":
return "", preview
return "", ""
def _transcript_created_at(record: dict[str, Any]) -> str | None:
value = record.get("created_at_ms")
if (
not isinstance(value, int | float)
or isinstance(value, bool)
or value < 0
):
return None
try:
return datetime.fromtimestamp(value / 1000).isoformat()
except (OSError, OverflowError, ValueError):
return None
def _scan_transcript_row(
session_key: str | None,
stem: str,
paths: tuple[Path, ...],
webui_dir: Path,
) -> dict[str, Any] | None:
path_key = session_key or f"websocket:{stem.removeprefix(_WEBUI_SESSION_STEM_PREFIX)}"
signature = _webui_activity_signature(path_key, webui_dir)
activity_updated_at = _webui_activity_updated_at(signature)
if activity_updated_at is None:
return None
preview = ""
fallback_preview = ""
created_at: str | None = None
saw_record = False
scanned_records = 0
scanned_chars = 0
for path in paths:
try:
with open(path, encoding="utf-8") as handle:
for line in handle:
if not line.strip():
continue
scanned_records += 1
scanned_chars += len(line)
record = _transcript_record(line)
if record is not None:
saw_record = True
chat_id = record.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
candidate = f"websocket:{chat_id.strip()}"
if _valid_transcript_session_key(candidate, stem):
session_key = candidate
if created_at is None:
created_at = _transcript_created_at(record)
user_preview, assistant_preview = _transcript_preview(record)
if user_preview:
preview = user_preview
break
if not fallback_preview and assistant_preview:
fallback_preview = assistant_preview
if (
scanned_records >= _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars >= _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
except OSError:
continue
if preview or (
scanned_records >= _SESSION_LIST_PREVIEW_MAX_RECORDS
or scanned_chars >= _SESSION_LIST_PREVIEW_MAX_CHARS
):
break
if not saw_record:
return None
if session_key is None:
fallback = f"websocket:{stem.removeprefix(_WEBUI_SESSION_STEM_PREFIX)}"
if not _valid_transcript_session_key(fallback, stem):
return None
session_key = fallback
if created_at is None:
try:
earliest_mtime = min(path.stat().st_mtime for path in paths)
created_at = datetime.fromtimestamp(earliest_mtime).isoformat()
except (OSError, OverflowError, ValueError):
created_at = activity_updated_at
return {
"key": session_key,
"created_at": created_at,
"updated_at": activity_updated_at,
"title": "",
"preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: None,
**_indexed_workspace_scope_fields({}),
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
"file": stem,
"mtime_ns": signature[_WEBUI_ACTIVITY_MTIME_NS],
"size": signature[_WEBUI_ACTIVITY_SIZE],
**signature,
}
def _scan_session_row(
session_manager: SessionManager,
path: Path,
@@ -688,7 +418,6 @@ def _scan_session_row(
"preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
**_indexed_workspace_scope_fields(metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name,
"mtime_ns": signature["mtime_ns"],
"size": signature["size"],
+3 -21
View File
@@ -7,7 +7,7 @@ domains; this module preserves the established Python and HTTP-facing seams.
from __future__ import annotations
from collections.abc import Callable, Iterable
from collections.abc import Iterable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -234,31 +234,13 @@ def update_model_configuration(
query: QueryParams,
*,
config_path: Path | None = None,
rename_model_preset: Callable[[str, str], int] | None = None,
) -> dict[str, Any]:
config = _load_settings_config(config_path)
names_before = set(config.model_presets)
changed = models.update_model_configuration(
if models.update_model_configuration(
config,
query,
oauth_status=_oauth_provider_status,
)
if changed:
removed = names_before - set(config.model_presets)
added = set(config.model_presets) - names_before
rename = (
(next(iter(removed)), next(iter(added)))
if len(removed) == len(added) == 1
else None
)
if rename is not None and rename_model_preset is not None:
rename_model_preset(*rename)
try:
_save_settings_config(config, config_path)
except BaseException:
rename_model_preset(rename[1], rename[0])
raise
else:
):
_save_settings_config(config, config_path)
return settings_payload(config_path=config_path)
+20 -87
View File
@@ -778,56 +778,6 @@ def _model_configuration_slug(label: str) -> str:
return normalized
def _model_configuration_name(value: str) -> str:
"""Validate a user-facing preset name without inventing a second identity."""
name = value.strip()
if not name:
raise WebUISettingsError("configuration name is required")
if name.casefold() == "default":
raise WebUISettingsError("configuration name is reserved")
if len(name) > 48:
raise WebUISettingsError("configuration name must be 48 characters or fewer")
if not name.isprintable():
raise WebUISettingsError("configuration name contains unsupported characters")
return name
def _model_configuration_name_exists(
config: Config,
name: str,
*,
exclude: str | None = None,
) -> bool:
normalized = name.casefold()
return any(
existing != exclude and existing.casefold() == normalized
for existing in config.model_presets
)
def _rename_model_configuration(config: Config, old_name: str, new_name: str) -> bool:
"""Rename one preset and every config reference to it."""
if old_name == new_name:
return False
if _model_configuration_name_exists(config, new_name, exclude=old_name):
raise WebUISettingsError("configuration already exists", status=409)
config.model_presets = {
(new_name if name == old_name else name): preset
for name, preset in config.model_presets.items()
}
defaults = config.agents.defaults
if defaults.model_preset == old_name:
defaults.model_preset = new_name
defaults.fallback_models = [
new_name if fallback == old_name else fallback
for fallback in defaults.fallback_models
]
if defaults.dream.model_override == old_name:
defaults.dream.model_override = new_name
return True
def _custom_provider_key(config: Config, display_name: str) -> str:
slug = _MODEL_CONFIGURATION_SLUG_RE.sub("-", display_name.strip().lower()).strip("-_")
base = f"custom-{slug or 'provider'}"
@@ -874,7 +824,7 @@ def _unique_model_configuration_name(config: Config, label: str) -> str:
base = "model"
candidate = base
suffix = 2
while _model_configuration_name_exists(config, candidate):
while candidate in config.model_presets:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
@@ -978,8 +928,6 @@ def model_settings_payload(
model_presets = [
{
"name": "default",
# Kept on the wire for older WebUI clients. It is no longer a
# separate product concept and always mirrors the canonical name.
"label": "Default",
"active": active_preset_name == "default",
"is_default": True,
@@ -1010,7 +958,7 @@ def model_settings_payload(
model_presets.append(
{
"name": name,
"label": name,
"label": preset.label or name,
"active": active_preset_name == name,
"is_default": False,
"model": preset.model,
@@ -1104,24 +1052,20 @@ def create_model_configuration(
*,
oauth_status: OAuthStatusReader,
) -> str:
raw_name = query_first(query, "name")
legacy_label = query_first_alias(query, "label", "displayName")
label = (query_first_alias(query, "label", "displayName") or "").strip()
raw_name = (query_first(query, "name") or label).strip()
model = (query_first(query, "model") or "").strip()
provider = (query_first(query, "provider") or "").strip()
if not label:
label = raw_name
if not model:
raise WebUISettingsError("model is required")
if not provider:
raise WebUISettingsError("provider is required")
# Old clients only sent `label`; preserve their slugging behaviour while
# new clients provide the one canonical, user-visible name directly.
name = (
_model_configuration_name(raw_name)
if raw_name is not None
else _model_configuration_slug(legacy_label or "")
)
if _model_configuration_name_exists(config, name):
name = _model_configuration_slug(raw_name or label)
if name in config.model_presets:
raise WebUISettingsError("configuration already exists", status=409)
_validate_configured_provider(config, provider, oauth_status)
@@ -1141,6 +1085,7 @@ def create_model_configuration(
query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
).strip() or None
config.model_presets[name] = ModelPresetConfig(
label=label,
model=model,
provider=provider,
max_tokens=max_tokens if max_tokens is not None else base.max_tokens,
@@ -1170,12 +1115,14 @@ def update_model_configuration(
raise WebUISettingsError("unknown model configuration")
changed = False
new_name_value = query_first_alias(query, "new_name", "newName")
if new_name_value is not None:
new_name = _model_configuration_name(new_name_value)
changed = _rename_model_configuration(config, name, new_name) or changed
name = new_name
preset = config.model_presets[name]
label = query_first_alias(query, "label", "displayName")
if label is not None:
label = label.strip()
if not label:
raise WebUISettingsError("label is required")
if preset.label != label:
preset.label = label
changed = True
model = query_first(query, "model")
if model is not None:
@@ -1281,6 +1228,7 @@ def migrate_model_configurations(config: Config) -> bool:
label = _model_configuration_label(primary.model)
name = _unique_model_configuration_name(config, label)
config.model_presets[name] = ModelPresetConfig(
label=label,
model=primary.model,
provider=primary.provider,
max_tokens=primary.max_tokens,
@@ -1299,6 +1247,7 @@ def migrate_model_configurations(config: Config) -> bool:
label = _model_configuration_label(fallback.model)
name = _unique_model_configuration_name(config, label)
config.model_presets[name] = ModelPresetConfig(
label=label,
model=fallback.model,
provider=fallback.provider,
max_tokens=(
@@ -1646,11 +1595,6 @@ class ModelSettingsHandler:
self.settings = settings
self.logger = logger
def _refresh_runtime_config(self) -> None:
"""Make a successful model-settings mutation visible to live clients now."""
if self.settings.refresh_runtime_config is not None:
self.settings.refresh_runtime_config()
async def handle(
self,
action: str,
@@ -1660,24 +1604,15 @@ class ModelSettingsHandler:
try:
if action == "agent-update":
payload = self.settings.mutate(operations.update_agent, request.query)
self._refresh_runtime_config()
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
)
if action == "model-update":
payload = self.settings.mutate(
operations.update_model,
request.query,
rename_model_preset=self.settings.rename_model_preset,
)
self._refresh_runtime_config()
return SettingsRouteResult.success(payload, decorate_restart=True)
mutation = {
"model-create": operations.create_model,
"model-update": operations.update_model,
"model-delete": operations.delete_model,
"models-migrate": operations.migrate_models,
"call-order-update": operations.update_call_order,
@@ -1685,7 +1620,6 @@ class ModelSettingsHandler:
}.get(action)
if mutation is not None:
payload = self.settings.mutate(mutation, request.query)
self._refresh_runtime_config()
return SettingsRouteResult.success(payload, decorate_restart=True)
if action == "provider-update":
@@ -1696,7 +1630,6 @@ class ModelSettingsHandler:
payload, image_restart_cleared = await operations.apply_image_runtime_change(
payload
)
self._refresh_runtime_config()
return SettingsRouteResult.success(
payload,
decorate_restart=True,
+7 -33
View File
@@ -5,15 +5,17 @@ from __future__ import annotations
import asyncio
import html
import json
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Callable, Mapping
from typing import Any, cast
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.image_generation import request_image_generation_reload
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
from nanobot.api.runtime import ApiRuntime, api_runtime_paths
from nanobot.apps.discovery import discovery_payload
from nanobot.bus.queue import MessageBus
from nanobot.channels.registry import load_channel_plugin
from nanobot.channels.validation import validate_channel_config
@@ -70,7 +72,6 @@ _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024
_MCP_RELOAD_TIMEOUT_SECONDS = 15.0
_query_first = contracts.query_first
@@ -127,6 +128,7 @@ _CAPABILITY_ROUTES = {
}
_SYSTEM_ROUTES = {
"/api/settings/apps-discovery": "apps-discovery",
"/api/settings/cli-apps": "cli-list",
"/api/settings/cli-apps/install": "cli-install",
"/api/settings/cli-apps/update": "cli-update",
@@ -227,7 +229,6 @@ class WebUISettingsRouter:
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
) -> None:
self.settings = settings
@@ -242,7 +243,6 @@ class WebUISettingsRouter:
self._channel_feature_action = channel_feature_action
self._channel_runtime_status = channel_runtime_status
self._mcp_runtime_status = mcp_runtime_status
self._mcp_reload = mcp_reload
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
self._mcp_oauth = McpOAuthManager()
self._restart_sections: set[str] = set()
@@ -463,6 +463,7 @@ class WebUISettingsRouter:
def _system_operations(self) -> system_domain.SystemSettingsOperations:
return system_domain.SystemSettingsOperations(
apps_discovery_payload=discovery_payload,
cli_apps_payload=cli_apps_payload,
cli_apps_action=cli_apps_action,
nanobot_features_payload=nanobot_features_payload,
@@ -474,7 +475,7 @@ class WebUISettingsRouter:
approve_code=approve_code,
deny_code=deny_code,
mcp_presets_action=mcp_presets_settings_action,
reload_mcp=self._reload_mcp_runtime,
reload_mcp=lambda: request_mcp_reload(self.bus),
mcp_runtime_status=self._mcp_runtime_status,
check_for_update=check_for_update,
channel_feature_action=self._channel_feature_action,
@@ -501,33 +502,6 @@ class WebUISettingsRouter:
self._restart_sections.discard("image")
return updated
async def _reload_mcp_runtime(self) -> dict[str, Any]:
if self._mcp_reload is None:
return {
"ok": False,
"message": "MCP runtime reload is unavailable. Restart nanobot to apply changes.",
"requires_restart": True,
}
try:
return await asyncio.wait_for(
self._mcp_reload(),
timeout=_MCP_RELOAD_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
return {
"ok": False,
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
except Exception as exc:
self.logger.exception("MCP hot reload failed")
return {
"ok": False,
"message": "MCP hot reload failed. Restart nanobot to pick up changes.",
"requires_restart": True,
"error": str(exc),
}
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
return self._query(request)
@@ -651,7 +625,7 @@ class WebUISettingsRouter:
name,
cfg,
redirect_uri,
reload_mcp=self._reload_mcp_runtime,
reload_mcp=lambda: request_mcp_reload(self.bus),
reset_credentials=reset,
)
except Exception as exc:

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