mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94e1d73e2 |
+2
-119
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -16,8 +16,6 @@ webui/node_modules/
|
||||
webui/dist/
|
||||
webui/coverage/
|
||||
webui/.vite/
|
||||
tui/node_modules/
|
||||
tui/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Python bytecode & caches
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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/` |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+44
-110
@@ -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,66 +795,12 @@ 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:
|
||||
await self.bus.publish_outbound(result)
|
||||
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."
|
||||
ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self)
|
||||
result = await dispatch_fn(ctx)
|
||||
if result:
|
||||
await self.bus.publish_outbound(result)
|
||||
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"},
|
||||
)
|
||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||
|
||||
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
|
||||
|
||||
+16
-13
@@ -769,25 +769,28 @@ 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]] = []
|
||||
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: 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)
|
||||
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):
|
||||
logger.debug("Pruned old dream session: {}", path.stem)
|
||||
else:
|
||||
logger.warning("Failed to prune dream session {}", path)
|
||||
to_remove = dream_files[: len(dream_files) - keep]
|
||||
for path in to_remove:
|
||||
try:
|
||||
path.unlink()
|
||||
logger.debug("Pruned old dream session: {}", path.stem)
|
||||
except OSError:
|
||||
logger.warning("Failed to prune dream session {}", path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]
|
||||
raise KeyError(f"model_preset {name!r} not found. Available: {', '.join(presets) or '(none)'}")
|
||||
if name not in presets:
|
||||
raise KeyError(f"model_preset {name!r} not found. Available: {', '.join(presets) or '(none)'}")
|
||||
return name
|
||||
|
||||
|
||||
+40
-117
@@ -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
@@ -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 usage
|
||||
if 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
|
||||
return self._estimate_response_usage(spec, messages, response)
|
||||
|
||||
def _estimate_response_usage(
|
||||
self,
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
self._states_by_key[key] = states
|
||||
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()
|
||||
|
||||
|
||||
@@ -153,21 +153,9 @@ 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,
|
||||
plugin_skill_dirs = list(
|
||||
enabled_agent_plugin_skill_dirs(Path(self._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),
|
||||
)
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return self._resolve_with_extra(
|
||||
|
||||
+400
-381
@@ -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,29 +1282,15 @@ 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:
|
||||
_log_mcp_connection_failure(name, e)
|
||||
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)
|
||||
for name, cfg in mcp_servers.items():
|
||||
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
|
||||
result = await connect_single_server(name, cfg)
|
||||
except Exception as e:
|
||||
_log_mcp_connection_failure(name, e)
|
||||
continue
|
||||
if result[1] is not None:
|
||||
server_stacks[result[0]] = result[1]
|
||||
|
||||
return server_stacks
|
||||
|
||||
@@ -1328,357 +1301,369 @@ 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 _runtime_status_store(
|
||||
state: Any,
|
||||
*,
|
||||
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
|
||||
|
||||
|
||||
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()))
|
||||
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 statuses.items()
|
||||
if name in configured and status in _MCP_RUNTIME_STATUSES
|
||||
}
|
||||
|
||||
|
||||
class MCPProvider:
|
||||
"""Own configured MCP connections and their dynamic tool registrations."""
|
||||
def _set_runtime_status(
|
||||
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:
|
||||
statuses[name] = status
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
servers: Mapping[str, MCPServerConfig],
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
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
|
||||
|
||||
@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,
|
||||
)
|
||||
def _record_connection_result(
|
||||
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)
|
||||
_set_runtime_status(state, connected_names, "connected")
|
||||
_set_runtime_status(state, attempted_names - connected_names, "failed")
|
||||
|
||||
@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."""
|
||||
return {
|
||||
name: status
|
||||
for name, status in self._runtime_statuses.items()
|
||||
if name in self._servers
|
||||
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 state._mcp_servers.items() if name not in state._mcp_stacks
|
||||
}
|
||||
|
||||
def _set_runtime_status(
|
||||
self,
|
||||
server_names: Iterable[str],
|
||||
status: MCPRuntimeStatus,
|
||||
) -> None:
|
||||
for name in server_names:
|
||||
self._runtime_statuses[name] = status
|
||||
|
||||
def _record_connection_result(
|
||||
self,
|
||||
attempted: Iterable[str],
|
||||
connected: Iterable[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")
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect configured servers that are not currently live."""
|
||||
async with self._lock:
|
||||
if self._closing:
|
||||
return
|
||||
configured_missing = {
|
||||
name: cfg
|
||||
for name, cfg in self._servers.items()
|
||||
if name not in self._connections
|
||||
}
|
||||
oauth_servers = {
|
||||
name: cfg
|
||||
for name, cfg in configured_missing.items()
|
||||
if cfg.auth == "oauth"
|
||||
}
|
||||
authorization_pending: set[str] = set()
|
||||
if oauth_servers:
|
||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||
|
||||
authorization_pending = {
|
||||
name
|
||||
for name, cfg in oauth_servers.items()
|
||||
if not mcp_oauth_has_credentials(name, cfg.url)
|
||||
}
|
||||
for name in authorization_pending:
|
||||
self._runtime_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:
|
||||
return
|
||||
self._set_runtime_status(missing_servers, "connecting")
|
||||
try:
|
||||
connected = await connect_mcp_servers(missing_servers, self._registry)
|
||||
if self._closing:
|
||||
await _close_mcp_connections(connected)
|
||||
return
|
||||
self._connections.update(connected)
|
||||
self._record_connection_result(missing_servers, connected)
|
||||
self._attach_reconnect_handlers(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)"
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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()
|
||||
try:
|
||||
next_servers = dict(self._server_loader())
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
|
||||
"requires_restart": True,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
current_servers = dict(self._servers)
|
||||
current_names = set(current_servers)
|
||||
next_names = set(next_servers)
|
||||
oauth_servers = {
|
||||
name: cfg
|
||||
for name, cfg in configured_missing.items()
|
||||
if getattr(cfg, "auth", None) == "oauth"
|
||||
}
|
||||
authorization_pending: set[str] = set()
|
||||
if oauth_servers:
|
||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||
|
||||
authorization_pending = {
|
||||
name
|
||||
for name, cfg in next_servers.items()
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
|
||||
for name, cfg in oauth_servers.items()
|
||||
if not mcp_oauth_has_credentials(name, cfg.url)
|
||||
}
|
||||
removed = sorted(current_names - next_names)
|
||||
added = sorted(next_names - current_names)
|
||||
changed = sorted(
|
||||
name
|
||||
for name in current_names & next_names
|
||||
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)
|
||||
|
||||
for name in [*removed, *authorization_pending]:
|
||||
self._runtime_statuses.pop(name, None)
|
||||
|
||||
self._servers = next_servers
|
||||
retry_missing = sorted(
|
||||
name
|
||||
for name in next_names
|
||||
if name not in self._connections
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
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)
|
||||
)
|
||||
elif unchanged:
|
||||
message = "MCP config is already live."
|
||||
elif retry_missing and not added and not changed and not removed:
|
||||
message = "MCP connections refreshed without restarting nanobot."
|
||||
statuses = _runtime_status_store(state)
|
||||
if statuses is not None:
|
||||
for name in authorization_pending:
|
||||
statuses.pop(name, None)
|
||||
missing_servers = {
|
||||
name: cfg
|
||||
for name, cfg in configured_missing.items()
|
||||
if name not in authorization_pending
|
||||
}
|
||||
if state._mcp_connecting or not missing_servers:
|
||||
return
|
||||
state._mcp_connecting = True
|
||||
_set_runtime_status(state, missing_servers, "connecting")
|
||||
try:
|
||||
connected = await connect_mcp_servers(missing_servers, registry)
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
for connection in connected.values():
|
||||
await connection.aclose()
|
||||
return
|
||||
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:
|
||||
message = "MCP config reloaded without restarting nanobot."
|
||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||
except asyncio.CancelledError:
|
||||
if task_is_cancelling():
|
||||
raise
|
||||
_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
|
||||
|
||||
logger.info(
|
||||
"MCP hot reload: added={} changed={} removed={} retried={} "
|
||||
"connected={} failed={} tools_removed={}",
|
||||
added,
|
||||
changed,
|
||||
removed,
|
||||
retry_missing,
|
||||
sorted(connected),
|
||||
failed,
|
||||
tools_removed,
|
||||
)
|
||||
|
||||
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": ok,
|
||||
"message": message,
|
||||
"added": added,
|
||||
"changed": changed,
|
||||
"removed": removed,
|
||||
"retried": retry_missing,
|
||||
"connected": sorted(self._connections),
|
||||
"configured": sorted(self._servers),
|
||||
"failed": failed,
|
||||
"tools_removed": tools_removed,
|
||||
"requires_restart": False,
|
||||
"ok": False,
|
||||
"message": "MCP connections are shutting down.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
try:
|
||||
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 {
|
||||
"ok": False,
|
||||
"message": "Could not reload MCP config. Restart nanobot to pick up changes.",
|
||||
"requires_restart": True,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _closing_result() -> dict[str, Any]:
|
||||
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
|
||||
|
||||
authorization_pending = {
|
||||
name
|
||||
for name, cfg in next_servers.items()
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
|
||||
}
|
||||
removed = sorted(current_names - next_names)
|
||||
added = sorted(next_names - current_names)
|
||||
changed = sorted(
|
||||
name
|
||||
for name in current_names & next_names
|
||||
if _server_signature(current_servers[name]) != _server_signature(next_servers[name])
|
||||
)
|
||||
|
||||
tools_removed = 0
|
||||
for name in [*removed, *changed]:
|
||||
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]:
|
||||
runtime_statuses.pop(name, None)
|
||||
|
||||
state._mcp_servers = next_servers
|
||||
retry_missing = sorted(
|
||||
name
|
||||
for name in next_names
|
||||
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
|
||||
)
|
||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||
connected: dict[str, MCPConnection] = {}
|
||||
if to_connect:
|
||||
_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)
|
||||
elif unchanged:
|
||||
message = "MCP config is already live."
|
||||
elif retry_missing and not added and not changed and not removed:
|
||||
message = "MCP connections refreshed without restarting nanobot."
|
||||
else:
|
||||
message = "MCP config reloaded without restarting nanobot."
|
||||
|
||||
logger.info(
|
||||
"MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}",
|
||||
added,
|
||||
changed,
|
||||
removed,
|
||||
retry_missing,
|
||||
sorted(connected),
|
||||
failed,
|
||||
tools_removed,
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "MCP connections are shutting down.",
|
||||
"requires_restart": True,
|
||||
"ok": ok,
|
||||
"message": message,
|
||||
"added": added,
|
||||
"changed": changed,
|
||||
"removed": removed,
|
||||
"retried": retry_missing,
|
||||
"connected": sorted(state._mcp_stacks),
|
||||
"configured": sorted(state._mcp_servers),
|
||||
"failed": failed,
|
||||
"tools_removed": tools_removed,
|
||||
"requires_restart": False,
|
||||
}
|
||||
|
||||
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(
|
||||
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)
|
||||
if not _tool_belongs_to_server(tool, tool_name, server_name):
|
||||
continue
|
||||
if isinstance(tool, _MCPWrapperBase):
|
||||
tool.set_reconnect_handler(reconnect)
|
||||
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 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,
|
||||
}
|
||||
|
||||
async def _refresh_terminated_server(
|
||||
self,
|
||||
server_name: str,
|
||||
tool_name: str,
|
||||
stale_tool: Tool,
|
||||
) -> Tool | None:
|
||||
async with self._lock:
|
||||
if self._closing:
|
||||
return None
|
||||
cfg = self._servers.get(server_name)
|
||||
if cfg is None:
|
||||
logger.warning(
|
||||
"MCP server '{}' session terminated but is no longer configured",
|
||||
server_name,
|
||||
)
|
||||
return None
|
||||
|
||||
current_tool = self._registry.get(tool_name)
|
||||
if (
|
||||
current_tool is not None
|
||||
and current_tool is not stale_tool
|
||||
and server_name in self._connections
|
||||
):
|
||||
return current_tool
|
||||
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(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(
|
||||
state: Any,
|
||||
registry: ToolRegistry,
|
||||
server_name: str,
|
||||
tool_name: str,
|
||||
stale_tool: Tool,
|
||||
) -> Tool | None:
|
||||
async with _reload_lock(state):
|
||||
if getattr(state, "_mcp_closing", False):
|
||||
return None
|
||||
cfg = state._mcp_servers.get(server_name)
|
||||
if cfg is None:
|
||||
logger.warning(
|
||||
"MCP server '{}' session terminated; refreshing connection",
|
||||
"MCP server '{}' session terminated but is no longer configured",
|
||||
server_name,
|
||||
)
|
||||
_unregister_server_tools(self._registry, server_name)
|
||||
await self._close_server(server_name)
|
||||
return None
|
||||
|
||||
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)
|
||||
return None
|
||||
self._connections.update(connected)
|
||||
self._record_connection_result({server_name}, connected)
|
||||
self._attach_reconnect_handlers(connected)
|
||||
if server_name not in connected:
|
||||
logger.warning(
|
||||
"MCP server '{}' reconnect failed after session termination",
|
||||
server_name,
|
||||
)
|
||||
return None
|
||||
return 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 state._mcp_stacks
|
||||
):
|
||||
return current_tool
|
||||
|
||||
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)
|
||||
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
||||
_unregister_server_tools(registry, server_name)
|
||||
await _close_server(state, server_name)
|
||||
|
||||
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)
|
||||
_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
|
||||
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)
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+50
-256
@@ -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,58 +537,38 @@ 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(
|
||||
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"
|
||||
"if ($PSVersionTable.PSVersion.Major -lt 6) { $OutputEncoding = [Console]::OutputEncoding }\n"
|
||||
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
|
||||
f"{command}\n"
|
||||
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
||||
)
|
||||
process = 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
|
||||
if program_name in ("cmd", "cmd.exe"):
|
||||
cmd_env = {**env, "COMSPEC": program}
|
||||
return await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdin=stdin,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=cmd_env,
|
||||
)
|
||||
command = ExecTool._normalize_powershell_command(command)
|
||||
command = (
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
||||
"if ($PSVersionTable.PSVersion.Major -lt 6) { $OutputEncoding = [Console]::OutputEncoding }\n"
|
||||
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
|
||||
f"{command}\n"
|
||||
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
||||
)
|
||||
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,
|
||||
)
|
||||
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||
args: list[str] = [shell_program]
|
||||
shell_name = Path(shell_program).name.lower()
|
||||
@@ -722,23 +687,22 @@ 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:
|
||||
with suppress(OSError, asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
subprocess.run,
|
||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
if _IS_WINDOWS:
|
||||
with suppress(OSError, asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
subprocess.run,
|
||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
@@ -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]:
|
||||
|
||||
+20
-123
@@ -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(
|
||||
client,
|
||||
url,
|
||||
headers={"User-Agent": self.user_agent},
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-23
@@ -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,7 +355,9 @@ 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)
|
||||
if response_text.strip():
|
||||
@@ -401,15 +390,16 @@ 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():
|
||||
logger.warning("Empty response for session {}, using fallback", session_key)
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -985,11 +858,6 @@ class WebSocketChannel(BaseChannel):
|
||||
"error",
|
||||
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")
|
||||
@@ -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,169 +1162,75 @@ 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(
|
||||
connection,
|
||||
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
|
||||
|
||||
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(
|
||||
task = asyncio.create_task(
|
||||
self._complete_webui_request(
|
||||
connection,
|
||||
request_id,
|
||||
operation.task,
|
||||
sequence=is_replay,
|
||||
action,
|
||||
cast(dict[str, Any], payload),
|
||||
)
|
||||
)
|
||||
self._webui_request_tasks[key] = delivery_task
|
||||
self._webui_request_tasks[key] = 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,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> 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)
|
||||
response = await self._http_router.dispatch_webui_mutation(
|
||||
connection,
|
||||
action,
|
||||
payload,
|
||||
)
|
||||
status = response.status_code
|
||||
body = bytes(response.body).decode("utf-8", errors="replace").strip()
|
||||
if 200 <= status < 300:
|
||||
try:
|
||||
result = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
result=result.result,
|
||||
status=result.status,
|
||||
message=result.message,
|
||||
status=502,
|
||||
message="WebUI mutation returned an invalid response",
|
||||
)
|
||||
return
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
result=result,
|
||||
)
|
||||
return
|
||||
result = await asyncio.shield(operation_task)
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
result=result.result,
|
||||
status=result.status,
|
||||
message=result.message,
|
||||
status=status,
|
||||
message=body or response.reason_phrase,
|
||||
)
|
||||
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:
|
||||
try:
|
||||
lock = self._webui_request_locks.setdefault(connection, asyncio.Lock())
|
||||
async with lock:
|
||||
response = await self._http_router.dispatch_webui_mutation(
|
||||
connection,
|
||||
action,
|
||||
payload,
|
||||
)
|
||||
status = response.status_code
|
||||
body = bytes(response.body).decode("utf-8", errors="replace").strip()
|
||||
if 200 <= status < 300:
|
||||
try:
|
||||
result = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return _WebUIRequestResult(
|
||||
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 _WebUIRequestResult(result=result)
|
||||
return _WebUIRequestResult(
|
||||
status=status,
|
||||
message=body or response.reason_phrase,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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
@@ -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")
|
||||
try:
|
||||
provider = make_provider(runtime_config)
|
||||
except ValueError as exc:
|
||||
_print_agent_start_error(exc)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
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
|
||||
sync_workspace_templates(runtime_config.workspace_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",
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
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,
|
||||
)
|
||||
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]"
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
if exit_code:
|
||||
raise typer.Exit(exit_code)
|
||||
return
|
||||
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()
|
||||
|
||||
from nanobot.cli.agent_runtime import run_local_agent
|
||||
asyncio.run(run_once())
|
||||
else:
|
||||
# Interactive mode — route through bus like other channels
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
run_local_agent(
|
||||
runtime_config,
|
||||
message=message,
|
||||
session_id=session_id or "cli:direct",
|
||||
markdown=markdown,
|
||||
logs=logs,
|
||||
)
|
||||
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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
||||
logger.warning("Gateway shutdown: {} cleanup incomplete: {}", label, exc)
|
||||
try:
|
||||
await asyncio.wait_for(agent.close_mcp(), timeout=close_timeout)
|
||||
except BaseException as exc: # noqa: BLE001 - shutdown must proceed
|
||||
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())
|
||||
asyncio.run(run())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
+99
-123
@@ -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,71 +257,54 @@ 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]")
|
||||
if not dev:
|
||||
console.print(
|
||||
"[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]"
|
||||
"Restart the gateway if you need it to pick up local source changes: "
|
||||
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
)
|
||||
if not dev:
|
||||
if not no_open:
|
||||
_open_webui_browser(webui_url, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(runtime)
|
||||
else:
|
||||
console.print(
|
||||
"Restart the gateway if you need it to pick up local source changes: "
|
||||
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
"[yellow]This gateway is controlled by another foreground command. "
|
||||
"Stop it from that terminal.[/yellow]"
|
||||
)
|
||||
if not no_open:
|
||||
_open_webui_browser(webui_url, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(runtime)
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]This gateway is controlled by another foreground command. "
|
||||
"Stop it from that terminal.[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
assert dev_browser_url is not None
|
||||
with run_webui_dev_server(
|
||||
target_url=webui_dev_proxy_target(webui_url),
|
||||
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, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(
|
||||
runtime,
|
||||
poll_hook=dev_server.ensure_running,
|
||||
)
|
||||
else:
|
||||
_wait_with_existing_foreground_gateway(
|
||||
runtime_config.gateway.host,
|
||||
effective_gateway_port,
|
||||
dev_server,
|
||||
)
|
||||
except WebUIDevError as exc:
|
||||
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]"
|
||||
)
|
||||
|
||||
try:
|
||||
assert dev_browser_url is not None
|
||||
with run_webui_dev_server(
|
||||
target_url=webui_dev_proxy_target(webui_url),
|
||||
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, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(
|
||||
runtime,
|
||||
poll_hook=dev_server.ensure_running,
|
||||
)
|
||||
else:
|
||||
_wait_with_existing_foreground_gateway(
|
||||
runtime_config.gateway.host,
|
||||
effective_gateway_port,
|
||||
dev_server,
|
||||
)
|
||||
except WebUIDevError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
return
|
||||
|
||||
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
|
||||
_host_for_local_browser(runtime_config.gateway.host),
|
||||
@@ -344,33 +319,34 @@ def webui(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
lease = GatewayClientLease(runtime, kind="webui")
|
||||
lease.acquire()
|
||||
try:
|
||||
ensure_shared_gateway(client_lease=lease)
|
||||
print_shared_gateway_controls()
|
||||
if dev_browser_url:
|
||||
dev_proxy_target = webui_dev_proxy_target(webui_url)
|
||||
try:
|
||||
with run_webui_dev_server(
|
||||
target_url=dev_proxy_target,
|
||||
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,
|
||||
)
|
||||
except WebUIDevError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
return
|
||||
_print_webui_foreground_lifecycle(attached=False)
|
||||
if dev_browser_url:
|
||||
dev_proxy_target = webui_dev_proxy_target(webui_url)
|
||||
try:
|
||||
with run_webui_dev_server(
|
||||
target_url=dev_proxy_target,
|
||||
browser_url=dev_browser_url,
|
||||
output=lambda message: console.print(f"[green]✓[/green] {message}"),
|
||||
) as dev_server:
|
||||
_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,
|
||||
)
|
||||
|
||||
@@ -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,26 +382,16 @@ 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]"
|
||||
)
|
||||
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(
|
||||
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
|
||||
)
|
||||
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("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]")
|
||||
console.print(
|
||||
@@ -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]")
|
||||
return
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-38
@@ -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,21 +532,9 @@ 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()
|
||||
self._arm_timer()
|
||||
|
||||
async def _execute_job(self, job: CronJob) -> None:
|
||||
"""Execute a single job."""
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+51
-337
@@ -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,25 +125,25 @@ 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] = {
|
||||
"pid": pid,
|
||||
"started_at": _utc_now(),
|
||||
"platform": self.platform_name,
|
||||
"port": options.port,
|
||||
"workspace": options.workspace,
|
||||
"config_path": options.config_path,
|
||||
"command": command,
|
||||
"log_path": str(self.paths.log_path),
|
||||
}
|
||||
state.update(self.process_identity_record(pid))
|
||||
self._write_state(state)
|
||||
self._write_state(
|
||||
{
|
||||
"pid": pid,
|
||||
"identity": self._process_identity(pid),
|
||||
"started_at": _utc_now(),
|
||||
"platform": self.platform_name,
|
||||
"port": options.port,
|
||||
"workspace": options.workspace,
|
||||
"config_path": options.config_path,
|
||||
"command": command,
|
||||
"log_path": str(self.paths.log_path),
|
||||
}
|
||||
)
|
||||
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):
|
||||
try:
|
||||
return poll() is None
|
||||
except OSError:
|
||||
pass
|
||||
return process_is_running(pid, platform_name=self.platform_name)
|
||||
if self.platform_name == "Windows":
|
||||
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 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")
|
||||
return False
|
||||
recorded = state.get("identity")
|
||||
if recorded is None:
|
||||
recorded = state.get("identity")
|
||||
return self.process_identity_match(recorded, pid)
|
||||
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:
|
||||
|
||||
@@ -782,68 +782,67 @@ class AnthropicProvider(LLMProvider):
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
try:
|
||||
async with self._client.messages.stream(**kwargs) as stream:
|
||||
# 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:
|
||||
chunk = await asyncio.wait_for(
|
||||
stream.__anext__(),
|
||||
timeout=idle_timeout_s,
|
||||
)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
if chunk.type == "content_block_start":
|
||||
block = getattr(chunk, "content_block", None)
|
||||
if getattr(block, "type", None) == "tool_use":
|
||||
index = int(getattr(chunk, "index", 0) or 0)
|
||||
state = {
|
||||
"call_id": str(getattr(block, "id", "") or ""),
|
||||
"name": str(getattr(block, "name", "") or ""),
|
||||
}
|
||||
tool_blocks[index] = state
|
||||
if on_tool_call_delta:
|
||||
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).
|
||||
tool_blocks: dict[int, dict[str, str]] = {}
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
stream.__anext__(),
|
||||
timeout=idle_timeout_s,
|
||||
)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
if chunk.type == "content_block_start":
|
||||
block = getattr(chunk, "content_block", None)
|
||||
if getattr(block, "type", None) == "tool_use":
|
||||
index = int(getattr(chunk, "index", 0) or 0)
|
||||
state = {
|
||||
"call_id": str(getattr(block, "id", "") or ""),
|
||||
"name": str(getattr(block, "name", "") or ""),
|
||||
}
|
||||
tool_blocks[index] = state
|
||||
if on_tool_call_delta:
|
||||
await on_tool_call_delta({
|
||||
"index": index,
|
||||
**state,
|
||||
"arguments_delta": "",
|
||||
})
|
||||
elif (
|
||||
chunk.type == "content_block_delta"
|
||||
and getattr(chunk.delta, "type", None) == "thinking_delta"
|
||||
):
|
||||
piece = getattr(chunk.delta, "thinking", None) or ""
|
||||
if piece and on_thinking_delta:
|
||||
await on_thinking_delta(piece)
|
||||
elif (
|
||||
chunk.type == "content_block_delta"
|
||||
and getattr(chunk.delta, "type", None) == "text_delta"
|
||||
):
|
||||
text = getattr(chunk.delta, "text", None) or ""
|
||||
if text and on_content_delta:
|
||||
await on_content_delta(text)
|
||||
elif (
|
||||
chunk.type == "content_block_delta"
|
||||
and getattr(chunk.delta, "type", None) == "input_json_delta"
|
||||
):
|
||||
partial = getattr(chunk.delta, "partial_json", None) or ""
|
||||
if partial and on_tool_call_delta:
|
||||
index = int(getattr(chunk, "index", 0) or 0)
|
||||
state = tool_blocks.get(index, {})
|
||||
await on_tool_call_delta({
|
||||
"index": index,
|
||||
**state,
|
||||
"arguments_delta": "",
|
||||
"call_id": state.get("call_id", ""),
|
||||
"name": state.get("name", ""),
|
||||
"arguments_delta": partial,
|
||||
})
|
||||
elif (
|
||||
chunk.type == "content_block_delta"
|
||||
and getattr(chunk.delta, "type", None) == "thinking_delta"
|
||||
):
|
||||
piece = getattr(chunk.delta, "thinking", None) or ""
|
||||
if piece and on_thinking_delta:
|
||||
await on_thinking_delta(piece)
|
||||
elif (
|
||||
chunk.type == "content_block_delta"
|
||||
and getattr(chunk.delta, "type", None) == "text_delta"
|
||||
):
|
||||
text = getattr(chunk.delta, "text", None) or ""
|
||||
if text and on_content_delta:
|
||||
await on_content_delta(text)
|
||||
elif (
|
||||
chunk.type == "content_block_delta"
|
||||
and getattr(chunk.delta, "type", None) == "input_json_delta"
|
||||
):
|
||||
partial = getattr(chunk.delta, "partial_json", None) or ""
|
||||
if partial and on_tool_call_delta:
|
||||
index = int(getattr(chunk, "index", 0) or 0)
|
||||
state = tool_blocks.get(index, {})
|
||||
await on_tool_call_delta({
|
||||
"index": index,
|
||||
"call_id": state.get("call_id", ""),
|
||||
"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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+18
-603
@@ -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
|
||||
on_archive(archive_chunk)
|
||||
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)
|
||||
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),
|
||||
)
|
||||
def __init__(self, workspace: Path):
|
||||
self.sessions_dir = ensure_dir(workspace / "sessions")
|
||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||
|
||||
@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))
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
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, 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"],
|
||||
|
||||
@@ -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,32 +234,14 @@ 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)
|
||||
):
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user